From 855b45e9b4e7459a16ccf0f25dd3cff1744c4198 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 02:50:56 +0000 Subject: [PATCH 01/13] feat(runtrace): render execution timelines and message sequences Co-Authored-By: jason.han --- .../testing-pilot-corpora-gate/SKILL.md | 2 +- .../testing-pilot-differential/SKILL.md | 4 +- .../testing-pilot-execution-referee/SKILL.md | 2 +- .agents/skills/testing-pilot-xpect/SKILL.md | 2 +- README.md | 6 +- .../unreleased/run-trace-renderings.added.md | 1 + cmd/sysml/check.go | 7 +- cmd/sysml/main.go | 14 +- cmd/sysml/render_run.go | 148 ++++++ cmd/sysml/render_run_test.go | 139 ++++++ cmd/sysml/usage.go | 2 + docs/internals/architecture.md | 4 +- docs/project/pilot-differential-baseline.json | 18 +- docs/project/pilot-differential.md | 8 +- docs/project/spec-compliance.md | 8 + docs/project/view-rendering-forms.md | 33 ++ docs/reference/cli.md | 22 + docs/reference/repl-commands.md | 13 + examples/README.md | 1 + examples/run-timeline/README.md | 21 + examples/run-timeline/run-timeline.sysml | 44 ++ internal/doc/queryexec/event.go | 2 +- internal/doc/queryexec/events.go | 40 +- internal/doc/queryexec/states.go | 13 +- internal/exec/runtime/state_executor.go | 42 +- internal/exec/runtime/trace.go | 49 +- internal/exec/runtime/trace_records_test.go | 75 +++- internal/exec/runtrace/render_golden_test.go | 264 +++++++++++ internal/exec/runtrace/runtrace.go | 87 ++++ internal/exec/runtrace/runtrace_test.go | 234 ++++++++++ internal/exec/runtrace/sequence.go | 224 ++++++++++ .../testdata/capped-sequence.text.golden | 209 +++++++++ .../testdata/capped-timeline.text.golden | 407 +++++++++++++++++ .../runtrace/testdata/dot-sequence.dot.golden | 1 + .../runtrace/testdata/dot-timeline.dot.golden | 1 + .../testdata/empty-sequence.mermaid.golden | 35 ++ .../testdata/empty-sequence.plantuml.golden | 44 ++ .../testdata/empty-sequence.text.golden | 3 + .../testdata/empty-timeline.mermaid.golden | 31 ++ .../testdata/empty-timeline.plantuml.golden | 4 + .../testdata/empty-timeline.text.golden | 3 + .../testdata/example-sequence.mermaid.golden | 37 ++ .../testdata/example-sequence.plantuml.golden | 46 ++ .../testdata/example-sequence.text.golden | 7 + .../testdata/example-timeline.mermaid.golden | 40 ++ .../testdata/example-timeline.plantuml.golden | 18 + .../testdata/example-timeline.text.golden | 19 + .../testdata/guard-timeline.text.golden | 6 + .../testdata/messages-sequence.text.golden | 11 + .../self-transition-timeline.text.golden | 7 + .../testdata/truncated-sequence.text.golden | 10 + .../testdata/truncated-timeline.text.golden | 7 + internal/exec/runtrace/timeline.go | 420 ++++++++++++++++++ internal/frontend/repl/complete.go | 13 + internal/frontend/repl/meta.go | 8 +- internal/frontend/repl/run_render.go | 66 +++ internal/frontend/repl/run_render_test.go | 49 ++ internal/frontend/repl/session.go | 3 +- internal/frontend/repl/trace.go | 28 +- internal/ir/view/data.go | 6 + internal/ir/view/form.go | 5 +- internal/ir/view/mermaid.go | 10 +- internal/ir/view/palette.go | 3 + internal/ir/view/plantuml.go | 7 +- internal/ir/view/run_timeline.go | 273 ++++++++++++ internal/ir/view/run_timeline_test.go | 193 ++++++++ internal/ir/view/text.go | 22 +- internal/ir/view/view.go | 50 ++- packaging/man/man1/sysml.1 | 4 + .../testdata/api_json_roundtrip_expected.txt | 3 +- .../testdata/corpus_roundtrip_expected.txt | 3 +- tests/hygiene/layering_test.go | 1 + 72 files changed, 3532 insertions(+), 110 deletions(-) create mode 100644 changes/unreleased/run-trace-renderings.added.md create mode 100644 cmd/sysml/render_run.go create mode 100644 cmd/sysml/render_run_test.go create mode 100644 examples/run-timeline/README.md create mode 100644 examples/run-timeline/run-timeline.sysml create mode 100644 internal/exec/runtrace/render_golden_test.go create mode 100644 internal/exec/runtrace/runtrace.go create mode 100644 internal/exec/runtrace/runtrace_test.go create mode 100644 internal/exec/runtrace/sequence.go create mode 100644 internal/exec/runtrace/testdata/capped-sequence.text.golden create mode 100644 internal/exec/runtrace/testdata/capped-timeline.text.golden create mode 100644 internal/exec/runtrace/testdata/dot-sequence.dot.golden create mode 100644 internal/exec/runtrace/testdata/dot-timeline.dot.golden create mode 100644 internal/exec/runtrace/testdata/empty-sequence.mermaid.golden create mode 100644 internal/exec/runtrace/testdata/empty-sequence.plantuml.golden create mode 100644 internal/exec/runtrace/testdata/empty-sequence.text.golden create mode 100644 internal/exec/runtrace/testdata/empty-timeline.mermaid.golden create mode 100644 internal/exec/runtrace/testdata/empty-timeline.plantuml.golden create mode 100644 internal/exec/runtrace/testdata/empty-timeline.text.golden create mode 100644 internal/exec/runtrace/testdata/example-sequence.mermaid.golden create mode 100644 internal/exec/runtrace/testdata/example-sequence.plantuml.golden create mode 100644 internal/exec/runtrace/testdata/example-sequence.text.golden create mode 100644 internal/exec/runtrace/testdata/example-timeline.mermaid.golden create mode 100644 internal/exec/runtrace/testdata/example-timeline.plantuml.golden create mode 100644 internal/exec/runtrace/testdata/example-timeline.text.golden create mode 100644 internal/exec/runtrace/testdata/guard-timeline.text.golden create mode 100644 internal/exec/runtrace/testdata/messages-sequence.text.golden create mode 100644 internal/exec/runtrace/testdata/self-transition-timeline.text.golden create mode 100644 internal/exec/runtrace/testdata/truncated-sequence.text.golden create mode 100644 internal/exec/runtrace/testdata/truncated-timeline.text.golden create mode 100644 internal/exec/runtrace/timeline.go create mode 100644 internal/frontend/repl/run_render.go create mode 100644 internal/frontend/repl/run_render_test.go create mode 100644 internal/ir/view/run_timeline.go create mode 100644 internal/ir/view/run_timeline_test.go diff --git a/.agents/skills/testing-pilot-corpora-gate/SKILL.md b/.agents/skills/testing-pilot-corpora-gate/SKILL.md index 8ddb963cb5..6926edd5e9 100644 --- a/.agents/skills/testing-pilot-corpora-gate/SKILL.md +++ b/.agents/skills/testing-pilot-corpora-gate/SKILL.md @@ -183,7 +183,7 @@ gate's own helpers are package-private but reusable (`pilotCorporaGate.files(t)` `actionlint`, `shellcheck`, `python3 scripts/check-doc-links.py`, `gofmt`, `go vet`, `go run -C tools ./cmd/pilot-diff` (validators pre-downloaded; ~4min, prints e.g. -the headline the committed baseline holds — `381 file(s), 343 fully agreeing; 38 agreed +the headline the committed baseline holds — `382 file(s), 344 fully agreeing; 38 agreed diagnostic(s), 43 only ours, 1629 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** diff --git a/.agents/skills/testing-pilot-differential/SKILL.md b/.agents/skills/testing-pilot-differential/SKILL.md index 706cdd00b6..8911a4762a 100644 --- a/.agents/skills/testing-pilot-differential/SKILL.md +++ b/.agents/skills/testing-pilot-differential/SKILL.md @@ -23,7 +23,7 @@ GNU-format diagnostics **relative to `--root`**. Consequences for testing: - `-validator /nonexistent` now says `run ./scripts/download-pilot-sysml-validator.sh`. - Measured at the `2026-08` pin after bare parameters took their effective range `[0..*]`, removing the adjudicated `Behaviors.kerml:14` multiplicity warning (the `[1]` `RocketEquation` inputs keep - its warning at `delta-v-budget.sysml:93`): `381 file(s), 343 fully agreeing; 38 agreed, 43 only + its warning at `delta-v-budget.sysml:93`): `382 file(s), 344 fully agreeing; 38 agreed, 43 only ours, 1629 only the pilot's`, JSON totals `openSysMLDiagnostics 83 / pilotDiagnostics 1669 / severityMismatch 2`; the two new only-ours rows are the expected `action-step-multiplicity-not-fixed` warnings on `training/18. Action Performance/Action Performance Example.sysml:10` and @@ -146,7 +146,7 @@ parameters took their effective range `[0..*]` and removed the adjudicated `Beha warning (the `[1]` `RocketEquation` inputs still produce the warning at `delta-v-budget.sysml:93`), is current: the action-step multiplicity rule adds the two expected `action-step-multiplicity-not-fixed` warnings on `takePhoto[*]` in the training corpus and -`takePicture[*]` in `Camera Example/Camera.sysml`; a live run gives `381 file(s), 343 fully +`takePicture[*]` in `Camera Example/Camera.sysml`; a live run gives `382 file(s), 344 fully agreeing; 38 agreed, 43 only ours, 1629 only the pilot's`, byte-identical to the committed baseline, and `docs/project/pilot-differential.md`'s "Results" table matches. The prior rebaseline, when the Legend of the Red Dragon example left for its own repository, gave diff --git a/.agents/skills/testing-pilot-execution-referee/SKILL.md b/.agents/skills/testing-pilot-execution-referee/SKILL.md index 7b9d620281..fac4c355ac 100644 --- a/.agents/skills/testing-pilot-execution-referee/SKILL.md +++ b/.agents/skills/testing-pilot-execution-referee/SKILL.md @@ -148,7 +148,7 @@ pilot answers the representation's own. See `pilot-exec-diff: :: model no/such/model.sysml: stat : no such file or directory`. - **Additivity.** `go run -C tools ./cmd/pilot-diff` must still print the headline the - committed baseline holds (`381 file(s), 343 fully agreeing; 38 agreed + committed baseline holds (`382 file(s), 344 fully agreeing; 38 agreed diagnostic(s), 43 only ours, 1629 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` diff --git a/.agents/skills/testing-pilot-xpect/SKILL.md b/.agents/skills/testing-pilot-xpect/SKILL.md index 3d64189a1f..82ba2a6d67 100644 --- a/.agents/skills/testing-pilot-xpect/SKILL.md +++ b/.agents/skills/testing-pilot-xpect/SKILL.md @@ -423,7 +423,7 @@ census in `w5c_census_test.go` is live two ways: perturb one pinned triple (e.g. ## Regression neighbour `go run -C tools ./cmd/pilot-diff` (~1m12s) must still print the headline the *committed* baseline holds — -at the `2026-08` pin that is `381 file(s), 343 fully agreeing; 38 agreed diagnostic(s), 43 +at the `2026-08` pin that is `382 file(s), 344 fully agreeing; 38 agreed diagnostic(s), 43 only ours, 1629 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 diff --git a/README.md b/README.md index 74280fe783..2f4a77c941 100644 --- a/README.md +++ b/README.md @@ -312,11 +312,11 @@ The project is under active development, with the core infrastructure operationa **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:** 343 of 381 files agree diagnostic-by-diagnostic; 43 diagnostics are ours alone and 1629 the reference's alone, and the first number must be read by root: the aggregate includes candidate conformance differences, intentional execution-scope warnings on reference corpora and diagnostics from our own examples ([differential](docs/project/pilot-differential.md), `go run -C tools ./cmd/pilot-diff`). +- **Corpus agreement:** 344 of 382 files agree diagnostic-by-diagnostic; 43 diagnostics are ours alone and 1629 the reference's alone, and the first number must be read by root: the aggregate includes candidate conformance differences, intentional execution-scope warnings on reference corpora and diagnostics from our own examples ([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 312 invalid models we wrote ourselves, the reference rejects 3 that we accept by default, and 300 both reject; 3 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 344 of 381 files agreeing, 42 diagnostics ours alone and 1629 the reference's alone, 0 declared rows we are silent on, and 0 of 312 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. +- **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 345 of 382 files agreeing, 42 diagnostics ours alone and 1629 the reference's alone, 0 declared rows we are silent on, and 0 of 312 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. @@ -328,7 +328,7 @@ What these numbers cannot show: the OMG corpora are demonstrations rather than a **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 (every conformance case 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:** 381 files compared diagnostic-by-diagnostic against the pinned OMG pilot implementation (`2026-08`), 343 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`. +**Reference differential:** 382 files compared diagnostic-by-diagnostic against the pinned OMG pilot implementation (`2026-08`), 344 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? 312 hand-written invalid models validated by both implementations, 303 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 report no semantic errors, gated by `tests/corpus/testdata/training_examples_expected.txt`; the gate does not count execution-scope warnings. 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. diff --git a/changes/unreleased/run-trace-renderings.added.md b/changes/unreleased/run-trace-renderings.added.md new file mode 100644 index 0000000000..704f2ef49d --- /dev/null +++ b/changes/unreleased/run-trace-renderings.added.md @@ -0,0 +1 @@ +- **Render recorded runs as timelines and message sequences.** The CLI and REPL can write a behavior's state occupancy and ordered messages as text, Mermaid or PlantUML without adding model-view vocabulary. diff --git a/cmd/sysml/check.go b/cmd/sysml/check.go index 18e66c3e58..0f597d0acb 100644 --- a/cmd/sysml/check.go +++ b/cmd/sysml/check.go @@ -601,6 +601,9 @@ func runChecks(files []string, exprs []string, c checks) int { } sess := newSession() + if len(renderRuns) > 0 { + sess.SetRecording(true) + } sess.SetCheckDiverge(c.checker.diverge) sess.SetCheckProperties(c.checker.properties) sess.SetCheckInputs(c.checker.inputs) @@ -774,7 +777,7 @@ func runChecks(files []string, exprs []string, c checks) int { rep.verdict(v) } c.runQueries(sess, rep) - return rep.finish() + return finishRunCheck(rep, sess) } for _, value := range c.actions { name, performer := repl.SplitBehavior(value) @@ -790,7 +793,7 @@ func runChecks(files []string, exprs []string, c checks) int { } c.runQueries(sess, rep) - return rep.finish() + return finishRunCheck(rep, sess) } // runQueries executes each -run-query after the behaviors named have run, so a diff --git a/cmd/sysml/main.go b/cmd/sysml/main.go index 203b8cce23..6d442ed283 100644 --- a/cmd/sysml/main.go +++ b/cmd/sysml/main.go @@ -99,6 +99,7 @@ var ( debugMode bool quietMode bool traceMode bool + renderRuns stringSlice schedule schedulePolicy listEngines bool probeEngines bool @@ -376,7 +377,18 @@ func runCLI() int { // Get positional arguments (files to load) args := flag.Args() - if renderForm != "" && renderView == "" && renderAllDir == "" { + if flagGiven("render-run") { + if message := runRenderModeMisuse(); message != "" { + fmt.Fprintln(os.Stderr, errPrefix, message) + return 2 + } + if _, err := runRenderTargetsFromFlags(); err != nil { + fmt.Fprintln(os.Stderr, errPrefix, err) + return 2 + } + } + + if renderForm != "" && renderView == "" && renderAllDir == "" && len(renderRuns) == 0 { fmt.Fprintln(os.Stderr, "sysml: -render-form is the form -render or -render-all writes; name the view to render with -render or a directory with -render-all") return 2 } diff --git a/cmd/sysml/render_run.go b/cmd/sysml/render_run.go new file mode 100644 index 0000000000..14f4a6ac63 --- /dev/null +++ b/cmd/sysml/render_run.go @@ -0,0 +1,148 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtrace" + "github.com/Open-MBEE/OpenSysML/internal/frontend/repl" + "github.com/Open-MBEE/OpenSysML/internal/ir/view" +) + +type runRenderTarget struct { + kind runtrace.Kind + path string + form view.Form +} + +func runRenderModeMisuse() string { + switch { + case len(renderRuns) == 0: + return "-render-run needs a value of the form =" + case len(modelChecks.actions) == 0 && len(modelChecks.states) == 0 && !modelChecks.advance.given: + return "-render-run needs -action, -state or -advance to record a behavior run" + case schedule.text == "explore": + return "-render-run cannot render -schedule explore; render one declared or replayed run" + case engine.text == "check" || engine.text == "smt" || engine.text == "all": + return "-render-run cannot render -engine check, smt or all; run one behavior schedule" + case modelChecks.runs.given || flagGiven("runs"): + return "-render-run cannot be combined with -runs" + case len(modelChecks.sweeps) > 0 || modelChecks.samples.given || flagGiven("sweep") || flagGiven("samples"): + return "-render-run cannot be combined with -sweep or -samples" + case len(modelChecks.records) > 0 || flagGiven("record-run"): + return "-render-run cannot be combined with -record-run" + case flagGiven("render") || renderView != "" || flagGiven("render-all") || renderAllDir != "": + return "-render-run cannot be combined with -render or -render-all" + case flagGiven("render-document") || renderDoc != "" || flagGiven("render-documents") || renderDocsDir != "": + return "-render-run cannot be combined with -render-document or -render-documents" + case flagGiven("convert") || convertFormat != "" || flagGiven("migrate") || migrateFormat != "": + return "-render-run cannot be combined with -convert or -migrate" + case flagGiven("query") || queryText != "": + return "-render-run cannot be combined with -query" + case outputPath != "": + return "-render-run names each output path; do not combine it with -output" + case renderPalette != "" || renderStyle != "" || renderPorts != "" || renderUnplaced != "": + return "-render-palette, -render-style, -render-ports and -render-unplaced apply to model renderings, not -render-run" + } + return "" +} + +func runRenderTargetsFromFlags() ([]runRenderTarget, error) { + targets := make([]runRenderTarget, 0, len(renderRuns)) + for _, value := range renderRuns { + kindText, path, ok := strings.Cut(value, "=") + if !ok || kindText == "" || path == "" { + return nil, fmt.Errorf("-render-run takes =, not %q", value) + } + kind, ok := runtrace.ParseKind(kindText) + if !ok { + return nil, fmt.Errorf("unknown -render-run kind %q; want %s", kindText, strings.Join(runRenderKindNames(), ", ")) + } + form := view.Form(renderForm) + if renderForm == "" { + var found bool + form, found = runRenderFormFromPath(path) + if !found { + return nil, fmt.Errorf("-render-run path %q has no recognized form extension; use -render-form text, mermaid or plantuml", path) + } + } else if !slices.Contains(view.Forms(), form) { + return nil, fmt.Errorf("unknown rendering form %q; -render-form takes %s", renderForm, formList()) + } + probe := &view.Rendering{Kind: runRenderViewKind(kind), Run: true} + if _, err := probe.WriteWith(form, view.Options{}); err != nil { + return nil, fmt.Errorf("-render-run %s: %w", kind, err) + } + targets = append(targets, runRenderTarget{kind: kind, path: path, form: form}) + } + return targets, nil +} + +func runRenderKindNames() []string { + kinds := runtrace.Kinds() + names := make([]string, len(kinds)) + for i, kind := range kinds { + names[i] = string(kind) + } + return names +} + +func runRenderViewKind(kind runtrace.Kind) view.Kind { + if kind == runtrace.KindTimeline { + return view.KindTimeline + } + return view.KindSequence +} + +func runRenderFormFromPath(path string) (view.Form, bool) { + switch strings.ToLower(filepath.Ext(path)) { + case ".mmd", ".mermaid": + return view.FormMermaid, true + case ".puml", ".plantuml": + return view.FormPlantUML, true + case ".txt": + return view.FormText, true + case ".dot", ".gv": + return view.FormDot, true + default: + return "", false + } +} + +func finishRunCheck(rep *reporter, sess *repl.Session) int { + status := rep.finish() + if len(renderRuns) == 0 { + return status + } + targets, err := runRenderTargetsFromFlags() + if err != nil { + fmt.Fprintln(os.Stderr, errPrefix, err) + return 2 + } + for _, target := range targets { + rendering, err := sess.RunTraceRendering(target.kind) + if err != nil { + fmt.Fprintln(os.Stderr, errPrefix, err) + return 2 + } + artifact, err := rendering.WriteWith(target.form, view.Options{Width: artifactWidth(target.path, terminalWidth())}) + if err != nil { + fmt.Fprintln(os.Stderr, errPrefix, err) + return 2 + } + name := "run " + string(target.kind) + reportRenderNoticesFrom(rendering, name) + if target.path == "-" { + if err := writeArtifact(artifact, target.form); err != nil { + fmt.Fprintln(os.Stderr, errPrefix, err) + return 2 + } + } else if err := writeArtifactFile(target.path, artifact, target.form); err != nil { + fmt.Fprintln(os.Stderr, errPrefix, err) + return 2 + } + } + return status +} diff --git a/cmd/sysml/render_run_test.go b/cmd/sysml/render_run_test.go new file mode 100644 index 0000000000..e2535a2878 --- /dev/null +++ b/cmd/sysml/render_run_test.go @@ -0,0 +1,139 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRenderRunExampleMatchesGoldens(t *testing.T) { + data, err := os.ReadFile(filepath.Join("..", "..", "examples", "run-timeline", "run-timeline.sysml")) + if err != nil { + t.Fatal(err) + } + binary := buildCLI(t) + dir := t.TempDir() + outputs := map[string]string{ + "example-timeline.text.golden": filepath.Join(dir, "timeline.txt"), + "example-timeline.mermaid.golden": filepath.Join(dir, "timeline.mmd"), + "example-timeline.plantuml.golden": filepath.Join(dir, "timeline.puml"), + "example-sequence.text.golden": filepath.Join(dir, "sequence.txt"), + "example-sequence.mermaid.golden": filepath.Join(dir, "sequence.mmd"), + "example-sequence.plantuml.golden": filepath.Join(dir, "sequence.puml"), + } + args := []string{ + "-instantiate", "RunTimeline::mission", + "-state", "RunTimeline::Sender::modes RunTimeline::mission.sender", + "-state", "RunTimeline::Receiver::modes RunTimeline::mission.sender.receiver", + "-advance", "6", + } + for golden, path := range outputs { + kind := "timeline" + if strings.Contains(golden, "sequence") { + kind = "sequence" + } + args = append(args, "-render-run", kind+"="+path) + } + got := check(t, binary, string(data), args...) + if got.status != 0 { + t.Fatalf("exit status = %d\n%s", got.status, got.output()) + } + for golden, path := range outputs { + actual, err := os.ReadFile(path) // #nosec G304 -- the path is created by the test. + if err != nil { + t.Fatal(err) + } + goldenPath := filepath.Join("..", "..", "internal", "exec", "runtrace", "testdata", golden) + want, err := os.ReadFile(goldenPath) + if err != nil { + t.Fatal(err) + } + if string(actual) != string(want) { + t.Errorf("%s differs from %s", path, goldenPath) + } + } +} + +func TestRenderRunWritesEachFormAfterTheVerdict(t *testing.T) { + binary := buildCLI(t) + dir := t.TempDir() + timelinePath := filepath.Join(dir, "timeline.txt") + sequencePath := filepath.Join(dir, "sequence.mmd") + got := check(t, binary, behaviorModel, + "-state", "Mission::Cycle", "-advance", "15", + "-render-run", "timeline="+timelinePath, + "-render-run", "sequence="+sequencePath, + ) + if got.status != 0 { + t.Fatalf("exit status = %d\n%s", got.status, got.output()) + } + if !strings.Contains(got.stdout, `Started state machine executor for "Mission::Cycle"`) { + t.Errorf("the run verdict is missing from stdout:\n%s", got.stdout) + } + if strings.Contains(got.stdout, "[trace]") { + t.Errorf("silent recording printed trace lines:\n%s", got.stdout) + } + timeline, err := os.ReadFile(timelinePath) // #nosec G304 -- this path is created by the test. + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(timeline), "run - timeline rendering") || + !strings.Contains(string(timeline), "working") { + t.Errorf("timeline output is missing the run's state occupancy:\n%s", timeline) + } + sequence, err := os.ReadFile(sequencePath) // #nosec G304 -- this path is created by the test. + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(sequence), "sequenceDiagram") || + !strings.Contains(string(sequence), "the run recorded no message") { + t.Errorf("sequence output is missing its empty-run rendering:\n%s", sequence) + } + if !strings.Contains(got.stderr, "wrote "+timelinePath) || !strings.Contains(got.stderr, "wrote "+sequencePath) { + t.Errorf("stderr does not report both artifacts:\n%s", got.stderr) + } +} + +func TestRenderRunWritesStdoutWithoutLosingTheVerdict(t *testing.T) { + binary := buildCLI(t) + got := check(t, binary, behaviorModel, + "-state", "Mission::Cycle", "-advance", "1", + "-render-run", "timeline=-", "-render-form", "text", + ) + if got.status != 0 { + t.Fatalf("exit status = %d\n%s", got.status, got.output()) + } + for _, want := range []string{`Started state machine executor for "Mission::Cycle"`, "run - timeline rendering", "waiting"} { + if !strings.Contains(got.stdout, want) { + t.Errorf("stdout is missing %q:\n%s", want, got.stdout) + } + } + if strings.Contains(got.stdout, "[trace]") { + t.Errorf("silent recording printed trace lines:\n%s", got.stdout) + } +} + +func TestRenderRunRejectsUnsupportedModesAndForms(t *testing.T) { + binary := buildCLI(t) + cases := []struct { + name string + args []string + want string + }{ + {"no behavior", []string{"-render-run", "timeline=run.txt"}, "needs -action, -state or -advance"}, + {"unknown kind", []string{"-state", "Mission::Cycle", "-render-run", "other=run.txt"}, "unknown -render-run kind"}, + {"unknown extension", []string{"-state", "Mission::Cycle", "-render-run", "timeline=run.svg"}, "use -render-form"}, + {"dot refused", []string{"-state", "Mission::Cycle", "-render-run", "timeline=run.dot"}, "not written as dot"}, + {"model rendering conflict", []string{"-state", "Mission::Cycle", "-render-run", "timeline=run.txt", "-render", "Demo::view"}, "cannot be combined with -render"}, + {"query conflict", []string{"-state", "Mission::Cycle", "-render-run", "timeline=run.txt", "-query", "sysml:name=*"}, "cannot be combined with -query"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := check(t, binary, behaviorModel, tc.args...) + if got.status != 2 || !strings.Contains(got.stderr, tc.want) { + t.Errorf("status = %d, want 2 with %q:\n%s", got.status, tc.want, got.output()) + } + }) + } +} diff --git a/cmd/sysml/usage.go b/cmd/sysml/usage.go index 9a57bcf382..8d1aec0c89 100644 --- a/cmd/sysml/usage.go +++ b/cmd/sysml/usage.go @@ -678,6 +678,7 @@ func registerFlags(fs *flag.FlagSet) { fs.BoolVar(&debugMode, "debug", false, "Report every diagnostic over the whole session buffer, with the pass that produced it") fs.BoolVar(&quietMode, "quiet", false, "Report errors only, suppressing warnings") fs.BoolVar(&traceMode, "trace", false, "Report each execution step: expression evaluation, calc invocation, action tokens, state transitions") + fs.Var(&renderRuns, "render-run", "Render the trace of a run as timeline or sequence into path (`-` is stdout); form comes from -render-form or the path extension") fs.BoolVar(&memStats, "memstats", false, "Report on stderr what the run cost: wall time, memory allocated, memory taken from the OS") fs.Var(&deprecatedFlag{instead: "-to has been replaced by -convert, as `sysml model.sysml -convert ttl`"}, "to", "Replaced by -convert, which names the output format") @@ -835,6 +836,7 @@ func optionGroups() []usage.OptionGroup { usage.Opt("debug", ""), usage.Opt("quiet", ""), usage.Opt("trace", ""), + usage.Opt("render-run", "="), usage.Opt("cpuprofile", fileArg), usage.Opt("memprofile", fileArg), usage.Opt("memstats", ""), diff --git a/docs/internals/architecture.md b/docs/internals/architecture.md index b5b3e11301..dbe997eb48 100644 --- a/docs/internals/architecture.md +++ b/docs/internals/architecture.md @@ -841,11 +841,11 @@ Every behavioral feature must have: **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:** 343 of 381 files agree diagnostic-by-diagnostic; 43 diagnostics are ours alone and 1629 the reference's alone, and the first number must be read by root: the aggregate includes candidate conformance differences, intentional execution-scope warnings on reference corpora and diagnostics from our own examples ([differential](../project/pilot-differential.md), `go run -C tools ./cmd/pilot-diff`). +- **Corpus agreement:** 344 of 382 files agree diagnostic-by-diagnostic; 43 diagnostics are ours alone and 1629 the reference's alone, and the first number must be read by root: the aggregate includes candidate conformance differences, intentional execution-scope warnings on reference corpora and diagnostics from our own examples ([differential](../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](../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 312 invalid models we wrote ourselves, the reference rejects 3 that we accept by default, and 300 both reject; 3 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](../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](../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 344 of 381 files agreeing, 42 diagnostics ours alone and 1629 the reference's alone, 0 declared rows we are silent on, and 0 of 312 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. +- **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](../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 345 of 382 files agreeing, 42 diagnostics ours alone and 1629 the reference's alone, 0 declared rows we are silent on, and 0 of 312 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](../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. diff --git a/docs/project/pilot-differential-baseline.json b/docs/project/pilot-differential-baseline.json index 1f476f0c21..7c8739feb4 100644 --- a/docs/project/pilot-differential-baseline.json +++ b/docs/project/pilot-differential-baseline.json @@ -60,8 +60,8 @@ "name": "examples", "dir": "examples", "origin": "ours", - "files": 45, - "digest": "sha256:8b0d334dcd91c03c9e2b12bc9be59204e0ec714520deaab52d81e3454c2c4744" + "files": 46, + "digest": "sha256:b7d1f41d23acfa138c5933900114814e911c7028cebd4c62df35c1afa6af6cbd" }, { "name": "probes", @@ -71,11 +71,11 @@ "digest": "sha256:b0153c55bbfcdacabab911725dc44e0e7f4d3a501a281b1f3f2a13cda19737c6" } ], - "recorded": "2026-10-03" + "recorded": "2026-10-04" }, "totals": { - "files": 381, - "filesFullyAgreeing": 343, + "files": 382, + "filesFullyAgreeing": 344, "agreement": 38, "severityMismatch": 2, "openSysMLOnly": 43, @@ -860,8 +860,8 @@ "name": "examples", "dir": "examples", "totals": { - "files": 45, - "filesFullyAgreeing": 30, + "files": 46, + "filesFullyAgreeing": 31, "agreement": 4, "severityMismatch": 1, "openSysMLOnly": 6, @@ -8445,8 +8445,8 @@ } ], "totals": { - "files": 381, - "filesFullyAgreeing": 344, + "files": 382, + "filesFullyAgreeing": 345, "agreement": 38, "severityMismatch": 2, "openSysMLOnly": 42, diff --git a/docs/project/pilot-differential.md b/docs/project/pilot-differential.md index be743c9d10..1ff40b678b 100644 --- a/docs/project/pilot-differential.md +++ b/docs/project/pilot-differential.md @@ -228,7 +228,7 @@ nor double-counted as two independent disagreements. --- -## Results (pilot `2026-08`, 381 files) +## Results (pilot `2026-08`, 382 files) | Root | Files | Fully agreeing | Ours | Pilot | Agreed | Severity-only | Only ours | Only pilot | |---|---:|---:|---:|---:|---:|---:|---:|---:| @@ -237,9 +237,9 @@ nor double-counted as two independent disagreements. | `examples/pilot-corpora/sysml-validation` | 56 | 56 | 0 | 0 | 0 | 0 | 0 | 0 | | `examples/pilot-corpora/kerml-examples` | 58 | 56 | 9 | 0 | 0 | 0 | 9 | 0 | | `tests/testdata` | 19 | 10 | 44 | 68 | 34 | 1 | 9 | 33 | -| `examples` | 45 | 30 | 11 | 1601 | 4 | 1 | 6 | 1596 | +| `examples` | 46 | 31 | 11 | 1601 | 4 | 1 | 6 | 1596 | | `tools/referee/diff/testdata` (probes) | 4 | 1 | 6 | 0 | 0 | 0 | 6 | 0 | -| **Total** | **381** | **343** | **83** | **1669** | **38** | **2** | **43** | **1629** | +| **Total** | **382** | **344** | **83** | **1669** | **38** | **2** | **43** | **1629** | **Read the `only ours` total by root, never as one number.** Step 2 removes nine resolver false positives from the reference's **own** corpora: `pilot-examples` 16 → **7** and @@ -1051,7 +1051,7 @@ page's history. | Count | Now | |---|---:| -| overall: fully agreeing / only ours / our diagnostics | **343 / 43 / 83** | +| overall: fully agreeing / only ours / our diagnostics | **344 / 43 / 83** | | only pilot | **1629** | | pilot diagnostics | **1669** | | severity-only | **2** | diff --git a/docs/project/spec-compliance.md b/docs/project/spec-compliance.md index 9d5a16843b..96aa548f6b 100644 --- a/docs/project/spec-compliance.md +++ b/docs/project/spec-compliance.md @@ -2592,6 +2592,14 @@ boundaries; the landed Track E behavior is recorded in the execution rows and th | `trace_test.go` | Golden trace test infrastructure | ~200 | | `trace_calc_test.go` | Trace determinism and canonical rendering unit tests | ~180 | +### Run renderings + +| Run output | Implementation | Tests | Status | +|---|---|---|---| +| State occupancy over clock time, per object machine, with transitions and choice/guard marks | `internal/exec/runtrace/timeline.go` `Timeline`; `internal/ir/view/run_timeline.go` text, Mermaid and PlantUML writers | `internal/exec/runtrace/runtrace_test.go`; run-rendering goldens under `internal/exec/runtrace/testdata/` | ✅ Implemented and tested | +| Ordered sends and accepts between objects, including unmatched and environment messages | `internal/exec/runtrace/sequence.go` `Sequence`; existing `KindSequence` writers | `internal/exec/runtrace/runtrace_test.go`; run-rendering goldens under `internal/exec/runtrace/testdata/` | ✅ Implemented and tested | +| State trace records retain the written state path and innermost orthogonal region | `internal/exec/runtime/trace.go` `TraceRecord`; `internal/exec/runtime/state_executor.go` `StateExecutor.RegionOf` | `internal/exec/runtime/trace_records_test.go` `TestStateTraceRecordsCarryWrittenPathsAndInnermostRegions` | ✅ Implemented and tested | + ### Runtime bounds: every limit a model can reach A run is bounded, and a bound that silently changed a result would be the worst outcome, so each diff --git a/docs/project/view-rendering-forms.md b/docs/project/view-rendering-forms.md index 839a0e351f..cdcfa3c91e 100644 --- a/docs/project/view-rendering-forms.md +++ b/docs/project/view-rendering-forms.md @@ -849,6 +849,8 @@ every palette, and text stays black. | --- | --- | --- | | CLI | `-render -render-form mermaid\|dot\|plantuml`; `-render-all ` writes `.mmd`, `.dot` or `.puml`; `-render-palette ` fills nodes in each form where applicable | [`docs/reference/cli.md`](../reference/cli.md#rendering-a-view) | | REPL | `%render mermaid\|dot\|plantuml [palette] [pilot\|cameo]`; `%help` names the options; form, palette and style complete where accepted | [`docs/reference/repl-commands.md`](../reference/repl-commands.md#rendering-a-view) | +| CLI run output | `-render-run timeline=` or `sequence=` writes text, Mermaid or PlantUML; DOT is refused | [`docs/reference/cli.md`](../reference/cli.md#rendering-a-run) | +| REPL run output | `%render-run timeline\|sequence [text\|mermaid\|plantuml\|dot]` renders the recorded run without changing the session | [`docs/reference/repl-commands.md`](../reference/repl-commands.md#rendering-a-run) | | LSP | `"form": "mermaid"`, `"dot"` or `"plantuml"` and `"palette": ""` on `opensysml/render`; `Rendering.Fills` carries each node's fill and border for clients drawing their own SVG | [`docs/reference/lsp.md`](../reference/lsp.md) | | VS Code | `SysML: Export Diagram` picks among the forms the server lists under its `openSysmlRenderForms` capability (the documented five for a server without it, which predates `csv` and `tsv`), sends the pick as `form`, and saves `.dot` or `.puml` (`.mmd`, `.md`, `.csv`, `.tsv`, `.txt` for the others) | [`docs/guide/08-editors.md`](../guide/08-editors.md#exporting-a-diagram) | | CLI, REPL, LSP, documents | `-render-style pilot\|cameo` beside `-render-palette`; `%render mermaid [palette] [pilot\|cameo]`; `"style": "cameo"` on `opensysml/render` and the `openSysmlRenderStyles` capability; `docrender.MarkdownOptions.Style`/`HTMLOptions.Style` and `docpdf.Options.Style`. Unsupported style details receive a `not represented: style …` notice; an unknown name is a typed `*view.UnknownDrawingStyleError` | [`docs/reference/cli.md`](../reference/cli.md#rendering-a-view), [`docs/reference/repl-commands.md`](../reference/repl-commands.md#rendering-a-view), [`docs/reference/lsp.md`](../reference/lsp.md) | @@ -863,8 +865,39 @@ render-form field — `RenderDocument` alone, to Markdown — so the wire contra and did not change. A view-render RPC added later would take the form as a string, as `-render-form` does. +## Run renderings + +Run renderings describe a recorded execution, not a model view. A timeline is a new +`view.KindTimeline`: no existing kind carries occupancy over time, and `KindState` is a graph of +declared states rather than the states an object held during one run. A run sequence reuses +`KindSequence` and its existing writers because both are lifelines with ordered messages. + +The timeline forms are text, Mermaid and PlantUML. Mermaid uses a compact Gantt chart with a +shared time axis; Mermaid's `timeline` grammar groups categorical periods and has neither +durations nor a shared time axis. PlantUML uses `concise` lifelines because parallel state +configurations are free text rather than a fixed ordered state axis that `robust` requires. DOT +is refused for both run kinds: it has no time axis, and the existing sequence writer also refuses +DOT. + +Each rendering is capped at 200 spans or messages for readability and to keep renderer input +within practical text-size limits. Later content is reported in a `not represented:` notice. +When a recorder itself dropped earlier events, the rendering starts from the first kept state +entry and reports that senders or acceptors of earlier messages may be missing. + +Run renderings are not embedded in documents. Document backends do not run behaviors: +`-render-document` refuses `-state` and `-advance`, and a document `Diagram` kind selects a model +rendering. Use `-render-run` or `%render-run` after executing the behavior instead. The LSP has +no live run and therefore does not offer run rendering. + ## Test contract +- `internal/exec/runtrace`: text, Mermaid and PlantUML goldens for both run kinds; empty, capped, + truncated, guard, unmatched and broadcast-message cases; choice marks, self-transitions and + DOT refusal. `internal/ir/view/run_timeline_test.go` checks that timeline form support does not + make it a model view kind or a pseudo-view. +- `cmd/sysml/render_run_test.go` and `internal/frontend/repl/run_render_test.go`: CLI artifact + output and incompatible modes, and REPL output, missing-trace handling, form refusal and + completion. - `internal/ir/view/dot_test.go`: a `*.dot.golden` beside every Mermaid golden for the tree, interconnection, state, state-entry, action and filtered fixtures, each walked by an in-test DOT syntax check — balanced braces, every edge endpoint declared as a node or a cluster, diff --git a/docs/reference/cli.md b/docs/reference/cli.md index ed882ba520..5f237476de 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -235,6 +235,7 @@ the same member-path parser as `Project` and `OrderBy`. | `--disable-lint ` | | Leave the named [lint](diagnostics.md) out of the diagnostics: `undeclared-signal`, `port-type-mismatch` or `deferred-keeper-unmarked`; comma-separated or repeated. An unknown code is a usage error | | `--no-record-cache` | | Parse every file loaded and hold it loaded, reading no interface record from the record cache and writing none: what a run does where `OPENSYSML_RECORD_CACHE=0`. By default a file whose bytes, library, conformance mode and record format match a record in the cache is held as that record — its scopes and symbols without its tree, and the diagnostics its analysis found — and a file analyzed by `-validate`, `-satisfy` or another load writes its record for the next run; see [Interface records](../internals/interface-records.md) | | `--trace` | | Report each execution step: expression evaluation, calc invocation, action tokens, state transitions, each `choice` the executor made among alternatives the library leaves unordered, naming the alternatives and the one taken, and each `unevaluable guard` it read only to report one and could not evaluate ([Choice points](../guide/06-behavior.md)). Under `-schedule explore` the table is printed first, then the trace of one witness run per distinct outcome, each under a `trace of outcome 's witness (run ):` heading ([Exploring every linearization](#exploring-every-linearization)) | +| `--render-run =` | | Repeatable; write a recorded run's `timeline` or `sequence` as text, Mermaid or PlantUML. The extension `.txt`, `.mmd`/`.mermaid` or `.puml`/`.plantuml` selects a form; use `-render-form` for another extension. DOT is refused. Requires `-state`, `-action` or `-advance` and cannot be combined with model/document rendering or schedule exploration | | `--convert ` | | Convert the model instead of running it: `sysml`, `kerml`, `ttl`, `turtle`, `rdf`, `api-json` or `json`. `ttl` writes the RDF graph in Turtle, `api-json` the same graph as the API's JSON element objects; both are [experimental](rdf-mapping.md#status-experimental) and every run that converts either says so on stderr (see [the RDF mapping](rdf-mapping.md)). The model argument may be a Flexo MMS project branch URL — `http(s)://host[:port][/base]/projects/{project}/branches/{branch}` or `flexo://{project}/{branch}` — both naming the endpoint `FLEXO_SYSMLV2_URL` configures — which is read as its head commit's RDF graph; see [Reading and pushing a repository branch](#reading-and-pushing-a-repository-branch) | | `--migrate ` | | Migrate a SysML v1 model — UML XMI, an Eclipse UML2 `.uml` file or a MagicDraw/Cameo `.mdzip` archive — to SysML v2 instead of running it, writing `sysml`, `kerml`, `ttl`, `turtle` or `rdf`. A migration is ledgered, not lossless: every v1 element is **mapped**, **approximated**, **unmapped** or **skipped**, and the run says so in a one-line summary, or element by element with `-migration-report`. The input is named by its `.xmi`, `.uml` or `.mdzip` extension or by `--from`; v2 input is refused with a pointer at `--convert`, and `--convert` on a v1 model is refused with a pointer here, since a migration is not a conversion (experimental; see [SysML v1 migration](sysml-v1-migration.md)) | | `--from ` | | Input format for `--convert` or `--migrate`: the `--convert` formats, `xmi`/`uml`/`mdzip` for a SysML v1 model to `--migrate`, or `fmu` for a Functional Mock-up Unit to import as a `calc def` evaluated through the `tool:fmi` engine (experimental; default: from the input's extension; `.xmi`, `.uml`, `.mdzip` and `.fmu` are recognized) — see [SysML v1 migration](sysml-v1-migration.md) and [FMI models (FMUs)](fmi.md) | @@ -462,6 +463,27 @@ sysml -e "x" -e "y" file.sysml sysml -e "result" file1.sysml file2.sysml ``` +## Rendering a run + +`-render-run =` writes the trace of a behavior run as a timeline or message sequence. +Run it with `-state`, `-action` or `-advance`; the trace is recorded silently unless `-trace` +also asks to print it. Repeat the flag for both renderings: + +```bash +sysml examples/run-timeline/run-timeline.sysml \ + -instantiate RunTimeline::mission \ + -state "RunTimeline::Sender::modes RunTimeline::mission.sender" \ + -state "RunTimeline::Receiver::modes RunTimeline::mission.sender.receiver" \ + -advance 6 \ + -render-run timeline=timeline.mmd \ + -render-run sequence=sequence.puml +``` + +The output extension selects text (`.txt`), Mermaid (`.mmd`, `.mermaid`) or PlantUML +(`.puml`, `.plantuml`); `-render-form` can select a form for another extension. DOT is not +available for run output. A run rendering cannot be combined with a model or document rendering, +query, schedule exploration or multi-run analysis. + ## Rendering a view `-render ` renders one view of the model and exits. Every file named on the command line is diff --git a/docs/reference/repl-commands.md b/docs/reference/repl-commands.md index 9cd3fd2fd8..84979ba590 100644 --- a/docs/reference/repl-commands.md +++ b/docs/reference/repl-commands.md @@ -70,6 +70,7 @@ into the parts it holds (`car.fl.hub`, `#3.fl`, `car.wheels[2]`). | `%builtins` | List the library functions the runtime implements directly (`sqrt`, `abs`, `max`, `floor`, `x->isEmpty()`, `x->sum()` …), each with the package an `import` must name for its bare name to resolve; the qualified name (`RealFunctions::sqrt(2.0)`) resolves anywhere | | `%view ` | Show what a view exposes: its own `expose` relationships plus the protected ones of the views it specializes, the views nested in it (each with its own exposed set), and its conformance to every viewpoint it satisfies. Conformance is a verdict of `conforms`, `violated` or `unevaluable` per viewpoint and per framed concern, with the reason, the exposed element a concern's condition failed for, and `(from )` where the `satisfy` is inherited. Asking about an element that is not a view says so | | `%render [form [palette] [style] [ports]]` | Render a view's exposed set in the kind its `render` member states: a containment tree with nested views as subtrees, an interconnection diagram of the exposed parts and the connections between them, a state machine's states and transitions, an action's nodes and successions, or a table of the exposed elements and what they declare. A view with no `render` member renders as a tree. Output is indented text by default, or the machine-readable form of the kind: a [Mermaid](#rendering-a-view) diagram with `mermaid`, a Markdown table with `markdown`, or a table as comma- or tab-separated values with `csv` or `tsv`, any notice listed after the records under `not represented:`; `dot` writes a graph-shaped kind as Graphviz DOT instead of Mermaid, in the Pilot visualizer's Standard B&W style, `plantuml` writes it — a sequence included — as PlantUML in the same style, and `dot ` or `plantuml ` fills the nodes by keyword family from `okabe-ito`, `tol-bright`, `tol-muted`, `tol-light`, `brewer-set2`, `brewer-dark2`, `viridis` or `cividis`; an unknown palette is refused with the names there are; `dot [palette] pilot` or `dot [palette] cameo` names the drawing style, the Pilot look (the default) or Cameo Systems Modeler's, and an unknown style is refused with the two there are. Asking for a form the kind cannot be written in tells you which form it uses. Read-only: it creates no object and leaves a `%action`/`%state` debugging session running. A view that exposes nothing renders empty and says so; a rendering kind this build does not produce is reported by kind and view rather than rendered as something else; an element the rendering cannot represent is reported, not dropped | +| `%render-run [text\|mermaid\|plantuml\|dot]` | Render the current session's recorded run as state occupancy over time or as ordered messages; text is the default. Requires `%trace on` before the run, accepts Mermaid or PlantUML, and refuses DOT. It leaves the session and its trace unchanged | | **Instantiation & Inspection** | | | `%instantiate ` | Create an object of a part definition and start the behaviors its type exhibits or performs. Each object runs its own machine, initialized after its feature values are built and run until it is quiescent. A second `%instantiate` of the same name creates a new object, and the name then refers to that one. A later submission keeps the object's identity but restarts its behaviors from their initial states, and says so | | `%features [all\|depth ] [json]` | Show what an object holds for each feature of its type. The object is named, addressed by id, or reached by a path: `%features car`, `%features #3`, `%features car.fl.hub`, `%features car.wheels[2]`. A feature with no value reads ``. States and actions hold no value, so they are listed after the values under a `Behaviors:` heading with what the object is doing with each: the current active state of a machine it exhibits (the state `%current` reports), the execution state of an action it performs, `not running` for a state or action it neither exhibits nor performs, or, for a named transition, the step it declares (`toggle: transition, modes.closed → modes.opened`). A behavior a redefinition renamed (`exhibit state fancyModes :>> modes`) is one execution under two names, and both rows report it. The values a running behavior owns — the attributes of the machine's own occurrence, an action's parameters and outputs — are listed under its row (`modes: exhibited state machine, current state running` followed by `count = 1`), apart from the performer's own values, and are bounded like any nested object. Reading a feature value builds the objects it holds, so the listing is bounded by default — 200 lines, nesting 8 deep — and a listing cut short says which form shows the rest. `all` lifts both bounds and reads the whole tree out; `depth ` bounds nesting at `n` levels and lifts the size bound, naming what it did not expand (`machine : Machine (not expanded: depth 1)`). `json` writes the object and everything reachable from it as one document in the shape the API's `Instantiate` returns (`instance`, `instances`, `diagnostics`), bounded by default at 1000 objects, with a graph cut short reported as a `warning` diagnostic. `all`/`depth` and `json` combine (`%features ctx all json`); `all` and `depth` together, a missing or negative depth, and an unknown word are errors naming the usage | @@ -117,6 +118,18 @@ into the parts it holds (`car.fl.hub`, `#3.fl`, `car.wheels[2]`). | `Ctrl-C` | Discard the line being typed, and any continuation buffered before it; at an empty `sysml>` prompt, exit REPL | | `Ctrl-D` | Exit REPL | +### Rendering a run + +Start a behavior with `%state` or `%action`, or advance an active run, then use `%render-run`: + +```text +%render-run timeline +%render-run sequence mermaid +``` + +The command reads the current trace and clock without changing either. Enable `%trace on` before +the run to record it; accepted forms are text, Mermaid and PlantUML. + The five solving commands (`%check`, `%explain`, `%solve`, `%configure`, `%optimize`) follow the design of the `ConstraintSolverService` in OpenMBEE's [HMF](https://github.com/hivecore-dev/hmf) (Apache 2.0); see [Acknowledgements](../../README.md#acknowledgements). `%check` asks a solver diff --git a/examples/README.md b/examples/README.md index 3907c0ca19..99ea4e336f 100644 --- a/examples/README.md +++ b/examples/README.md @@ -27,6 +27,7 @@ Each of these is a model and a walkthrough of the commands that exercise it. | Model | Walkthrough | What it demonstrates | | --- | --- | --- | | [runtime-showcase/](runtime-showcase/) | [runtime-showcase/README.md](runtime-showcase/README.md) | what only running a model answers: a recursive mass rollup over a materialized launch vehicle, a delta-v budget with units carried through to an analysis verdict, a reliability product asserted of two missions, a mission action branching on its budget and a clocked state machine — each with one well-formed construct that fails at runtime with the reason named — a spacecraft downlink whose ground station and spacecraft run on one clock through an interface, with orthogonal regions, timed do actions and a schedule choice, and the same failures found in the published Apollo 11 model | +| [run-timeline/](run-timeline/) | [run-timeline/README.md](run-timeline/README.md) | render a recorded run of two object state machines as state timelines and a message sequence, including timed transitions and a parallel-region scheduling choice | | [disposal-robot-demo/robot.sysml](disposal-robot-demo/robot.sysml) | [disposal-robot-demo/README.md](disposal-robot-demo/README.md) | one bomb-disposal robot, end to end: structure, calculations, an action with a fork/join, a branch and a nested flow, a hierarchical state machine an object exhibits, assignment through a feature chain, the solver commands, the view renderings, and [the same questions from Python](disposal-robot-demo/robot_demo.py) | | [disposal-team-demo/team.sysml](disposal-team-demo/team.sysml) | [disposal-team-demo/README.md](disposal-team-demo/README.md) | the team around that robot, written for what the robot demo does not reach: quantities with units, `select` and `reduce` over a fleet, a message crossing the connector two parts are joined by, an occurrence with a snapshot and a timeslice, and a requirement, use case, verification case and analysis case over the same subject | | [relay-probe-demo/mission.sysml](relay-probe-demo/mission.sysml) | [relay-probe-demo/README.md](relay-probe-demo/README.md) | one individual probe across its mission phases: event occurrences ordered in time, snapshots and a timeslice of one individual, occurrences with multiplicity, a calculation reading across two snapshots, a requirement whose subject is a snapshot, and a beacon inside a timeslice sending telemetry through its probe's own port | diff --git a/examples/run-timeline/README.md b/examples/run-timeline/README.md new file mode 100644 index 0000000000..c76b5a07bf --- /dev/null +++ b/examples/run-timeline/README.md @@ -0,0 +1,21 @@ +# Run timeline and message sequence + +This example runs two object state machines on one clock. The sender's parallel +transmission and power regions make a deterministic scheduling choice, then its +timed transition sends `Ping` to its receiver part. Both renderings are built +from the recorded run, not from model views. + +From the repository root: + +```bash +sysml examples/run-timeline/run-timeline.sysml \ + -instantiate RunTimeline::mission \ + -state "RunTimeline::Sender::modes RunTimeline::mission.sender" \ + -state "RunTimeline::Receiver::modes RunTimeline::mission.sender.receiver" \ + -advance 6 \ + -render-run timeline=timeline.mmd \ + -render-run sequence=sequence.puml +``` + +Use `.txt`, `.mmd`/`.mermaid`, or `.puml`/`.plantuml` for text, Mermaid, or +PlantUML output. Run renderings do not accept DOT. diff --git a/examples/run-timeline/run-timeline.sysml b/examples/run-timeline/run-timeline.sysml new file mode 100644 index 0000000000..cd63a5810c --- /dev/null +++ b/examples/run-timeline/run-timeline.sysml @@ -0,0 +1,44 @@ +package RunTimeline { + private import SI::*; + + item def Ping; + part def Receiver { + exhibit state modes { + entry; then listening; + state listening; + state received; + state timedOut; + transition first listening accept Ping then received; + transition first listening accept after 4 [s] then timedOut; + } + } + + part def Sender { + part receiver : Receiver; + exhibit state modes parallel { + state transmission { + entry; then waiting; + state waiting; + state sending { + entry send new Ping() to receiver; + } + state sent; + transition first waiting accept after 1 [s] then sending; + transition first sending accept after 2 [s] then sent; + } + state power { + entry; then charged; + state charged; + state low; + transition first charged accept after 4 [s] then low; + transition first low accept after 4 [s] then charged; + } + } + } + + part def Mission { + part sender : Sender; + } + + part mission : Mission; +} diff --git a/internal/doc/queryexec/event.go b/internal/doc/queryexec/event.go index f2bbcd4df1..d8f56795f4 100644 --- a/internal/doc/queryexec/event.go +++ b/internal/doc/queryexec/event.go @@ -50,7 +50,7 @@ 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) } +func (ev Event) Payload() []string { return ev.record.PayloadTexts() } // Alternatives are a choice's alternatives as offered, nil for any other record. func (ev Event) Alternatives() []string { diff --git a/internal/doc/queryexec/events.go b/internal/doc/queryexec/events.go index 7260dd7e88..789e403e2d 100644 --- a/internal/doc/queryexec/events.go +++ b/internal/doc/queryexec/events.go @@ -3,7 +3,6 @@ package queryexec import ( "fmt" "math" - "sort" "strconv" "strings" @@ -131,7 +130,7 @@ func (e *executor) evaluateEvents(expression queryplan.Expression) (sequence, er time: instantValue(e.context.Runtime, at), object: object, label: labels.label(object), - machine: eventMachine(record), + machine: record.Machine(), target: labels.label(record.Target), index: index, })) @@ -295,7 +294,7 @@ func (e *executor) eventPropertyValues(row Value, property string) ([]Value, boo return text(eventName(record)), true, nil case propertyPayload: out := make([]Value, 0, len(record.Payload)) - for _, cell := range payloadTexts(record) { + for _, cell := range record.PayloadTexts() { out = append(out, valueAt(StringValue(cell), origin)) } return out, true, nil @@ -317,26 +316,6 @@ func (e *executor) eventPropertyValues(row Value, property string) ([]Value, boo 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 { @@ -358,18 +337,3 @@ func eventName(record runtime.TraceRecord) string { } 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/states.go b/internal/doc/queryexec/states.go index 63a66ef0d7..7c690ee652 100644 --- a/internal/doc/queryexec/states.go +++ b/internal/doc/queryexec/states.go @@ -221,7 +221,7 @@ func activeState(inst *runtime.Instance, label string, machine *runtime.ObjectBe symbol: stateSymbol(exec.Graph(), leaf), name: leaf.Name, path: exec.StatePath(leaf), - region: regionName(exec.Graph(), leaf), + region: regionName(exec, leaf), enclosing: names, behavior: machine.Symbol, } @@ -229,14 +229,9 @@ func activeState(inst *runtime.Instance, label string, machine *runtime.ObjectBe // 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 - } +func regionName(exec *runtime.StateExecutor, state *ast.StateNode) string { + if region := exec.RegionOf(state); region != nil { + return region.Name } return "" } diff --git a/internal/exec/runtime/state_executor.go b/internal/exec/runtime/state_executor.go index 30c28efb84..55d98f1c0a 100644 --- a/internal/exec/runtime/state_executor.go +++ b/internal/exec/runtime/state_executor.go @@ -4055,7 +4055,7 @@ func (e *StateExecutor) stepDoAction(act *doAction, goOn func(*doRun) (*doRun, e e.moved = true if e.trace() != nil { - e.trace().RecordDoStep(e.traceOrigin(), act.state.Name) + e.trace().RecordDoStep(e.traceOrigin(), act.state.Name, e.StatePath(act.state), e.RegionPath(act.state)) } if run == nil { act.pending = act.pending[1:] @@ -5195,7 +5195,7 @@ func (e *StateExecutor) performEntry(state *ast.StateNode) error { // Record trace if e.trace() != nil { - e.trace().RecordStateEntry(e.traceOrigin(), state.Name, len(e.behaviorsOf(state).Entry) > 0) + e.trace().RecordStateEntry(e.traceOrigin(), state.Name, e.StatePath(state), e.RegionPath(state), len(e.behaviorsOf(state).Entry) > 0) } } @@ -5294,7 +5294,7 @@ func (e *StateExecutor) exitState(state *ast.StateNode) error { // Record trace if !e.graph.HiddenStates[state] && e.trace() != nil { - e.trace().RecordStateExit(e.traceOrigin(), state.Name, len(e.behaviorsOf(state).Exit) > 0) + e.trace().RecordStateExit(e.traceOrigin(), state.Name, e.StatePath(state), e.RegionPath(state), len(e.behaviorsOf(state).Exit) > 0) } // Execute exit actions @@ -5517,6 +5517,42 @@ func (e *StateExecutor) StatePath(state *ast.StateNode) string { return strings.Join(append(parts, state.Name), ".") } +// RegionOf returns the innermost orthogonal region a state stands in, or nil +// when the state is outside every region. +func (e *StateExecutor) RegionOf(state *ast.StateNode) *ast.StateRegion { + for current := state; current != nil; current = e.graph.ParentState[current] { + if region := e.graph.RegionOf[current]; region != nil { + return region + } + if region := e.graph.HiddenRegionOf[current]; region != nil { + return region + } + } + return nil +} + +// RegionPath qualifies the innermost region by the written states enclosing it. +func (e *StateExecutor) RegionPath(state *ast.StateNode) string { + region := e.RegionOf(state) + if region == nil { + return "" + } + owner := e.graph.RegionOwner[region] + parts := make([]string, 0) + if owner != nil { + for _, enclosing := range e.EnclosingStates(owner) { + parts = append(parts, enclosing.Name) + } + if !e.graph.HiddenStates[owner] && owner.Name != "" { + parts = append(parts, owner.Name) + } + } + if region.Name != "" { + parts = append(parts, region.Name) + } + return strings.Join(parts, ".") +} + // 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 { diff --git a/internal/exec/runtime/trace.go b/internal/exec/runtime/trace.go index dd7e82b22b..e6920bcc72 100644 --- a/internal/exec/runtime/trace.go +++ b/internal/exec/runtime/trace.go @@ -81,6 +81,9 @@ type TraceRecord struct { // State is the state entered, exited or stepped; From and To are a fired // transition's endpoints. State, From, To string + // Path qualifies a state by its written enclosing states; Region qualifies + // the innermost orthogonal region, and is empty outside every region. + Path, Region 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 @@ -137,6 +140,40 @@ func (r TraceRecord) Text() string { return r.Kind.String() + " " + r.Event } +// Machine 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 (r TraceRecord) Machine() string { + behavior := r.Origin.Behavior + if behavior == nil { + return "" + } + if r.Origin.Object != nil { + for _, b := range r.Origin.Object.Behaviors() { + if b.Symbol == behavior || (b.State != nil && b.State.StateMachineSymbol() == behavior) { + if b.Name != "" { + return b.Name + } + break + } + } + } + return behavior.Name +} + +// PayloadTexts renders a message payload as `name = value` entries in name order. +func (r TraceRecord) PayloadTexts() []string { + names := make([]string, 0, len(r.Payload)) + for name := range r.Payload { + names = append(names, name) + } + sort.Strings(names) + out := make([]string, 0, len(names)) + for _, name := range names { + out = append(out, name+" = "+FormatValue(r.Payload[name])) + } + return out +} + // 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. // @@ -323,13 +360,13 @@ func (tr *TraceRecorder) RecordStateEndedWithOccurrence(machine string, abandone } // 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}) +func (tr *TraceRecorder) RecordStateEntry(origin TraceOrigin, state, path, region string, hasEntryAction bool) { + tr.add(TraceRecord{Kind: TraceEntry, Origin: origin, State: state, Path: path, Region: region, 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}) +func (tr *TraceRecorder) RecordStateExit(origin TraceOrigin, state, path, region string, hasExitAction bool) { + tr.add(TraceRecord{Kind: TraceExit, Origin: origin, State: state, Path: path, Region: region, Action: hasExitAction}) } // RecordActionNodeEnter records a token entering the flow an action node owns, @@ -483,8 +520,8 @@ func (tr *TraceRecorder) record(entry string) { // 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}) +func (tr *TraceRecorder) RecordDoStep(origin TraceOrigin, state, path, region string) { + tr.add(TraceRecord{Kind: TraceDo, Origin: origin, State: state, Path: path, Region: region}) } // RecordEvent records an event being processed. diff --git a/internal/exec/runtime/trace_records_test.go b/internal/exec/runtime/trace_records_test.go index b81da7e437..2c78c38fbb 100644 --- a/internal/exec/runtime/trace_records_test.go +++ b/internal/exec/runtime/trace_records_test.go @@ -107,21 +107,82 @@ func TestTraceRecordsCarryTheBehaviorThatMadeThem(t *testing.T) { } } +func TestStateTraceRecordsCarryWrittenPathsAndInnermostRegions(t *testing.T) { + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, ` + package test { + attribute def Ping; + state def Machine { + entry; then open; + state open parallel { + state pointing { + entry; then slewing; + state slewing; + state settled; + transition first slewing accept Ping then settled; + } + state power { + entry; then awake; + state awake; + } + } + } + }`)) + exec, err := ctx.CreateStateExecutor(oneSymbol(t, idx, "test::Machine")) + if err != nil { + t.Fatalf("create executor: %v", err) + } + trace := NewTraceRecorder() + exec.SetTrace(trace) + if err := exec.initialize(); err != nil { + t.Fatalf("initialize: %v", err) + } + exec.SendSignal("Ping", nil) + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run: %v", err) + } + + got := make(map[string]map[TraceKind]TraceRecord) + for _, record := range trace.Records() { + if (record.Kind == TraceEntry || record.Kind == TraceExit) && + (record.State == "slewing" || record.State == "awake") { + if got[record.State] == nil { + got[record.State] = make(map[TraceKind]TraceRecord) + } + got[record.State][record.Kind] = record + } + } + for state, region := range map[string]string{"slewing": "open.pointing", "awake": "open.power"} { + record, ok := got[state][TraceEntry] + if !ok { + t.Fatalf("trace has no entry record for %s: %+v", state, trace.Records()) + } + wantPath := "open." + state + if record.Path != wantPath || record.Region != region { + t.Errorf("%s path/region = %q / %q, want %q / %q", state, record.Path, record.Region, wantPath, region) + } + } + if record, ok := got["slewing"][TraceExit]; !ok { + t.Errorf("trace has no exit record for slewing: %+v", trace.Records()) + } else if record.Path != "open.slewing" || record.Region != "open.pointing" { + t.Errorf("slewing exit path/region = %q / %q, want open.slewing / open.pointing", record.Path, record.Region) + } +} + // 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) + tr.RecordStateEntry(at(0, nil), "a", "a", "", false) + tr.RecordStateEntry(at(1, nil), "b", "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.RecordStateEntry(at(2, nil), "c", "c", "", false) + tr.RecordStateEntry(at(3, nil), "d", "d", "", false) tr.RecordAcceptAt(mark, at(2, nil), "Go", nil) states := func() string { var out []string @@ -150,7 +211,7 @@ 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) + tr.RecordStateEntry(at(float64(i)), s, s, "", false) } states := func() string { var out []string @@ -160,8 +221,8 @@ func TestEventRecorderCaptureRestoresTruncation(t *testing.T) { return strings.Join(out, " ") } capture := captureTrace(tr) - tr.RecordStateEntry(at(4), "e", false) - tr.RecordStateEntry(at(5), "f", false) + tr.RecordStateEntry(at(4), "e", "e", "", false) + tr.RecordStateEntry(at(5), "f", "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) } diff --git a/internal/exec/runtrace/render_golden_test.go b/internal/exec/runtrace/render_golden_test.go new file mode 100644 index 0000000000..4a40b5925a --- /dev/null +++ b/internal/exec/runtrace/render_golden_test.go @@ -0,0 +1,264 @@ +package runtrace + +import ( + "flag" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/ir/view" + "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" +) + +var updateRunGoldens = flag.Bool("update", false, "rewrite run-rendering goldens") + +type renderingGolden struct { + name string + form view.Form + run *view.Rendering +} + +func TestRunRenderingGoldens(t *testing.T) { + example, options := exampleRun(t) + cases := make([]renderingGolden, 0) + for _, kind := range []Kind{KindTimeline, KindSequence} { + run, err := Render(kind, example, options) + if err != nil { + t.Fatal(err) + } + cases = appendForms(cases, "example-"+string(kind), run) + empty, err := Render(kind, runtime.NewTraceRecorder(), Options{}) + if err != nil { + t.Fatal(err) + } + cases = appendForms(cases, "empty-"+string(kind), empty) + } + + cappedTimeline := longTimeline() + cappedTimelineOptions := Options{Until: 201, Limit: 200} + capped, err := Render(KindTimeline, cappedTimeline, cappedTimelineOptions) + if err != nil { + t.Fatal(err) + } + cases = append(cases, renderingGolden{name: "capped-timeline", form: view.FormText, run: capped}) + cappedSequence, err := Render(KindSequence, longSequence(), Options{Until: 201, Limit: 200}) + if err != nil { + t.Fatal(err) + } + cases = append(cases, renderingGolden{name: "capped-sequence", form: view.FormText, run: cappedSequence}) + + truncatedTimeline := truncatedTimelineTrace() + truncated, err := Render(KindTimeline, truncatedTimeline, Options{Until: 4}) + if err != nil { + t.Fatal(err) + } + cases = append(cases, renderingGolden{name: "truncated-timeline", form: view.FormText, run: truncated}) + truncatedSequence, err := Render(KindSequence, truncatedSequenceTrace(), Options{Until: 4}) + if err != nil { + t.Fatal(err) + } + cases = append(cases, renderingGolden{name: "truncated-sequence", form: view.FormText, run: truncatedSequence}) + + guard, guardOptions := guardTrace() + guardRun, err := Render(KindTimeline, guard, guardOptions) + if err != nil { + t.Fatal(err) + } + cases = append(cases, renderingGolden{name: "guard-timeline", form: view.FormText, run: guardRun}) + messages := messageEdgeCases() + messageRun, err := Render(KindSequence, messages, Options{Until: 4}) + if err != nil { + t.Fatal(err) + } + cases = append(cases, renderingGolden{name: "messages-sequence", form: view.FormText, run: messageRun}) + selfTransition, err := Render(KindTimeline, selfTransitionTrace(), Options{Until: 2}) + if err != nil { + t.Fatal(err) + } + cases = append(cases, renderingGolden{name: "self-transition-timeline", form: view.FormText, run: selfTransition}) + for _, kind := range []Kind{KindTimeline, KindSequence} { + run, err := Render(kind, example, options) + if err != nil { + t.Fatal(err) + } + cases = append(cases, renderingGolden{name: "dot-" + string(kind), form: view.FormDot, run: run}) + } + + for _, tc := range cases { + t.Run(tc.name+"."+string(tc.form), func(t *testing.T) { + got, err := tc.run.Write(tc.form) + if tc.form == view.FormDot { + if err == nil { + t.Fatal("DOT rendering unexpectedly succeeded") + } + got = err.Error() + "\n" + } else if err != nil { + t.Fatal(err) + } + path := filepath.Join("testdata", tc.name+"."+string(tc.form)+".golden") + if *updateRunGoldens { + if err := os.WriteFile(path, []byte(got), 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(want) != got { + t.Errorf("golden mismatch; run with -update to accept:\n%s", firstDifference(string(want), got)) + } + }) + } +} + +func appendForms(cases []renderingGolden, name string, run *view.Rendering) []renderingGolden { + for _, form := range []view.Form{view.FormText, view.FormMermaid, view.FormPlantUML} { + cases = append(cases, renderingGolden{name: name, form: form, run: run}) + } + return cases +} + +func exampleRun(t *testing.T) (*runtime.TraceRecorder, Options) { + t.Helper() + path := filepath.Join("..", "..", "..", "examples", "run-timeline", "run-timeline.sysml") + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + src := source.New(path, data) + file := parser.New(src).ParseFile() + index := libs.NewModelIndex() + index.AddDocument(path, file) + resolver := resolve.New(index) + ctx := runtime.NewContext(runtime.NewModel(semantics.NewModel(resolver), resolver), 100_000) + ctx.SetTrace(runtime.NewTraceRecorder()) + if err := ctx.SetSchedule(runtime.DefaultSchedulePolicy); err != nil { + t.Fatal(err) + } + packageScope := index.DocumentRoot(path).Children()[0] + _, err = ctx.Instantiate(mustSymbol(t, packageScope, "mission")) + if err != nil { + t.Fatal(err) + } + if _, err := ctx.Advance(6); err != nil { + t.Fatal(err) + } + return ctx.Trace(), Options{Until: 6} +} + +func mustSymbol(t *testing.T, scope *symbols.Scope, name string) *symbols.Symbol { + t.Helper() + if scope == nil { + t.Fatalf("no scope while looking up %q", name) + } + symbol, ok := scope.LookupLocal(name) + if !ok { + t.Fatalf("no symbol %q", name) + } + return symbol +} + +func traceOrigin(at float64, object *runtime.Instance, behavior *symbols.Symbol) runtime.TraceOrigin { + return runtime.TraceOrigin{At: at, Object: object, Behavior: behavior} +} + +func traceObject(id int64, name string) (*runtime.Instance, *symbols.Symbol) { + typeSymbol := &symbols.Symbol{Name: name} + return &runtime.Instance{ID: id, Type: typeSymbol}, &symbols.Symbol{Name: "modes"} +} + +func longTimeline() *runtime.TraceRecorder { + trace := runtime.NewTraceRecorder() + object, behavior := traceObject(1, "sender") + trace.RecordStateEntry(traceOrigin(0, object, behavior), "s0", "s0", "", false) + for at := 1; at <= 201; at++ { + from := fmt.Sprintf("s%d", at-1) + to := fmt.Sprintf("s%d", at) + trace.RecordStateExit(traceOrigin(float64(at), object, behavior), from, from, "", false) + trace.RecordStateTransition(traceOrigin(float64(at), object, behavior), from, to, "tick") + trace.RecordStateEntry(traceOrigin(float64(at), object, behavior), to, to, "", false) + } + return trace +} + +func longSequence() *runtime.TraceRecorder { + trace := runtime.NewTraceRecorder() + sender, senderBehavior := traceObject(1, "sender") + receiver, receiverBehavior := traceObject(2, "receiver") + for at := 0; at < 201; at++ { + trace.RecordSend(traceOrigin(float64(at), sender, senderBehavior), runtime.Message{SignalType: "Ping"}, receiver) + trace.RecordAccept(traceOrigin(float64(at), receiver, receiverBehavior), "Ping", nil) + } + return trace +} + +func truncatedTimelineTrace() *runtime.TraceRecorder { + trace := runtime.NewEventRecorder(2) + object, behavior := traceObject(1, "sender") + trace.RecordStateEntry(traceOrigin(0, object, behavior), "old", "old", "", false) + trace.RecordStateExit(traceOrigin(1, object, behavior), "old", "old", "", false) + trace.RecordStateEntry(traceOrigin(2, object, behavior), "next", "next", "", false) + trace.RecordStateExit(traceOrigin(3, object, behavior), "next", "next", "", false) + trace.RecordStateEntry(traceOrigin(4, object, behavior), "held", "held", "", false) + return trace +} + +func truncatedSequenceTrace() *runtime.TraceRecorder { + trace := runtime.NewEventRecorder(2) + sender, senderBehavior := traceObject(1, "sender") + receiver, receiverBehavior := traceObject(2, "receiver") + trace.RecordSend(traceOrigin(0, sender, senderBehavior), runtime.Message{SignalType: "Old"}, receiver) + trace.RecordAccept(traceOrigin(1, receiver, receiverBehavior), "Old", nil) + trace.RecordSend(traceOrigin(2, sender, senderBehavior), runtime.Message{SignalType: "Ping"}, receiver) + trace.RecordAccept(traceOrigin(3, receiver, receiverBehavior), "Ping", nil) + return trace +} + +func guardTrace() (*runtime.TraceRecorder, Options) { + trace := runtime.NewTraceRecorder() + object, behavior := traceObject(1, "sender") + origin := traceOrigin(1, object, behavior) + trace.RecordStateEntry(traceOrigin(0, object, behavior), "waiting", "waiting", "", false) + trace.RecordNote(origin, runtime.UnevaluableGuard{Where: "waiting", Alternative: "accept Ping", Reason: "unbound"}) + return trace, Options{Until: 2} +} + +func messageEdgeCases() *runtime.TraceRecorder { + trace := runtime.NewTraceRecorder() + sender, senderBehavior := traceObject(1, "sender") + receiver, receiverBehavior := traceObject(2, "receiver") + trace.RecordSend(traceOrigin(0, sender, senderBehavior), runtime.Message{SignalType: "Unmatched"}, receiver) + trace.RecordSend(traceOrigin(1, nil, nil), runtime.Message{SignalType: "Outside"}, nil) + trace.RecordSend(traceOrigin(2, sender, senderBehavior), runtime.Message{SignalType: "Broadcast"}, nil) + trace.RecordAccept(traceOrigin(2.5, receiver, receiverBehavior), "Broadcast", nil) + trace.RecordAccept(traceOrigin(3, receiver, receiverBehavior), "Missing", nil) + return trace +} + +func selfTransitionTrace() *runtime.TraceRecorder { + trace := runtime.NewTraceRecorder() + object, behavior := traceObject(1, "sender") + trace.RecordStateEntry(traceOrigin(0, object, behavior), "idle", "idle", "", false) + trace.RecordStateExit(traceOrigin(1, object, behavior), "idle", "idle", "", false) + trace.RecordStateTransition(traceOrigin(1, object, behavior), "idle", "idle", "accept Tick") + trace.RecordStateEntry(traceOrigin(1, object, behavior), "idle", "idle", "", false) + return trace +} + +func firstDifference(want, got string) string { + for i := 0; i < len(want) && i < len(got); i++ { + if want[i] != got[i] { + return fmt.Sprintf("first difference at byte %d\nwant: %q\ngot: %q", i, want[i:], got[i:]) + } + } + return fmt.Sprintf("want %d bytes, got %d", len(want), len(got)) +} diff --git a/internal/exec/runtrace/runtrace.go b/internal/exec/runtrace/runtrace.go new file mode 100644 index 0000000000..f951661119 --- /dev/null +++ b/internal/exec/runtrace/runtrace.go @@ -0,0 +1,87 @@ +package runtrace + +import ( + "errors" + "fmt" + "strings" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/ir/view" +) + +// Kind is a rendering of a recorded execution run. +type Kind string + +const ( + // KindTimeline renders state occupancy over a run's clock time. + KindTimeline Kind = "timeline" + // KindSequence renders the messages recorded during a run. + KindSequence Kind = "sequence" + // DefaultLimit bounds the number of spans or messages in a run rendering. + DefaultLimit = 200 +) + +// Kinds are the run renderings, in the order they are offered. +func Kinds() []Kind { return []Kind{KindTimeline, KindSequence} } + +// ParseKind reports whether s names a run rendering. +func ParseKind(s string) (Kind, bool) { + kind := Kind(s) + switch kind { + case KindTimeline, KindSequence: + return kind, true + } + return "", false +} + +// Options tune the labels, end instant and size of a run rendering. +type Options struct { + Label func(*runtime.Instance) string + Until float64 + Limit int +} + +// ErrNoTrace reports a session with no recorded trace. +var ErrNoTrace = errors.New("the session records no trace") + +// Render builds one run rendering from its trace. +func Render(kind Kind, trace *runtime.TraceRecorder, options Options) (*view.Rendering, error) { + if trace == nil { + return nil, ErrNoTrace + } + switch kind { + case KindTimeline: + return Timeline(trace, options), nil + case KindSequence: + return Sequence(trace, options), nil + default: + return nil, fmt.Errorf("unknown run rendering %q; ask for %s", kind, kindNames()) + } +} + +// Timeline builds a state-occupancy timeline from the trace. +func Timeline(trace *runtime.TraceRecorder, options Options) *view.Rendering { + return timeline(trace, options) +} + +// Sequence builds a message sequence from the trace. +func Sequence(trace *runtime.TraceRecorder, options Options) *view.Rendering { + return sequence(trace, options) +} + +func kindNames() string { + kinds := Kinds() + names := make([]string, len(kinds)) + for i, kind := range kinds { + names[i] = string(kind) + } + return strings.Join(names, ", ") +} + +func runRendering(kind view.Kind, options Options) *view.Rendering { + return &view.Rendering{ + Kind: kind, + Run: true, + Stated: fmt.Sprintf("the trace of a run to t = %s", runInstant(options.Until)), + } +} diff --git a/internal/exec/runtrace/runtrace_test.go b/internal/exec/runtrace/runtrace_test.go new file mode 100644 index 0000000000..ddb8ea4403 --- /dev/null +++ b/internal/exec/runtrace/runtrace_test.go @@ -0,0 +1,234 @@ +package runtrace + +import ( + "errors" + "fmt" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/ir/view" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" +) + +func TestKindParsingAndMissingTrace(t *testing.T) { + if got := fmt.Sprint(Kinds()); got != "[timeline sequence]" { + t.Fatalf("Kinds() = %s", got) + } + for _, want := range Kinds() { + got, ok := ParseKind(string(want)) + if !ok || got != want { + t.Errorf("ParseKind(%q) = %q, %t", want, got, ok) + } + } + if _, ok := ParseKind("Timeline"); ok { + t.Error("ParseKind accepted a differently cased kind") + } + if _, err := Render(KindTimeline, nil, Options{}); !errors.Is(err, ErrNoTrace) { + t.Errorf("Render with no trace = %v, want ErrNoTrace", err) + } + if _, err := Render(Kind("other"), runtime.NewEventRecorder(0), Options{}); err == nil || + !strings.Contains(err.Error(), "timeline, sequence") { + t.Errorf("Render with an unknown kind = %v", err) + } +} + +func TestTimelineGroupsStateChangesAndOrdersParallelLeaves(t *testing.T) { + trace := runtime.NewEventRecorder(0) + rover := testObject(1, "Rover") + drone := testObject(2, "Drone") + machine := &symbols.Symbol{Name: "Machine"} + otherMachine := &symbols.Symbol{Name: "Other"} + origin := func(at float64) runtime.TraceOrigin { + return runtime.TraceOrigin{At: at, Object: rover, Behavior: machine} + } + trace.RecordStateEntry(origin(0), "open", "open", "", false) + trace.RecordStateEntry(origin(0), "slewing", "open.slewing", "open.pointing", false) + trace.RecordStateEntry(origin(0), "awake", "open.awake", "open.power", false) + trace.RecordStateEntry(origin(0), "blink", "open.pointing.blink", "open.pointing", false) + trace.RecordStateExit(origin(0), "blink", "open.pointing.blink", "open.pointing", false) + trace.RecordNote(origin(1), runtime.ChoicePoint{ + Kind: runtime.ChoiceTransition, Where: "open", Alternatives: []string{"slewing", "settled"}, Taken: 0, + }) + trace.RecordStateExit(origin(1.5), "slewing", "open.slewing", "open.pointing", false) + trace.RecordStateTransition(origin(1.5), "slewing", "settled", "accept Ping") + trace.RecordStateEntry(origin(1.5), "settled", "open.settled", "open.pointing", false) + trace.RecordStateExit(origin(2), "awake", "open.awake", "open.power", false) + trace.RecordStateTransition(origin(2), "awake", "awake", "after 1 s") + trace.RecordStateEntry(origin(2), "awake", "open.awake", "open.power", false) + trace.RecordNote(runtime.TraceOrigin{At: 2.5, Object: rover, Behavior: otherMachine}, + runtime.UnevaluableGuard{Where: "open", Alternative: "stalled", Reason: "value unavailable"}) + trace.RecordStateEntry(runtime.TraceOrigin{At: 0, Object: drone, Behavior: machine}, "ready", "ready", "", false) + environmentChoice := runtime.ChoicePoint{Kind: runtime.ChoiceRegionOrder, Where: "dispatch"} + trace.RecordNote(runtime.TraceOrigin{At: 3}, environmentChoice) + + rendering := Timeline(trace, Options{ + Until: 4, + Label: func(instance *runtime.Instance) string { + if instance.ID == rover.ID { + return "rover" + } + return "drone" + }, + }) + if !rendering.Run || rendering.Kind != view.KindTimeline || rendering.Stated != "the trace of a run to t = 4" { + t.Fatalf("timeline metadata = %+v", rendering) + } + if len(rendering.Lanes) != 2 || rendering.Lanes[0].ID != "l0" || rendering.Lanes[1].ID != "l1" { + t.Fatalf("lanes = %+v, want first-record order", rendering.Lanes) + } + lane := rendering.Lanes[0] + if lane.Name != "rover.Machine" { + t.Errorf("lane name = %q", lane.Name) + } + if len(lane.Spans) != 3 { + t.Fatalf("spans = %+v, want initial, triggered and self-transition spans", lane.Spans) + } + want := []view.Span{ + {State: "slewing | awake", From: 0, To: 1.5, Through: []string{"blink"}}, + {State: "settled | awake", From: 1.5, To: 2, Triggers: []string{"accept Ping"}}, + {State: "settled | awake", From: 2, To: 4, Triggers: []string{"after 1 s"}, Open: true}, + } + for i := range want { + if got := lane.Spans[i]; got.State != want[i].State || got.From != want[i].From || got.To != want[i].To || + got.Open != want[i].Open || strings.Join(got.Triggers, "|") != strings.Join(want[i].Triggers, "|") || + strings.Join(got.Through, "|") != strings.Join(want[i].Through, "|") { + t.Errorf("span %d = %+v, want %+v", i, got, want[i]) + } + } + if len(lane.Transitions) != 2 || lane.Transitions[0].Event != "accept Ping" || + lane.Transitions[1].From != "awake" || lane.Transitions[1].To != "awake" { + t.Errorf("transitions = %+v", lane.Transitions) + } + if len(lane.Marks) != 2 || lane.Marks[0].Kind != "choice" || + lane.Marks[0].Text == "" || lane.Marks[1].Kind != "guard" || !strings.Contains(lane.Marks[1].Text, "unevaluable guard") { + t.Errorf("marks = %+v", lane.Marks) + } + if rendering.Lanes[1].Name != "drone.Machine" || len(rendering.Lanes[1].Spans) != 1 || + rendering.Lanes[1].Spans[0].State != "ready" { + t.Errorf("second lane = %+v", rendering.Lanes[1]) + } + if !strings.Contains(strings.Join(rendering.Notices, "\n"), environmentChoice.String()) { + t.Errorf("unattached mark notice = %v", rendering.Notices) + } +} + +func TestTimelineTruncationAndSpanLimit(t *testing.T) { + truncated := runtime.NewEventRecorder(3) + object := testObject(1, "Rover") + behavior := &symbols.Symbol{Name: "Machine"} + origin := func(at float64) runtime.TraceOrigin { + return runtime.TraceOrigin{At: at, Object: object, Behavior: behavior} + } + truncated.RecordStateEntry(origin(0), "lost", "lost", "", false) + truncated.RecordStateExit(origin(1), "lost", "lost", "", false) + truncated.RecordStateEntry(origin(2), "kept", "kept", "", false) + truncated.RecordStateExit(origin(3), "kept", "kept", "", false) + got := Timeline(truncated, Options{Until: 4}) + if len(got.Lanes) != 1 || len(got.Lanes[0].Spans) != 1 || + got.Lanes[0].Spans[0].State != "kept" || got.Lanes[0].Spans[0].From != 2 || + !strings.Contains(strings.Join(got.Notices, "\n"), "1 earlier records up to t = 0 were dropped") { + t.Errorf("truncated rendering = %+v", got) + } + + long := runtime.NewEventRecorder(0) + long.RecordStateEntry(origin(0), "s0", "s0", "", false) + for i := 1; i <= 202; i++ { + at := float64(i) + long.RecordStateExit(origin(at), fmt.Sprintf("s%d", i-1), fmt.Sprintf("s%d", i-1), "", false) + long.RecordStateEntry(origin(at), fmt.Sprintf("s%d", i), fmt.Sprintf("s%d", i), "", false) + } + limited := Timeline(long, Options{Until: 203, Limit: 200}) + if count := len(limited.Lanes[0].Spans); count != 200 { + t.Fatalf("limited spans = %d, want 200", count) + } + last := limited.Lanes[0].Spans[199] + if last.From != 199 || last.To != 200 || last.Open { + t.Errorf("last capped span = %+v", last) + } + if !strings.Contains(strings.Join(limited.Notices, "\n"), + "3 later state changes after t = 200 are not drawn (at most 200 spans are)") { + t.Errorf("cap notice = %v", limited.Notices) + } +} + +func TestSequencePairsMessagesAndKeepsUnmatchedEndpoints(t *testing.T) { + trace := runtime.NewEventRecorder(0) + sender := testObject(1, "Sender") + receiver := testObject(2, "Receiver") + other := testObject(3, "Other") + trace.RecordSend(runtime.TraceOrigin{At: 0, Object: sender}, runtime.Message{ + SignalType: "Ping", Payload: map[string]runtime.Value{"z": runtime.NewStringValue("last"), "a": runtime.NewStringValue("first")}, + }, receiver) + trace.RecordSend(runtime.TraceOrigin{At: 0.5, Object: sender}, runtime.Message{SignalType: "Ping"}, other) + trace.RecordAccept(runtime.TraceOrigin{At: 1, Object: other}, "Ping", nil) + trace.RecordAccept(runtime.TraceOrigin{At: 2, Object: receiver}, "Ping", nil) + trace.RecordSend(runtime.TraceOrigin{At: 3, Object: other}, runtime.Message{SignalType: "Notice"}, receiver) + trace.RecordSend(runtime.TraceOrigin{At: 4}, runtime.Message{SignalType: "Broadcast"}, other) + trace.RecordAccept(runtime.TraceOrigin{At: 5, Object: receiver}, "Pong", nil) + rendering := Sequence(trace, Options{ + Until: 6, + Label: func(instance *runtime.Instance) string { return instance.Type.Name }, + }) + if !rendering.Run || rendering.Kind != view.KindSequence || len(rendering.Edges) != 5 { + t.Fatalf("sequence = %+v", rendering) + } + wantLabels := []string{ + `t=0..2 Ping (a = "first", z = "last")`, + "t=0.5..1 Ping", + "t=3 Notice (not accepted)", + "t=4 Broadcast (not accepted)", + "t=5 Pong", + } + for i, want := range wantLabels { + edge := rendering.Edges[i] + if edge.Kind != view.EdgeFlow || edge.Label != want { + t.Errorf("edge %d = %+v, want label %q", i, edge, want) + } + } + if rendering.Edges[0].From != "n0" || rendering.Edges[0].To != "n1" || + rendering.Edges[1].From != "n0" || rendering.Edges[1].To != "n2" { + t.Errorf("paired endpoints = %+v", rendering.Edges[:2]) + } + if rendering.Edges[2].To != "n1" || rendering.Edges[3].From != "n3" || + rendering.Edges[3].To != "n2" || rendering.Edges[4].From != "n3" { + t.Errorf("unmatched endpoints = %+v", rendering.Edges[2:]) + } + if len(rendering.Roots) != 4 || + rendering.Roots[0].Kind != "object" || rendering.Roots[0].Name != "Sender" || rendering.Roots[0].Type != "Sender" || + rendering.Roots[3].Kind != "environment" || rendering.Roots[3].Name != "environment" { + t.Errorf("participants = %+v", rendering.Roots) + } +} + +func TestSequenceCapsMessagesAndReportsDroppedRecords(t *testing.T) { + object := testObject(1, "Sender") + trace := runtime.NewEventRecorder(0) + for i := 0; i < 5; i++ { + trace.RecordSend(runtime.TraceOrigin{At: float64(i), Object: object}, + runtime.Message{SignalType: "Ping"}, nil) + } + rendering := Sequence(trace, Options{Until: 5, Limit: 2}) + if len(rendering.Edges) != 2 { + t.Fatalf("edges = %d, want 2", len(rendering.Edges)) + } + notices := strings.Join(rendering.Notices, "\n") + if !strings.Contains(notices, "3 later messages, t = 2 to 4, are not drawn (at most 2 are)") { + t.Errorf("message cap notice = %s", notices) + } + truncated := runtime.NewEventRecorder(3) + for i := 0; i < 5; i++ { + truncated.RecordSend(runtime.TraceOrigin{At: float64(i), Object: object}, + runtime.Message{SignalType: "Ping"}, nil) + } + truncatedRendering := Sequence(truncated, Options{Until: 5}) + truncatedNotices := strings.Join(truncatedRendering.Notices, "\n") + if !strings.Contains(truncatedNotices, "2 earlier records up to t = 1 were dropped") || + !strings.Contains(truncatedNotices, "senders or acceptors of earlier messages may be missing") { + t.Errorf("truncation notice = %s", truncatedNotices) + } +} + +func testObject(id int64, typeName string) *runtime.Instance { + return &runtime.Instance{ID: id, Type: &symbols.Symbol{Name: typeName}} +} diff --git a/internal/exec/runtrace/sequence.go b/internal/exec/runtrace/sequence.go new file mode 100644 index 0000000000..b9e22d0da8 --- /dev/null +++ b/internal/exec/runtrace/sequence.go @@ -0,0 +1,224 @@ +package runtrace + +import ( + "fmt" + "sort" + "strings" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/ir/view" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" +) + +type sendRef struct { + index int + record runtime.TraceRecord + target int64 + used bool +} + +type sendQueue struct { + items []*sendRef + cursor int +} + +type runMessage struct { + at float64 + index int + from participant + to participant + label string +} + +type participant struct { + object *runtime.Instance + behavior *symbols.Symbol +} + +type participantKey struct { + object int64 + behavior *symbols.Symbol + ambient bool +} + +type participantSet struct { + ids map[participantKey]string + roots []*view.Node + labelFn func(*runtime.Instance) string +} + +func sequence(trace *runtime.TraceRecorder, options Options) *view.Rendering { + out := runRendering(view.KindSequence, options) + if trace == nil { + return out + } + records := trace.Records() + queues := make(map[string]map[int64]*sendQueue) + var sends []*sendRef + var messages []runMessage + for index, record := range records { + switch record.Kind { + case runtime.TraceSend: + target := int64(0) + if record.Target != nil { + target = record.Target.ID + } + ref := &sendRef{index: index, record: record, target: target} + sends = append(sends, ref) + if queues[record.Event] == nil { + queues[record.Event] = make(map[int64]*sendQueue) + } + queue := queues[record.Event][target] + if queue == nil { + queue = &sendQueue{} + queues[record.Event][target] = queue + } + queue.items = append(queue.items, ref) + case runtime.TraceAccept: + var paired *sendRef + objectID := int64(0) + if record.Origin.Object != nil { + objectID = record.Origin.Object.ID + } + if byTarget := queues[record.Event]; byTarget != nil { + broadcast := byTarget[0] + targeted := byTarget[objectID] + paired = earliestSend(broadcast, targeted) + if paired != nil { + paired.used = true + consumeSend(paired, broadcast, targeted) + } + } + if paired == nil { + messages = append(messages, runMessage{ + at: record.Origin.At, index: index, + from: participant{}, to: participant{object: record.Origin.Object, behavior: record.Origin.Behavior}, + label: messageLabel(record.Origin.At, 0, record.Event, record.PayloadTexts(), false, false), + }) + continue + } + payload := paired.record.PayloadTexts() + messages = append(messages, runMessage{ + at: paired.record.Origin.At, index: paired.index, + from: participant{object: paired.record.Origin.Object, behavior: paired.record.Origin.Behavior}, + to: participant{object: record.Origin.Object, behavior: record.Origin.Behavior}, + label: messageLabel(paired.record.Origin.At, record.Origin.At, paired.record.Event, payload, true, false), + }) + } + } + for _, send := range sends { + if send.used { + continue + } + to := participant{} + if send.record.Target != nil { + to.object = send.record.Target + } + messages = append(messages, runMessage{ + at: send.record.Origin.At, index: send.index, + from: participant{object: send.record.Origin.Object, behavior: send.record.Origin.Behavior}, + to: to, + label: messageLabel(send.record.Origin.At, 0, send.record.Event, send.record.PayloadTexts(), false, true), + }) + } + sort.SliceStable(messages, func(i, j int) bool { + if messages[i].at != messages[j].at { + return messages[i].at < messages[j].at + } + return messages[i].index < messages[j].index + }) + limit := effectiveLimit(options.Limit) + if len(messages) > limit { + later := messages[limit:] + verb := "are" + if len(later) == 1 { + verb = "is" + } + out.Notices = append(out.Notices, fmt.Sprintf("%d later %s, t = %s to %s, %s not drawn (at most %d are)", + len(later), plural(len(later), "message", "messages"), runInstant(later[0].at), runInstant(later[len(later)-1].at), verb, limit)) + messages = messages[:limit] + } + if count, upTo := trace.Dropped(); count > 0 { + out.Notices = append(out.Notices, fmt.Sprintf("%d earlier records up to t = %s were dropped; senders or acceptors of earlier messages may be missing", + count, runInstant(upTo))) + } + participants := participantSet{ids: make(map[participantKey]string), labelFn: options.Label} + for _, message := range messages { + from := participants.add(message.from) + to := participants.add(message.to) + out.Edges = append(out.Edges, view.Edge{From: from, To: to, Kind: view.EdgeFlow, Label: message.label}) + } + out.Roots = participants.roots + return out +} + +func earliestSend(first, second *sendQueue) *sendRef { + var a, b *sendRef + if first != nil && first.cursor < len(first.items) { + a = first.items[first.cursor] + } + if second != nil && second.cursor < len(second.items) { + b = second.items[second.cursor] + } + if a == nil { + return b + } + if b == nil || a.index < b.index { + return a + } + return b +} + +func consumeSend(send *sendRef, first, second *sendQueue) { + if first != nil && first.cursor < len(first.items) && first.items[first.cursor] == send { + first.cursor++ + return + } + if second != nil && second.cursor < len(second.items) && second.items[second.cursor] == send { + second.cursor++ + } +} + +func messageLabel(at, acceptedAt float64, event string, payload []string, accepted, unmatched bool) string { + time := runInstant(at) + if accepted && acceptedAt > at { + time += ".." + runInstant(acceptedAt) + } + label := "t=" + time + " " + event + if len(payload) > 0 { + label += " (" + strings.Join(payload, ", ") + ")" + } + if unmatched { + label += " (not accepted)" + } + return label +} + +func (set *participantSet) add(participant participant) string { + key := participantKey{} + node := &view.Node{} + switch { + case participant.object != nil: + key.object = participant.object.ID + node.Kind = "object" + node.Name = instanceName(participant.object, set.labelFn) + if participant.object.Type != nil { + node.Type = participant.object.Type.Name + } + case participant.behavior != nil: + key.behavior = participant.behavior + node.Kind = "behavior" + node.Name = participant.behavior.Name + default: + key.ambient = true + node.Kind = "environment" + node.Name = "environment" + } + if id, ok := set.ids[key]; ok { + return id + } + node.ID = fmt.Sprintf("n%d", len(set.roots)) + set.ids[key] = node.ID + set.roots = append(set.roots, node) + return node.ID +} diff --git a/internal/exec/runtrace/testdata/capped-sequence.text.golden b/internal/exec/runtrace/testdata/capped-sequence.text.golden new file mode 100644 index 0000000000..f32b572a2d --- /dev/null +++ b/internal/exec/runtrace/testdata/capped-sequence.text.golden @@ -0,0 +1,209 @@ +run - sequence rendering (the trace of a run to t = 201) + +object #1 : sender +object #2 : receiver + +messages: + #1 => #2: t=0 Ping + #1 => #2: t=1 Ping + #1 => #2: t=2 Ping + #1 => #2: t=3 Ping + #1 => #2: t=4 Ping + #1 => #2: t=5 Ping + #1 => #2: t=6 Ping + #1 => #2: t=7 Ping + #1 => #2: t=8 Ping + #1 => #2: t=9 Ping + #1 => #2: t=10 Ping + #1 => #2: t=11 Ping + #1 => #2: t=12 Ping + #1 => #2: t=13 Ping + #1 => #2: t=14 Ping + #1 => #2: t=15 Ping + #1 => #2: t=16 Ping + #1 => #2: t=17 Ping + #1 => #2: t=18 Ping + #1 => #2: t=19 Ping + #1 => #2: t=20 Ping + #1 => #2: t=21 Ping + #1 => #2: t=22 Ping + #1 => #2: t=23 Ping + #1 => #2: t=24 Ping + #1 => #2: t=25 Ping + #1 => #2: t=26 Ping + #1 => #2: t=27 Ping + #1 => #2: t=28 Ping + #1 => #2: t=29 Ping + #1 => #2: t=30 Ping + #1 => #2: t=31 Ping + #1 => #2: t=32 Ping + #1 => #2: t=33 Ping + #1 => #2: t=34 Ping + #1 => #2: t=35 Ping + #1 => #2: t=36 Ping + #1 => #2: t=37 Ping + #1 => #2: t=38 Ping + #1 => #2: t=39 Ping + #1 => #2: t=40 Ping + #1 => #2: t=41 Ping + #1 => #2: t=42 Ping + #1 => #2: t=43 Ping + #1 => #2: t=44 Ping + #1 => #2: t=45 Ping + #1 => #2: t=46 Ping + #1 => #2: t=47 Ping + #1 => #2: t=48 Ping + #1 => #2: t=49 Ping + #1 => #2: t=50 Ping + #1 => #2: t=51 Ping + #1 => #2: t=52 Ping + #1 => #2: t=53 Ping + #1 => #2: t=54 Ping + #1 => #2: t=55 Ping + #1 => #2: t=56 Ping + #1 => #2: t=57 Ping + #1 => #2: t=58 Ping + #1 => #2: t=59 Ping + #1 => #2: t=60 Ping + #1 => #2: t=61 Ping + #1 => #2: t=62 Ping + #1 => #2: t=63 Ping + #1 => #2: t=64 Ping + #1 => #2: t=65 Ping + #1 => #2: t=66 Ping + #1 => #2: t=67 Ping + #1 => #2: t=68 Ping + #1 => #2: t=69 Ping + #1 => #2: t=70 Ping + #1 => #2: t=71 Ping + #1 => #2: t=72 Ping + #1 => #2: t=73 Ping + #1 => #2: t=74 Ping + #1 => #2: t=75 Ping + #1 => #2: t=76 Ping + #1 => #2: t=77 Ping + #1 => #2: t=78 Ping + #1 => #2: t=79 Ping + #1 => #2: t=80 Ping + #1 => #2: t=81 Ping + #1 => #2: t=82 Ping + #1 => #2: t=83 Ping + #1 => #2: t=84 Ping + #1 => #2: t=85 Ping + #1 => #2: t=86 Ping + #1 => #2: t=87 Ping + #1 => #2: t=88 Ping + #1 => #2: t=89 Ping + #1 => #2: t=90 Ping + #1 => #2: t=91 Ping + #1 => #2: t=92 Ping + #1 => #2: t=93 Ping + #1 => #2: t=94 Ping + #1 => #2: t=95 Ping + #1 => #2: t=96 Ping + #1 => #2: t=97 Ping + #1 => #2: t=98 Ping + #1 => #2: t=99 Ping + #1 => #2: t=100 Ping + #1 => #2: t=101 Ping + #1 => #2: t=102 Ping + #1 => #2: t=103 Ping + #1 => #2: t=104 Ping + #1 => #2: t=105 Ping + #1 => #2: t=106 Ping + #1 => #2: t=107 Ping + #1 => #2: t=108 Ping + #1 => #2: t=109 Ping + #1 => #2: t=110 Ping + #1 => #2: t=111 Ping + #1 => #2: t=112 Ping + #1 => #2: t=113 Ping + #1 => #2: t=114 Ping + #1 => #2: t=115 Ping + #1 => #2: t=116 Ping + #1 => #2: t=117 Ping + #1 => #2: t=118 Ping + #1 => #2: t=119 Ping + #1 => #2: t=120 Ping + #1 => #2: t=121 Ping + #1 => #2: t=122 Ping + #1 => #2: t=123 Ping + #1 => #2: t=124 Ping + #1 => #2: t=125 Ping + #1 => #2: t=126 Ping + #1 => #2: t=127 Ping + #1 => #2: t=128 Ping + #1 => #2: t=129 Ping + #1 => #2: t=130 Ping + #1 => #2: t=131 Ping + #1 => #2: t=132 Ping + #1 => #2: t=133 Ping + #1 => #2: t=134 Ping + #1 => #2: t=135 Ping + #1 => #2: t=136 Ping + #1 => #2: t=137 Ping + #1 => #2: t=138 Ping + #1 => #2: t=139 Ping + #1 => #2: t=140 Ping + #1 => #2: t=141 Ping + #1 => #2: t=142 Ping + #1 => #2: t=143 Ping + #1 => #2: t=144 Ping + #1 => #2: t=145 Ping + #1 => #2: t=146 Ping + #1 => #2: t=147 Ping + #1 => #2: t=148 Ping + #1 => #2: t=149 Ping + #1 => #2: t=150 Ping + #1 => #2: t=151 Ping + #1 => #2: t=152 Ping + #1 => #2: t=153 Ping + #1 => #2: t=154 Ping + #1 => #2: t=155 Ping + #1 => #2: t=156 Ping + #1 => #2: t=157 Ping + #1 => #2: t=158 Ping + #1 => #2: t=159 Ping + #1 => #2: t=160 Ping + #1 => #2: t=161 Ping + #1 => #2: t=162 Ping + #1 => #2: t=163 Ping + #1 => #2: t=164 Ping + #1 => #2: t=165 Ping + #1 => #2: t=166 Ping + #1 => #2: t=167 Ping + #1 => #2: t=168 Ping + #1 => #2: t=169 Ping + #1 => #2: t=170 Ping + #1 => #2: t=171 Ping + #1 => #2: t=172 Ping + #1 => #2: t=173 Ping + #1 => #2: t=174 Ping + #1 => #2: t=175 Ping + #1 => #2: t=176 Ping + #1 => #2: t=177 Ping + #1 => #2: t=178 Ping + #1 => #2: t=179 Ping + #1 => #2: t=180 Ping + #1 => #2: t=181 Ping + #1 => #2: t=182 Ping + #1 => #2: t=183 Ping + #1 => #2: t=184 Ping + #1 => #2: t=185 Ping + #1 => #2: t=186 Ping + #1 => #2: t=187 Ping + #1 => #2: t=188 Ping + #1 => #2: t=189 Ping + #1 => #2: t=190 Ping + #1 => #2: t=191 Ping + #1 => #2: t=192 Ping + #1 => #2: t=193 Ping + #1 => #2: t=194 Ping + #1 => #2: t=195 Ping + #1 => #2: t=196 Ping + #1 => #2: t=197 Ping + #1 => #2: t=198 Ping + #1 => #2: t=199 Ping + +not represented: + - 1 later message, t = 200 to 200, is not drawn (at most 200 are) diff --git a/internal/exec/runtrace/testdata/capped-timeline.text.golden b/internal/exec/runtrace/testdata/capped-timeline.text.golden new file mode 100644 index 0000000000..4c254db10a --- /dev/null +++ b/internal/exec/runtrace/testdata/capped-timeline.text.golden @@ -0,0 +1,407 @@ +run - timeline rendering (the trace of a run to t = 201) + +#1.modes + 0 .. 1 s0 + 1 .. 2 s1 on tick + 2 .. 3 s2 on tick + 3 .. 4 s3 on tick + 4 .. 5 s4 on tick + 5 .. 6 s5 on tick + 6 .. 7 s6 on tick + 7 .. 8 s7 on tick + 8 .. 9 s8 on tick + 9 .. 10 s9 on tick + 10 .. 11 s10 on tick + 11 .. 12 s11 on tick + 12 .. 13 s12 on tick + 13 .. 14 s13 on tick + 14 .. 15 s14 on tick + 15 .. 16 s15 on tick + 16 .. 17 s16 on tick + 17 .. 18 s17 on tick + 18 .. 19 s18 on tick + 19 .. 20 s19 on tick + 20 .. 21 s20 on tick + 21 .. 22 s21 on tick + 22 .. 23 s22 on tick + 23 .. 24 s23 on tick + 24 .. 25 s24 on tick + 25 .. 26 s25 on tick + 26 .. 27 s26 on tick + 27 .. 28 s27 on tick + 28 .. 29 s28 on tick + 29 .. 30 s29 on tick + 30 .. 31 s30 on tick + 31 .. 32 s31 on tick + 32 .. 33 s32 on tick + 33 .. 34 s33 on tick + 34 .. 35 s34 on tick + 35 .. 36 s35 on tick + 36 .. 37 s36 on tick + 37 .. 38 s37 on tick + 38 .. 39 s38 on tick + 39 .. 40 s39 on tick + 40 .. 41 s40 on tick + 41 .. 42 s41 on tick + 42 .. 43 s42 on tick + 43 .. 44 s43 on tick + 44 .. 45 s44 on tick + 45 .. 46 s45 on tick + 46 .. 47 s46 on tick + 47 .. 48 s47 on tick + 48 .. 49 s48 on tick + 49 .. 50 s49 on tick + 50 .. 51 s50 on tick + 51 .. 52 s51 on tick + 52 .. 53 s52 on tick + 53 .. 54 s53 on tick + 54 .. 55 s54 on tick + 55 .. 56 s55 on tick + 56 .. 57 s56 on tick + 57 .. 58 s57 on tick + 58 .. 59 s58 on tick + 59 .. 60 s59 on tick + 60 .. 61 s60 on tick + 61 .. 62 s61 on tick + 62 .. 63 s62 on tick + 63 .. 64 s63 on tick + 64 .. 65 s64 on tick + 65 .. 66 s65 on tick + 66 .. 67 s66 on tick + 67 .. 68 s67 on tick + 68 .. 69 s68 on tick + 69 .. 70 s69 on tick + 70 .. 71 s70 on tick + 71 .. 72 s71 on tick + 72 .. 73 s72 on tick + 73 .. 74 s73 on tick + 74 .. 75 s74 on tick + 75 .. 76 s75 on tick + 76 .. 77 s76 on tick + 77 .. 78 s77 on tick + 78 .. 79 s78 on tick + 79 .. 80 s79 on tick + 80 .. 81 s80 on tick + 81 .. 82 s81 on tick + 82 .. 83 s82 on tick + 83 .. 84 s83 on tick + 84 .. 85 s84 on tick + 85 .. 86 s85 on tick + 86 .. 87 s86 on tick + 87 .. 88 s87 on tick + 88 .. 89 s88 on tick + 89 .. 90 s89 on tick + 90 .. 91 s90 on tick + 91 .. 92 s91 on tick + 92 .. 93 s92 on tick + 93 .. 94 s93 on tick + 94 .. 95 s94 on tick + 95 .. 96 s95 on tick + 96 .. 97 s96 on tick + 97 .. 98 s97 on tick + 98 .. 99 s98 on tick + 99 .. 100 s99 on tick + 100 .. 101 s100 on tick + 101 .. 102 s101 on tick + 102 .. 103 s102 on tick + 103 .. 104 s103 on tick + 104 .. 105 s104 on tick + 105 .. 106 s105 on tick + 106 .. 107 s106 on tick + 107 .. 108 s107 on tick + 108 .. 109 s108 on tick + 109 .. 110 s109 on tick + 110 .. 111 s110 on tick + 111 .. 112 s111 on tick + 112 .. 113 s112 on tick + 113 .. 114 s113 on tick + 114 .. 115 s114 on tick + 115 .. 116 s115 on tick + 116 .. 117 s116 on tick + 117 .. 118 s117 on tick + 118 .. 119 s118 on tick + 119 .. 120 s119 on tick + 120 .. 121 s120 on tick + 121 .. 122 s121 on tick + 122 .. 123 s122 on tick + 123 .. 124 s123 on tick + 124 .. 125 s124 on tick + 125 .. 126 s125 on tick + 126 .. 127 s126 on tick + 127 .. 128 s127 on tick + 128 .. 129 s128 on tick + 129 .. 130 s129 on tick + 130 .. 131 s130 on tick + 131 .. 132 s131 on tick + 132 .. 133 s132 on tick + 133 .. 134 s133 on tick + 134 .. 135 s134 on tick + 135 .. 136 s135 on tick + 136 .. 137 s136 on tick + 137 .. 138 s137 on tick + 138 .. 139 s138 on tick + 139 .. 140 s139 on tick + 140 .. 141 s140 on tick + 141 .. 142 s141 on tick + 142 .. 143 s142 on tick + 143 .. 144 s143 on tick + 144 .. 145 s144 on tick + 145 .. 146 s145 on tick + 146 .. 147 s146 on tick + 147 .. 148 s147 on tick + 148 .. 149 s148 on tick + 149 .. 150 s149 on tick + 150 .. 151 s150 on tick + 151 .. 152 s151 on tick + 152 .. 153 s152 on tick + 153 .. 154 s153 on tick + 154 .. 155 s154 on tick + 155 .. 156 s155 on tick + 156 .. 157 s156 on tick + 157 .. 158 s157 on tick + 158 .. 159 s158 on tick + 159 .. 160 s159 on tick + 160 .. 161 s160 on tick + 161 .. 162 s161 on tick + 162 .. 163 s162 on tick + 163 .. 164 s163 on tick + 164 .. 165 s164 on tick + 165 .. 166 s165 on tick + 166 .. 167 s166 on tick + 167 .. 168 s167 on tick + 168 .. 169 s168 on tick + 169 .. 170 s169 on tick + 170 .. 171 s170 on tick + 171 .. 172 s171 on tick + 172 .. 173 s172 on tick + 173 .. 174 s173 on tick + 174 .. 175 s174 on tick + 175 .. 176 s175 on tick + 176 .. 177 s176 on tick + 177 .. 178 s177 on tick + 178 .. 179 s178 on tick + 179 .. 180 s179 on tick + 180 .. 181 s180 on tick + 181 .. 182 s181 on tick + 182 .. 183 s182 on tick + 183 .. 184 s183 on tick + 184 .. 185 s184 on tick + 185 .. 186 s185 on tick + 186 .. 187 s186 on tick + 187 .. 188 s187 on tick + 188 .. 189 s188 on tick + 189 .. 190 s189 on tick + 190 .. 191 s190 on tick + 191 .. 192 s191 on tick + 192 .. 193 s192 on tick + 193 .. 194 s193 on tick + 194 .. 195 s194 on tick + 195 .. 196 s195 on tick + 196 .. 197 s196 on tick + 197 .. 198 s197 on tick + 198 .. 199 s198 on tick + 199 .. 200 s199 on tick + transitions: + t=1 s0 -> s1 (tick) + t=2 s1 -> s2 (tick) + t=3 s2 -> s3 (tick) + t=4 s3 -> s4 (tick) + t=5 s4 -> s5 (tick) + t=6 s5 -> s6 (tick) + t=7 s6 -> s7 (tick) + t=8 s7 -> s8 (tick) + t=9 s8 -> s9 (tick) + t=10 s9 -> s10 (tick) + t=11 s10 -> s11 (tick) + t=12 s11 -> s12 (tick) + t=13 s12 -> s13 (tick) + t=14 s13 -> s14 (tick) + t=15 s14 -> s15 (tick) + t=16 s15 -> s16 (tick) + t=17 s16 -> s17 (tick) + t=18 s17 -> s18 (tick) + t=19 s18 -> s19 (tick) + t=20 s19 -> s20 (tick) + t=21 s20 -> s21 (tick) + t=22 s21 -> s22 (tick) + t=23 s22 -> s23 (tick) + t=24 s23 -> s24 (tick) + t=25 s24 -> s25 (tick) + t=26 s25 -> s26 (tick) + t=27 s26 -> s27 (tick) + t=28 s27 -> s28 (tick) + t=29 s28 -> s29 (tick) + t=30 s29 -> s30 (tick) + t=31 s30 -> s31 (tick) + t=32 s31 -> s32 (tick) + t=33 s32 -> s33 (tick) + t=34 s33 -> s34 (tick) + t=35 s34 -> s35 (tick) + t=36 s35 -> s36 (tick) + t=37 s36 -> s37 (tick) + t=38 s37 -> s38 (tick) + t=39 s38 -> s39 (tick) + t=40 s39 -> s40 (tick) + t=41 s40 -> s41 (tick) + t=42 s41 -> s42 (tick) + t=43 s42 -> s43 (tick) + t=44 s43 -> s44 (tick) + t=45 s44 -> s45 (tick) + t=46 s45 -> s46 (tick) + t=47 s46 -> s47 (tick) + t=48 s47 -> s48 (tick) + t=49 s48 -> s49 (tick) + t=50 s49 -> s50 (tick) + t=51 s50 -> s51 (tick) + t=52 s51 -> s52 (tick) + t=53 s52 -> s53 (tick) + t=54 s53 -> s54 (tick) + t=55 s54 -> s55 (tick) + t=56 s55 -> s56 (tick) + t=57 s56 -> s57 (tick) + t=58 s57 -> s58 (tick) + t=59 s58 -> s59 (tick) + t=60 s59 -> s60 (tick) + t=61 s60 -> s61 (tick) + t=62 s61 -> s62 (tick) + t=63 s62 -> s63 (tick) + t=64 s63 -> s64 (tick) + t=65 s64 -> s65 (tick) + t=66 s65 -> s66 (tick) + t=67 s66 -> s67 (tick) + t=68 s67 -> s68 (tick) + t=69 s68 -> s69 (tick) + t=70 s69 -> s70 (tick) + t=71 s70 -> s71 (tick) + t=72 s71 -> s72 (tick) + t=73 s72 -> s73 (tick) + t=74 s73 -> s74 (tick) + t=75 s74 -> s75 (tick) + t=76 s75 -> s76 (tick) + t=77 s76 -> s77 (tick) + t=78 s77 -> s78 (tick) + t=79 s78 -> s79 (tick) + t=80 s79 -> s80 (tick) + t=81 s80 -> s81 (tick) + t=82 s81 -> s82 (tick) + t=83 s82 -> s83 (tick) + t=84 s83 -> s84 (tick) + t=85 s84 -> s85 (tick) + t=86 s85 -> s86 (tick) + t=87 s86 -> s87 (tick) + t=88 s87 -> s88 (tick) + t=89 s88 -> s89 (tick) + t=90 s89 -> s90 (tick) + t=91 s90 -> s91 (tick) + t=92 s91 -> s92 (tick) + t=93 s92 -> s93 (tick) + t=94 s93 -> s94 (tick) + t=95 s94 -> s95 (tick) + t=96 s95 -> s96 (tick) + t=97 s96 -> s97 (tick) + t=98 s97 -> s98 (tick) + t=99 s98 -> s99 (tick) + t=100 s99 -> s100 (tick) + t=101 s100 -> s101 (tick) + t=102 s101 -> s102 (tick) + t=103 s102 -> s103 (tick) + t=104 s103 -> s104 (tick) + t=105 s104 -> s105 (tick) + t=106 s105 -> s106 (tick) + t=107 s106 -> s107 (tick) + t=108 s107 -> s108 (tick) + t=109 s108 -> s109 (tick) + t=110 s109 -> s110 (tick) + t=111 s110 -> s111 (tick) + t=112 s111 -> s112 (tick) + t=113 s112 -> s113 (tick) + t=114 s113 -> s114 (tick) + t=115 s114 -> s115 (tick) + t=116 s115 -> s116 (tick) + t=117 s116 -> s117 (tick) + t=118 s117 -> s118 (tick) + t=119 s118 -> s119 (tick) + t=120 s119 -> s120 (tick) + t=121 s120 -> s121 (tick) + t=122 s121 -> s122 (tick) + t=123 s122 -> s123 (tick) + t=124 s123 -> s124 (tick) + t=125 s124 -> s125 (tick) + t=126 s125 -> s126 (tick) + t=127 s126 -> s127 (tick) + t=128 s127 -> s128 (tick) + t=129 s128 -> s129 (tick) + t=130 s129 -> s130 (tick) + t=131 s130 -> s131 (tick) + t=132 s131 -> s132 (tick) + t=133 s132 -> s133 (tick) + t=134 s133 -> s134 (tick) + t=135 s134 -> s135 (tick) + t=136 s135 -> s136 (tick) + t=137 s136 -> s137 (tick) + t=138 s137 -> s138 (tick) + t=139 s138 -> s139 (tick) + t=140 s139 -> s140 (tick) + t=141 s140 -> s141 (tick) + t=142 s141 -> s142 (tick) + t=143 s142 -> s143 (tick) + t=144 s143 -> s144 (tick) + t=145 s144 -> s145 (tick) + t=146 s145 -> s146 (tick) + t=147 s146 -> s147 (tick) + t=148 s147 -> s148 (tick) + t=149 s148 -> s149 (tick) + t=150 s149 -> s150 (tick) + t=151 s150 -> s151 (tick) + t=152 s151 -> s152 (tick) + t=153 s152 -> s153 (tick) + t=154 s153 -> s154 (tick) + t=155 s154 -> s155 (tick) + t=156 s155 -> s156 (tick) + t=157 s156 -> s157 (tick) + t=158 s157 -> s158 (tick) + t=159 s158 -> s159 (tick) + t=160 s159 -> s160 (tick) + t=161 s160 -> s161 (tick) + t=162 s161 -> s162 (tick) + t=163 s162 -> s163 (tick) + t=164 s163 -> s164 (tick) + t=165 s164 -> s165 (tick) + t=166 s165 -> s166 (tick) + t=167 s166 -> s167 (tick) + t=168 s167 -> s168 (tick) + t=169 s168 -> s169 (tick) + t=170 s169 -> s170 (tick) + t=171 s170 -> s171 (tick) + t=172 s171 -> s172 (tick) + t=173 s172 -> s173 (tick) + t=174 s173 -> s174 (tick) + t=175 s174 -> s175 (tick) + t=176 s175 -> s176 (tick) + t=177 s176 -> s177 (tick) + t=178 s177 -> s178 (tick) + t=179 s178 -> s179 (tick) + t=180 s179 -> s180 (tick) + t=181 s180 -> s181 (tick) + t=182 s181 -> s182 (tick) + t=183 s182 -> s183 (tick) + t=184 s183 -> s184 (tick) + t=185 s184 -> s185 (tick) + t=186 s185 -> s186 (tick) + t=187 s186 -> s187 (tick) + t=188 s187 -> s188 (tick) + t=189 s188 -> s189 (tick) + t=190 s189 -> s190 (tick) + t=191 s190 -> s191 (tick) + t=192 s191 -> s192 (tick) + t=193 s192 -> s193 (tick) + t=194 s193 -> s194 (tick) + t=195 s194 -> s195 (tick) + t=196 s195 -> s196 (tick) + t=197 s196 -> s197 (tick) + t=198 s197 -> s198 (tick) + t=199 s198 -> s199 (tick) + t=200 s199 -> s200 (tick) + +not represented: + - 2 later state changes after t = 200 are not drawn (at most 200 spans are) diff --git a/internal/exec/runtrace/testdata/dot-sequence.dot.golden b/internal/exec/runtrace/testdata/dot-sequence.dot.golden new file mode 100644 index 0000000000..0c5ca9ecb1 --- /dev/null +++ b/internal/exec/runtrace/testdata/dot-sequence.dot.golden @@ -0,0 +1 @@ +a sequence rendering is not written as dot; ask for text, mermaid or plantuml diff --git a/internal/exec/runtrace/testdata/dot-timeline.dot.golden b/internal/exec/runtrace/testdata/dot-timeline.dot.golden new file mode 100644 index 0000000000..a98d2944b7 --- /dev/null +++ b/internal/exec/runtrace/testdata/dot-timeline.dot.golden @@ -0,0 +1 @@ +a timeline rendering is not written as dot; ask for text, mermaid or plantuml diff --git a/internal/exec/runtrace/testdata/empty-sequence.mermaid.golden b/internal/exec/runtrace/testdata/empty-sequence.mermaid.golden new file mode 100644 index 0000000000..5b594f1808 --- /dev/null +++ b/internal/exec/runtrace/testdata/empty-sequence.mermaid.golden @@ -0,0 +1,35 @@ +--- +config: + fontFamily: "Helvetica, Arial, sans-serif" + theme: base + themeVariables: + fontFamily: "Helvetica, Arial, sans-serif" + fontSize: "14px" + primaryColor: "#FFFFFF" + secondaryColor: "#FFFFFF" + tertiaryColor: "#FFFFFF" + background: "#FFFFFF" + primaryBorderColor: "#181818" + primaryTextColor: "#000000" + lineColor: "#181818" + textColor: "#000000" + noteBkgColor: "#FEFFDD" + noteBorderColor: "#181818" + noteTextColor: "#000000" + actorBkg: "#FFFFFF" + actorBorder: "#181818" + actorTextColor: "#000000" + actorLineColor: "#181818" + signalColor: "#181818" + signalTextColor: "#000000" + labelBoxBkgColor: "#FFFFFF" + labelBoxBorderColor: "#181818" + labelTextColor: "#000000" + loopTextColor: "#000000" + activationBorderColor: "#181818" + activationBkgColor: "#FFFFFF" + sequenceNumberColor: "#000000" +--- +%% run — sequence rendering (the trace of a run to t = 0) +sequenceDiagram + participant empty as the run recorded no message#59; the rendering is empty diff --git a/internal/exec/runtrace/testdata/empty-sequence.plantuml.golden b/internal/exec/runtrace/testdata/empty-sequence.plantuml.golden new file mode 100644 index 0000000000..11571b7044 --- /dev/null +++ b/internal/exec/runtrace/testdata/empty-sequence.plantuml.golden @@ -0,0 +1,44 @@ +@startuml +' run — sequence rendering (the trace of a run to t = 0) + +skinparam wrapWidth 300 +hide stereotype +participant "the run recorded no message; the rendering is empty" as empty +@enduml diff --git a/internal/exec/runtrace/testdata/empty-sequence.text.golden b/internal/exec/runtrace/testdata/empty-sequence.text.golden new file mode 100644 index 0000000000..e15a123751 --- /dev/null +++ b/internal/exec/runtrace/testdata/empty-sequence.text.golden @@ -0,0 +1,3 @@ +run - sequence rendering (the trace of a run to t = 0) + +the run recorded no message; the rendering is empty diff --git a/internal/exec/runtrace/testdata/empty-timeline.mermaid.golden b/internal/exec/runtrace/testdata/empty-timeline.mermaid.golden new file mode 100644 index 0000000000..ac38284459 --- /dev/null +++ b/internal/exec/runtrace/testdata/empty-timeline.mermaid.golden @@ -0,0 +1,31 @@ +--- +config: + fontFamily: "Helvetica, Arial, sans-serif" + theme: base + themeCSS: ".edgeLabel rect { opacity: 1 !important; } .cluster-label .nodeLabel { text-align: center; }" + gantt: + displayMode: compact + themeVariables: + fontFamily: "Helvetica, Arial, sans-serif" + fontSize: "14px" + primaryColor: "#FFFFFF" + secondaryColor: "#FFFFFF" + tertiaryColor: "#FFFFFF" + background: "#FFFFFF" + primaryBorderColor: "#181818" + primaryTextColor: "#000000" + lineColor: "#181818" + textColor: "#000000" + noteBkgColor: "#FEFFDD" + noteBorderColor: "#181818" + noteTextColor: "#000000" + clusterBkg: "#FFFFFF" + clusterBorder: "#181818" + edgeLabelBackground: "#FFFFFF" +--- +%% run — timeline rendering (the trace of a run to t = 0) +%% the run recorded no state; the rendering is empty +gantt + dateFormat x + axisFormat %M:%S.%L + todayMarker off diff --git a/internal/exec/runtrace/testdata/empty-timeline.plantuml.golden b/internal/exec/runtrace/testdata/empty-timeline.plantuml.golden new file mode 100644 index 0000000000..5c8107793d --- /dev/null +++ b/internal/exec/runtrace/testdata/empty-timeline.plantuml.golden @@ -0,0 +1,4 @@ +@startuml +' run — timeline rendering (the trace of a run to t = 0) +' the run recorded no state; the rendering is empty +@enduml diff --git a/internal/exec/runtrace/testdata/empty-timeline.text.golden b/internal/exec/runtrace/testdata/empty-timeline.text.golden new file mode 100644 index 0000000000..5bd7f8acad --- /dev/null +++ b/internal/exec/runtrace/testdata/empty-timeline.text.golden @@ -0,0 +1,3 @@ +run - timeline rendering (the trace of a run to t = 0) + +the run recorded no state; the rendering is empty diff --git a/internal/exec/runtrace/testdata/example-sequence.mermaid.golden b/internal/exec/runtrace/testdata/example-sequence.mermaid.golden new file mode 100644 index 0000000000..e239eb4d28 --- /dev/null +++ b/internal/exec/runtrace/testdata/example-sequence.mermaid.golden @@ -0,0 +1,37 @@ +--- +config: + fontFamily: "Helvetica, Arial, sans-serif" + theme: base + themeVariables: + fontFamily: "Helvetica, Arial, sans-serif" + fontSize: "14px" + primaryColor: "#FFFFFF" + secondaryColor: "#FFFFFF" + tertiaryColor: "#FFFFFF" + background: "#FFFFFF" + primaryBorderColor: "#181818" + primaryTextColor: "#000000" + lineColor: "#181818" + textColor: "#000000" + noteBkgColor: "#FEFFDD" + noteBorderColor: "#181818" + noteTextColor: "#000000" + actorBkg: "#FFFFFF" + actorBorder: "#181818" + actorTextColor: "#000000" + actorLineColor: "#181818" + signalColor: "#181818" + signalTextColor: "#000000" + labelBoxBkgColor: "#FFFFFF" + labelBoxBorderColor: "#181818" + labelTextColor: "#000000" + loopTextColor: "#000000" + activationBorderColor: "#181818" + activationBkgColor: "#FFFFFF" + sequenceNumberColor: "#000000" +--- +%% run — sequence rendering (the trace of a run to t = 6) +sequenceDiagram + participant n0 as «object»
#35;2 : Sender + participant n1 as «object»
#35;4 : Receiver + n0->>n1: t=1 Ping diff --git a/internal/exec/runtrace/testdata/example-sequence.plantuml.golden b/internal/exec/runtrace/testdata/example-sequence.plantuml.golden new file mode 100644 index 0000000000..c7d6cf2225 --- /dev/null +++ b/internal/exec/runtrace/testdata/example-sequence.plantuml.golden @@ -0,0 +1,46 @@ +@startuml +' run — sequence rendering (the trace of a run to t = 6) + +skinparam wrapWidth 300 +hide stereotype +participant "//«object»//\n**#2 : Sender**" as n0 <> <> +participant "//«object»//\n**#4 : Receiver**" as n1 <> <> +n0 -> n1 : t=1 Ping +@enduml diff --git a/internal/exec/runtrace/testdata/example-sequence.text.golden b/internal/exec/runtrace/testdata/example-sequence.text.golden new file mode 100644 index 0000000000..dc18913ef7 --- /dev/null +++ b/internal/exec/runtrace/testdata/example-sequence.text.golden @@ -0,0 +1,7 @@ +run - sequence rendering (the trace of a run to t = 6) + +object #2 : Sender +object #4 : Receiver + +messages: + #2 => #4: t=1 Ping diff --git a/internal/exec/runtrace/testdata/example-timeline.mermaid.golden b/internal/exec/runtrace/testdata/example-timeline.mermaid.golden new file mode 100644 index 0000000000..98adebe147 --- /dev/null +++ b/internal/exec/runtrace/testdata/example-timeline.mermaid.golden @@ -0,0 +1,40 @@ +--- +config: + fontFamily: "Helvetica, Arial, sans-serif" + theme: base + themeCSS: ".edgeLabel rect { opacity: 1 !important; } .cluster-label .nodeLabel { text-align: center; }" + gantt: + displayMode: compact + themeVariables: + fontFamily: "Helvetica, Arial, sans-serif" + fontSize: "14px" + primaryColor: "#FFFFFF" + secondaryColor: "#FFFFFF" + tertiaryColor: "#FFFFFF" + background: "#FFFFFF" + primaryBorderColor: "#181818" + primaryTextColor: "#000000" + lineColor: "#181818" + textColor: "#000000" + noteBkgColor: "#FEFFDD" + noteBorderColor: "#181818" + noteTextColor: "#000000" + clusterBkg: "#FFFFFF" + clusterBorder: "#181818" + edgeLabelBackground: "#FFFFFF" +--- +%% run — timeline rendering (the trace of a run to t = 6) +%% not represented: Mermaid gantt draws no note; choice and guard records are comments +%% t=0 choice entering modes: next waiting(entry), charged(entry) (unordered; took waiting(entry) first) +gantt + dateFormat x + axisFormat %M:%S.%L + todayMarker off + section #35;2.modes + waiting | charged :l0s0, 0, 1000 + sending | charged (time) :l0s1, 1000, 3000 + sent | charged (time) :l0s2, 3000, 4000 + sent | low (time) :active, l0s3, 4000, 6000 + section #35;4.modes + listening :l1s0, 0, 1000 + received (accept Ping) :active, l1s1, 1000, 6000 diff --git a/internal/exec/runtrace/testdata/example-timeline.plantuml.golden b/internal/exec/runtrace/testdata/example-timeline.plantuml.golden new file mode 100644 index 0000000000..d3382a447c --- /dev/null +++ b/internal/exec/runtrace/testdata/example-timeline.plantuml.golden @@ -0,0 +1,18 @@ +@startuml +' run — timeline rendering (the trace of a run to t = 6) +scale 1 as 140 pixels +concise "#2.modes" as l0 +concise "#4.modes" as l1 +@0 +l0 is "waiting | charged" +note top of l0 : choice entering modes: next waiting(entry), charged(entry) (unordered; took waiting(entry) first) +l1 is "listening" +@1 +l0 is "sending | charged (time)" +l1 is "received (accept Ping)" +@3 +l0 is "sent | charged (time)" +@4 +l0 is "sent | low (time)" +@6 +@enduml diff --git a/internal/exec/runtrace/testdata/example-timeline.text.golden b/internal/exec/runtrace/testdata/example-timeline.text.golden new file mode 100644 index 0000000000..27d03ca690 --- /dev/null +++ b/internal/exec/runtrace/testdata/example-timeline.text.golden @@ -0,0 +1,19 @@ +run - timeline rendering (the trace of a run to t = 6) + +#2.modes + 0 .. 1 waiting | charged + 1 .. 3 sending | charged on time + 3 .. 4 sent | charged on time + 4 .. 6 sent | low on time (held at the end) + transitions: + t=1 waiting -> sending (time) + t=3 sending -> sent (time) + t=4 charged -> low (time) + noted: + t=0 choice entering modes: next waiting(entry), charged(entry) (unordered; took waiting(entry) first) + +#4.modes + 0 .. 1 listening + 1 .. 6 received on accept Ping (held at the end) + transitions: + t=1 listening -> received (accept Ping) diff --git a/internal/exec/runtrace/testdata/guard-timeline.text.golden b/internal/exec/runtrace/testdata/guard-timeline.text.golden new file mode 100644 index 0000000000..9ca5788c53 --- /dev/null +++ b/internal/exec/runtrace/testdata/guard-timeline.text.golden @@ -0,0 +1,6 @@ +run - timeline rendering (the trace of a run to t = 2) + +#1.modes + 0 .. 2 waiting (held at the end) + noted: + t=1 unevaluable guard waiting: transition accept Ping: unbound (not selected) diff --git a/internal/exec/runtrace/testdata/messages-sequence.text.golden b/internal/exec/runtrace/testdata/messages-sequence.text.golden new file mode 100644 index 0000000000..93b9c1f238 --- /dev/null +++ b/internal/exec/runtrace/testdata/messages-sequence.text.golden @@ -0,0 +1,11 @@ +run - sequence rendering (the trace of a run to t = 4) + +object #1 : sender +object #2 : receiver +environment environment + +messages: + #1 => #2: t=0 Unmatched (not accepted) + environment => environment: t=1 Outside (not accepted) + #1 => #2: t=2..2.5 Broadcast + environment => #2: t=3 Missing diff --git a/internal/exec/runtrace/testdata/self-transition-timeline.text.golden b/internal/exec/runtrace/testdata/self-transition-timeline.text.golden new file mode 100644 index 0000000000..f553e40436 --- /dev/null +++ b/internal/exec/runtrace/testdata/self-transition-timeline.text.golden @@ -0,0 +1,7 @@ +run - timeline rendering (the trace of a run to t = 2) + +#1.modes + 0 .. 1 idle + 1 .. 2 idle on accept Tick (held at the end) + transitions: + t=1 idle -> idle (accept Tick) diff --git a/internal/exec/runtrace/testdata/truncated-sequence.text.golden b/internal/exec/runtrace/testdata/truncated-sequence.text.golden new file mode 100644 index 0000000000..52093d9168 --- /dev/null +++ b/internal/exec/runtrace/testdata/truncated-sequence.text.golden @@ -0,0 +1,10 @@ +run - sequence rendering (the trace of a run to t = 4) + +object #1 : sender +object #2 : receiver + +messages: + #1 => #2: t=2..3 Ping + +not represented: + - 2 earlier records up to t = 1 were dropped; senders or acceptors of earlier messages may be missing diff --git a/internal/exec/runtrace/testdata/truncated-timeline.text.golden b/internal/exec/runtrace/testdata/truncated-timeline.text.golden new file mode 100644 index 0000000000..918f532bd1 --- /dev/null +++ b/internal/exec/runtrace/testdata/truncated-timeline.text.golden @@ -0,0 +1,7 @@ +run - timeline rendering (the trace of a run to t = 4) + +#1.modes + 4 .. 4 held (held at the end) + +not represented: + - 3 earlier records up to t = 2 were dropped; a lane starts at its first state entry kept diff --git a/internal/exec/runtrace/timeline.go b/internal/exec/runtrace/timeline.go new file mode 100644 index 0000000000..306d14db17 --- /dev/null +++ b/internal/exec/runtrace/timeline.go @@ -0,0 +1,420 @@ +package runtrace + +import ( + "fmt" + "sort" + "strconv" + "strings" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/ir/view" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" +) + +type laneKey struct { + object int64 + behavior *symbols.Symbol +} + +type timelineLane struct { + rendering *view.Lane + key laneKey + regions map[string]int + states map[string]activeState + first map[string]int + nextOrder int +} + +type activeState struct { + record runtime.TraceRecord + order int +} + +type timelineGroup struct { + at float64 + states []runtime.TraceRecord + transitions []runtime.TraceRecord +} + +type transientState struct { + path string + record runtime.TraceRecord + order int +} + +func timeline(trace *runtime.TraceRecorder, options Options) *view.Rendering { + out := runRendering(view.KindTimeline, options) + if trace == nil { + return out + } + records := trace.Records() + lanes := make([]*timelineLane, 0) + byKey := make(map[laneKey]*timelineLane) + for _, record := range records { + if record.Kind != runtime.TraceEntry && record.Kind != runtime.TraceExit { + continue + } + key := keyOf(record.Origin) + if lane := byKey[key]; lane != nil { + lane.regionOrder(record.Region) + if record.Kind == runtime.TraceEntry { + path := statePath(record) + if _, seen := lane.first[path]; !seen { + lane.first[path] = lane.nextOrder + lane.nextOrder++ + } + } + continue + } + lane := &view.Lane{ + ID: fmt.Sprintf("l%d", len(lanes)), + Name: laneName(record, options.Label), + } + stateLane := &timelineLane{ + rendering: lane, key: key, + regions: make(map[string]int), states: make(map[string]activeState), first: make(map[string]int), + } + stateLane.regionOrder(record.Region) + if record.Kind == runtime.TraceEntry { + path := statePath(record) + stateLane.first[path] = stateLane.nextOrder + stateLane.nextOrder++ + } + lanes = append(lanes, stateLane) + byKey[key] = stateLane + } + groups := make(map[*timelineLane]map[float64]*timelineGroup, len(lanes)) + for _, lane := range lanes { + groups[lane] = make(map[float64]*timelineGroup) + } + for _, record := range records { + if record.Kind != runtime.TraceEntry && record.Kind != runtime.TraceExit { + continue + } + lane := byKey[keyOf(record.Origin)] + if lane != nil { + group := groups[lane][record.Origin.At] + if group == nil { + group = &timelineGroup{at: record.Origin.At} + groups[lane][record.Origin.At] = group + } + group.states = append(group.states, record) + } + } + for _, record := range records { + key := keyOf(record.Origin) + lane := byKey[key] + switch record.Kind { + case runtime.TraceTransition: + if lane == nil { + out.Notices = append(out.Notices, fmt.Sprintf("transition at t = %s has no state lane: %s -> %s", + runInstant(record.Origin.At), record.From, record.To)) + continue + } + transition := view.LaneTransition{At: record.Origin.At, From: record.From, To: record.To, Event: record.Event} + lane.rendering.Transitions = append(lane.rendering.Transitions, transition) + if group := groups[lane][record.Origin.At]; group != nil { + group.transitions = append(group.transitions, record) + } + case runtime.TraceChoice, runtime.TraceGuard: + if lane == nil { + lane = laneForObject(lanes, key.object) + } + if lane == nil { + out.Notices = append(out.Notices, fmt.Sprintf("t=%s %s", runInstant(record.Origin.At), record.Text())) + continue + } + lane.rendering.Marks = append(lane.rendering.Marks, view.Mark{ + At: record.Origin.At, Kind: record.Kind.String(), Text: record.Text(), + }) + } + } + for _, lane := range lanes { + ordered := make([]*timelineGroup, 0, len(groups[lane])) + for _, group := range groups[lane] { + ordered = append(ordered, group) + } + sort.SliceStable(ordered, func(i, j int) bool { return ordered[i].at < ordered[j].at }) + currentSpan := -1 + for _, group := range ordered { + through, changed := lane.apply(group.states) + if !changed { + continue + } + if currentSpan >= 0 { + lane.rendering.Spans[currentSpan].To = group.at + lane.rendering.Spans[currentSpan].Open = false + } + leaves := lane.leaves() + if len(leaves) == 0 { + currentSpan = -1 + continue + } + stateNames := make([]string, len(leaves)) + for i, leaf := range leaves { + stateNames[i] = leaf.record.State + } + span := view.Span{State: strings.Join(stateNames, " | "), From: group.at, To: group.at} + for _, item := range through { + span.Through = append(span.Through, item.record.State) + } + for _, transition := range group.transitions { + if transition.Event != "" { + span.Triggers = append(span.Triggers, transition.Event) + } + } + lane.rendering.Spans = append(lane.rendering.Spans, span) + currentSpan = len(lane.rendering.Spans) - 1 + } + if currentSpan >= 0 { + span := &lane.rendering.Spans[currentSpan] + span.To = max(options.Until, span.From) + span.Open = true + } + sort.SliceStable(lane.rendering.Transitions, func(i, j int) bool { + return lane.rendering.Transitions[i].At < lane.rendering.Transitions[j].At + }) + sort.SliceStable(lane.rendering.Marks, func(i, j int) bool { + return lane.rendering.Marks[i].At < lane.rendering.Marks[j].At + }) + out.Lanes = append(out.Lanes, *lane.rendering) + } + if count, upTo := trace.Dropped(); count > 0 { + out.Notices = append(out.Notices, fmt.Sprintf("%d earlier records up to t = %s were dropped; a lane starts at its first state entry kept", + count, runInstant(upTo))) + } + applyTimelineLimit(out, options.Limit) + return out +} + +func (lane *timelineLane) apply(records []runtime.TraceRecord) ([]transientState, bool) { + entered := make(map[string]runtime.TraceRecord) + var exitedLeaves []transientState + changed := false + for _, record := range records { + path := statePath(record) + lane.regionOrder(record.Region) + switch record.Kind { + case runtime.TraceEntry: + order, seen := lane.first[path] + if !seen { + order = lane.nextOrder + lane.nextOrder++ + lane.first[path] = order + } + if current, ok := lane.states[path]; ok { + order = current.order + } + lane.states[path] = activeState{record: record, order: order} + entered[path] = record + changed = true + case runtime.TraceExit: + current, ok := lane.states[path] + if !ok { + continue + } + if !lane.hasDescendant(path) { + exitedLeaves = append(exitedLeaves, transientState{path: path, record: current.record, order: current.order}) + } + delete(lane.states, path) + changed = true + } + } + through := make([]transientState, 0) + for _, exited := range exitedLeaves { + if _, wasEntered := entered[exited.path]; !wasEntered { + continue + } + if _, active := lane.states[exited.path]; active { + continue + } + through = append(through, exited) + } + sort.SliceStable(through, func(i, j int) bool { + left, right := lane.regionOrder(through[i].record.Region), lane.regionOrder(through[j].record.Region) + if left != right { + return left < right + } + return through[i].order < through[j].order + }) + filtered := through[:0] + for _, item := range through { + parent := false + for _, other := range through { + if strings.HasPrefix(other.path, item.path+".") { + parent = true + break + } + } + if !parent { + filtered = append(filtered, item) + } + } + return filtered, changed +} + +func (lane *timelineLane) leaves() []activeState { + var leaves []activeState + for path, state := range lane.states { + if !lane.hasDescendant(path) { + leaves = append(leaves, state) + } + } + sort.SliceStable(leaves, func(i, j int) bool { + left, right := lane.regionOrder(leaves[i].record.Region), lane.regionOrder(leaves[j].record.Region) + if left != right { + return left < right + } + return leaves[i].order < leaves[j].order + }) + return leaves +} + +func (lane *timelineLane) hasDescendant(path string) bool { + prefix := path + "." + for active := range lane.states { + if strings.HasPrefix(active, prefix) { + return true + } + } + return false +} + +func (lane *timelineLane) regionOrder(region string) int { + if order, ok := lane.regions[region]; ok { + return order + } + order := len(lane.regions) + lane.regions[region] = order + return order +} + +func keyOf(origin runtime.TraceOrigin) laneKey { + key := laneKey{behavior: origin.Behavior} + if origin.Object != nil { + key.object = origin.Object.ID + } + return key +} + +func laneName(record runtime.TraceRecord, label func(*runtime.Instance) string) string { + machine := record.Machine() + if machine == "" && record.Origin.Behavior != nil { + machine = record.Origin.Behavior.Name + } + if record.Origin.Object != nil { + name := instanceName(record.Origin.Object, label) + if machine != "" { + return name + "." + machine + } + return name + } + if machine != "" { + return machine + } + return "environment" +} + +func instanceName(instance *runtime.Instance, label func(*runtime.Instance) string) string { + if label != nil { + if name := label(instance); name != "" { + return name + } + } + return fmt.Sprintf("#%d", instance.ID) +} + +func laneForObject(lanes []*timelineLane, object int64) *timelineLane { + if object == 0 { + return nil + } + for _, lane := range lanes { + if lane.key.object == object { + return lane + } + } + return nil +} + +func statePath(record runtime.TraceRecord) string { + if record.Path != "" { + return record.Path + } + return record.State +} + +type spanRef struct { + lane int + index int + from float64 +} + +func applyTimelineLimit(rendering *view.Rendering, requested int) { + limit := effectiveLimit(requested) + var spans []spanRef + for laneIndex, lane := range rendering.Lanes { + for spanIndex, span := range lane.Spans { + spans = append(spans, spanRef{lane: laneIndex, index: spanIndex, from: span.From}) + } + } + if len(spans) <= limit { + return + } + sort.SliceStable(spans, func(i, j int) bool { return spans[i].from < spans[j].from }) + cutoff := spans[limit].from + dropped := 0 + for laneIndex := range rendering.Lanes { + lane := &rendering.Lanes[laneIndex] + kept := lane.Spans[:0] + for _, span := range lane.Spans { + if span.From >= cutoff { + dropped++ + continue + } + if span.To > cutoff || span.Open && span.To >= cutoff { + span.To = cutoff + span.Open = false + } + kept = append(kept, span) + } + lane.Spans = kept + transitions := lane.Transitions[:0] + for _, transition := range lane.Transitions { + if transition.At <= cutoff { + transitions = append(transitions, transition) + } + } + lane.Transitions = transitions + marks := lane.Marks[:0] + for _, mark := range lane.Marks { + if mark.At <= cutoff { + marks = append(marks, mark) + } + } + lane.Marks = marks + } + verb := "are" + if dropped == 1 { + verb = "is" + } + rendering.Notices = append(rendering.Notices, fmt.Sprintf("%d later state %s after t = %s %s not drawn (at most %d spans are)", + dropped, plural(dropped, "change", "changes"), runInstant(cutoff), verb, limit)) +} + +func effectiveLimit(limit int) int { + if limit <= 0 { + return DefaultLimit + } + return limit +} + +func runInstant(instant float64) string { + return strconv.FormatFloat(instant, 'g', -1, 64) +} + +func plural(count int, singular, plural string) string { + if count == 1 { + return singular + } + return plural +} diff --git a/internal/frontend/repl/complete.go b/internal/frontend/repl/complete.go index 3b008a2a9f..473b84ed0a 100644 --- a/internal/frontend/repl/complete.go +++ b/internal/frontend/repl/complete.go @@ -11,6 +11,7 @@ import ( "github.com/Open-MBEE/OpenSysML/internal/exec/objref" "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtrace" "github.com/Open-MBEE/OpenSysML/internal/ir/view" "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" "github.com/Open-MBEE/OpenSysML/internal/syntax/source" @@ -57,6 +58,18 @@ func (s *Session) Complete(line string, pos int) Completion { word := lastField(head) return completion(word, matchingPrefix(renderForms(), word)) } + if command == "%render-run" && argumentIndex(head) == 1 { + word := lastField(head) + kinds := make([]string, 0, len(runtrace.Kinds())) + for _, kind := range runtrace.Kinds() { + kinds = append(kinds, string(kind)) + } + return completion(word, matchingPrefix(kinds, word)) + } + if command == "%render-run" && argumentIndex(head) == 2 { + word := lastField(head) + return completion(word, matchingPrefix([]string{"text", "mermaid", "plantuml", "dot"}, word)) + } if command == "%render" && atPaletteArgument(head) { word := lastField(head) return completion(word, matchingPrefix(renderPalettes(), word)) diff --git a/internal/frontend/repl/meta.go b/internal/frontend/repl/meta.go index 6d2adef4a2..860aece203 100644 --- a/internal/frontend/repl/meta.go +++ b/internal/frontend/repl/meta.go @@ -29,7 +29,10 @@ import ( // renderUsage is how %render is written: a view, the form to write it in, text // when none is named, then a palette, style and port display the form draws. -const renderUsage = "usage: %render [text|mermaid|markdown|dot|plantuml|csv|tsv [palette] [pilot|cameo] [minimal|full]]" +const ( + renderUsage = "usage: %render [text|mermaid|markdown|dot|plantuml|csv|tsv [palette] [pilot|cameo] [minimal|full]]" + renderRunUsage = "usage: %render-run [text|mermaid|plantuml|dot]" +) // isMeta reports whether a trimmed input line is a meta command. func isMeta(line string) bool { @@ -231,6 +234,7 @@ var metaCommandTable = []metaCommand{ {name: cmdSamples, group: groupBehavioral, args: " [()] []

=.....", desc: "run an analysis case or calc over values drawn uniformly from each range with the given seed, and print the table"}, {name: cmdRuns, group: groupBehavioral, args: " [] [...]", desc: "run an action times, each run's modeled randomness seeded from the given seed — left out under %draws min, max or average — and print the table of the observables with each one's distribution"}, {name: cmdRunQuery, group: groupBehavioral, args: " [

=...]", desc: "execute a document query and print its rows, with each binding written as ="}, + {name: "%render-run", group: groupBehavioral, args: " [form]", desc: "render the recorded run as a state timeline or message sequence"}, {name: cmdRenderDocument, group: groupBehavioral, args: " [mermaid|dot|plantuml]", desc: "compile a document definition, run its queries and print the rendered Markdown, its graph-shaped diagrams as Mermaid, Graphviz DOT or PlantUML"}, {name: "%constraint", group: groupBehavioral, args: argName, desc: "evaluate a constraint definition"}, {name: "%requirement", group: groupBehavioral, args: argName, desc: "evaluate a requirement definition"}, @@ -442,6 +446,8 @@ func (s *Session) metaSessionCommand(fields []string, line string) (metaResult, return metaOut(s.doView(fields[1])), true case "%render": return metaOut(s.metaRender(fields[1:])), true + case "%render-run": + return metaOut(s.metaRenderRun(fields[1:])), true case "%quit", "%exit": return metaOut([]string{"goodbye"}, true, nil), true } diff --git a/internal/frontend/repl/run_render.go b/internal/frontend/repl/run_render.go new file mode 100644 index 0000000000..1b0720e45a --- /dev/null +++ b/internal/frontend/repl/run_render.go @@ -0,0 +1,66 @@ +package repl + +import ( + "errors" + "fmt" + "slices" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtrace" + "github.com/Open-MBEE/OpenSysML/internal/ir/view" +) + +func runViewKind(kind runtrace.Kind) view.Kind { + if kind == runtrace.KindTimeline { + return view.KindTimeline + } + return view.KindSequence +} + +func (s *Session) metaRenderRun(args []string) ([]string, bool, error) { + if len(args) < 1 || len(args) > 2 { + return []string{renderRunUsage}, false, nil + } + kind, ok := runtrace.ParseKind(args[0]) + if !ok { + return []string{fmt.Sprintf("unknown run rendering %q; want timeline or sequence; %s", args[0], renderRunUsage)}, false, nil + } + form := view.FormText + if len(args) == 2 { + form = view.Form(args[1]) + if !slices.Contains(view.Forms(), form) { + return []string{fmt.Sprintf("unknown form %q; %s", args[1], renderRunUsage)}, false, nil + } + } + if _, err := (&view.Rendering{Kind: runViewKind(kind), Run: true}).WriteWith(form, view.Options{}); err != nil { + return []string{"error: " + err.Error()}, false, nil + } + rendering, err := s.runTraceRendering(kind) + if errors.Is(err, runtrace.ErrNoTrace) { + return []string{"error: the session records no trace; %trace on before the run"}, false, nil + } + if err != nil { + return []string{"error: " + err.Error()}, false, nil + } + lines, err := artifactLines(rendering, form, view.Options{Width: s.renderWidth}) + if err != nil { + return []string{"error: " + err.Error()}, false, nil + } + return lines, false, nil +} + +// RunTraceRendering renders the trace the session recorded so far. +func (s *Session) RunTraceRendering(kind runtrace.Kind) (*view.Rendering, error) { + defer s.enter()() + return s.runTraceRendering(kind) +} + +func (s *Session) runTraceRendering(kind runtrace.Kind) (*view.Rendering, error) { + var trace *runtime.TraceRecorder + var until float64 + if s.rtCtx != nil { + trace = s.rtCtx.Trace() + until = s.rtCtx.Clock().Now() + } + return runtrace.Render(kind, trace, runtrace.Options{Label: s.instanceName, Until: until}) +} diff --git a/internal/frontend/repl/run_render_test.go b/internal/frontend/repl/run_render_test.go new file mode 100644 index 0000000000..cd502f22b5 --- /dev/null +++ b/internal/frontend/repl/run_render_test.go @@ -0,0 +1,49 @@ +package repl + +import ( + "slices" + "strings" + "testing" +) + +func TestRenderRunBuildsTimelineAndSequence(t *testing.T) { + s := loadFixture(t, "testdata/lamp_signals.sysml") + wants(t, run(t, s, "%trace on"), "trace: on") + wants(t, run(t, s, "%instantiate bulb"), "Created instance") + wants(t, run(t, s, "%state bulb"), "Current state: off") + run(t, s, "%send go") + run(t, s, "%advance 1") + + timeline := run(t, s, "%render-run timeline") + wants(t, timeline, "run - timeline rendering", "Lamps::bulb.lamp", "on") + sequence := run(t, s, "%render-run sequence mermaid") + wants(t, sequence, "sequenceDiagram", "go") + wants(t, run(t, s, "%render-run timeline mermaid"), "gantt", "on (accept go)") + wants(t, run(t, s, "%render-run sequence text"), "run - sequence rendering", "go") +} + +func TestRenderRunReportsMissingTraceAndRefusesDot(t *testing.T) { + fresh := NewSession() + wants(t, run(t, fresh, "%render-run timeline"), "error: the session records no trace; %trace on before the run") + wants(t, run(t, fresh, "%render-run"), renderRunUsage) + wants(t, run(t, fresh, "%render-run neither"), `unknown run rendering "neither"`, "timeline or sequence") + wants(t, run(t, fresh, "%render-run timeline svg"), `unknown form "svg"`) + + s := loadFixture(t, "testdata/lamp_signals.sysml") + run(t, s, "%trace on") + run(t, s, "%instantiate bulb") + run(t, s, "%state bulb") + run(t, s, "%advance 1") + wants(t, run(t, s, "%render-run timeline dot"), "error:", "not written as dot") +} + +func TestRenderRunCompletesKindAndForm(t *testing.T) { + s := NewSession() + if got := s.Complete("%render-run ti", len("%render-run ti")); !slices.Equal(got.Candidates, []string{"timeline"}) { + t.Errorf("completing run kind offered %v", got.Candidates) + } + if got := s.Complete("%render-run timeline p", len("%render-run timeline p")); !slices.Equal(got.Candidates, []string{"plantuml"}) { + t.Errorf("completing run form offered %v", got.Candidates) + } + wants(t, strings.Join(helpText(), "\n"), "%render-run [form]") +} diff --git a/internal/frontend/repl/session.go b/internal/frontend/repl/session.go index 1330925002..8ead1e18f0 100644 --- a/internal/frontend/repl/session.go +++ b/internal/frontend/repl/session.go @@ -130,7 +130,8 @@ type Session struct { notedBlocker blockerNote // trace records execution steps while tracing is on, nil otherwise. - trace *runtime.TraceRecorder + trace *runtime.TraceRecorder + traceSilent bool // budgets bounds every runtime context this session creates. budgets runtime.Budgets diff --git a/internal/frontend/repl/trace.go b/internal/frontend/repl/trace.go index d55ffba8cf..d60460ab00 100644 --- a/internal/frontend/repl/trace.go +++ b/internal/frontend/repl/trace.go @@ -37,7 +37,7 @@ func (s *Session) noteSummary(notes []runtime.RunNote, before int) []string { parts = append(parts, fmt.Sprintf("%d %s", diverged, plural(diverged, "tool answer diverged", "tool answers diverged"))) } line := " " + strings.Join(parts, "; ") - if s.trace == nil { + if s.trace == nil || s.traceSilent { line += "; %trace on to see them" } return []string{line} @@ -50,7 +50,7 @@ const tracePrefix = "[trace] " // Tracing reports whether execution steps are being recorded. func (s *Session) Tracing() bool { defer s.reading()() - return s.trace != nil + return s.trace != nil && !s.traceSilent } // SetTracing turns recording of execution steps on or off. It takes effect at @@ -61,13 +61,33 @@ func (s *Session) SetTracing(on bool) { s.setTracing(on) } -func (s *Session) setTracing(on bool) { +// SetRecording records typed execution events without printing trace lines. +func (s *Session) SetRecording(on bool) { + defer s.enter()() switch { case on && s.trace == nil: + s.trace = runtime.NewEventRecorder(0) + s.traceSilent = true + case !on: + s.trace = nil + s.traceSilent = false + } + s.attachTrace() +} + +func (s *Session) setTracing(on bool) { + switch { + case on && (s.trace == nil || s.traceSilent): s.trace = runtime.NewTraceRecorder() + s.traceSilent = false case !on: s.trace = nil + s.traceSilent = false } + s.attachTrace() +} + +func (s *Session) attachTrace() { if s.rtCtx != nil { s.rtCtx.SetTrace(s.trace) } @@ -89,7 +109,7 @@ func onOff(on bool) string { // drainTrace returns what was recorded since the last command, prefixed, and // resets the recorder so each command reports only its own steps. func (s *Session) drainTrace() []string { - if s.trace == nil { + if s.trace == nil || s.traceSilent { return nil } entries := s.trace.Entries() diff --git a/internal/ir/view/data.go b/internal/ir/view/data.go index ab0860ccc1..f30f756892 100644 --- a/internal/ir/view/data.go +++ b/internal/ir/view/data.go @@ -11,6 +11,8 @@ type Data struct { // Kind is the rendering produced, and Stated how the kind was decided. Kind Kind Stated string + // Run marks a rendering of a run's trace rather than of a view. + Run bool // Nodes are every node of the rendering, parents before children, each // naming its parent. Nodes []NodeData @@ -19,6 +21,8 @@ type Data struct { // Columns and Rows are the tabular rendering, empty for every other kind. Columns []string Rows []RowData + // Lanes are a run timeline's object machines. + Lanes []Lane // Canvas is the drawing surface the view states, nil for none. Canvas *Canvas // Notes are the note boxes drawn on the canvas, anchored to a node ID or free. @@ -84,7 +88,9 @@ func (r *Rendering) Data() Data { View: r.View, Kind: r.Kind, Stated: r.Stated, + Run: r.Run, Columns: r.Columns, + Lanes: r.Lanes, Canvas: r.Canvas, Notes: r.Notes, Notices: r.Notices, diff --git a/internal/ir/view/form.go b/internal/ir/view/form.go index 675907ec9e..48bb6eb4ca 100644 --- a/internal/ir/view/form.go +++ b/internal/ir/view/form.go @@ -63,6 +63,9 @@ const ( // MachineForm is the machine-readable form of renderings of this kind. func (k Kind) MachineForm() Form { + if k == KindTimeline { + return FormMermaid + } if k == KindTable { return FormMarkdown } @@ -88,7 +91,7 @@ func (k Kind) SupportsForm(form Form) bool { } case FormPlantUML: switch k { - case KindTree, KindInterconnection, KindState, KindAction, KindSequence: + case KindTree, KindInterconnection, KindState, KindAction, KindSequence, KindTimeline: return true } } diff --git a/internal/ir/view/mermaid.go b/internal/ir/view/mermaid.go index 67133a8b41..1ded7e48c7 100644 --- a/internal/ir/view/mermaid.go +++ b/internal/ir/view/mermaid.go @@ -39,6 +39,9 @@ func (r *Rendering) Mermaid() string { // some Layout positions draws the nodes the DOT form draws: the placed ones, // and the unplaced ones too under UnplacedStrip. func (r *Rendering) MermaidWith(options Options) string { + if r.Run && r.Kind == KindTimeline { + return r.runTimelineMermaid(options) + } r = r.settleUnplaced(options.Unplaced, FormMermaid) direction := options.Direction var b strings.Builder @@ -46,7 +49,9 @@ func (r *Rendering) MermaidWith(options Options) string { labels.skin = skinOf(options.Style) ports := r.portView(options.Ports) r.writeMermaidFrontmatter(&b, labels, options, ports) - if r.View == "" { + if r.Run { + fmt.Fprintf(&b, "%%%% run — %s rendering", r.Kind) + } else if r.View == "" { fmt.Fprintf(&b, "%%%% %s rendering", r.Kind) } else { fmt.Fprintf(&b, "%%%% %s — %s rendering", r.View, r.Kind) @@ -488,6 +493,9 @@ func (r *Rendering) writeMermaidFrontmatter(b *strings.Builder, labels labeller, fmt.Fprintf(b, " themeCSS: %q\n", ".edgeLabel rect { opacity: 1 !important; } "+mermaidTitleCSS) } + if r.Run && r.Kind == KindTimeline { + b.WriteString(" gantt:\n displayMode: compact\n") + } b.WriteString(" themeVariables:\n") variables := []themeVariable{ {"fontFamily", font}, diff --git a/internal/ir/view/palette.go b/internal/ir/view/palette.go index b766528774..24893a10fe 100644 --- a/internal/ir/view/palette.go +++ b/internal/ir/view/palette.go @@ -104,6 +104,9 @@ var paletteForms = []Form{FormDot, FormMermaid, FormPlantUML} // SupportsPalette reports whether a rendering of the kind is drawn as nodes a // palette can fill: the kinds a form that fills nodes is written for. func (k Kind) SupportsPalette() bool { + if k == KindTimeline { + return false + } return slices.ContainsFunc(paletteForms, k.SupportsForm) } diff --git a/internal/ir/view/plantuml.go b/internal/ir/view/plantuml.go index 020137267b..8c55745ae8 100644 --- a/internal/ir/view/plantuml.go +++ b/internal/ir/view/plantuml.go @@ -36,6 +36,9 @@ func (r *Rendering) PlantUMLWith(options Options) (string, error) { if err := options.Ports.check(); err != nil { return "", err } + if r.Run && r.Kind == KindTimeline { + return r.runTimelinePlantUML(), nil + } r = r.settleUnplaced(options.Unplaced, FormPlantUML) w := &plantumlWriter{borders: r.Kind.paletteBorders(), fills: familyFills{palette: options.Palette, tree: r.Kind == KindTree}, labels: labelsOf(r.Roots, false, nil), ports: r.portView(options.Ports)} @@ -65,7 +68,9 @@ func (r *Rendering) PlantUMLWith(options Options) (string, error) { notices = append(notices, r.visualNotices(noFontOrEdgeStyle, true)...) b := &w.b b.WriteString("@startuml\n") - if r.View == "" { + if r.Run { + fmt.Fprintf(b, "' run — %s rendering", r.Kind) + } else if r.View == "" { fmt.Fprintf(b, "' %s rendering", r.Kind) } else { fmt.Fprintf(b, "' %s — %s rendering", r.View, r.Kind) diff --git a/internal/ir/view/run_timeline.go b/internal/ir/view/run_timeline.go new file mode 100644 index 0000000000..49ebf6b0f3 --- /dev/null +++ b/internal/ir/view/run_timeline.go @@ -0,0 +1,273 @@ +package view + +import ( + "fmt" + "math" + "sort" + "strconv" + "strings" + "unicode/utf8" +) + +func writeRunTimelineText(b *strings.Builder, r *Rendering) { + if len(r.Lanes) == 0 { + b.WriteString(r.EmptyReason() + "\n") + return + } + for i, lane := range r.Lanes { + if i > 0 { + b.WriteString("\n") + } + b.WriteString(lane.Name + "\n") + width := 0 + times := make([]string, len(lane.Spans)) + for i, span := range lane.Spans { + times[i] = runInstant(span.From) + " .. " + runInstant(span.To) + width = max(width, utf8.RuneCountInString(times[i])) + } + for i, span := range lane.Spans { + fmt.Fprintf(b, " %s%s %s", times[i], strings.Repeat(" ", width-utf8.RuneCountInString(times[i])), span.State) + if len(span.Triggers) > 0 { + fmt.Fprintf(b, " on %s", strings.Join(span.Triggers, ", ")) + } + if span.Open { + b.WriteString(" (held at the end)") + } + b.WriteString("\n") + if len(span.Through) > 0 { + fmt.Fprintf(b, " via %s\n", strings.Join(span.Through, ", ")) + } + } + if len(lane.Transitions) > 0 { + b.WriteString(" transitions:\n") + for _, transition := range lane.Transitions { + fmt.Fprintf(b, " t=%s %s -> %s", runInstant(transition.At), transition.From, transition.To) + if transition.Event != "" { + fmt.Fprintf(b, " (%s)", transition.Event) + } + b.WriteString("\n") + } + } + if len(lane.Marks) > 0 { + b.WriteString(" noted:\n") + for _, mark := range lane.Marks { + fmt.Fprintf(b, " t=%s %s\n", runInstant(mark.At), mark.Text) + } + } + } +} + +func (r *Rendering) runTimelineMermaid(options Options) string { + var b strings.Builder + r.writeMermaidFrontmatter(&b, labelsOf(nil, false, nil), options, portView{}) + fmt.Fprintf(&b, "%%%% run — %s rendering", r.Kind) + if r.Stated != "" { + fmt.Fprintf(&b, " (%s)", r.Stated) + } + b.WriteString("\n") + notices := append([]string(nil), r.Notices...) + notices = append(notices, r.mermaidTimelineNotices()...) + for _, notice := range notices { + fmt.Fprintf(&b, "%%%% not represented: %s\n", notice) + } + if len(r.Lanes) == 0 { + fmt.Fprintf(&b, "%%%% %s\n", r.EmptyReason()) + } else { + for _, lane := range r.Lanes { + for _, mark := range lane.Marks { + text := strings.NewReplacer("\r", " ", "\n", " ").Replace(mark.Text) + fmt.Fprintf(&b, "%%%% t=%s %s\n", runInstant(mark.At), text) + } + } + } + b.WriteString("gantt\n") + b.WriteString(" dateFormat x\n") + b.WriteString(" axisFormat %M:%S.%L\n") + b.WriteString(" todayMarker off\n") + for _, lane := range r.Lanes { + fmt.Fprintf(&b, " section %s\n", ganttText(lane.Name)) + for i, span := range lane.Spans { + if span.To <= span.From { + continue + } + label := timelineStateLabel(span) + if span.Open { + fmt.Fprintf(&b, " %s :active, %ss%d, %d, %d\n", + ganttText(label), lane.ID, i, runMilliseconds(span.From), runMilliseconds(span.To)) + } else { + fmt.Fprintf(&b, " %s :%ss%d, %d, %d\n", + ganttText(label), lane.ID, i, runMilliseconds(span.From), runMilliseconds(span.To)) + } + } + } + return b.String() +} + +func (r *Rendering) mermaidTimelineNotices() []string { + var notices []string + zero := 0 + fractional := !wholeMillisecond(runUntil(r)) + for _, lane := range r.Lanes { + for _, span := range lane.Spans { + if span.To <= span.From { + zero++ + } + fractional = fractional || !wholeMillisecond(span.From) || !wholeMillisecond(span.To) + } + for _, transition := range lane.Transitions { + fractional = fractional || !wholeMillisecond(transition.At) + } + for _, mark := range lane.Marks { + fractional = fractional || !wholeMillisecond(mark.At) + } + } + if zero > 0 { + verb := "are" + if zero == 1 { + verb = "is" + } + notices = append(notices, fmt.Sprintf("%d %s held for no time %s listed in the text form", + zero, plural(zero, "state", "states"), verb)) + } + if fractional { + notices = append(notices, "instants are rounded to the nearest millisecond") + } + if runUntil(r) >= 3600 { + notices = append(notices, "the axis reads minutes and seconds; it wraps past an hour") + } + for _, lane := range r.Lanes { + if len(lane.Marks) > 0 { + notices = append(notices, "Mermaid gantt draws no note; choice and guard records are comments") + break + } + } + return notices +} + +func (r *Rendering) runTimelinePlantUML() string { + var b strings.Builder + b.WriteString("@startuml\n") + fmt.Fprintf(&b, "' run — %s rendering", r.Kind) + if r.Stated != "" { + fmt.Fprintf(&b, " (%s)", r.Stated) + } + b.WriteString("\n") + for _, notice := range r.Notices { + fmt.Fprintf(&b, "' not represented: %s\n", strings.ReplaceAll(notice, "\n", " ")) + } + if len(r.Lanes) == 0 { + fmt.Fprintf(&b, "' %s\n@enduml\n", r.EmptyReason()) + return b.String() + } + fmt.Fprintf(&b, "scale 1 as %d pixels\n", timelineScale(r.Lanes, runUntil(r))) + for _, lane := range r.Lanes { + fmt.Fprintf(&b, "concise %s as %s\n", plantumlQuote(plantumlText(lane.Name)), lane.ID) + } + var changes []timelineChange + until := runUntil(r) + for _, lane := range r.Lanes { + for i, span := range lane.Spans { + changes = append(changes, timelineChange{span.From, fmt.Sprintf("%s is %s", lane.ID, + plantumlQuote(plantumlText(timelineStateLabel(span))))}) + if !span.Open && i == len(lane.Spans)-1 { + changes = append(changes, timelineChange{span.To, lane.ID + " is {hidden}"}) + } + } + for _, mark := range lane.Marks { + changes = append(changes, timelineChange{mark.At, fmt.Sprintf("note top of %s : %s", lane.ID, plantumlText(mark.Text))}) + } + } + changes = append(changes, timelineChange{at: until}) + sort.SliceStable(changes, func(i, j int) bool { return changes[i].at < changes[j].at }) + last := math.NaN() + for _, item := range changes { + if math.IsNaN(last) || item.at != last { + fmt.Fprintf(&b, "@%s\n", runInstant(item.at)) + last = item.at + } + if item.line != "" { + b.WriteString(item.line + "\n") + } + } + b.WriteString("@enduml\n") + return b.String() +} + +type timelineChange struct { + at float64 + line string +} + +func timelineScale(lanes []Lane, until float64) int { + shortest := math.Inf(1) + for _, lane := range lanes { + for _, span := range lane.Spans { + if duration := span.To - span.From; duration > 0 { + shortest = math.Min(shortest, duration) + } + } + } + duration := math.Max(0, until) + scale := 1 + if !math.IsInf(shortest, 1) { + scale = max(1, int(math.Ceil(140/shortest))) + } + if float64(scale)*duration > 2400 && duration > 0 { + scale = max(1, int(2400/duration)) + } + return scale +} + +func timelineStateLabel(span Span) string { + label := span.State + if len(span.Triggers) > 0 { + label += " (" + strings.Join(span.Triggers, ", ") + ")" + } + return label +} + +func runUntil(r *Rendering) float64 { + const prefix = "the trace of a run to t = " + if strings.HasPrefix(r.Stated, prefix) { + if until, err := strconv.ParseFloat(strings.TrimPrefix(r.Stated, prefix), 64); err == nil { + return until + } + } + var until float64 + for _, lane := range r.Lanes { + for _, span := range lane.Spans { + until = math.Max(until, span.To) + } + for _, transition := range lane.Transitions { + until = math.Max(until, transition.At) + } + for _, mark := range lane.Marks { + until = math.Max(until, mark.At) + } + } + return until +} + +func runInstant(instant float64) string { + return strconv.FormatFloat(instant, 'g', -1, 64) +} + +func runMilliseconds(instant float64) int64 { + return int64(math.Round(instant * 1000)) +} + +func wholeMillisecond(instant float64) bool { + scaled := instant * 1000 + return math.Abs(scaled-math.Round(scaled)) < 1e-7 +} + +func plural(count int, singular, plural string) string { + if count == 1 { + return singular + } + return plural +} + +func ganttText(text string) string { + return strings.NewReplacer("\r", " ", "\n", " ", ":", "#58;", "#", "#35;", ";", "#59;").Replace(text) +} diff --git a/internal/ir/view/run_timeline_test.go b/internal/ir/view/run_timeline_test.go new file mode 100644 index 0000000000..221fe473fc --- /dev/null +++ b/internal/ir/view/run_timeline_test.go @@ -0,0 +1,193 @@ +package view + +import ( + "errors" + "slices" + "strings" + "testing" +) + +func TestTimelineKindIsOnlyAStandaloneRunKind(t *testing.T) { + if KindTimeline.Supported() || slices.Contains(Kinds(), KindTimeline) { + t.Errorf("timeline is offered as a view kind: supported=%t kinds=%v", KindTimeline.Supported(), Kinds()) + } + if _, ok := PseudoViewKind("timeline"); ok { + t.Error("#timeline resolves to a pseudo-view") + } + if _, _, ok := ParsePseudoView("#timeline"); ok { + t.Error("#timeline parses as a pseudo-view") + } + if KindTimeline.MachineForm() != FormMermaid { + t.Errorf("timeline machine form = %s, want mermaid", KindTimeline.MachineForm()) + } + for _, form := range []Form{FormText, FormMermaid, FormPlantUML} { + if !KindTimeline.SupportsForm(form) { + t.Errorf("timeline does not support %s", form) + } + } + for _, form := range []Form{FormMarkdown, FormDot, FormCSV, FormTSV} { + rendering := &Rendering{Kind: KindTimeline, Run: true} + if KindTimeline.SupportsForm(form) { + t.Errorf("timeline supports %s", form) + } + if _, err := rendering.Write(form); !errors.Is(err, ErrWrongForm) { + t.Errorf("timeline in %s: error = %v, want ErrWrongForm", form, err) + } + } +} + +func TestRunTimelineTextWritesLanesSpansTransitionsAndMarks(t *testing.T) { + rendering := &Rendering{ + Kind: KindTimeline, Run: true, Stated: "the trace of a run to t = 4", + Lanes: []Lane{{ + ID: "l0", Name: "T::rover.Machine", + Spans: []Span{ + {State: "closed", From: 0, To: 1.5}, + {State: "open | parked", From: 1.5, To: 4, Triggers: []string{"accept Open"}, Through: []string{"a", "b"}, Open: true}, + }, + Transitions: []LaneTransition{{At: 1.5, From: "closed", To: "open", Event: "accept Open"}}, + Marks: []Mark{{At: 1.5, Kind: "choice", Text: "choice entering open: parked(entry), opening(entry)"}}, + }}, + Notices: []string{"one earlier record was dropped"}, + } + text := rendering.Text() + for _, want := range []string{ + "run - timeline rendering (the trace of a run to t = 4)", + "T::rover.Machine", + "0 .. 1.5 closed", + "1.5 .. 4 open | parked on accept Open (held at the end)", + " via a, b", + "transitions:", + "t=1.5 closed -> open (accept Open)", + "noted:", + "t=1.5 choice entering open: parked(entry), opening(entry)", + "not represented:", + "one earlier record was dropped", + } { + if !strings.Contains(text, want) { + t.Errorf("timeline text is missing %q:\n%s", want, text) + } + } + if !rendering.Data().Run || len(rendering.Data().Lanes) != 1 { + t.Errorf("timeline data = %+v, want its run and lane", rendering.Data()) + } +} + +func TestRunTimelineMermaidWritesCompactGantt(t *testing.T) { + rendering := &Rendering{ + Kind: KindTimeline, Run: true, Stated: "the trace of a run to t = 3600.5", + Lanes: []Lane{{ + ID: "l0", Name: "lane: # ;", + Spans: []Span{ + {State: "idle: #;", From: 0, To: 0.2505}, + {State: "ready", From: 0.2505, To: 3600.5, Triggers: []string{"accept Go"}, Open: true}, + {State: "instant", From: 3600.5, To: 3600.5}, + }, + Marks: []Mark{{At: 0.125, Kind: "guard", Text: "guard: x\nnot evaluated"}}, + }}, + } + mermaid := rendering.Mermaid() + for _, want := range []string{ + "fontFamily: \"Helvetica, Arial, sans-serif\"", + " gantt:\n displayMode: compact\n", + "%% run — timeline rendering (the trace of a run to t = 3600.5)", + "%% t=0.125 guard: x not evaluated", + "gantt", + "dateFormat x", + "axisFormat %M:%S.%L", + "todayMarker off", + "section lane#58; #35; #59;", + "idle#58; #35;#59;", + ":active, l0s1, 251, 3600500", + "1 state held for no time is listed in the text form", + "instants are rounded to the nearest millisecond", + "the axis reads minutes and seconds; it wraps past an hour", + "Mermaid gantt draws no note; choice and guard records are comments", + } { + if !strings.Contains(mermaid, want) { + t.Errorf("timeline Mermaid is missing %q:\n%s", want, mermaid) + } + } + if !strings.Contains(mermaid, "%% t=0.125 guard: x not evaluated\ngantt") { + t.Errorf("multiline mark escaped the Mermaid comment:\n%s", mermaid) + } +} + +func TestRunTimelinePlantUMLWritesTimingChanges(t *testing.T) { + rendering := &Rendering{ + Kind: KindTimeline, Run: true, Stated: "the trace of a run to t = 3", + Lanes: []Lane{{ + ID: "l0", Name: "Rover", + Spans: []Span{ + {State: "idle", From: 0, To: 1.25}, + {State: "moving", From: 1.25, To: 3, Triggers: []string{"accept Go"}, Open: true}, + }, + Marks: []Mark{{At: 1.25, Kind: "choice", Text: "choice: moving"}}, + }}, + } + puml, err := rendering.PlantUML() + if err != nil { + t.Fatalf("PlantUML: %v", err) + } + for _, want := range []string{ + "@startuml\n' run — timeline rendering (the trace of a run to t = 3)", + "scale 1 as 112 pixels", + `concise "Rover" as l0`, + "@0\nl0 is \"idle\"\n", + "@1.25\nl0 is \"moving (accept Go)\"\nnote top of l0 : choice: moving\n", + "@3\n@enduml", + } { + if !strings.Contains(puml, want) { + t.Errorf("timeline PlantUML is missing %q:\n%s", want, puml) + } + } + if strings.Contains(puml, " skinparam wrapWidth 300 hide stereotype -participant "//«object»//\n**#2 : Sender**" as n0 <> <> -participant "//«object»//\n**#4 : Receiver**" as n1 <> <> -n0 -> n1 : t=1 Ping +participant "//«object»//\n**'mission.controller' : Controller**" as n0 <> <> +participant "//«object»//\n**'mission.instrument' : Instrument**" as n1 <> <> +n0 -> n1 : t=1 Ping (seq = 1) +n1 -> n0 : t=1 Ack (seq = 1) +n0 -> n1 : t=3 Ping (seq = 1) +n1 -> n0 : t=3 Ack (seq = 1) +n0 -> n1 : t=5 Ping (seq = 1) +n1 -> n0 : t=5 Ack (seq = 1) @enduml diff --git a/internal/exec/runtrace/testdata/example-sequence.text.golden b/internal/exec/runtrace/testdata/example-sequence.text.golden index dc18913ef7..e49c65eb03 100644 --- a/internal/exec/runtrace/testdata/example-sequence.text.golden +++ b/internal/exec/runtrace/testdata/example-sequence.text.golden @@ -1,7 +1,12 @@ run - sequence rendering (the trace of a run to t = 6) -object #2 : Sender -object #4 : Receiver +object RunTimeline::mission.controller : Controller +object RunTimeline::mission.instrument : Instrument messages: - #2 => #4: t=1 Ping + RunTimeline::mission.controller => RunTimeline::mission.instrument: t=1 Ping (seq = 1) + RunTimeline::mission.instrument => RunTimeline::mission.controller: t=1 Ack (seq = 1) + RunTimeline::mission.controller => RunTimeline::mission.instrument: t=3 Ping (seq = 1) + RunTimeline::mission.instrument => RunTimeline::mission.controller: t=3 Ack (seq = 1) + RunTimeline::mission.controller => RunTimeline::mission.instrument: t=5 Ping (seq = 1) + RunTimeline::mission.instrument => RunTimeline::mission.controller: t=5 Ack (seq = 1) diff --git a/internal/exec/runtrace/testdata/example-timeline.mermaid.golden b/internal/exec/runtrace/testdata/example-timeline.mermaid.golden index 98adebe147..30a5783864 100644 --- a/internal/exec/runtrace/testdata/example-timeline.mermaid.golden +++ b/internal/exec/runtrace/testdata/example-timeline.mermaid.golden @@ -24,17 +24,24 @@ config: edgeLabelBackground: "#FFFFFF" --- %% run — timeline rendering (the trace of a run to t = 6) +%% not represented: 1 state held for no time is listed in the text form %% not represented: Mermaid gantt draws no note; choice and guard records are comments %% t=0 choice entering modes: next waiting(entry), charged(entry) (unordered; took waiting(entry) first) gantt dateFormat x axisFormat %M:%S.%L todayMarker off - section #35;2.modes + section RunTimeline#58;#58;mission.controller.modes waiting | charged :l0s0, 0, 1000 - sending | charged (time) :l0s1, 1000, 3000 - sent | charged (time) :l0s2, 3000, 4000 - sent | low (time) :active, l0s3, 4000, 6000 - section #35;4.modes - listening :l1s0, 0, 1000 - received (accept Ping) :active, l1s1, 1000, 6000 + acknowledged | charged (time, accept Ack) :l0s1, 1000, 2000 + waiting | charged (time) :l0s2, 2000, 3000 + acknowledged | charged (time, accept Ack) :l0s3, 3000, 4000 + waiting | charged (time) :l0s4, 4000, 5000 + acknowledged | charged (time, accept Ack) :l0s5, 5000, 6000 + section RunTimeline#58;#58;mission.instrument.modes + waiting :l1s0, 0, 1000 + acknowledged (accept Ping) :l1s1, 1000, 2500 + waiting (time) :l1s2, 2500, 3000 + acknowledged (accept Ping) :l1s3, 3000, 4500 + waiting (time) :l1s4, 4500, 5000 + acknowledged (accept Ping) :active, l1s5, 5000, 6000 diff --git a/internal/exec/runtrace/testdata/example-timeline.plantuml.golden b/internal/exec/runtrace/testdata/example-timeline.plantuml.golden index d3382a447c..29ae86b7b3 100644 --- a/internal/exec/runtrace/testdata/example-timeline.plantuml.golden +++ b/internal/exec/runtrace/testdata/example-timeline.plantuml.golden @@ -1,18 +1,29 @@ @startuml ' run — timeline rendering (the trace of a run to t = 6) -scale 1 as 140 pixels -concise "#2.modes" as l0 -concise "#4.modes" as l1 +scale 1 as 280 pixels +concise "RunTimeline::mission.controller.modes" as l0 +concise "RunTimeline::mission.instrument.modes" as l1 @0 l0 is "waiting | charged" note top of l0 : choice entering modes: next waiting(entry), charged(entry) (unordered; took waiting(entry) first) -l1 is "listening" +l1 is "waiting" @1 -l0 is "sending | charged (time)" -l1 is "received (accept Ping)" +l0 is "acknowledged | charged (time, accept Ack)" +l1 is "acknowledged (accept Ping)" +@2 +l0 is "waiting | charged (time)" +@2.5 +l1 is "waiting (time)" @3 -l0 is "sent | charged (time)" +l0 is "acknowledged | charged (time, accept Ack)" +l1 is "acknowledged (accept Ping)" @4 -l0 is "sent | low (time)" +l0 is "waiting | charged (time)" +@4.5 +l1 is "waiting (time)" +@5 +l0 is "acknowledged | charged (time, accept Ack)" +l1 is "acknowledged (accept Ping)" @6 +l0 is "waiting | charged (time)" @enduml diff --git a/internal/exec/runtrace/testdata/example-timeline.text.golden b/internal/exec/runtrace/testdata/example-timeline.text.golden index 27d03ca690..219aff2349 100644 --- a/internal/exec/runtrace/testdata/example-timeline.text.golden +++ b/internal/exec/runtrace/testdata/example-timeline.text.golden @@ -1,19 +1,39 @@ run - timeline rendering (the trace of a run to t = 6) -#2.modes +RunTimeline::mission.controller.modes 0 .. 1 waiting | charged - 1 .. 3 sending | charged on time - 3 .. 4 sent | charged on time - 4 .. 6 sent | low on time (held at the end) + 1 .. 2 acknowledged | charged (time, accept Ack) + via sending + 2 .. 3 waiting | charged (time) + 3 .. 4 acknowledged | charged (time, accept Ack) + via sending + 4 .. 5 waiting | charged (time) + 5 .. 6 acknowledged | charged (time, accept Ack) + via sending + 6 .. 6 waiting | charged (time) (held at the end) transitions: t=1 waiting -> sending (time) - t=3 sending -> sent (time) - t=4 charged -> low (time) + t=1 sending -> acknowledged (accept Ack) + t=2 acknowledged -> waiting (time) + t=3 waiting -> sending (time) + t=3 sending -> acknowledged (accept Ack) + t=4 acknowledged -> waiting (time) + t=5 waiting -> sending (time) + t=5 sending -> acknowledged (accept Ack) + t=6 acknowledged -> waiting (time) noted: t=0 choice entering modes: next waiting(entry), charged(entry) (unordered; took waiting(entry) first) -#4.modes - 0 .. 1 listening - 1 .. 6 received on accept Ping (held at the end) +RunTimeline::mission.instrument.modes + 0 .. 1 waiting + 1 .. 2.5 acknowledged (accept Ping) + 2.5 .. 3 waiting (time) + 3 .. 4.5 acknowledged (accept Ping) + 4.5 .. 5 waiting (time) + 5 .. 6 acknowledged (accept Ping) (held at the end) transitions: - t=1 listening -> received (accept Ping) + t=1 waiting -> acknowledged (accept Ping) + t=2.5 acknowledged -> waiting (time) + t=3 waiting -> acknowledged (accept Ping) + t=4.5 acknowledged -> waiting (time) + t=5 waiting -> acknowledged (accept Ping) diff --git a/internal/exec/runtrace/testdata/hash-label-timeline.mermaid.golden b/internal/exec/runtrace/testdata/hash-label-timeline.mermaid.golden new file mode 100644 index 0000000000..3a3d9b4f12 --- /dev/null +++ b/internal/exec/runtrace/testdata/hash-label-timeline.mermaid.golden @@ -0,0 +1,32 @@ +--- +config: + fontFamily: "Helvetica, Arial, sans-serif" + theme: base + themeCSS: ".edgeLabel rect { opacity: 1 !important; } .cluster-label .nodeLabel { text-align: center; }" + gantt: + displayMode: compact + themeVariables: + fontFamily: "Helvetica, Arial, sans-serif" + fontSize: "14px" + primaryColor: "#FFFFFF" + secondaryColor: "#FFFFFF" + tertiaryColor: "#FFFFFF" + background: "#FFFFFF" + primaryBorderColor: "#181818" + primaryTextColor: "#000000" + lineColor: "#181818" + textColor: "#000000" + noteBkgColor: "#FEFFDD" + noteBorderColor: "#181818" + noteTextColor: "#000000" + clusterBkg: "#FFFFFF" + clusterBorder: "#181818" + edgeLabelBackground: "#FFFFFF" +--- +%% run — timeline rendering (the trace of a run to t = 1) +gantt + dateFormat x + axisFormat %M:%S.%L + todayMarker off + section #35;7.modes + ready :active, l0s0, 0, 1000 diff --git a/internal/exec/runtrace/testdata/hash-label-timeline.plantuml.golden b/internal/exec/runtrace/testdata/hash-label-timeline.plantuml.golden new file mode 100644 index 0000000000..1ba501621e --- /dev/null +++ b/internal/exec/runtrace/testdata/hash-label-timeline.plantuml.golden @@ -0,0 +1,8 @@ +@startuml +' run — timeline rendering (the trace of a run to t = 1) +scale 1 as 140 pixels +concise "7.modes" as l0 +@0 +l0 is "ready" +@1 +@enduml diff --git a/internal/exec/runtrace/testdata/hash-label-timeline.text.golden b/internal/exec/runtrace/testdata/hash-label-timeline.text.golden new file mode 100644 index 0000000000..c846088c26 --- /dev/null +++ b/internal/exec/runtrace/testdata/hash-label-timeline.text.golden @@ -0,0 +1,4 @@ +run - timeline rendering (the trace of a run to t = 1) + +#7.modes + 0 .. 1 ready (held at the end) diff --git a/internal/exec/runtrace/testdata/self-transition-timeline.text.golden b/internal/exec/runtrace/testdata/self-transition-timeline.text.golden index f553e40436..b46579cbce 100644 --- a/internal/exec/runtrace/testdata/self-transition-timeline.text.golden +++ b/internal/exec/runtrace/testdata/self-transition-timeline.text.golden @@ -2,6 +2,6 @@ run - timeline rendering (the trace of a run to t = 2) #1.modes 0 .. 1 idle - 1 .. 2 idle on accept Tick (held at the end) + 1 .. 2 idle (accept Tick) (held at the end) transitions: t=1 idle -> idle (accept Tick) diff --git a/internal/frontend/repl/query.go b/internal/frontend/repl/query.go index ff49fc785c..5878506830 100644 --- a/internal/frontend/repl/query.go +++ b/internal/frontend/repl/query.go @@ -384,6 +384,20 @@ func (s *Session) instanceName(inst *runtime.Instance) string { return "" } +// runTraceLabel returns the label used for an object in a run rendering. +func (s *Session) runTraceLabel(inst *runtime.Instance) string { + if inst == nil { + return "" + } + if label, ok := s.heldLabel(inst.ID); ok && label != "" { + return label + } + if label := s.instanceName(inst); label != "" { + return label + } + return fmt.Sprintf("#%d", inst.ID) +} + // resolveCheckTarget resolves the element a constraint/requirement check names. // The second result is non-nil for a check that cannot be made at all. func (s *Session) resolveCheckTarget(name string) (checkTarget, *Verdict) { diff --git a/internal/frontend/repl/run_render.go b/internal/frontend/repl/run_render.go index 1b0720e45a..a01cd2bae4 100644 --- a/internal/frontend/repl/run_render.go +++ b/internal/frontend/repl/run_render.go @@ -62,5 +62,5 @@ func (s *Session) runTraceRendering(kind runtrace.Kind) (*view.Rendering, error) trace = s.rtCtx.Trace() until = s.rtCtx.Clock().Now() } - return runtrace.Render(kind, trace, runtrace.Options{Label: s.instanceName, Until: until}) + return runtrace.Render(kind, trace, runtrace.Options{Label: s.runTraceLabel, Until: until}) } diff --git a/internal/frontend/repl/run_render_test.go b/internal/frontend/repl/run_render_test.go index cd502f22b5..ab41a8696b 100644 --- a/internal/frontend/repl/run_render_test.go +++ b/internal/frontend/repl/run_render_test.go @@ -22,6 +22,16 @@ func TestRenderRunBuildsTimelineAndSequence(t *testing.T) { wants(t, run(t, s, "%render-run sequence text"), "run - sequence rendering", "go") } +func TestRenderRunUsesHeldObjectPathLabels(t *testing.T) { + s := loadFixture(t, "testdata/run_render_nested.sysml") + run(t, s, "%trace on") + run(t, s, "%instantiate mission") + run(t, s, `%state "NestedRun::Controller::modes NestedRun::mission.controller"`) + run(t, s, "%advance 1") + + wants(t, run(t, s, "%render-run timeline"), "NestedRun::mission.controller.modes") +} + func TestRenderRunReportsMissingTraceAndRefusesDot(t *testing.T) { fresh := NewSession() wants(t, run(t, fresh, "%render-run timeline"), "error: the session records no trace; %trace on before the run") diff --git a/internal/frontend/repl/testdata/run_render_nested.sysml b/internal/frontend/repl/testdata/run_render_nested.sysml new file mode 100644 index 0000000000..b0f3a0a86d --- /dev/null +++ b/internal/frontend/repl/testdata/run_render_nested.sysml @@ -0,0 +1,17 @@ +package NestedRun { + private import SI::*; + + part def Controller { + exhibit state modes { + entry; then waiting; + state waiting; + transition first waiting accept after 1 [s] then done; + } + } + + part def Mission { + part controller : Controller; + } + + part mission : Mission; +} diff --git a/internal/ir/view/data.go b/internal/ir/view/data.go index f30f756892..1e4569edaa 100644 --- a/internal/ir/view/data.go +++ b/internal/ir/view/data.go @@ -13,6 +13,8 @@ type Data struct { Stated string // Run marks a rendering of a run's trace rather than of a view. Run bool + // RunUntil is the clock instant through which a run rendering was recorded. + RunUntil float64 // Nodes are every node of the rendering, parents before children, each // naming its parent. Nodes []NodeData @@ -85,15 +87,16 @@ type RowData struct { // Data is the rendering in machine-consumable form. func (r *Rendering) Data() Data { out := Data{ - View: r.View, - Kind: r.Kind, - Stated: r.Stated, - Run: r.Run, - Columns: r.Columns, - Lanes: r.Lanes, - Canvas: r.Canvas, - Notes: r.Notes, - Notices: r.Notices, + View: r.View, + Kind: r.Kind, + Stated: r.Stated, + Run: r.Run, + RunUntil: r.RunUntil, + Columns: r.Columns, + Lanes: r.Lanes, + Canvas: r.Canvas, + Notes: r.Notes, + Notices: r.Notices, } for _, root := range r.Roots { out.Nodes = appendNodeData(out.Nodes, root, "") diff --git a/internal/ir/view/plantuml.go b/internal/ir/view/plantuml.go index 8c55745ae8..1dab7a2155 100644 --- a/internal/ir/view/plantuml.go +++ b/internal/ir/view/plantuml.go @@ -462,7 +462,7 @@ func plantumlQuote(text string) string { } // plantumlText writes text so PlantUML shows it as it is. A quote, a backslash, -// an angle bracket and the creole escape `~` become `` escapes, as does +// a hash, an angle bracket and the creole escape `~` become `` escapes, as does // each of a run of the characters creole reads doubled (`**`, `//`, `__`, `--`, // `[[`, `]]`); a newline becomes `\n`. func plantumlText(text string) string { @@ -472,7 +472,7 @@ func plantumlText(text string) string { switch { case c == '\n': out.WriteString(`\n`) - case strings.ContainsRune(`"\<>~`, c), + case strings.ContainsRune(`"#\<>~`, c), strings.ContainsRune("*/_-[]", c) && (i > 0 && runes[i-1] == c || i+1 < len(runes) && runes[i+1] == c): fmt.Fprintf(&out, "", c) default: diff --git a/internal/ir/view/plantuml_test.go b/internal/ir/view/plantuml_test.go index f296b7fa7b..af352fe4a4 100644 --- a/internal/ir/view/plantuml_test.go +++ b/internal/ir/view/plantuml_test.go @@ -458,6 +458,7 @@ func TestPlantUMLSequencePaletteFillsParticipants(t *testing.T) { func TestPlantUMLEscapesLabels(t *testing.T) { cases := map[string]string{ `say "hi"`: `say hi`, + `#`: `id`, `a\b`: `ab`, "xy": "xby", "a**b //c d__e f--g": "ab c de fg", diff --git a/internal/ir/view/run_timeline.go b/internal/ir/view/run_timeline.go index 49ebf6b0f3..334f35ddb1 100644 --- a/internal/ir/view/run_timeline.go +++ b/internal/ir/view/run_timeline.go @@ -28,7 +28,7 @@ func writeRunTimelineText(b *strings.Builder, r *Rendering) { for i, span := range lane.Spans { fmt.Fprintf(b, " %s%s %s", times[i], strings.Repeat(" ", width-utf8.RuneCountInString(times[i])), span.State) if len(span.Triggers) > 0 { - fmt.Fprintf(b, " on %s", strings.Join(span.Triggers, ", ")) + fmt.Fprintf(b, " (%s)", strings.Join(span.Triggers, ", ")) } if span.Open { b.WriteString(" (held at the end)") @@ -106,7 +106,7 @@ func (r *Rendering) runTimelineMermaid(options Options) string { func (r *Rendering) mermaidTimelineNotices() []string { var notices []string zero := 0 - fractional := !wholeMillisecond(runUntil(r)) + fractional := !wholeMillisecond(r.RunUntil) for _, lane := range r.Lanes { for _, span := range lane.Spans { if span.To <= span.From { @@ -132,7 +132,7 @@ func (r *Rendering) mermaidTimelineNotices() []string { if fractional { notices = append(notices, "instants are rounded to the nearest millisecond") } - if runUntil(r) >= 3600 { + if r.RunUntil >= 3600 { notices = append(notices, "the axis reads minutes and seconds; it wraps past an hour") } for _, lane := range r.Lanes { @@ -159,12 +159,12 @@ func (r *Rendering) runTimelinePlantUML() string { fmt.Fprintf(&b, "' %s\n@enduml\n", r.EmptyReason()) return b.String() } - fmt.Fprintf(&b, "scale 1 as %d pixels\n", timelineScale(r.Lanes, runUntil(r))) + fmt.Fprintf(&b, "scale 1 as %d pixels\n", timelineScale(r.Lanes, r.RunUntil)) for _, lane := range r.Lanes { fmt.Fprintf(&b, "concise %s as %s\n", plantumlQuote(plantumlText(lane.Name)), lane.ID) } var changes []timelineChange - until := runUntil(r) + until := r.RunUntil for _, lane := range r.Lanes { for i, span := range lane.Spans { changes = append(changes, timelineChange{span.From, fmt.Sprintf("%s is %s", lane.ID, @@ -226,28 +226,6 @@ func timelineStateLabel(span Span) string { return label } -func runUntil(r *Rendering) float64 { - const prefix = "the trace of a run to t = " - if strings.HasPrefix(r.Stated, prefix) { - if until, err := strconv.ParseFloat(strings.TrimPrefix(r.Stated, prefix), 64); err == nil { - return until - } - } - var until float64 - for _, lane := range r.Lanes { - for _, span := range lane.Spans { - until = math.Max(until, span.To) - } - for _, transition := range lane.Transitions { - until = math.Max(until, transition.At) - } - for _, mark := range lane.Marks { - until = math.Max(until, mark.At) - } - } - return until -} - func runInstant(instant float64) string { return strconv.FormatFloat(instant, 'g', -1, 64) } diff --git a/internal/ir/view/run_timeline_test.go b/internal/ir/view/run_timeline_test.go index 221fe473fc..aa615c15bb 100644 --- a/internal/ir/view/run_timeline_test.go +++ b/internal/ir/view/run_timeline_test.go @@ -38,7 +38,7 @@ func TestTimelineKindIsOnlyAStandaloneRunKind(t *testing.T) { func TestRunTimelineTextWritesLanesSpansTransitionsAndMarks(t *testing.T) { rendering := &Rendering{ - Kind: KindTimeline, Run: true, Stated: "the trace of a run to t = 4", + Kind: KindTimeline, Run: true, RunUntil: 4, Stated: "the trace of a run to t = 4", Lanes: []Lane{{ ID: "l0", Name: "T::rover.Machine", Spans: []Span{ @@ -55,7 +55,7 @@ func TestRunTimelineTextWritesLanesSpansTransitionsAndMarks(t *testing.T) { "run - timeline rendering (the trace of a run to t = 4)", "T::rover.Machine", "0 .. 1.5 closed", - "1.5 .. 4 open | parked on accept Open (held at the end)", + "1.5 .. 4 open | parked (accept Open) (held at the end)", " via a, b", "transitions:", "t=1.5 closed -> open (accept Open)", @@ -68,14 +68,14 @@ func TestRunTimelineTextWritesLanesSpansTransitionsAndMarks(t *testing.T) { t.Errorf("timeline text is missing %q:\n%s", want, text) } } - if !rendering.Data().Run || len(rendering.Data().Lanes) != 1 { + if !rendering.Data().Run || rendering.Data().RunUntil != 4 || len(rendering.Data().Lanes) != 1 { t.Errorf("timeline data = %+v, want its run and lane", rendering.Data()) } } func TestRunTimelineMermaidWritesCompactGantt(t *testing.T) { rendering := &Rendering{ - Kind: KindTimeline, Run: true, Stated: "the trace of a run to t = 3600.5", + Kind: KindTimeline, Run: true, RunUntil: 3600.5, Stated: "the trace of a run to t = 3600.5", Lanes: []Lane{{ ID: "l0", Name: "lane: # ;", Spans: []Span{ @@ -115,7 +115,7 @@ func TestRunTimelineMermaidWritesCompactGantt(t *testing.T) { func TestRunTimelinePlantUMLWritesTimingChanges(t *testing.T) { rendering := &Rendering{ - Kind: KindTimeline, Run: true, Stated: "the trace of a run to t = 3", + Kind: KindTimeline, Run: true, RunUntil: 3, Stated: "the trace of a run to t = 3", Lanes: []Lane{{ ID: "l0", Name: "Rover", Spans: []Span{ @@ -146,8 +146,22 @@ func TestRunTimelinePlantUMLWritesTimingChanges(t *testing.T) { } } +func TestRunTimelinePlantUMLUsesRunUntilField(t *testing.T) { + rendering := &Rendering{ + Kind: KindTimeline, Run: true, RunUntil: 7, Stated: "the trace of a run to t = 4", + Lanes: []Lane{{ID: "l0", Name: "Rover", Spans: []Span{{State: "moving", From: 1, To: 2, Open: true}}}}, + } + puml, err := rendering.PlantUML() + if err != nil { + t.Fatalf("PlantUML: %v", err) + } + if !strings.Contains(puml, "@7\n@enduml") { + t.Errorf("PlantUML ignored RunUntil:\n%s", puml) + } +} + func TestRunTimelineAndSequenceEmptyMessages(t *testing.T) { - timeline := &Rendering{Kind: KindTimeline, Run: true, Stated: "the trace of a run to t = 4"} + timeline := &Rendering{Kind: KindTimeline, Run: true, RunUntil: 4, Stated: "the trace of a run to t = 4"} if !timeline.Empty() || !timeline.blank() { t.Errorf("empty timeline: Empty=%t blank=%t", timeline.Empty(), timeline.blank()) } @@ -162,7 +176,7 @@ func TestRunTimelineAndSequenceEmptyMessages(t *testing.T) { if err != nil || !strings.Contains(puml, timeline.EmptyReason()) { t.Errorf("empty timeline PlantUML = %q, %v", puml, err) } - sequence := &Rendering{Kind: KindSequence, Run: true, Stated: "the trace of a run to t = 4"} + sequence := &Rendering{Kind: KindSequence, Run: true, RunUntil: 4, Stated: "the trace of a run to t = 4"} if got := sequence.EmptyReason(); got != "the run recorded no message; the rendering is empty" { t.Errorf("sequence empty reason = %q", got) } @@ -173,7 +187,7 @@ func TestRunTimelineAndSequenceEmptyMessages(t *testing.T) { func TestRunSequenceHeadersDoNotChangeItsWriters(t *testing.T) { rendering := &Rendering{ - Kind: KindSequence, Run: true, Stated: "the trace of a run to t = 2", + Kind: KindSequence, Run: true, RunUntil: 2, Stated: "the trace of a run to t = 2", Roots: []*Node{{ID: "n0", Kind: "object", Name: "A"}, {ID: "n1", Kind: "object", Name: "B"}}, Edges: []Edge{{From: "n0", To: "n1", Label: "t=1 Ping", Kind: EdgeFlow}}, } diff --git a/internal/ir/view/view.go b/internal/ir/view/view.go index 36f91906cf..9c6c4d8e44 100644 --- a/internal/ir/view/view.go +++ b/internal/ir/view/view.go @@ -319,6 +319,8 @@ type Rendering struct { Stated string // Run marks a rendering of a run's trace rather than of a view. Run bool + // RunUntil is the clock instant through which a run rendering was recorded. + RunUntil float64 // Roots are the top-level nodes, in the order the view exposes them. Roots []*Node // Edges join nodes, in the order the model and the lowered graphs give them. From b08e815e0e4df6855cb764c4692408b030dbfd98 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 03:36:41 +0000 Subject: [PATCH 03/13] fix(view): keep run rendering labels literal Co-Authored-By: jason.han --- .../unreleased/run-trace-renderings.fixed.md | 2 +- .../testdata/example-sequence.mermaid.golden | 4 +- .../testdata/example-sequence.plantuml.golden | 4 +- .../testdata/example-timeline.text.golden | 9 ++-- internal/ir/view/mermaid.go | 6 ++- internal/ir/view/plantuml.go | 6 ++- internal/ir/view/run_timeline.go | 6 +-- internal/ir/view/run_timeline_test.go | 48 ++++++++++++++++++- internal/ir/view/sequence.go | 14 ++++++ 9 files changed, 81 insertions(+), 18 deletions(-) diff --git a/changes/unreleased/run-trace-renderings.fixed.md b/changes/unreleased/run-trace-renderings.fixed.md index b0a9f97b9d..72c9dec7d2 100644 --- a/changes/unreleased/run-trace-renderings.fixed.md +++ b/changes/unreleased/run-trace-renderings.fixed.md @@ -1 +1 @@ -- **Correct run rendering labels and output details.** Run diagrams preserve held object paths, explicit end instants, literal PlantUML labels, payload-bearing sibling messages and text output when written to stdout. +- **Keep run rendering output specific to sessions.** Run diagrams preserve held object paths, explicit end instants, payload-bearing sibling messages and text output when written to stdout. Timeline span origins appear on their span line, and sequence lifelines preserve full session paths rather than applying model-name formatting. diff --git a/internal/exec/runtrace/testdata/example-sequence.mermaid.golden b/internal/exec/runtrace/testdata/example-sequence.mermaid.golden index 4727885794..651fb56e2f 100644 --- a/internal/exec/runtrace/testdata/example-sequence.mermaid.golden +++ b/internal/exec/runtrace/testdata/example-sequence.mermaid.golden @@ -32,8 +32,8 @@ config: --- %% run — sequence rendering (the trace of a run to t = 6) sequenceDiagram - participant n0 as «object»
'mission.controller' : Controller - participant n1 as «object»
'mission.instrument' : Instrument + participant n0 as RunTimeline::mission.controller : Controller + participant n1 as RunTimeline::mission.instrument : Instrument n0->>n1: t=1 Ping (seq = 1) n1->>n0: t=1 Ack (seq = 1) n0->>n1: t=3 Ping (seq = 1) diff --git a/internal/exec/runtrace/testdata/example-sequence.plantuml.golden b/internal/exec/runtrace/testdata/example-sequence.plantuml.golden index 0c318792c4..3d8bdf033f 100644 --- a/internal/exec/runtrace/testdata/example-sequence.plantuml.golden +++ b/internal/exec/runtrace/testdata/example-sequence.plantuml.golden @@ -40,8 +40,8 @@ note { skinparam wrapWidth 300 hide stereotype -participant "//«object»//\n**'mission.controller' : Controller**" as n0 <> <> -participant "//«object»//\n**'mission.instrument' : Instrument**" as n1 <> <> +participant "RunTimeline::mission.controller : Controller" as n0 <> <> +participant "RunTimeline::mission.instrument : Instrument" as n1 <> <> n0 -> n1 : t=1 Ping (seq = 1) n1 -> n0 : t=1 Ack (seq = 1) n0 -> n1 : t=3 Ping (seq = 1) diff --git a/internal/exec/runtrace/testdata/example-timeline.text.golden b/internal/exec/runtrace/testdata/example-timeline.text.golden index 219aff2349..cf351312ba 100644 --- a/internal/exec/runtrace/testdata/example-timeline.text.golden +++ b/internal/exec/runtrace/testdata/example-timeline.text.golden @@ -2,14 +2,11 @@ run - timeline rendering (the trace of a run to t = 6) RunTimeline::mission.controller.modes 0 .. 1 waiting | charged - 1 .. 2 acknowledged | charged (time, accept Ack) - via sending + 1 .. 2 acknowledged | charged (time, accept Ack) via sending 2 .. 3 waiting | charged (time) - 3 .. 4 acknowledged | charged (time, accept Ack) - via sending + 3 .. 4 acknowledged | charged (time, accept Ack) via sending 4 .. 5 waiting | charged (time) - 5 .. 6 acknowledged | charged (time, accept Ack) - via sending + 5 .. 6 acknowledged | charged (time, accept Ack) via sending 6 .. 6 waiting | charged (time) (held at the end) transitions: t=1 waiting -> sending (time) diff --git a/internal/ir/view/mermaid.go b/internal/ir/view/mermaid.go index 1ded7e48c7..1df33a1e44 100644 --- a/internal/ir/view/mermaid.go +++ b/internal/ir/view/mermaid.go @@ -1569,7 +1569,11 @@ func (r *Rendering) writeSequenceDiagram(b *strings.Builder, labels labeller, op return } for _, node := range r.Roots { - fmt.Fprintf(b, " participant %s as %s\n", node.ID, labels.mermaid(node)) + label := labels.mermaid(node) + if r.Run { + label = mermaidText(runParticipantLabel(node)) + } + fmt.Fprintf(b, " participant %s as %s\n", node.ID, label) } r.writeSequenceNotes(b) writtenNotes := map[int]bool{} diff --git a/internal/ir/view/plantuml.go b/internal/ir/view/plantuml.go index 1dab7a2155..aadd380a00 100644 --- a/internal/ir/view/plantuml.go +++ b/internal/ir/view/plantuml.go @@ -339,7 +339,11 @@ func (w *plantumlWriter) writeSequenceDiagram(r *Rendering) { return } for _, node := range r.Roots { - fmt.Fprintf(b, "participant %s as %s%s\n", plantumlQuote(w.plantumlLabel(node)), node.ID, w.decoration(node)) + label := w.plantumlLabel(node) + if r.Run { + label = plantumlText(runParticipantLabel(node)) + } + fmt.Fprintf(b, "participant %s as %s%s\n", plantumlQuote(label), node.ID, w.decoration(node)) } for _, edge := range r.Edges { w.writeArrow("", edge.From, edge.To, "->", edge.Label) diff --git a/internal/ir/view/run_timeline.go b/internal/ir/view/run_timeline.go index 334f35ddb1..3b641e01a1 100644 --- a/internal/ir/view/run_timeline.go +++ b/internal/ir/view/run_timeline.go @@ -30,13 +30,13 @@ func writeRunTimelineText(b *strings.Builder, r *Rendering) { if len(span.Triggers) > 0 { fmt.Fprintf(b, " (%s)", strings.Join(span.Triggers, ", ")) } + if len(span.Through) > 0 { + fmt.Fprintf(b, " via %s", strings.Join(span.Through, ", ")) + } if span.Open { b.WriteString(" (held at the end)") } b.WriteString("\n") - if len(span.Through) > 0 { - fmt.Fprintf(b, " via %s\n", strings.Join(span.Through, ", ")) - } } if len(lane.Transitions) > 0 { b.WriteString(" transitions:\n") diff --git a/internal/ir/view/run_timeline_test.go b/internal/ir/view/run_timeline_test.go index aa615c15bb..03174b047e 100644 --- a/internal/ir/view/run_timeline_test.go +++ b/internal/ir/view/run_timeline_test.go @@ -55,8 +55,7 @@ func TestRunTimelineTextWritesLanesSpansTransitionsAndMarks(t *testing.T) { "run - timeline rendering (the trace of a run to t = 4)", "T::rover.Machine", "0 .. 1.5 closed", - "1.5 .. 4 open | parked (accept Open) (held at the end)", - " via a, b", + "1.5 .. 4 open | parked (accept Open) via a, b (held at the end)", "transitions:", "t=1.5 closed -> open (accept Open)", "noted:", @@ -205,3 +204,48 @@ func TestRunSequenceHeadersDoNotChangeItsWriters(t *testing.T) { t.Errorf("run sequence PlantUML = %q, %v", puml, err) } } + +func TestRunSequenceParticipantLabelsPreserveSessionPaths(t *testing.T) { + rendering := &Rendering{ + Kind: KindSequence, + Run: true, + Roots: []*Node{ + {ID: "n0", Kind: "object", Name: "RunTimeline::mission.controller", Type: "Controller"}, + {ID: "n1", Kind: "object", Name: "RunTimeline::mission.instrument", Type: "Instrument"}, + }, + } + mermaid := rendering.Mermaid() + for _, want := range []string{ + "participant n0 as RunTimeline::mission.controller : Controller", + "participant n1 as RunTimeline::mission.instrument : Instrument", + } { + if !strings.Contains(mermaid, want) { + t.Errorf("run Mermaid is missing %q:\n%s", want, mermaid) + } + } + puml, err := rendering.PlantUML() + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + `participant "RunTimeline::mission.controller : Controller" as n0`, + `participant "RunTimeline::mission.instrument : Instrument" as n1`, + } { + if !strings.Contains(puml, want) { + t.Errorf("run PlantUML is missing %q:\n%s", want, puml) + } + } + + model := *rendering + model.Run = false + if got, want := model.Mermaid(), " participant n0 as «object»
'mission.controller' : Controller"; !strings.Contains(got, want) { + t.Errorf("model Mermaid participant changed: want %q in\n%s", want, got) + } + modelPUML, err := model.PlantUML() + if err != nil { + t.Fatal(err) + } + if want := `**'mission.controller' : Controller**`; !strings.Contains(modelPUML, want) { + t.Errorf("model PlantUML participant changed: want %q in\n%s", want, modelPUML) + } +} diff --git a/internal/ir/view/sequence.go b/internal/ir/view/sequence.go index ac769c7a55..cf7a0ec1fc 100644 --- a/internal/ir/view/sequence.go +++ b/internal/ir/view/sequence.go @@ -10,6 +10,20 @@ import ( "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) +func runParticipantLabel(node *Node) string { + label := node.Name + if node.Type != "" { + if label != "" { + label += " : " + } + label += node.Type + } + if label == "" { + return node.Kind + } + return label +} + // renderSequence renders the occurrences of an interaction as lifelines and the // flows between them as directed messages, ordered as the model states. A // lifeline list is flat — a sequence diagram nests nothing — so a message From 26122f0aa27c0effc80d9df588d691f5657df35f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 03:37:11 +0000 Subject: [PATCH 04/13] chore(changes): drop the fix fragment for an unreleased feature Co-Authored-By: jason.han --- changes/unreleased/run-trace-renderings.fixed.md | 1 - 1 file changed, 1 deletion(-) delete mode 100644 changes/unreleased/run-trace-renderings.fixed.md diff --git a/changes/unreleased/run-trace-renderings.fixed.md b/changes/unreleased/run-trace-renderings.fixed.md deleted file mode 100644 index 72c9dec7d2..0000000000 --- a/changes/unreleased/run-trace-renderings.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- **Keep run rendering output specific to sessions.** Run diagrams preserve held object paths, explicit end instants, payload-bearing sibling messages and text output when written to stdout. Timeline span origins appear on their span line, and sequence lifelines preserve full session paths rather than applying model-name formatting. From 345f65faadefff7f29a4747ca07e7fbbf1c2ce29 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 03:48:53 +0000 Subject: [PATCH 05/13] fix(view): improve run timeline sizing Co-Authored-By: jason.han --- .../testdata/empty-timeline.mermaid.golden | 1 + .../testdata/example-timeline.mermaid.golden | 1 + .../testdata/example-timeline.plantuml.golden | 4 +- .../hash-label-timeline.mermaid.golden | 1 + .../hash-label-timeline.plantuml.golden | 1 + internal/ir/view/mermaid.go | 1 + internal/ir/view/run_timeline.go | 48 ++++++++++++++----- internal/ir/view/run_timeline_test.go | 39 +++++++++++++-- 8 files changed, 79 insertions(+), 17 deletions(-) diff --git a/internal/exec/runtrace/testdata/empty-timeline.mermaid.golden b/internal/exec/runtrace/testdata/empty-timeline.mermaid.golden index ac38284459..f1caa16891 100644 --- a/internal/exec/runtrace/testdata/empty-timeline.mermaid.golden +++ b/internal/exec/runtrace/testdata/empty-timeline.mermaid.golden @@ -5,6 +5,7 @@ config: themeCSS: ".edgeLabel rect { opacity: 1 !important; } .cluster-label .nodeLabel { text-align: center; }" gantt: displayMode: compact + leftPadding: 75 themeVariables: fontFamily: "Helvetica, Arial, sans-serif" fontSize: "14px" diff --git a/internal/exec/runtrace/testdata/example-timeline.mermaid.golden b/internal/exec/runtrace/testdata/example-timeline.mermaid.golden index 30a5783864..72136eebf1 100644 --- a/internal/exec/runtrace/testdata/example-timeline.mermaid.golden +++ b/internal/exec/runtrace/testdata/example-timeline.mermaid.golden @@ -5,6 +5,7 @@ config: themeCSS: ".edgeLabel rect { opacity: 1 !important; } .cluster-label .nodeLabel { text-align: center; }" gantt: displayMode: compact + leftPadding: 283 themeVariables: fontFamily: "Helvetica, Arial, sans-serif" fontSize: "14px" diff --git a/internal/exec/runtrace/testdata/example-timeline.plantuml.golden b/internal/exec/runtrace/testdata/example-timeline.plantuml.golden index 29ae86b7b3..5757b79065 100644 --- a/internal/exec/runtrace/testdata/example-timeline.plantuml.golden +++ b/internal/exec/runtrace/testdata/example-timeline.plantuml.golden @@ -1,6 +1,6 @@ @startuml ' run — timeline rendering (the trace of a run to t = 6) -scale 1 as 280 pixels +scale 1 as 311 pixels concise "RunTimeline::mission.controller.modes" as l0 concise "RunTimeline::mission.instrument.modes" as l1 @0 @@ -26,4 +26,6 @@ l0 is "acknowledged | charged (time, accept Ack)" l1 is "acknowledged (accept Ping)" @6 l0 is "waiting | charged (time)" +l0 is {hidden} +l1 is {hidden} @enduml diff --git a/internal/exec/runtrace/testdata/hash-label-timeline.mermaid.golden b/internal/exec/runtrace/testdata/hash-label-timeline.mermaid.golden index 3a3d9b4f12..849487d8c8 100644 --- a/internal/exec/runtrace/testdata/hash-label-timeline.mermaid.golden +++ b/internal/exec/runtrace/testdata/hash-label-timeline.mermaid.golden @@ -5,6 +5,7 @@ config: themeCSS: ".edgeLabel rect { opacity: 1 !important; } .cluster-label .nodeLabel { text-align: center; }" gantt: displayMode: compact + leftPadding: 80 themeVariables: fontFamily: "Helvetica, Arial, sans-serif" fontSize: "14px" diff --git a/internal/exec/runtrace/testdata/hash-label-timeline.plantuml.golden b/internal/exec/runtrace/testdata/hash-label-timeline.plantuml.golden index 1ba501621e..d431c874c1 100644 --- a/internal/exec/runtrace/testdata/hash-label-timeline.plantuml.golden +++ b/internal/exec/runtrace/testdata/hash-label-timeline.plantuml.golden @@ -5,4 +5,5 @@ concise "7.modes" as l0 @0 l0 is "ready" @1 +l0 is {hidden} @enduml diff --git a/internal/ir/view/mermaid.go b/internal/ir/view/mermaid.go index 1df33a1e44..7bc94b68c9 100644 --- a/internal/ir/view/mermaid.go +++ b/internal/ir/view/mermaid.go @@ -495,6 +495,7 @@ func (r *Rendering) writeMermaidFrontmatter(b *strings.Builder, labels labeller, } if r.Run && r.Kind == KindTimeline { b.WriteString(" gantt:\n displayMode: compact\n") + fmt.Fprintf(b, " leftPadding: %d\n", runTimelineMermaidLeftPadding(r.Lanes)) } b.WriteString(" themeVariables:\n") variables := []themeVariable{ diff --git a/internal/ir/view/run_timeline.go b/internal/ir/view/run_timeline.go index 3b641e01a1..8b0dd6c3da 100644 --- a/internal/ir/view/run_timeline.go +++ b/internal/ir/view/run_timeline.go @@ -159,7 +159,7 @@ func (r *Rendering) runTimelinePlantUML() string { fmt.Fprintf(&b, "' %s\n@enduml\n", r.EmptyReason()) return b.String() } - fmt.Fprintf(&b, "scale 1 as %d pixels\n", timelineScale(r.Lanes, r.RunUntil)) + fmt.Fprintf(&b, "scale 1 as %s pixels\n", strconv.FormatFloat(timelineScale(r.Lanes, r.RunUntil), 'f', -1, 64)) for _, lane := range r.Lanes { fmt.Fprintf(&b, "concise %s as %s\n", plantumlQuote(plantumlText(lane.Name)), lane.ID) } @@ -169,7 +169,7 @@ func (r *Rendering) runTimelinePlantUML() string { for i, span := range lane.Spans { changes = append(changes, timelineChange{span.From, fmt.Sprintf("%s is %s", lane.ID, plantumlQuote(plantumlText(timelineStateLabel(span))))}) - if !span.Open && i == len(lane.Spans)-1 { + if !span.Open && i == len(lane.Spans)-1 && span.To < until { changes = append(changes, timelineChange{span.To, lane.ID + " is {hidden}"}) } } @@ -177,6 +177,9 @@ func (r *Rendering) runTimelinePlantUML() string { changes = append(changes, timelineChange{mark.At, fmt.Sprintf("note top of %s : %s", lane.ID, plantumlText(mark.Text))}) } } + for _, lane := range r.Lanes { + changes = append(changes, timelineChange{at: until, line: lane.ID + " is {hidden}"}) + } changes = append(changes, timelineChange{at: until}) sort.SliceStable(changes, func(i, j int) bool { return changes[i].at < changes[j].at }) last := math.NaN() @@ -198,26 +201,45 @@ type timelineChange struct { line string } -func timelineScale(lanes []Lane, until float64) int { - shortest := math.Inf(1) +func timelineScale(lanes []Lane, until float64) float64 { + const ( + minimumSpanWidth = 140.0 + characterWidth = 7.0 + labelPadding = 24.0 + maximumWidth = 4000.0 + ) + duration := math.Max(0, until) + scale := 1.0 for _, lane := range lanes { for _, span := range lane.Spans { - if duration := span.To - span.From; duration > 0 { - shortest = math.Min(shortest, duration) + spanDuration := span.To - span.From + if spanDuration > 0 { + duration = math.Max(duration, span.To) + labelWidth := float64(utf8.RuneCountInString(timelineStateLabel(span)))*characterWidth + labelPadding + spanWidth := math.Max(minimumSpanWidth, labelWidth) + scale = math.Max(scale, math.Ceil(spanWidth/spanDuration)) } } } - duration := math.Max(0, until) - scale := 1 - if !math.IsInf(shortest, 1) { - scale = max(1, int(math.Ceil(140/shortest))) - } - if float64(scale)*duration > 2400 && duration > 0 { - scale = max(1, int(2400/duration)) + if duration > 0 && scale*duration > maximumWidth { + scale = maximumWidth / duration } return scale } +func runTimelineMermaidLeftPadding(lanes []Lane) int { + const ( + defaultPadding = 75 + characterWidth = 7 + labelPadding = 24 + ) + padding := defaultPadding + for _, lane := range lanes { + padding = max(padding, utf8.RuneCountInString(lane.Name)*characterWidth+labelPadding) + } + return padding +} + func timelineStateLabel(span Span) string { label := span.State if len(span.Triggers) > 0 { diff --git a/internal/ir/view/run_timeline_test.go b/internal/ir/view/run_timeline_test.go index 03174b047e..b8f82b2f02 100644 --- a/internal/ir/view/run_timeline_test.go +++ b/internal/ir/view/run_timeline_test.go @@ -88,7 +88,7 @@ func TestRunTimelineMermaidWritesCompactGantt(t *testing.T) { mermaid := rendering.Mermaid() for _, want := range []string{ "fontFamily: \"Helvetica, Arial, sans-serif\"", - " gantt:\n displayMode: compact\n", + " gantt:\n displayMode: compact\n leftPadding: 87\n", "%% run — timeline rendering (the trace of a run to t = 3600.5)", "%% t=0.125 guard: x not evaluated", "gantt", @@ -112,6 +112,19 @@ func TestRunTimelineMermaidWritesCompactGantt(t *testing.T) { } } +func TestRunTimelineMermaidLeftPaddingKeepsDefaultMinimum(t *testing.T) { + rendering := &Rendering{ + Kind: KindTimeline, + Run: true, + Lanes: []Lane{{ + ID: "l0", Name: "A", + }}, + } + if got := rendering.Mermaid(); !strings.Contains(got, " leftPadding: 75\n") { + t.Errorf("timeline Mermaid left padding is below the default:\n%s", got) + } +} + func TestRunTimelinePlantUMLWritesTimingChanges(t *testing.T) { rendering := &Rendering{ Kind: KindTimeline, Run: true, RunUntil: 3, Stated: "the trace of a run to t = 3", @@ -134,7 +147,7 @@ func TestRunTimelinePlantUMLWritesTimingChanges(t *testing.T) { `concise "Rover" as l0`, "@0\nl0 is \"idle\"\n", "@1.25\nl0 is \"moving (accept Go)\"\nnote top of l0 : choice: moving\n", - "@3\n@enduml", + "@3\nl0 is {hidden}\n@enduml", } { if !strings.Contains(puml, want) { t.Errorf("timeline PlantUML is missing %q:\n%s", want, puml) @@ -154,11 +167,31 @@ func TestRunTimelinePlantUMLUsesRunUntilField(t *testing.T) { if err != nil { t.Fatalf("PlantUML: %v", err) } - if !strings.Contains(puml, "@7\n@enduml") { + if !strings.Contains(puml, "@7\nl0 is {hidden}\n@enduml") { t.Errorf("PlantUML ignored RunUntil:\n%s", puml) } } +func TestRunTimelinePlantUMLScaleFitsLabelsAndCapsWidth(t *testing.T) { + span := Span{ + State: "acknowledged | charged", From: 0, To: 1, + Triggers: []string{"time", "accept Ack"}, + } + lanes := []Lane{{Spans: []Span{span}}} + scale := timelineScale(lanes, 6) + labelWidth := max(140, len([]rune(timelineStateLabel(span)))*7+24) + if scale < float64(labelWidth) { + t.Errorf("scale = %g pixels/s, want at least %d pixels for the label", scale, labelWidth) + } + if width := scale * 6; width > 4000 { + t.Errorf("timeline width = %g pixels, want at most 4000", width) + } + longScale := timelineScale(lanes, 10_000) + if width := longScale * 10_000; width > 4000 { + t.Errorf("long timeline width = %g pixels, want at most 4000", width) + } +} + func TestRunTimelineAndSequenceEmptyMessages(t *testing.T) { timeline := &Rendering{Kind: KindTimeline, Run: true, RunUntil: 4, Stated: "the trace of a run to t = 4"} if !timeline.Empty() || !timeline.blank() { From 8300b2b859c487e042c53b0e4ffc323326231ab9 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 04:02:33 +0000 Subject: [PATCH 06/13] fix(runtrace): correct empty and zero-duration renderings Co-Authored-By: jason.han --- .../testdata/example-timeline.mermaid.golden | 2 +- .../testdata/example-timeline.plantuml.golden | 2 +- .../hash-label-timeline.mermaid.golden | 2 +- internal/frontend/repl/run_render.go | 7 ++- internal/frontend/repl/run_render_test.go | 21 +++++++ internal/ir/view/run_timeline.go | 59 ++++++++++++------- internal/ir/view/run_timeline_test.go | 56 +++++++++++++++++- 7 files changed, 122 insertions(+), 27 deletions(-) diff --git a/internal/exec/runtrace/testdata/example-timeline.mermaid.golden b/internal/exec/runtrace/testdata/example-timeline.mermaid.golden index 72136eebf1..b49ebb4171 100644 --- a/internal/exec/runtrace/testdata/example-timeline.mermaid.golden +++ b/internal/exec/runtrace/testdata/example-timeline.mermaid.golden @@ -45,4 +45,4 @@ gantt waiting (time) :l1s2, 2500, 3000 acknowledged (accept Ping) :l1s3, 3000, 4500 waiting (time) :l1s4, 4500, 5000 - acknowledged (accept Ping) :active, l1s5, 5000, 6000 + acknowledged (accept Ping) :l1s5, 5000, 6000 diff --git a/internal/exec/runtrace/testdata/example-timeline.plantuml.golden b/internal/exec/runtrace/testdata/example-timeline.plantuml.golden index 5757b79065..f4a2e7aade 100644 --- a/internal/exec/runtrace/testdata/example-timeline.plantuml.golden +++ b/internal/exec/runtrace/testdata/example-timeline.plantuml.golden @@ -1,5 +1,6 @@ @startuml ' run — timeline rendering (the trace of a run to t = 6) +' not represented: 1 state held for no time is listed in the text form scale 1 as 311 pixels concise "RunTimeline::mission.controller.modes" as l0 concise "RunTimeline::mission.instrument.modes" as l1 @@ -25,7 +26,6 @@ l1 is "waiting (time)" l0 is "acknowledged | charged (time, accept Ack)" l1 is "acknowledged (accept Ping)" @6 -l0 is "waiting | charged (time)" l0 is {hidden} l1 is {hidden} @enduml diff --git a/internal/exec/runtrace/testdata/hash-label-timeline.mermaid.golden b/internal/exec/runtrace/testdata/hash-label-timeline.mermaid.golden index 849487d8c8..4b92438f66 100644 --- a/internal/exec/runtrace/testdata/hash-label-timeline.mermaid.golden +++ b/internal/exec/runtrace/testdata/hash-label-timeline.mermaid.golden @@ -30,4 +30,4 @@ gantt axisFormat %M:%S.%L todayMarker off section #35;7.modes - ready :active, l0s0, 0, 1000 + ready :l0s0, 0, 1000 diff --git a/internal/frontend/repl/run_render.go b/internal/frontend/repl/run_render.go index a01cd2bae4..1e36eaa606 100644 --- a/internal/frontend/repl/run_render.go +++ b/internal/frontend/repl/run_render.go @@ -5,7 +5,6 @@ import ( "fmt" "slices" - "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" "github.com/Open-MBEE/OpenSysML/internal/exec/runtrace" "github.com/Open-MBEE/OpenSysML/internal/ir/view" ) @@ -56,10 +55,12 @@ func (s *Session) RunTraceRendering(kind runtrace.Kind) (*view.Rendering, error) } func (s *Session) runTraceRendering(kind runtrace.Kind) (*view.Rendering, error) { - var trace *runtime.TraceRecorder + trace := s.trace var until float64 if s.rtCtx != nil { - trace = s.rtCtx.Trace() + if trace == nil { + trace = s.rtCtx.Trace() + } until = s.rtCtx.Clock().Now() } return runtrace.Render(kind, trace, runtrace.Options{Label: s.runTraceLabel, Until: until}) diff --git a/internal/frontend/repl/run_render_test.go b/internal/frontend/repl/run_render_test.go index ab41a8696b..0e53acc66d 100644 --- a/internal/frontend/repl/run_render_test.go +++ b/internal/frontend/repl/run_render_test.go @@ -1,6 +1,8 @@ package repl import ( + "fmt" + "path/filepath" "slices" "strings" "testing" @@ -47,6 +49,25 @@ func TestRenderRunReportsMissingTraceAndRefusesDot(t *testing.T) { wants(t, run(t, s, "%render-run timeline dot"), "error:", "not written as dot") } +func TestRenderRunWithoutRuntimeContext(t *testing.T) { + examplePath, err := filepath.Abs(filepath.Join("..", "..", "..", "examples", "run-timeline", "run-timeline.sysml")) + if err != nil { + t.Fatalf("resolve example path: %v", err) + } + s := NewSession() + run(t, s, fmt.Sprintf("%%load %q", examplePath)) + run(t, s, "%trace on") + if s.rtCtx != nil { + t.Fatal("loading a model and enabling trace created a runtime context") + } + + wants(t, run(t, s, "%render-run timeline"), "the run recorded no state; the rendering is empty") + wants(t, run(t, s, "%render-run sequence"), "the run recorded no message; the rendering is empty") + + run(t, s, "%trace off") + wants(t, run(t, s, "%render-run timeline"), "error: the session records no trace; %trace on before the run") +} + func TestRenderRunCompletesKindAndForm(t *testing.T) { s := NewSession() if got := s.Complete("%render-run ti", len("%render-run ti")); !slices.Equal(got.Candidates, []string{"timeline"}) { diff --git a/internal/ir/view/run_timeline.go b/internal/ir/view/run_timeline.go index 8b0dd6c3da..c60016b26d 100644 --- a/internal/ir/view/run_timeline.go +++ b/internal/ir/view/run_timeline.go @@ -91,13 +91,8 @@ func (r *Rendering) runTimelineMermaid(options Options) string { continue } label := timelineStateLabel(span) - if span.Open { - fmt.Fprintf(&b, " %s :active, %ss%d, %d, %d\n", - ganttText(label), lane.ID, i, runMilliseconds(span.From), runMilliseconds(span.To)) - } else { - fmt.Fprintf(&b, " %s :%ss%d, %d, %d\n", - ganttText(label), lane.ID, i, runMilliseconds(span.From), runMilliseconds(span.To)) - } + fmt.Fprintf(&b, " %s :%ss%d, %d, %d\n", + ganttText(label), lane.ID, i, runMilliseconds(span.From), runMilliseconds(span.To)) } } return b.String() @@ -105,13 +100,9 @@ func (r *Rendering) runTimelineMermaid(options Options) string { func (r *Rendering) mermaidTimelineNotices() []string { var notices []string - zero := 0 fractional := !wholeMillisecond(r.RunUntil) for _, lane := range r.Lanes { for _, span := range lane.Spans { - if span.To <= span.From { - zero++ - } fractional = fractional || !wholeMillisecond(span.From) || !wholeMillisecond(span.To) } for _, transition := range lane.Transitions { @@ -121,13 +112,8 @@ func (r *Rendering) mermaidTimelineNotices() []string { fractional = fractional || !wholeMillisecond(mark.At) } } - if zero > 0 { - verb := "are" - if zero == 1 { - verb = "is" - } - notices = append(notices, fmt.Sprintf("%d %s held for no time %s listed in the text form", - zero, plural(zero, "state", "states"), verb)) + if notice := timelineZeroSpanNotice(r.Lanes); notice != "" { + notices = append(notices, notice) } if fractional { notices = append(notices, "instants are rounded to the nearest millisecond") @@ -152,7 +138,11 @@ func (r *Rendering) runTimelinePlantUML() string { fmt.Fprintf(&b, " (%s)", r.Stated) } b.WriteString("\n") - for _, notice := range r.Notices { + notices := append([]string(nil), r.Notices...) + if notice := timelineZeroSpanNotice(r.Lanes); notice != "" { + notices = append(notices, notice) + } + for _, notice := range notices { fmt.Fprintf(&b, "' not represented: %s\n", strings.ReplaceAll(notice, "\n", " ")) } if len(r.Lanes) == 0 { @@ -166,10 +156,19 @@ func (r *Rendering) runTimelinePlantUML() string { var changes []timelineChange until := r.RunUntil for _, lane := range r.Lanes { + lastDrawn := -1 for i, span := range lane.Spans { + if span.To > span.From { + lastDrawn = i + } + } + for i, span := range lane.Spans { + if span.To <= span.From { + continue + } changes = append(changes, timelineChange{span.From, fmt.Sprintf("%s is %s", lane.ID, plantumlQuote(plantumlText(timelineStateLabel(span))))}) - if !span.Open && i == len(lane.Spans)-1 && span.To < until { + if !span.Open && i == lastDrawn && span.To < until { changes = append(changes, timelineChange{span.To, lane.ID + " is {hidden}"}) } } @@ -196,6 +195,26 @@ func (r *Rendering) runTimelinePlantUML() string { return b.String() } +func timelineZeroSpanNotice(lanes []Lane) string { + zero := 0 + for _, lane := range lanes { + for _, span := range lane.Spans { + if span.To <= span.From { + zero++ + } + } + } + if zero == 0 { + return "" + } + verb := "are" + if zero == 1 { + verb = "is" + } + return fmt.Sprintf("%d %s held for no time %s listed in the text form", + zero, plural(zero, "state", "states"), verb) +} + type timelineChange struct { at float64 line string diff --git a/internal/ir/view/run_timeline_test.go b/internal/ir/view/run_timeline_test.go index b8f82b2f02..412fddd4a0 100644 --- a/internal/ir/view/run_timeline_test.go +++ b/internal/ir/view/run_timeline_test.go @@ -97,7 +97,7 @@ func TestRunTimelineMermaidWritesCompactGantt(t *testing.T) { "todayMarker off", "section lane#58; #35; #59;", "idle#58; #35;#59;", - ":active, l0s1, 251, 3600500", + "ready (accept Go) :l0s1, 251, 3600500", "1 state held for no time is listed in the text form", "instants are rounded to the nearest millisecond", "the axis reads minutes and seconds; it wraps past an hour", @@ -110,6 +110,9 @@ func TestRunTimelineMermaidWritesCompactGantt(t *testing.T) { if !strings.Contains(mermaid, "%% t=0.125 guard: x not evaluated\ngantt") { t.Errorf("multiline mark escaped the Mermaid comment:\n%s", mermaid) } + if strings.Contains(mermaid, ":active") { + t.Errorf("open timeline span uses the unstyled active tag:\n%s", mermaid) + } } func TestRunTimelineMermaidLeftPaddingKeepsDefaultMinimum(t *testing.T) { @@ -172,6 +175,57 @@ func TestRunTimelinePlantUMLUsesRunUntilField(t *testing.T) { } } +func TestRunTimelinePlantUMLOmitsZeroDurationSpans(t *testing.T) { + rendering := &Rendering{ + Kind: KindTimeline, Run: true, RunUntil: 2, + Lanes: []Lane{{ID: "l0", Name: "Rover", Spans: []Span{ + {State: "first", From: 0, To: 1}, + {State: "middle instant", From: 1, To: 1}, + {State: "following", From: 1, To: 2, Open: true}, + {State: "ending instant", From: 2, To: 2}, + }}}, + } + puml, err := rendering.PlantUML() + if err != nil { + t.Fatalf("PlantUML: %v", err) + } + for _, want := range []string{ + "' not represented: 2 states held for no time are listed in the text form", + "@0\nl0 is \"first\"\n", + "@1\nl0 is \"following\"\n", + "@2\nl0 is {hidden}\n", + } { + if !strings.Contains(puml, want) { + t.Errorf("timeline PlantUML is missing %q:\n%s", want, puml) + } + } + if strings.Contains(puml, "middle instant") || strings.Contains(puml, "ending instant") { + t.Errorf("timeline PlantUML rendered zero-duration state labels:\n%s", puml) + } +} + +func TestRunTimelinePlantUMLClosesBeforeTrailingZeroDurationSpan(t *testing.T) { + rendering := &Rendering{ + Kind: KindTimeline, Run: true, RunUntil: 3, + Lanes: []Lane{{ID: "l0", Name: "Rover", Spans: []Span{ + {State: "stopped", From: 0, To: 1}, + {State: "instant", From: 1, To: 1}, + }}}, + } + puml, err := rendering.PlantUML() + if err != nil { + t.Fatalf("PlantUML: %v", err) + } + for _, want := range []string{ + "@1\nl0 is {hidden}\n", + "@3\nl0 is {hidden}\n", + } { + if !strings.Contains(puml, want) { + t.Errorf("timeline PlantUML is missing %q:\n%s", want, puml) + } + } +} + func TestRunTimelinePlantUMLScaleFitsLabelsAndCapsWidth(t *testing.T) { span := Span{ State: "acknowledged | charged", From: 0, To: 1, From c6a599a66093e46a70ca25e73d901fab6892c735 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 05:47:12 +0000 Subject: [PATCH 07/13] fix(runtrace): address run rendering review findings Co-Authored-By: jason.han --- cmd/sysml/render_run.go | 14 ++ cmd/sysml/render_run_test.go | 55 +++++- docs/manual/query-cookbook.md | 3 +- docs/project/spec-compliance.md | 6 +- docs/project/view-rendering-forms.md | 14 +- docs/reference/cli.md | 4 +- internal/doc/queryexec/event.go | 2 +- internal/doc/queryexec/events.go | 1 + internal/doc/queryexec/events_test.go | 6 + internal/exec/runtime/action_executor.go | 2 +- internal/exec/runtime/context.go | 3 +- internal/exec/runtime/held_image.go | 3 + internal/exec/runtime/held_image_test.go | 12 ++ internal/exec/runtime/occurrence_terminate.go | 2 +- internal/exec/runtime/signal.go | 4 + internal/exec/runtime/signal_test.go | 45 +++++ internal/exec/runtime/state_executor.go | 6 +- internal/exec/runtime/trace.go | 43 +++-- internal/exec/runtime/trace_records_test.go | 2 +- internal/exec/runtrace/render_golden_test.go | 80 ++++++++- internal/exec/runtrace/runtrace_test.go | 167 +++++++++++++++++- internal/exec/runtrace/sequence.go | 15 +- .../testdata/capped-timeline.text.golden | 2 +- .../parallel-regions-timeline.text.golden | 9 + .../runtrace/testdata/parallel-regions.sysml | 26 +++ .../serial-pairing-sequence.text.golden | 9 + .../runtrace/testdata/serial-pairing.sysml | 70 ++++++++ .../testdata/termination-timeline.text.golden | 12 ++ .../exec/runtrace/testdata/termination.sysml | 36 ++++ internal/exec/runtrace/timeline.go | 135 +++++++++----- 30 files changed, 696 insertions(+), 92 deletions(-) create mode 100644 internal/exec/runtrace/testdata/parallel-regions-timeline.text.golden create mode 100644 internal/exec/runtrace/testdata/parallel-regions.sysml create mode 100644 internal/exec/runtrace/testdata/serial-pairing-sequence.text.golden create mode 100644 internal/exec/runtrace/testdata/serial-pairing.sysml create mode 100644 internal/exec/runtrace/testdata/termination-timeline.text.golden create mode 100644 internal/exec/runtrace/testdata/termination.sysml diff --git a/cmd/sysml/render_run.go b/cmd/sysml/render_run.go index dcbf7c39e6..da5d33a669 100644 --- a/cmd/sysml/render_run.go +++ b/cmd/sysml/render_run.go @@ -22,6 +22,10 @@ func runRenderModeMisuse() string { switch { case len(renderRuns) == 0: return "-render-run needs a value of the form =" + case modelChecks.jsonOut && renderRunWritesStdout(): + return "-render-run cannot write to stdout with -json; name a file for the rendering" + case flagGiven("compare-results"): + return "-render-run cannot be combined with -compare-results" case len(modelChecks.actions) == 0 && len(modelChecks.states) == 0 && !modelChecks.advance.given: return "-render-run needs -action, -state or -advance to record a behavior run" case schedule.text == "explore": @@ -50,6 +54,16 @@ func runRenderModeMisuse() string { return "" } +func renderRunWritesStdout() bool { + for _, value := range renderRuns { + _, path, ok := strings.Cut(value, "=") + if ok && path == "-" { + return true + } + } + return false +} + func runRenderTargetsFromFlags() ([]runRenderTarget, error) { targets := make([]runRenderTarget, 0, len(renderRuns)) for _, value := range renderRuns { diff --git a/cmd/sysml/render_run_test.go b/cmd/sysml/render_run_test.go index fdda7321df..e12177051c 100644 --- a/cmd/sysml/render_run_test.go +++ b/cmd/sysml/render_run_test.go @@ -3,6 +3,7 @@ package main import ( "os" "path/filepath" + "sort" "strings" "testing" ) @@ -28,7 +29,13 @@ func TestRenderRunExampleMatchesGoldens(t *testing.T) { "-state", "RunTimeline::Instrument::modes RunTimeline::mission.instrument", "-advance", "6", } - for golden, path := range outputs { + keys := make([]string, 0, len(outputs)) + for golden := range outputs { + keys = append(keys, golden) + } + sort.Strings(keys) + for _, golden := range keys { + path := outputs[golden] kind := "timeline" if strings.Contains(golden, "sequence") { kind = "sequence" @@ -39,7 +46,8 @@ func TestRenderRunExampleMatchesGoldens(t *testing.T) { if got.status != 0 { t.Fatalf("exit status = %d\n%s", got.status, got.output()) } - for golden, path := range outputs { + for _, golden := range keys { + path := outputs[golden] actual, err := os.ReadFile(path) // #nosec G304 -- the path is created by the test. if err != nil { t.Fatal(err) @@ -114,6 +122,49 @@ func TestRenderRunWritesStdoutWithoutLosingTheVerdict(t *testing.T) { } } +func TestRenderRunRefusesJSONStdoutAndCompareResults(t *testing.T) { + binary := buildCLI(t) + dir := t.TempDir() + cases := []struct { + name string + args []string + want string + path string + }{ + { + name: "JSON stdout", + args: []string{ + "-state", "Mission::Cycle", "-advance", "1", "-json", + "-render-run", "timeline=" + filepath.Join(dir, "timeline.txt"), + "-render-run", "sequence=-", + }, + want: "-render-run cannot write to stdout with -json; name a file for the rendering", + path: filepath.Join(dir, "timeline.txt"), + }, + { + name: "compare results", + args: []string{ + "-state", "Mission::Cycle", "-advance", "1", + "-compare-results", filepath.Join(dir, "missing.json"), + "-render-run", "timeline=" + filepath.Join(dir, "timeline.txt"), + }, + want: "-render-run cannot be combined with -compare-results", + path: filepath.Join(dir, "timeline.txt"), + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := check(t, binary, behaviorModel, tc.args...) + if got.status != 2 || !strings.Contains(got.stderr, tc.want) { + t.Errorf("status = %d, want 2 with %q:\n%s", got.status, tc.want, got.output()) + } + if _, err := os.Stat(tc.path); !os.IsNotExist(err) { + t.Errorf("rendering output path exists after refusal: stat error = %v", err) + } + }) + } +} + func TestRenderRunRejectsUnsupportedModesAndForms(t *testing.T) { binary := buildCLI(t) cases := []struct { diff --git a/docs/manual/query-cookbook.md b/docs/manual/query-cookbook.md index a6c6fb9ebc..b8f927245e 100644 --- a/docs/manual/query-cookbook.md +++ b/docs/manual/query-cookbook.md @@ -1823,7 +1823,8 @@ sysml> %run-query Accepted root=spareDome `kind` names the records to keep — `accept`, `send`, `transition`, `entry`, `exit`, `do`, `choice` (a due order or region order the run drew, with -`alternatives` and `taken`) or `guard` (one it could not evaluate), several +`alternatives` and `taken`), `guard` (one it could not evaluate) or `terminate` +(a state machine's performance ending), several separated by commas, `all` by default — and a `source` left out reads every object's records. `since` and `before` take a duration or a bare number of the clock's seconds; a bound that is not a duration (`1 [m]`), or an interval diff --git a/docs/project/spec-compliance.md b/docs/project/spec-compliance.md index 96aa548f6b..bda8feac72 100644 --- a/docs/project/spec-compliance.md +++ b/docs/project/spec-compliance.md @@ -2596,9 +2596,9 @@ boundaries; the landed Track E behavior is recorded in the execution rows and th | Run output | Implementation | Tests | Status | |---|---|---|---| -| State occupancy over clock time, per object machine, with transitions and choice/guard marks | `internal/exec/runtrace/timeline.go` `Timeline`; `internal/ir/view/run_timeline.go` text, Mermaid and PlantUML writers | `internal/exec/runtrace/runtrace_test.go`; run-rendering goldens under `internal/exec/runtrace/testdata/` | ✅ Implemented and tested | -| Ordered sends and accepts between objects, including unmatched and environment messages | `internal/exec/runtrace/sequence.go` `Sequence`; existing `KindSequence` writers | `internal/exec/runtrace/runtrace_test.go`; run-rendering goldens under `internal/exec/runtrace/testdata/` | ✅ Implemented and tested | -| State trace records retain the written state path and innermost orthogonal region | `internal/exec/runtime/trace.go` `TraceRecord`; `internal/exec/runtime/state_executor.go` `StateExecutor.RegionOf` | `internal/exec/runtime/trace_records_test.go` `TestStateTraceRecordsCarryWrittenPathsAndInnermostRegions` | ✅ Implemented and tested | +| State occupancy over clock time, per object machine, with transitions and choice/guard marks; parallel leaves are keyed by state and region, termination closes occupancy, and the 200-span cap retains stable lane prefixes | `internal/exec/runtrace/timeline.go` `Timeline`, `stateKey`, `applyTimelineLimit`; `internal/ir/view/run_timeline.go` text, Mermaid and PlantUML writers | `internal/exec/runtrace/runtrace_test.go`; parallel-region, termination and capped run-rendering goldens under `internal/exec/runtrace/testdata/` | ✅ Implemented and tested | +| Ordered sends and accepts between objects, including unmatched and environment messages; nonzero message serials pair exactly, while serial-zero traces retain FIFO event/target pairing | `internal/exec/runtrace/sequence.go` `Sequence`; `internal/exec/runtime/trace.go` `TraceRecord.Message` | `internal/exec/runtrace/runtrace_test.go`; serial-pairing and existing run-rendering goldens under `internal/exec/runtrace/testdata/` | ✅ Implemented and tested | +| State trace records retain the written state path and innermost orthogonal region; termination records retain their printed line while exposing the ending instant as an `Events(kind = "terminate")` row | `internal/exec/runtime/trace.go` `TraceRecord`, `TraceTerminate`; `internal/exec/runtime/state_executor.go` `StateExecutor.RegionOf`, `StateExecutor.terminateMachine`; `internal/doc/queryexec/events.go` `eventKinds`; `internal/exec/runtrace/timeline.go` | `internal/exec/runtime/trace_records_test.go` `TestStateTraceRecordsCarryWrittenPathsAndInnermostRegions`; runtrace termination fixture and golden | ✅ Implemented and tested | ### Runtime bounds: every limit a model can reach diff --git a/docs/project/view-rendering-forms.md b/docs/project/view-rendering-forms.md index cdcfa3c91e..e0ac80d33c 100644 --- a/docs/project/view-rendering-forms.md +++ b/docs/project/view-rendering-forms.md @@ -880,9 +880,17 @@ is refused for both run kinds: it has no time axis, and the existing sequence wr DOT. Each rendering is capped at 200 spans or messages for readability and to keep renderer input -within practical text-size limits. Later content is reported in a `not represented:` notice. -When a recorder itself dropped earlier events, the rendering starts from the first kept state -entry and reports that senders or acceptors of earlier messages may be missing. +within practical text-size limits. Timelines select spans in stable time order, with lane order +breaking ties, and retain only a prefix of each lane; a lane's last retained span ends at its first +dropped span's start. Later state changes and messages are reported in a `not represented:` notice. +Parallel-region state identity includes both the state path and region path, so same-named states in +sibling regions remain separate leaves. A termination record closes the lane's occupancy at that +instant and adds a `terminate` mark without changing the legacy printed trace line. + +Sequence renderings pair nonzero message serials exactly. Serial-zero records retain the legacy +FIFO pairing by event and target, using only serial-zero sends. When a recorder itself dropped +earlier events, the rendering starts from the first kept state entry and reports that senders or +acceptors of earlier messages may be missing. Run renderings are not embedded in documents. Document backends do not run behaviors: `-render-document` refuses `-state` and `-advance`, and a document `Diagram` kind selects a model diff --git a/docs/reference/cli.md b/docs/reference/cli.md index ea00bd58ba..b8d884b81b 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -482,7 +482,9 @@ sysml examples/run-timeline/run-timeline.sysml \ The output extension selects text (`.txt`), Mermaid (`.mmd`, `.mermaid`) or PlantUML (`.puml`, `.plantuml`); `-render-form` can select a form for another extension. DOT is not available for run output. A run rendering cannot be combined with a model or document rendering, -query, schedule exploration or multi-run analysis. +query, schedule exploration or multi-run analysis. Run output cannot be written to standard output +with `-json`; name a file for each rendering instead. `-render-run` cannot be combined with +`-compare-results`. ## Rendering a view diff --git a/internal/doc/queryexec/event.go b/internal/doc/queryexec/event.go index d8f56795f4..00e787ba57 100644 --- a/internal/doc/queryexec/event.go +++ b/internal/doc/queryexec/event.go @@ -25,7 +25,7 @@ type Event struct { } // Kind names the record's kind as its `kind` property does: `accept`, `send`, -// `transition`, `entry`, `exit`, `do`, `choice`, `guard`. +// `transition`, `entry`, `exit`, `do`, `choice`, `guard`, `terminate`. func (ev Event) Kind() string { return ev.record.Kind.String() } // At is the clock instant the record was made at, in clock units. diff --git a/internal/doc/queryexec/events.go b/internal/doc/queryexec/events.go index 789e403e2d..35fde7c2e9 100644 --- a/internal/doc/queryexec/events.go +++ b/internal/doc/queryexec/events.go @@ -40,6 +40,7 @@ var eventKinds = map[string]runtime.TraceKind{ "do": runtime.TraceDo, "choice": runtime.TraceChoice, "guard": runtime.TraceGuard, + "terminate": runtime.TraceTerminate, } // evaluateEvents lists the trace's records of the requested kinds, made by the diff --git a/internal/doc/queryexec/events_test.go b/internal/doc/queryexec/events_test.go index 5e7ff785d7..44d31e814f 100644 --- a/internal/doc/queryexec/events_test.go +++ b/internal/doc/queryexec/events_test.go @@ -279,6 +279,12 @@ func TestExecuteEventsByKind(t *testing.T) { } } +func TestEventKindsIncludeTerminate(t *testing.T) { + if got, ok := eventKinds["terminate"]; !ok || got != runtime.TraceTerminate { + t.Fatalf("terminate event kind = %q, %t; want %q", got, ok, runtime.TraceTerminate) + } +} + // 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) { diff --git a/internal/exec/runtime/action_executor.go b/internal/exec/runtime/action_executor.go index c175855ac3..a01da04e82 100644 --- a/internal/exec/runtime/action_executor.go +++ b/internal/exec/runtime/action_executor.go @@ -2612,7 +2612,7 @@ func (e *ActionExecutor) awaitSignal(token *Token, accept lower.Accept, usage *a 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) + msg.Serial, acceptedEventName(msg), msg.Payload) } if accept.ParamName == "" { return nil, true, nil diff --git a/internal/exec/runtime/context.go b/internal/exec/runtime/context.go index 61c59464ce..f530bf411f 100644 --- a/internal/exec/runtime/context.go +++ b/internal/exec/runtime/context.go @@ -264,7 +264,8 @@ type Context struct { // 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 + messages []Message + messageSerial uint64 // 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 diff --git a/internal/exec/runtime/held_image.go b/internal/exec/runtime/held_image.go index c257504fb9..58b8cd7cc8 100644 --- a/internal/exec/runtime/held_image.go +++ b/internal/exec/runtime/held_image.go @@ -793,6 +793,9 @@ 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...) + for _, msg := range messages { + dst.messageSerial = max(dst.messageSerial, msg.Serial) + } dst.bus.posts += uint64(len(messages)) dst.workChanged() for sym, ids := range img.occurrences { diff --git a/internal/exec/runtime/held_image_test.go b/internal/exec/runtime/held_image_test.go index b63e4f1d50..b0e1e63707 100644 --- a/internal/exec/runtime/held_image_test.go +++ b/internal/exec/runtime/held_image_test.go @@ -79,6 +79,18 @@ func imageInto(t *testing.T, ctx *Context, objects ...*Instance) *Context { return dst } +func TestHeldImageImportAdvancesTheMessageSerial(t *testing.T) { + _, ctx, bulb := lampBulb(t) + ctx.messages = append(ctx.messages, Message{Object: bulb.ID, SignalType: "Ping", Serial: 41}) + dst := imageInto(t, ctx, bulb) + dst.PostMessage(Message{SignalType: "Pong"}) + + messages := dst.PendingMessages() + if len(messages) != 2 || messages[0].Serial != 41 || messages[1].Serial != 42 { + t.Fatalf("messages = %+v, want imported serial 41 followed by 42", messages) + } +} + // 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) { diff --git a/internal/exec/runtime/occurrence_terminate.go b/internal/exec/runtime/occurrence_terminate.go index 0eed943acc..ccfcfb9f23 100644 --- a/internal/exec/runtime/occurrence_terminate.go +++ b/internal/exec/runtime/occurrence_terminate.go @@ -178,7 +178,7 @@ func (e *StateExecutor) endTerminated() { } abandoned := e.abandonMachine() if e.trace() != nil { - e.trace().RecordStateEndedWithOccurrence(symbolText(e.stateMachine), abandoned) + e.trace().RecordStateEndedWithOccurrence(e.traceOrigin(), symbolText(e.stateMachine), abandoned) } e.state = StateTerminated e.ctx.endPerformanceLife(e.occurrence) diff --git a/internal/exec/runtime/signal.go b/internal/exec/runtime/signal.go index 8d7d718d62..09a447e1ea 100644 --- a/internal/exec/runtime/signal.go +++ b/internal/exec/runtime/signal.go @@ -34,6 +34,8 @@ import ( // 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 { + // Serial is the bus's number for the message, unique within a context's run; 0 for none. + Serial uint64 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 @@ -112,6 +114,8 @@ func (ctx *Context) postFrom(msg Message, from *Instance, behavior *symbols.Symb if msg.Delivery == DeliverAnyone { msg.Delivery = deliveryOf(msg) } + ctx.messageSerial++ + msg.Serial = ctx.messageSerial ctx.messages = append(ctx.messages, msg) ctx.bus.posts++ ctx.workChanged() diff --git a/internal/exec/runtime/signal_test.go b/internal/exec/runtime/signal_test.go index 98628f6ce5..fc0c0b70c5 100644 --- a/internal/exec/runtime/signal_test.go +++ b/internal/exec/runtime/signal_test.go @@ -12,6 +12,51 @@ import ( "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) +func TestMessageSerialsStayMonotonicAcrossSnapshotRestore(t *testing.T) { + _, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, "package P {}")) + trace := NewTraceRecorder() + ctx.SetTrace(trace) + ctx.PostMessage(Message{SignalType: "Ping"}) + snapshot, err := ctx.Snapshot() + if err != nil { + t.Fatalf("snapshot: %v", err) + } + defer snapshot.Release() + + ctx.PostMessage(Message{SignalType: "Ping"}) + snapshot.Restore() + ctx.PostMessage(Message{SignalType: "Ping"}) + + messages := ctx.PendingMessages() + if len(messages) != 2 || messages[0].Serial != 1 || messages[1].Serial != 3 { + t.Fatalf("pending messages = %+v, want serials 1 and 3", messages) + } + records := trace.Records() + if len(records) != 2 || records[0].Message != 1 || records[1].Message != 3 { + t.Fatalf("send records = %+v, want serials 1 and 3", records) + } +} + +func TestMessageSerialsStayMonotonicAcrossJournalRollback(t *testing.T) { + _, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, "package P {}")) + trace := NewTraceRecorder() + ctx.SetTrace(trace) + ctx.PostMessage(Message{SignalType: "Ping"}) + _, rollback := ctx.beginJournal() + ctx.PostMessage(Message{SignalType: "Ping"}) + rollback() + ctx.PostMessage(Message{SignalType: "Ping"}) + + messages := ctx.PendingMessages() + if len(messages) != 2 || messages[0].Serial != 1 || messages[1].Serial != 3 { + t.Fatalf("pending messages = %+v, want serials 1 and 3", messages) + } + records := trace.Records() + if len(records) != 2 || records[0].Message != 1 || records[1].Message != 3 { + t.Fatalf("send records = %+v, want serials 1 and 3", records) + } +} + // A message built outside a send carries its payload by feature name: a feature // redefined under another name is one slot, reported when two entries name it // rather than resolved by map order, and an entry naming no feature is refused. diff --git a/internal/exec/runtime/state_executor.go b/internal/exec/runtime/state_executor.go index 55d98f1c0a..ebbfd2824e 100644 --- a/internal/exec/runtime/state_executor.go +++ b/internal/exec/runtime/state_executor.go @@ -812,9 +812,9 @@ func (e *StateExecutor) recordAccept(event Event, mark int, at float64) { 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) + tr.RecordAcceptAt(mark, origin, payload.Serial, acceptedEventName(payload), payload.Payload) case Call: - tr.RecordAcceptAt(mark, origin, payload.Operation, payload.Args) + tr.RecordAcceptAt(mark, origin, 0, payload.Operation, payload.Args) } } @@ -2370,7 +2370,7 @@ func (e *StateExecutor) terminateMachine(fromName string, trigger ast.Node, stop } abandoned := e.abandonMachine() if e.trace() != nil { - e.trace().RecordStateTerminate(name, abandoned) + e.trace().RecordStateTerminate(e.traceOrigin(), name, abandoned) } e.state = StateTerminated e.ctx.endPerformanceLife(e.occurrence) diff --git a/internal/exec/runtime/trace.go b/internal/exec/runtime/trace.go index e6920bcc72..52e81f8aae 100644 --- a/internal/exec/runtime/trace.go +++ b/internal/exec/runtime/trace.go @@ -38,6 +38,9 @@ const ( TraceChoice // TraceGuard is a guard the run read to report a choice and could not evaluate. TraceGuard + // TraceTerminate is a state machine's performance ending without exiting its states: + // a terminate action, or the end of the occurrence performing it. + TraceTerminate ) // String names the kind as a query reads it. @@ -61,6 +64,8 @@ func (k TraceKind) String() string { return "choice" case TraceGuard: return "guard" + case TraceTerminate: + return "terminate" } return fmt.Sprintf("TraceKind(%d)", int(k)) } @@ -88,6 +93,8 @@ type TraceRecord struct { // accept or send carries; Payload is the message's payload. Event string Payload map[string]Value + // Message is the Serial of the message a send posted or an accept took; 0 for none. + Message uint64 // 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 @@ -127,6 +134,8 @@ func (r TraceRecord) Line() (line string, printed bool) { return fmt.Sprintf("do: %s", r.State), true case TraceChoice, TraceGuard: return r.Note.String(), true + case TraceTerminate: + return r.text, true } return "", false } @@ -303,11 +312,11 @@ func (tr *TraceRecorder) Mark() int { // 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) { +func (tr *TraceRecorder) RecordAcceptAt(mark int, origin TraceOrigin, serial uint64, event string, payload map[string]Value) { if !tr.enabled { return } - record := TraceRecord{Kind: TraceAccept, Origin: origin, Event: event, Payload: payload} + record := TraceRecord{Kind: TraceAccept, Origin: origin, Event: event, Message: serial, 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:]) @@ -325,8 +334,8 @@ func (tr *TraceRecorder) RecordStateTransition(origin TraceOrigin, fromState, to // 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}) +func (tr *TraceRecorder) RecordAccept(origin TraceOrigin, serial uint64, event string, payload map[string]Value) { + tr.add(TraceRecord{Kind: TraceAccept, Origin: origin, Event: event, Message: serial, Payload: payload}) } // RecordSend records a message posted onto the bus by the object at origin, or @@ -336,27 +345,31 @@ func (tr *TraceRecorder) RecordSend(origin TraceOrigin, msg Message, target *Ins if msg.EventName != "" { event = msg.EventName } - tr.add(TraceRecord{Kind: TraceSend, Origin: origin, Event: event, To: msg.Target, Target: target, Payload: msg.Payload}) + tr.add(TraceRecord{Kind: TraceSend, Origin: origin, Event: event, Message: msg.Serial, 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) { +// RecordStateTerminate records a state machine's performance ending without exiting its +// states: a terminate action, or the end of the occurrence performing it. +func (tr *TraceRecorder) RecordStateTerminate(origin TraceOrigin, stop string, abandoned []string) { + text := fmt.Sprintf("terminate: %s", stop) if len(abandoned) == 0 { - tr.line(fmt.Sprintf("terminate: %s", stop)) - return + tr.add(TraceRecord{Kind: TraceTerminate, Origin: origin, text: text}) + } else { + text += fmt.Sprintf(" (do behavior abandoned: %s)", strings.Join(abandoned, ", ")) + tr.add(TraceRecord{Kind: TraceTerminate, Origin: origin, text: text}) } - 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) { +func (tr *TraceRecorder) RecordStateEndedWithOccurrence(origin TraceOrigin, machine string, abandoned []string) { + text := fmt.Sprintf("terminated with occurrence: %s", machine) if len(abandoned) == 0 { - tr.line(fmt.Sprintf("terminated with occurrence: %s", machine)) - return + tr.add(TraceRecord{Kind: TraceTerminate, Origin: origin, text: text}) + } else { + text += fmt.Sprintf(" (do behavior abandoned: %s)", strings.Join(abandoned, ", ")) + tr.add(TraceRecord{Kind: TraceTerminate, Origin: origin, text: text}) } - 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. diff --git a/internal/exec/runtime/trace_records_test.go b/internal/exec/runtime/trace_records_test.go index 2c78c38fbb..8a69126e94 100644 --- a/internal/exec/runtime/trace_records_test.go +++ b/internal/exec/runtime/trace_records_test.go @@ -183,7 +183,7 @@ func TestEventRecorderKeepsTheMostRecentRecords(t *testing.T) { mark := tr.Mark() tr.RecordStateEntry(at(2, nil), "c", "c", "", false) tr.RecordStateEntry(at(3, nil), "d", "d", "", false) - tr.RecordAcceptAt(mark, at(2, nil), "Go", nil) + tr.RecordAcceptAt(mark, at(2, nil), 0, "Go", nil) states := func() string { var out []string for _, r := range tr.Records() { diff --git a/internal/exec/runtrace/render_golden_test.go b/internal/exec/runtrace/render_golden_test.go index 67e68115ac..97ba5f8f4b 100644 --- a/internal/exec/runtrace/render_golden_test.go +++ b/internal/exec/runtrace/render_golden_test.go @@ -41,6 +41,40 @@ func TestRunRenderingGoldens(t *testing.T) { cases = appendForms(cases, "empty-"+string(kind), empty) } + parallelTrace, parallelOptions, _, _ := fixtureRun(t, "parallel-regions.sysml", "box", 2) + parallelTimeline, err := Render(KindTimeline, parallelTrace, parallelOptions) + if err != nil { + t.Fatal(err) + } + cases = append(cases, renderingGolden{name: "parallel-regions-timeline", form: view.FormText, run: parallelTimeline}) + + serialTrace, serialOptions, serialCtx, mission := fixtureRun(t, "serial-pairing.sysml", "mission", 2) + serialLabels := make(map[int64]string) + for _, name := range []string{"alpha", "beta", "receiver"} { + value, err := mission.GetFeatureValue(serialCtx, name) + if err != nil { + t.Fatal(err) + } + id, ok := value.HeldValue().Object() + if !ok { + t.Fatalf("mission.%s does not hold an object", name) + } + serialLabels[id] = "SerialPairing::mission." + name + } + serialOptions.Label = func(inst *runtime.Instance) string { return serialLabels[inst.ID] } + serialSequence, err := Render(KindSequence, serialTrace, serialOptions) + if err != nil { + t.Fatal(err) + } + cases = append(cases, renderingGolden{name: "serial-pairing-sequence", form: view.FormText, run: serialSequence}) + + terminationTrace, terminationOptions, _, _ := fixtureRun(t, "termination.sysml", "mission", 10) + terminationTimeline, err := Render(KindTimeline, terminationTrace, terminationOptions) + if err != nil { + t.Fatal(err) + } + cases = append(cases, renderingGolden{name: "termination-timeline", form: view.FormText, run: terminationTimeline}) + cappedTimeline := longTimeline() cappedTimelineOptions := Options{Until: 201, Limit: 200} capped, err := Render(KindTimeline, cappedTimeline, cappedTimelineOptions) @@ -178,6 +212,42 @@ func exampleRun(t *testing.T) (*runtime.TraceRecorder, Options) { } } +func fixtureRun(t *testing.T, filename, instanceName string, until float64) (*runtime.TraceRecorder, Options, *runtime.Context, *runtime.Instance) { + t.Helper() + path := filepath.Join("testdata", filename) + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + src := source.New(path, data) + file := parser.New(src).ParseFile() + index := libs.NewModelIndex() + index.AddDocument(path, file) + resolver := resolve.New(index) + ctx := runtime.NewContext(runtime.NewModel(semantics.NewModel(resolver), resolver), 100_000) + ctx.SetTrace(runtime.NewTraceRecorder()) + if err := ctx.SetSchedule(runtime.DefaultSchedulePolicy); err != nil { + t.Fatal(err) + } + packageScope := index.DocumentRoot(path).Children()[0] + root, err := ctx.Instantiate(mustSymbol(t, packageScope, instanceName)) + if err != nil { + t.Fatal(err) + } + if _, err := ctx.Advance(until); err != nil { + t.Fatal(err) + } + return ctx.Trace(), Options{ + Until: until, + Label: func(inst *runtime.Instance) string { + if inst.Type != nil { + return inst.Type.Name + } + return "" + }, + }, ctx, root +} + func mustSymbol(t *testing.T, scope *symbols.Scope, name string) *symbols.Symbol { t.Helper() if scope == nil { @@ -219,7 +289,7 @@ func longSequence() *runtime.TraceRecorder { receiver, receiverBehavior := traceObject(2, "receiver") for at := 0; at < 201; at++ { trace.RecordSend(traceOrigin(float64(at), sender, senderBehavior), runtime.Message{SignalType: "Ping"}, receiver) - trace.RecordAccept(traceOrigin(float64(at), receiver, receiverBehavior), "Ping", nil) + trace.RecordAccept(traceOrigin(float64(at), receiver, receiverBehavior), 0, "Ping", nil) } return trace } @@ -240,9 +310,9 @@ func truncatedSequenceTrace() *runtime.TraceRecorder { sender, senderBehavior := traceObject(1, "sender") receiver, receiverBehavior := traceObject(2, "receiver") trace.RecordSend(traceOrigin(0, sender, senderBehavior), runtime.Message{SignalType: "Old"}, receiver) - trace.RecordAccept(traceOrigin(1, receiver, receiverBehavior), "Old", nil) + trace.RecordAccept(traceOrigin(1, receiver, receiverBehavior), 0, "Old", nil) trace.RecordSend(traceOrigin(2, sender, senderBehavior), runtime.Message{SignalType: "Ping"}, receiver) - trace.RecordAccept(traceOrigin(3, receiver, receiverBehavior), "Ping", nil) + trace.RecordAccept(traceOrigin(3, receiver, receiverBehavior), 0, "Ping", nil) return trace } @@ -262,8 +332,8 @@ func messageEdgeCases() *runtime.TraceRecorder { trace.RecordSend(traceOrigin(0, sender, senderBehavior), runtime.Message{SignalType: "Unmatched"}, receiver) trace.RecordSend(traceOrigin(1, nil, nil), runtime.Message{SignalType: "Outside"}, nil) trace.RecordSend(traceOrigin(2, sender, senderBehavior), runtime.Message{SignalType: "Broadcast"}, nil) - trace.RecordAccept(traceOrigin(2.5, receiver, receiverBehavior), "Broadcast", nil) - trace.RecordAccept(traceOrigin(3, receiver, receiverBehavior), "Missing", nil) + trace.RecordAccept(traceOrigin(2.5, receiver, receiverBehavior), 0, "Broadcast", nil) + trace.RecordAccept(traceOrigin(3, receiver, receiverBehavior), 0, "Missing", nil) return trace } diff --git a/internal/exec/runtrace/runtrace_test.go b/internal/exec/runtrace/runtrace_test.go index 52fde50854..0c52d86c0a 100644 --- a/internal/exec/runtrace/runtrace_test.go +++ b/internal/exec/runtrace/runtrace_test.go @@ -113,6 +113,23 @@ func TestTimelineGroupsStateChangesAndOrdersParallelLeaves(t *testing.T) { } } +func TestTimelineDistinguishesSameNamedStatesInSiblingRegions(t *testing.T) { + trace, options, _, _ := fixtureRun(t, "parallel-regions.sysml", "box", 2) + rendering, err := Render(KindTimeline, trace, options) + if err != nil { + t.Fatal(err) + } + got, err := rendering.Write(view.FormText) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"0 .. 1 waiting | waiting", "1 .. 2 done | waiting"} { + if !strings.Contains(got, want) { + t.Errorf("timeline is missing %q:\n%s", want, got) + } + } +} + func TestTimelineTruncationAndSpanLimit(t *testing.T) { truncated := runtime.NewEventRecorder(3) object := testObject(1, "Rover") @@ -147,11 +164,70 @@ func TestTimelineTruncationAndSpanLimit(t *testing.T) { t.Errorf("last capped span = %+v", last) } if !strings.Contains(strings.Join(limited.Notices, "\n"), - "3 later state changes after t = 200 are not drawn (at most 200 spans are)") { + "3 later state changes from t = 200 are not drawn (at most 200 spans are)") { t.Errorf("cap notice = %v", limited.Notices) } } +func TestTimelineCapUsesStableSpanIdentityForEqualInstants(t *testing.T) { + trace := runtime.NewEventRecorder(0) + behavior := &symbols.Symbol{Name: "Machine"} + for i := int64(1); i <= 201; i++ { + object := testObject(i, fmt.Sprintf("Object%d", i)) + trace.RecordStateEntry(runtime.TraceOrigin{At: 0, Object: object, Behavior: behavior}, + fmt.Sprintf("state%d", i), fmt.Sprintf("state%d", i), "", false) + } + + rendering := Timeline(trace, Options{Until: 1, Limit: 200}) + total := 0 + for _, lane := range rendering.Lanes { + total += len(lane.Spans) + } + if total != 200 { + t.Fatalf("retained %d spans, want exactly 200", total) + } + for i := 0; i < 200; i++ { + lane := rendering.Lanes[i] + if lane.Name != fmt.Sprintf("#%d.Machine", i+1) || len(lane.Spans) != 1 || + lane.Spans[0].State != fmt.Sprintf("state%d", i+1) { + t.Errorf("retained lane %d = %+v, want the corresponding first stable span", i, lane) + } + } + if len(rendering.Notices) == 0 || + rendering.Notices[len(rendering.Notices)-1] != "1 later state change from t = 0 is not drawn (at most 200 spans are)" { + t.Fatalf("cap notice = %v", rendering.Notices) + } +} + +func TestTimelineClosesTerminatedMachineAtTermination(t *testing.T) { + trace, options, _, _ := fixtureRun(t, "termination.sysml", "mission", 10) + rendering := Timeline(trace, options) + var terminatedLane *view.Lane + for i := range rendering.Lanes { + if strings.Contains(rendering.Lanes[i].Name, "Stopper.modes") { + terminatedLane = &rendering.Lanes[i] + break + } + } + if terminatedLane == nil { + t.Fatalf("no stopper lane in %+v", rendering.Lanes) + } + if len(terminatedLane.Spans) != 1 { + t.Fatalf("stopper spans = %+v, want one span ending at termination", terminatedLane.Spans) + } + span := terminatedLane.Spans[0] + if span.From != 0 || span.To != 2 || span.Open { + t.Errorf("stopper span = %+v, want a closed span from 0 to 2", span) + } + for _, mark := range terminatedLane.Marks { + if mark.Kind == "terminate" && mark.At == 2 && + mark.Text == "terminated with occurrence: modes (do behavior abandoned: waiting)" { + return + } + } + t.Errorf("stopper marks = %+v, want a terminate mark at 2", terminatedLane.Marks) +} + func TestSequencePairsMessagesAndKeepsUnmatchedEndpoints(t *testing.T) { trace := runtime.NewEventRecorder(0) sender := testObject(1, "Sender") @@ -161,11 +237,11 @@ func TestSequencePairsMessagesAndKeepsUnmatchedEndpoints(t *testing.T) { SignalType: "Ping", Payload: map[string]runtime.Value{"z": runtime.NewStringValue("last"), "a": runtime.NewStringValue("first")}, }, receiver) trace.RecordSend(runtime.TraceOrigin{At: 0.5, Object: sender}, runtime.Message{SignalType: "Ping"}, other) - trace.RecordAccept(runtime.TraceOrigin{At: 1, Object: other}, "Ping", nil) - trace.RecordAccept(runtime.TraceOrigin{At: 2, Object: receiver}, "Ping", nil) + trace.RecordAccept(runtime.TraceOrigin{At: 1, Object: other}, 0, "Ping", nil) + trace.RecordAccept(runtime.TraceOrigin{At: 2, Object: receiver}, 0, "Ping", nil) trace.RecordSend(runtime.TraceOrigin{At: 3, Object: other}, runtime.Message{SignalType: "Notice"}, receiver) trace.RecordSend(runtime.TraceOrigin{At: 4}, runtime.Message{SignalType: "Broadcast"}, other) - trace.RecordAccept(runtime.TraceOrigin{At: 5, Object: receiver}, "Pong", nil) + trace.RecordAccept(runtime.TraceOrigin{At: 5, Object: receiver}, 0, "Pong", nil) rendering := Sequence(trace, Options{ Until: 6, Label: func(instance *runtime.Instance) string { return instance.Type.Name }, @@ -201,6 +277,89 @@ func TestSequencePairsMessagesAndKeepsUnmatchedEndpoints(t *testing.T) { } } +func TestSequencePairsNonzeroSerialsAndRetainsLegacyPairing(t *testing.T) { + trace := runtime.NewEventRecorder(0) + alpha, alphaBehavior := traceObject(1, "Alpha") + beta, betaBehavior := traceObject(2, "Beta") + legacy, legacyBehavior := traceObject(3, "Legacy") + receiver, receiverBehavior := traceObject(4, "Receiver") + trace.RecordSend(traceOrigin(0, alpha, alphaBehavior), runtime.Message{ + Serial: 11, SignalType: "Ping", Payload: map[string]runtime.Value{"seq": runtime.NewStringValue("one")}, + }, receiver) + trace.RecordSend(traceOrigin(0.5, beta, betaBehavior), runtime.Message{ + Serial: 12, SignalType: "Ping", Payload: map[string]runtime.Value{"seq": runtime.NewStringValue("two")}, + }, receiver) + trace.RecordSend(traceOrigin(1, legacy, legacyBehavior), runtime.Message{ + SignalType: "Ping", Payload: map[string]runtime.Value{"seq": runtime.NewStringValue("three")}, + }, receiver) + trace.RecordAccept(traceOrigin(2, receiver, receiverBehavior), 12, "Ping", nil) + trace.RecordAccept(traceOrigin(3, receiver, receiverBehavior), 0, "Ping", nil) + + rendering := Sequence(trace, Options{ + Until: 4, + Label: func(instance *runtime.Instance) string { return instance.Type.Name }, + }) + if len(rendering.Edges) != 3 { + t.Fatalf("edges = %+v, want the two sends and one serial-zero send", rendering.Edges) + } + if !strings.Contains(rendering.Edges[0].Label, `seq = "one"`) || !strings.Contains(rendering.Edges[0].Label, "not accepted") { + t.Errorf("earlier same-event send = %+v, want unmatched Alpha message", rendering.Edges[0]) + } + if !strings.Contains(rendering.Edges[1].Label, `seq = "two"`) || strings.Contains(rendering.Edges[1].Label, "not accepted") || + rendering.Edges[1].From != "n2" { + t.Errorf("matching serial send = %+v, want the Beta message accepted", rendering.Edges[1]) + } + if !strings.Contains(rendering.Edges[2].Label, `seq = "three"`) || strings.Contains(rendering.Edges[2].Label, "not accepted") || + rendering.Edges[2].From != "n3" { + t.Errorf("serial-zero send = %+v, want legacy event/target pairing", rendering.Edges[2]) + } +} + +func TestSequenceUsesAcceptedBetaMessageIdentityEndToEnd(t *testing.T) { + trace, options, ctx, mission := fixtureRun(t, "serial-pairing.sysml", "mission", 2) + labels := make(map[int64]string) + for _, name := range []string{"alpha", "beta", "receiver"} { + value, err := mission.GetFeatureValue(ctx, name) + if err != nil { + t.Fatal(err) + } + id, ok := value.HeldValue().Object() + if !ok { + t.Fatalf("mission.%s does not hold an object", name) + } + labels[id] = "SerialPairing::mission." + name + } + options.Label = func(instance *runtime.Instance) string { return labels[instance.ID] } + rendering := Sequence(trace, options) + rootNames := make(map[string]string, len(rendering.Roots)) + for _, root := range rendering.Roots { + rootNames[root.ID] = root.Name + } + alphaUnmatched, betaAccepted := false, false + for _, edge := range rendering.Edges { + switch { + case strings.Contains(edge.Label, "seq = 1"): + alphaUnmatched = rootNames[edge.From] == "SerialPairing::mission.alpha" && + strings.Contains(edge.Label, "not accepted") + case strings.Contains(edge.Label, "seq = 2"): + betaAccepted = rootNames[edge.From] == "SerialPairing::mission.beta" && + !strings.Contains(edge.Label, "not accepted") + } + } + if !alphaUnmatched || !betaAccepted { + t.Fatalf("sequence edges = %+v, roots = %+v; want Alpha unmatched and Beta's payload accepted", + rendering.Edges, rendering.Roots) + } + artifact, err := rendering.WriteWith(view.FormText, view.Options{}) + if err != nil { + t.Fatal(err) + } + if text := string(artifact); !strings.Contains(text, "SerialPairing::mission.beta") || + !strings.Contains(text, "seq = 2") { + t.Errorf("sequence diagram does not identify Beta and its payload:\n%s", text) + } +} + func TestSequenceCapsMessagesAndReportsDroppedRecords(t *testing.T) { object := testObject(1, "Sender") trace := runtime.NewEventRecorder(0) diff --git a/internal/exec/runtrace/sequence.go b/internal/exec/runtrace/sequence.go index d8c15ca624..05ad89d926 100644 --- a/internal/exec/runtrace/sequence.go +++ b/internal/exec/runtrace/sequence.go @@ -54,6 +54,7 @@ func sequence(trace *runtime.TraceRecorder, options Options) *view.Rendering { } records := trace.Records() queues := make(map[string]map[int64]*sendQueue) + bySerial := make(map[uint64]*sendRef) var sends []*sendRef var messages []runMessage for index, record := range records { @@ -65,6 +66,10 @@ func sequence(trace *runtime.TraceRecorder, options Options) *view.Rendering { } ref := &sendRef{index: index, record: record, target: target} sends = append(sends, ref) + if record.Message != 0 { + bySerial[record.Message] = ref + continue + } if queues[record.Event] == nil { queues[record.Event] = make(map[int64]*sendQueue) } @@ -80,7 +85,15 @@ func sequence(trace *runtime.TraceRecorder, options Options) *view.Rendering { if record.Origin.Object != nil { objectID = record.Origin.Object.ID } - if byTarget := queues[record.Event]; byTarget != nil { + if record.Message != 0 { + paired = bySerial[record.Message] + if paired != nil && paired.used { + paired = nil + } + if paired != nil { + paired.used = true + } + } else if byTarget := queues[record.Event]; byTarget != nil { broadcast := byTarget[0] targeted := byTarget[objectID] paired = earliestSend(broadcast, targeted) diff --git a/internal/exec/runtrace/testdata/capped-timeline.text.golden b/internal/exec/runtrace/testdata/capped-timeline.text.golden index 2786d9885c..20e91dde38 100644 --- a/internal/exec/runtrace/testdata/capped-timeline.text.golden +++ b/internal/exec/runtrace/testdata/capped-timeline.text.golden @@ -404,4 +404,4 @@ run - timeline rendering (the trace of a run to t = 201) t=200 s199 -> s200 (tick) not represented: - - 2 later state changes after t = 200 are not drawn (at most 200 spans are) + - 2 later state changes from t = 200 are not drawn (at most 200 spans are) diff --git a/internal/exec/runtrace/testdata/parallel-regions-timeline.text.golden b/internal/exec/runtrace/testdata/parallel-regions-timeline.text.golden new file mode 100644 index 0000000000..df88f97290 --- /dev/null +++ b/internal/exec/runtrace/testdata/parallel-regions-timeline.text.golden @@ -0,0 +1,9 @@ +run - timeline rendering (the trace of a run to t = 2) + +box.modes + 0 .. 1 waiting | waiting + 1 .. 2 done | waiting (time) (held at the end) + transitions: + t=1 waiting -> done (time) + noted: + t=0 choice entering open: next left.waiting(entry), right.waiting(entry) (unordered; took left.waiting(entry) first) diff --git a/internal/exec/runtrace/testdata/parallel-regions.sysml b/internal/exec/runtrace/testdata/parallel-regions.sysml new file mode 100644 index 0000000000..e11fb602ea --- /dev/null +++ b/internal/exec/runtrace/testdata/parallel-regions.sysml @@ -0,0 +1,26 @@ +package Par { + private import SI::*; + + part def Box { + exhibit state modes { + entry; then open; + + state open parallel { + state left { + entry; then waiting; + state waiting; + state done; + + transition first waiting accept after 1 [s] then done; + } + + state right { + entry; then waiting; + state waiting; + } + } + } + } + + part box : Box; +} diff --git a/internal/exec/runtrace/testdata/serial-pairing-sequence.text.golden b/internal/exec/runtrace/testdata/serial-pairing-sequence.text.golden new file mode 100644 index 0000000000..d6801b09dd --- /dev/null +++ b/internal/exec/runtrace/testdata/serial-pairing-sequence.text.golden @@ -0,0 +1,9 @@ +run - sequence rendering (the trace of a run to t = 2) + +object SerialPairing::mission.alpha : alpha +object SerialPairing::mission.receiver : Receiver +object SerialPairing::mission.beta : beta + +messages: + SerialPairing::mission.alpha => SerialPairing::mission.receiver: t=0 Ping (seq = 1) (not accepted) + SerialPairing::mission.beta => SerialPairing::mission.receiver: t=1 Ping (seq = 2) diff --git a/internal/exec/runtrace/testdata/serial-pairing.sysml b/internal/exec/runtrace/testdata/serial-pairing.sysml new file mode 100644 index 0000000000..8b370f20cf --- /dev/null +++ b/internal/exec/runtrace/testdata/serial-pairing.sysml @@ -0,0 +1,70 @@ +package SerialPairing { + private import ScalarValues::*; + private import SI::*; + + package Alpha { + item def Ping { + attribute seq : Integer; + } + } + + package Beta { + item def Ping { + attribute seq : Integer; + } + } + + port def PingPort { + out item alpha : Alpha::Ping; + out item beta : Beta::Ping; + } + + part def Sender { + port p : PingPort; + } + + part def Receiver { + port p : ~PingPort; + + exhibit state listening { + entry; then waiting; + + state waiting; + state received; + + transition first waiting accept order : Beta::Ping via p then received; + } + } + + part def Mission { + part alpha : Sender { + exhibit state dispatch { + entry; then sent; + + state sent { + entry send new Alpha::Ping(seq = 1) via p; + } + } + } + + part beta : Sender { + exhibit state dispatch { + entry; then waiting; + + state waiting; + state sent { + entry send new Beta::Ping(seq = 2) via p; + } + + transition first waiting accept after 1 [s] then sent; + } + } + + part receiver : Receiver; + + connect alpha.p to receiver.p; + connect beta.p to receiver.p; + } + + part mission : Mission; +} diff --git a/internal/exec/runtrace/testdata/termination-timeline.text.golden b/internal/exec/runtrace/testdata/termination-timeline.text.golden new file mode 100644 index 0000000000..586c542ddf --- /dev/null +++ b/internal/exec/runtrace/testdata/termination-timeline.text.golden @@ -0,0 +1,12 @@ +run - timeline rendering (the trace of a run to t = 10) + +Stopper.modes + 0 .. 2 waiting + noted: + t=2 terminated with occurrence: modes (do behavior abandoned: waiting) + +Runner.modes + 0 .. 10 waiting + 10 .. 10 finished (time) (held at the end) + transitions: + t=10 waiting -> finished (time) diff --git a/internal/exec/runtrace/testdata/termination.sysml b/internal/exec/runtrace/testdata/termination.sysml new file mode 100644 index 0000000000..7e35bc006b --- /dev/null +++ b/internal/exec/runtrace/testdata/termination.sysml @@ -0,0 +1,36 @@ +package Termination { + private import ScalarValues::*; + private import SI::*; + + part def Stopper { + exhibit state modes { + entry; then waiting; + + state waiting { + do action stop { + first start; + then action wait accept after 2 [s]; + then terminate this; + } + } + } + } + + part def Runner { + exhibit state modes { + entry; then waiting; + + state waiting; + state finished; + + transition first waiting accept after 10 [s] then finished; + } + } + + part def Mission { + part stopper : Stopper; + part runner : Runner; + } + + part mission : Mission; +} diff --git a/internal/exec/runtrace/timeline.go b/internal/exec/runtrace/timeline.go index 306d14db17..f77bd357a0 100644 --- a/internal/exec/runtrace/timeline.go +++ b/internal/exec/runtrace/timeline.go @@ -20,11 +20,16 @@ type timelineLane struct { rendering *view.Lane key laneKey regions map[string]int - states map[string]activeState - first map[string]int + states map[stateKey]activeState + first map[stateKey]int nextOrder int } +type stateKey struct { + path string + region string +} + type activeState struct { record runtime.TraceRecord order int @@ -34,10 +39,11 @@ type timelineGroup struct { at float64 states []runtime.TraceRecord transitions []runtime.TraceRecord + terminated []runtime.TraceRecord } type transientState struct { - path string + key stateKey record runtime.TraceRecord order int } @@ -51,16 +57,16 @@ func timeline(trace *runtime.TraceRecorder, options Options) *view.Rendering { lanes := make([]*timelineLane, 0) byKey := make(map[laneKey]*timelineLane) for _, record := range records { - if record.Kind != runtime.TraceEntry && record.Kind != runtime.TraceExit { + if record.Kind != runtime.TraceEntry && record.Kind != runtime.TraceExit && record.Kind != runtime.TraceTerminate { continue } key := keyOf(record.Origin) if lane := byKey[key]; lane != nil { lane.regionOrder(record.Region) if record.Kind == runtime.TraceEntry { - path := statePath(record) - if _, seen := lane.first[path]; !seen { - lane.first[path] = lane.nextOrder + key := stateKeyOf(record) + if _, seen := lane.first[key]; !seen { + lane.first[key] = lane.nextOrder lane.nextOrder++ } } @@ -72,12 +78,11 @@ func timeline(trace *runtime.TraceRecorder, options Options) *view.Rendering { } stateLane := &timelineLane{ rendering: lane, key: key, - regions: make(map[string]int), states: make(map[string]activeState), first: make(map[string]int), + regions: make(map[string]int), states: make(map[stateKey]activeState), first: make(map[stateKey]int), } stateLane.regionOrder(record.Region) if record.Kind == runtime.TraceEntry { - path := statePath(record) - stateLane.first[path] = stateLane.nextOrder + stateLane.first[stateKeyOf(record)] = stateLane.nextOrder stateLane.nextOrder++ } lanes = append(lanes, stateLane) @@ -88,7 +93,7 @@ func timeline(trace *runtime.TraceRecorder, options Options) *view.Rendering { groups[lane] = make(map[float64]*timelineGroup) } for _, record := range records { - if record.Kind != runtime.TraceEntry && record.Kind != runtime.TraceExit { + if record.Kind != runtime.TraceEntry && record.Kind != runtime.TraceExit && record.Kind != runtime.TraceTerminate { continue } lane := byKey[keyOf(record.Origin)] @@ -98,7 +103,11 @@ func timeline(trace *runtime.TraceRecorder, options Options) *view.Rendering { group = &timelineGroup{at: record.Origin.At} groups[lane][record.Origin.At] = group } - group.states = append(group.states, record) + if record.Kind == runtime.TraceTerminate { + group.terminated = append(group.terminated, record) + } else { + group.states = append(group.states, record) + } } } for _, record := range records { @@ -127,6 +136,16 @@ func timeline(trace *runtime.TraceRecorder, options Options) *view.Rendering { lane.rendering.Marks = append(lane.rendering.Marks, view.Mark{ At: record.Origin.At, Kind: record.Kind.String(), Text: record.Text(), }) + case runtime.TraceTerminate: + if lane == nil { + lane = laneForObject(lanes, key.object) + } + if lane == nil { + continue + } + lane.rendering.Marks = append(lane.rendering.Marks, view.Mark{ + At: record.Origin.At, Kind: record.Kind.String(), Text: record.Text(), + }) } } for _, lane := range lanes { @@ -137,7 +156,7 @@ func timeline(trace *runtime.TraceRecorder, options Options) *view.Rendering { sort.SliceStable(ordered, func(i, j int) bool { return ordered[i].at < ordered[j].at }) currentSpan := -1 for _, group := range ordered { - through, changed := lane.apply(group.states) + through, changed := lane.apply(group.states, len(group.terminated) > 0) if !changed { continue } @@ -187,45 +206,51 @@ func timeline(trace *runtime.TraceRecorder, options Options) *view.Rendering { return out } -func (lane *timelineLane) apply(records []runtime.TraceRecord) ([]transientState, bool) { - entered := make(map[string]runtime.TraceRecord) +func (lane *timelineLane) apply(records []runtime.TraceRecord, terminated bool) ([]transientState, bool) { + entered := make(map[stateKey]runtime.TraceRecord) var exitedLeaves []transientState changed := false for _, record := range records { - path := statePath(record) + key := stateKeyOf(record) lane.regionOrder(record.Region) switch record.Kind { case runtime.TraceEntry: - order, seen := lane.first[path] + order, seen := lane.first[key] if !seen { order = lane.nextOrder lane.nextOrder++ - lane.first[path] = order + lane.first[key] = order } - if current, ok := lane.states[path]; ok { + if current, ok := lane.states[key]; ok { order = current.order } - lane.states[path] = activeState{record: record, order: order} - entered[path] = record + lane.states[key] = activeState{record: record, order: order} + entered[key] = record changed = true case runtime.TraceExit: - current, ok := lane.states[path] + current, ok := lane.states[key] if !ok { continue } - if !lane.hasDescendant(path) { - exitedLeaves = append(exitedLeaves, transientState{path: path, record: current.record, order: current.order}) + if !lane.hasDescendant(key) { + exitedLeaves = append(exitedLeaves, transientState{key: key, record: current.record, order: current.order}) } - delete(lane.states, path) + delete(lane.states, key) changed = true } } + if terminated { + for key := range lane.states { + delete(lane.states, key) + } + changed = true + } through := make([]transientState, 0) for _, exited := range exitedLeaves { - if _, wasEntered := entered[exited.path]; !wasEntered { + if _, wasEntered := entered[exited.key]; !wasEntered { continue } - if _, active := lane.states[exited.path]; active { + if _, active := lane.states[exited.key]; active { continue } through = append(through, exited) @@ -241,7 +266,7 @@ func (lane *timelineLane) apply(records []runtime.TraceRecord) ([]transientState for _, item := range through { parent := false for _, other := range through { - if strings.HasPrefix(other.path, item.path+".") { + if other.key.descendsFrom(item.key) { parent = true break } @@ -255,8 +280,8 @@ func (lane *timelineLane) apply(records []runtime.TraceRecord) ([]transientState func (lane *timelineLane) leaves() []activeState { var leaves []activeState - for path, state := range lane.states { - if !lane.hasDescendant(path) { + for key, state := range lane.states { + if !lane.hasDescendant(key) { leaves = append(leaves, state) } } @@ -270,16 +295,22 @@ func (lane *timelineLane) leaves() []activeState { return leaves } -func (lane *timelineLane) hasDescendant(path string) bool { - prefix := path + "." +func (lane *timelineLane) hasDescendant(parent stateKey) bool { for active := range lane.states { - if strings.HasPrefix(active, prefix) { + if active.descendsFrom(parent) { return true } } return false } +func (key stateKey) descendsFrom(parent stateKey) bool { + if !strings.HasPrefix(key.path, parent.path+".") { + return false + } + return parent.region == "" || key.region == parent.region || strings.HasPrefix(key.region, parent.region+".") +} + func (lane *timelineLane) regionOrder(region string) int { if order, ok := lane.regions[region]; ok { return order @@ -343,6 +374,10 @@ func statePath(record runtime.TraceRecord) string { return record.State } +func stateKeyOf(record runtime.TraceRecord) stateKey { + return stateKey{path: statePath(record), region: record.Region} +} + type spanRef struct { lane int index int @@ -362,32 +397,36 @@ func applyTimelineLimit(rendering *view.Rendering, requested int) { } sort.SliceStable(spans, func(i, j int) bool { return spans[i].from < spans[j].from }) cutoff := spans[limit].from + firstDropped := make(map[int]int) + for _, span := range spans[limit:] { + if index, exists := firstDropped[span.lane]; !exists || span.index < index { + firstDropped[span.lane] = span.index + } + } dropped := 0 for laneIndex := range rendering.Lanes { lane := &rendering.Lanes[laneIndex] - kept := lane.Spans[:0] - for _, span := range lane.Spans { - if span.From >= cutoff { - dropped++ - continue - } - if span.To > cutoff || span.Open && span.To >= cutoff { - span.To = cutoff - span.Open = false - } - kept = append(kept, span) + dropIndex, hasDrops := firstDropped[laneIndex] + if !hasDrops { + continue + } + dropped += len(lane.Spans) - dropIndex + boundary := lane.Spans[dropIndex].From + lane.Spans = lane.Spans[:dropIndex] + if dropIndex > 0 { + lane.Spans[dropIndex-1].To = boundary + lane.Spans[dropIndex-1].Open = false } - lane.Spans = kept transitions := lane.Transitions[:0] for _, transition := range lane.Transitions { - if transition.At <= cutoff { + if transition.At <= boundary { transitions = append(transitions, transition) } } lane.Transitions = transitions marks := lane.Marks[:0] for _, mark := range lane.Marks { - if mark.At <= cutoff { + if mark.At <= boundary { marks = append(marks, mark) } } @@ -397,7 +436,7 @@ func applyTimelineLimit(rendering *view.Rendering, requested int) { if dropped == 1 { verb = "is" } - rendering.Notices = append(rendering.Notices, fmt.Sprintf("%d later state %s after t = %s %s not drawn (at most %d spans are)", + rendering.Notices = append(rendering.Notices, fmt.Sprintf("%d later state %s from t = %s %s not drawn (at most %d spans are)", dropped, plural(dropped, "change", "changes"), runInstant(cutoff), verb, limit)) } From dbfa435fb6898147ae2ce6a064dece61c2dc27fa Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 06:26:31 +0000 Subject: [PATCH 08/13] fix(runtime): remap imported message serials Co-Authored-By: jason.han --- internal/exec/runtime/held_image.go | 30 ++++++++++++------ internal/exec/runtime/held_image_test.go | 39 ++++++++++++++++++++---- 2 files changed, 54 insertions(+), 15 deletions(-) diff --git a/internal/exec/runtime/held_image.go b/internal/exec/runtime/held_image.go index 58b8cd7cc8..02fc144b87 100644 --- a/internal/exec/runtime/held_image.go +++ b/internal/exec/runtime/held_image.go @@ -585,7 +585,10 @@ func (img *HeldImage) Materialize(dst *Context) error { if err := img.clockFree(dst); err != nil { return err } - m := &materializing{dst: dst, img: img, made: make(map[int64]*Instance, len(img.objects))} + m := &materializing{ + dst: dst, img: img, made: make(map[int64]*Instance, len(img.objects)), + nextMessageSerial: dst.messageSerial, messageSerials: make(map[uint64]uint64), + } mark := dst.materializeMark() if err := m.run(); err != nil { mark.rollBack(dst) @@ -691,11 +694,13 @@ func (mark materializeMark) rollBack(ctx *Context) { // materializing builds one context's objects for an image. type materializing struct { - dst *Context - img *HeldImage - made map[int64]*Instance - runs []*runState - recorded []sharedKey + dst *Context + img *HeldImage + made map[int64]*Instance + runs []*runState + recorded []sharedKey + nextMessageSerial uint64 + messageSerials map[uint64]uint64 } // bring answers the object made here for an imaged identity. @@ -793,9 +798,7 @@ 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...) - for _, msg := range messages { - dst.messageSerial = max(dst.messageSerial, msg.Serial) - } + dst.messageSerial = m.nextMessageSerial dst.bus.posts += uint64(len(messages)) dst.workChanged() for sym, ids := range img.occurrences { @@ -962,6 +965,15 @@ func (m *materializing) runState(run imagedRun) *runState { // message is a message as dst carries it. func (m *materializing) message(msg Message) (Message, error) { out := msg + if msg.Serial != 0 { + serial, ok := m.messageSerials[msg.Serial] + if !ok { + m.nextMessageSerial++ + serial = m.nextMessageSerial + m.messageSerials[msg.Serial] = serial + } + out.Serial = serial + } var err error if out.Payload, err = m.values(msg.Payload); err != nil { return Message{}, err diff --git a/internal/exec/runtime/held_image_test.go b/internal/exec/runtime/held_image_test.go index b0e1e63707..3185ab3005 100644 --- a/internal/exec/runtime/held_image_test.go +++ b/internal/exec/runtime/held_image_test.go @@ -79,15 +79,42 @@ func imageInto(t *testing.T, ctx *Context, objects ...*Instance) *Context { return dst } -func TestHeldImageImportAdvancesTheMessageSerial(t *testing.T) { +func TestHeldImageImportRemapsMessageSerialsBeforeAccept(t *testing.T) { _, ctx, bulb := lampBulb(t) - ctx.messages = append(ctx.messages, Message{Object: bulb.ID, SignalType: "Ping", Serial: 41}) - dst := imageInto(t, ctx, bulb) - dst.PostMessage(Message{SignalType: "Pong"}) + ctx.PostMessage(Message{Object: bulb.ID, SignalType: "Ping"}) + ctx.messages = append(ctx.messages, Message{Object: bulb.ID, SignalType: "Ping"}) + img, err := ctx.Image(bulb) + if err != nil { + t.Fatalf("Image: %v", err) + } + + trace := NewTraceRecorder() + dst := NewContext(ctx.Model(), 10000) + dst.SetTrace(trace) + dst.PostMessage(Message{SignalType: "Ping"}) + if records := trace.Records(); len(records) != 1 || records[0].Kind != TraceSend || records[0].Message != 1 { + t.Fatalf("destination trace = %+v, want a send of serial 1", records) + } + if err := img.Materialize(dst); err != nil { + t.Fatalf("Materialize: %v", err) + } messages := dst.PendingMessages() - if len(messages) != 2 || messages[0].Serial != 41 || messages[1].Serial != 42 { - t.Fatalf("messages = %+v, want imported serial 41 followed by 42", messages) + if len(messages) != 3 || messages[0].Serial != 1 || messages[1].Serial != 2 || messages[2].Serial != 0 { + t.Fatalf("messages = %+v, want destination serial 1, imported serial 2, and imported serial 0", messages) + } + accepted, ok := dst.TakeMessage(func(msg Message) bool { return msg.Serial == 2 }) + if !ok { + t.Fatal("could not take the imported message") + } + trace.RecordAccept(TraceOrigin{}, accepted.Serial, accepted.SignalType, nil) + records := trace.Records() + if len(records) != 2 || records[0].Message != 1 || records[1].Kind != TraceAccept || records[1].Message != 2 { + t.Fatalf("trace records = %+v, want send serial 1 and accept serial 2", records) + } + dst.PostMessage(Message{SignalType: "Pong"}) + if got := dst.PendingMessages()[len(dst.PendingMessages())-1].Serial; got != 3 { + t.Errorf("next message serial = %d, want 3", got) } } From cfd108dd38d49d36be9f80dfff77ece6592be9fc Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 06:26:44 +0000 Subject: [PATCH 09/13] feat(runtrace): link run renderings to their source Co-Authored-By: jason.han --- .../unreleased/run-trace-renderings.added.md | 2 +- cmd/sysml/main.go | 4 +- cmd/sysml/render_run.go | 15 ++- cmd/sysml/render_run_test.go | 56 +++++++++ cmd/sysml/usage.go | 2 +- docs/project/spec-compliance.md | 3 +- docs/project/view-rendering-forms.md | 28 +++-- docs/reference/cli.md | 11 +- docs/reference/repl-commands.md | 9 +- internal/exec/runtime/state_executor.go | 20 ++- internal/exec/runtime/trace.go | 22 +++- internal/exec/runtrace/render_golden_test.go | 56 ++++++++- internal/exec/runtrace/runtrace_test.go | 49 +++++++- internal/exec/runtrace/sequence.go | 1 + .../example-sequence-links.mermaid.golden | 44 +++++++ .../example-sequence-links.plantuml.golden | 51 ++++++++ .../example-timeline-links.plantuml.golden | 31 +++++ ...lel-regions-timeline-links.plantuml.golden | 12 ++ .../parallel-regions-timeline.text.golden | 2 +- internal/exec/runtrace/timeline.go | 93 ++++++++++++-- internal/frontend/repl/complete.go | 2 +- internal/frontend/repl/meta.go | 4 +- internal/frontend/repl/run_render.go | 39 +++++- internal/frontend/repl/run_render_test.go | 39 +++++- internal/ir/view/plantuml.go | 2 +- internal/ir/view/run_timeline.go | 47 ++++++- internal/ir/view/run_timeline_test.go | 117 ++++++++++++++++++ internal/ir/view/view.go | 10 +- packaging/man/man1/sysml.1 | 7 +- 29 files changed, 714 insertions(+), 64 deletions(-) create mode 100644 internal/exec/runtrace/testdata/example-sequence-links.mermaid.golden create mode 100644 internal/exec/runtrace/testdata/example-sequence-links.plantuml.golden create mode 100644 internal/exec/runtrace/testdata/example-timeline-links.plantuml.golden create mode 100644 internal/exec/runtrace/testdata/parallel-regions-timeline-links.plantuml.golden diff --git a/changes/unreleased/run-trace-renderings.added.md b/changes/unreleased/run-trace-renderings.added.md index 704f2ef49d..d9263e58f7 100644 --- a/changes/unreleased/run-trace-renderings.added.md +++ b/changes/unreleased/run-trace-renderings.added.md @@ -1 +1 @@ -- **Render recorded runs as timelines and message sequences.** The CLI and REPL can write a behavior's state occupancy and ordered messages as text, Mermaid or PlantUML without adding model-view vocabulary. +- **Render recorded runs as timelines and message sequences.** The CLI and REPL can write a behavior's state occupancy and ordered messages as text, Mermaid or PlantUML without adding model-view vocabulary. Source links connect sequence participants to their type declarations and PlantUML timeline lanes and single-state spans to their declarations. diff --git a/cmd/sysml/main.go b/cmd/sysml/main.go index 414d7a1728..c26f3b2f3c 100644 --- a/cmd/sysml/main.go +++ b/cmd/sysml/main.go @@ -397,8 +397,8 @@ func runCLI() int { fmt.Fprintln(os.Stderr, "sysml: -render-palette is the palette -render or -render-all fills DOT, Mermaid or PlantUML with; name the view to render with -render or a directory with -render-all") return 2 } - if renderLink != "" && renderView == "" && renderAllDir == "" && renderDoc == "" && renderDocsDir == "" { - fmt.Fprintln(os.Stderr, "sysml: -render-link links rendered elements to their source; name what to render with -render, -render-all, -render-document or -render-documents") + if renderLink != "" && renderView == "" && renderAllDir == "" && renderDoc == "" && renderDocsDir == "" && len(renderRuns) == 0 { + fmt.Fprintln(os.Stderr, "sysml: -render-link links rendered elements to their source; name what to render with -render, -render-all, -render-document, -render-documents or -render-run") return 2 } if renderPorts != "" && renderView == "" && renderAllDir == "" { diff --git a/cmd/sysml/render_run.go b/cmd/sysml/render_run.go index da5d33a669..a51cf4cc8f 100644 --- a/cmd/sysml/render_run.go +++ b/cmd/sysml/render_run.go @@ -90,7 +90,7 @@ func runRenderTargetsFromFlags() ([]runRenderTarget, error) { return nil, fmt.Errorf("unknown rendering form %q; -render-form takes %s", renderForm, formList()) } probe := &view.Rendering{Kind: runRenderViewKind(kind), Run: true} - if _, err := probe.WriteWith(form, view.Options{}); err != nil { + if _, err := probe.WriteWith(form, view.Options{Links: view.Links{Template: renderLink}}); err != nil { return nil, fmt.Errorf("-render-run %s: %w", kind, err) } targets = append(targets, runRenderTarget{kind: kind, path: path, form: form}) @@ -139,13 +139,24 @@ func finishRunCheck(rep *reporter, sess *repl.Session) int { fmt.Fprintln(os.Stderr, errPrefix, err) return 2 } + var sites view.Sites + if renderLink != "" { + sites, err = sess.ViewSites() + if err != nil { + fmt.Fprintln(os.Stderr, errPrefix, err) + return 2 + } + } for _, target := range targets { rendering, err := sess.RunTraceRendering(target.kind) if err != nil { fmt.Fprintln(os.Stderr, errPrefix, err) return 2 } - artifact, err := rendering.WriteWith(target.form, view.Options{Width: artifactWidth(target.path, terminalWidth())}) + artifact, err := rendering.WriteWith(target.form, view.Options{ + Width: artifactWidth(target.path, terminalWidth()), + Links: view.Links{Template: renderLink, Sites: sites}, + }) if err != nil { fmt.Fprintln(os.Stderr, errPrefix, err) return 2 diff --git a/cmd/sysml/render_run_test.go b/cmd/sysml/render_run_test.go index e12177051c..b82245bf39 100644 --- a/cmd/sysml/render_run_test.go +++ b/cmd/sysml/render_run_test.go @@ -103,6 +103,62 @@ func TestRenderRunWritesEachFormAfterTheVerdict(t *testing.T) { } } +func TestRenderRunLinksTimelineAndSequence(t *testing.T) { + data, err := os.ReadFile(filepath.Join("..", "..", "examples", "run-timeline", "run-timeline.sysml")) + if err != nil { + t.Fatal(err) + } + binary := buildCLI(t) + dir := t.TempDir() + timelinePath := filepath.Join(dir, "timeline.puml") + sequencePath := filepath.Join(dir, "sequence.mmd") + got := check(t, binary, string(data), + "-instantiate", "RunTimeline::mission", + "-state", "RunTimeline::Controller::modes RunTimeline::mission.controller", + "-state", "RunTimeline::Instrument::modes RunTimeline::mission.instrument", + "-advance", "6", + "-render-link", "https://example.test/src/{file}#L{line}", + "-render-run", "timeline="+timelinePath, + "-render-run", "sequence="+sequencePath, + ) + if got.status != 0 { + t.Fatalf("exit status = %d\n%s", got.status, got.output()) + } + timeline, err := os.ReadFile(timelinePath) // #nosec G304 -- this path is created by the test. + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(timeline), `[[https://example.test/src/`) || + !strings.Contains(string(timeline), `#L`) || + !strings.Contains(string(timeline), `RunTimeline::mission.controller.modes`) { + t.Errorf("linked timeline is missing source links or visible labels:\n%s", timeline) + } + sequence, err := os.ReadFile(sequencePath) // #nosec G304 -- this path is created by the test. + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(sequence), "link n0: Source @ https://example.test/src/") || + !strings.Contains(string(sequence), "RunTimeline::mission.controller") { + t.Errorf("linked sequence is missing participant links or visible labels:\n%s", sequence) + } +} + +func TestRenderRunRejectsInvalidLinkBeforeWriting(t *testing.T) { + binary := buildCLI(t) + path := filepath.Join(t.TempDir(), "timeline.puml") + got := check(t, binary, behaviorModel, + "-state", "Mission::Cycle", "-advance", "1", + "-render-link", "https://example.test/{unknown}", + "-render-run", "timeline="+path, + ) + if got.status != 2 || !strings.Contains(got.stderr, "unknown link template placeholder {unknown}") { + t.Fatalf("status = %d, want invalid-link refusal:\n%s", got.status, got.output()) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Errorf("rendering output path exists after invalid-link refusal: stat error = %v", err) + } +} + func TestRenderRunWritesStdoutWithoutLosingTheVerdict(t *testing.T) { binary := buildCLI(t) got := check(t, binary, behaviorModel, diff --git a/cmd/sysml/usage.go b/cmd/sysml/usage.go index a4db8fdd79..e0601e8e93 100644 --- a/cmd/sysml/usage.go +++ b/cmd/sysml/usage.go @@ -665,7 +665,7 @@ func registerFlags(fs *flag.FlagSet) { fs.StringVar(&renderAllDir, "render-all", "", "Render every declared view into this directory") fs.StringVar(&renderForm, "render-form", "", "Form -render or -render-all writes: text, mermaid, markdown, dot, plantuml, csv or tsv (csv and tsv for a table); default from the destination for -render, each kind's machine form for -render-all") fs.StringVar(&renderPalette, "render-palette", "", "Palette the dot, mermaid or plantuml form fills nodes from, by keyword family: okabe-ito, tol-bright, tol-muted, tol-light, brewer-set2, brewer-dark2, viridis or cividis; default black and white") - fs.StringVar(&renderLink, "render-link", "", "Link template for rendered elements: {file} is the path as loaded; use absolute paths for vscode:// or file:// links. Placeholders: {file}, {line}, {col}, {qname}, {id}") + fs.StringVar(&renderLink, "render-link", "", "Source link template for -render, -render-all, -render-document, -render-documents and -render-run. Placeholders: {file}, {line}, {col}, {qname}, {id}; {file} is the path as loaded (use absolute paths for vscode:// or file:// links)") fs.StringVar(&renderStyle, "render-style", "", "Drawing style of the dot or mermaid form: pilot (default), the Pilot visualizer's black and white, or cameo, the look of Cameo Systems Modeler; applies to -render, -render-all and document diagrams") fs.StringVar(&renderPorts, "render-ports", "", "How much of a part's ports -render or -render-all draws on an interconnection: minimal (default), the ports its connectors end at, each a small square on the part's border named beside it, or full, every port, labelled name : Type") fs.StringVar(&renderUnplaced, "render-unplaced", "", "Where a graph form of a view some Layout positions puts the nodes none does: omit (default) leaves them undrawn in every form, strip draws them, in rows below the dot drawing; applies to -render, -render-all and document diagrams") diff --git a/docs/project/spec-compliance.md b/docs/project/spec-compliance.md index 52399ada17..090d9199f0 100644 --- a/docs/project/spec-compliance.md +++ b/docs/project/spec-compliance.md @@ -2598,9 +2598,10 @@ boundaries; the landed Track E behavior is recorded in the execution rows and th | Run output | Implementation | Tests | Status | |---|---|---|---| -| State occupancy over clock time, per object machine, with transitions and choice/guard marks; parallel leaves are keyed by state and region, termination closes occupancy, and the 200-span cap retains stable lane prefixes | `internal/exec/runtrace/timeline.go` `Timeline`, `stateKey`, `applyTimelineLimit`; `internal/ir/view/run_timeline.go` text, Mermaid and PlantUML writers | `internal/exec/runtrace/runtrace_test.go`; parallel-region, termination and capped run-rendering goldens under `internal/exec/runtrace/testdata/` | ✅ Implemented and tested | +| State occupancy over clock time, per object machine, with transitions and choice/guard marks; parallel leaves are keyed by state and region, colliding labels use the shortest distinguishing state path or region, termination closes occupancy, and the 200-span cap retains stable lane prefixes ending no later than each lane's first dropped instant while preserving earlier ends across inactive gaps | `internal/exec/runtrace/timeline.go` `Timeline`, `stateKey`, `applyTimelineLimit`; `internal/ir/view/run_timeline.go` text, Mermaid and PlantUML writers | `internal/exec/runtrace/runtrace_test.go`; parallel-region, termination and capped run-rendering goldens under `internal/exec/runtrace/testdata/` | ✅ Implemented and tested | | Ordered sends and accepts between objects, including unmatched and environment messages; nonzero message serials pair exactly, while serial-zero traces retain FIFO event/target pairing | `internal/exec/runtrace/sequence.go` `Sequence`; `internal/exec/runtime/trace.go` `TraceRecord.Message` | `internal/exec/runtrace/runtrace_test.go`; serial-pairing and existing run-rendering goldens under `internal/exec/runtrace/testdata/` | ✅ Implemented and tested | | State trace records retain the written state path and innermost orthogonal region; termination records retain their printed line while exposing the ending instant as an `Events(kind = "terminate")` row | `internal/exec/runtime/trace.go` `TraceRecord`, `TraceTerminate`; `internal/exec/runtime/state_executor.go` `StateExecutor.RegionOf`, `StateExecutor.terminateMachine`; `internal/doc/queryexec/events.go` `eventKinds`; `internal/exec/runtrace/timeline.go` | `internal/exec/runtime/trace_records_test.go` `TestStateTraceRecordsCarryWrittenPathsAndInnermostRegions`; runtrace termination fixture and golden | ✅ Implemented and tested | +| Located declarations flow from state-entry/exit records into run views: PlantUML timeline lanes link to machine declarations and single-state spans to state declarations; run sequence participants link to their instance types, while parallel spans and Mermaid gantt timelines remain unlinked | `internal/exec/runtime/state_executor.go` `stateSourceOrigin`; `internal/exec/runtime/trace.go` `TraceRecord.Source`; `internal/exec/runtrace/timeline.go`, `sequence.go`; `internal/ir/view/run_timeline.go`, `links.go` | linked run-rendering goldens under `internal/exec/runtrace/testdata/`; `internal/ir/view/run_timeline_test.go`; CLI and REPL run-rendering link tests | ✅ Implemented and tested | ### Runtime bounds: every limit a model can reach diff --git a/docs/project/view-rendering-forms.md b/docs/project/view-rendering-forms.md index 7f65073e18..fbf9ca2e52 100644 --- a/docs/project/view-rendering-forms.md +++ b/docs/project/view-rendering-forms.md @@ -822,6 +822,10 @@ origins and bundled library declarations — is not linked. | Mermaid flowchart | Linkable nodes receive `click` statements after the edges and classes. Edges and subgraphs are not linked. | | Mermaid state diagram | Simple states are linked; composite states are not. | | Mermaid sequence diagram | Participants receive `link` statements; messages are not linked. Mermaid CLI 11.16.0 drops participant URL fragments in SVG. | +| Run timeline, PlantUML | A lane links to its state-machine declaration; a span links only when it represents one state. Parallel spans with several active states are unlinked. | +| Run timeline, Mermaid gantt | No links: `click`/`href` directives do not produce anchors in Mermaid CLI's SVG output. | +| Run sequence, Mermaid and PlantUML | Object participants link to the declaration of their instance type. The environment participant and messages are unlinked because trace records have no message source site. | +| Run text | No links; labels remain the recorded paths and states. | The writers emit no link syntax when links are disabled or no site is available. @@ -875,8 +879,8 @@ every palette, and text stays black. | --- | --- | --- | | CLI | `-render -render-form mermaid\|dot\|plantuml`; `-render-all ` writes `.mmd`, `.dot` or `.puml`; `-render-palette ` fills nodes in each form where applicable | [`docs/reference/cli.md`](../reference/cli.md#rendering-a-view) | | REPL | `%render mermaid\|dot\|plantuml [palette] [pilot\|cameo]`; `%help` names the options; form, palette and style complete where accepted | [`docs/reference/repl-commands.md`](../reference/repl-commands.md#rendering-a-view) | -| CLI run output | `-render-run timeline=` or `sequence=` writes text, Mermaid or PlantUML; DOT is refused | [`docs/reference/cli.md`](../reference/cli.md#rendering-a-run) | -| REPL run output | `%render-run timeline\|sequence [text\|mermaid\|plantuml\|dot]` renders the recorded run without changing the session | [`docs/reference/repl-commands.md`](../reference/repl-commands.md#rendering-a-run) | +| CLI run output | `-render-run timeline=` or `sequence=` writes text, Mermaid or PlantUML; `-render-link` links PlantUML timeline lanes and single-state spans and sequence participants; DOT is refused | [`docs/reference/cli.md`](../reference/cli.md#rendering-a-run) | +| REPL run output | `%render-run timeline\|sequence [text\|mermaid\|plantuml\|dot] [link=