Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
9a71206
feat(runtime): report the inputs an analysis run bound
devin-ai-integration[bot] Sep 24, 2026
648d9d4
feat(analysis): generate analysis-run records into the model
devin-ai-integration[bot] Sep 24, 2026
6e6305a
feat(repl): record analysis runs into the model with %record
devin-ai-integration[bot] Sep 24, 2026
f414912
feat(cli): record analysis runs with -record-run and -record-into
devin-ai-integration[bot] Sep 24, 2026
e944f44
test(repl): query recorded runs by the features they carry
devin-ai-integration[bot] Sep 24, 2026
7b2b008
docs: document recording analysis runs
devin-ai-integration[bot] Sep 24, 2026
9ef590a
fix(repl): place records by the case's owner chain and rebuild on rol…
devin-ai-integration[bot] Sep 24, 2026
419a40d
fix(hygiene): assign the record package a layer and recount the bundl…
devin-ai-integration[bot] Sep 24, 2026
9aefe23
docs: correct the recorded-run examples and extension counts
devin-ai-integration[bot] Sep 24, 2026
8f9e3e8
docs: record into the queried package in the run-log example
devin-ai-integration[bot] Sep 24, 2026
f8276e4
chore(referee): re-record the pilot differential provenance
devin-ai-integration[bot] Sep 24, 2026
638d396
fix(analysis): settle unset members and keep unit companions collisio…
devin-ai-integration[bot] Sep 24, 2026
45340bb
fix(repl): split %record's into outside names and keep sweep errors u…
devin-ai-integration[bot] Sep 24, 2026
19e6d21
fix(cli): refuse misused run bounds and honour -id and -from on recor…
devin-ai-integration[bot] Sep 24, 2026
3e2e0b0
refactor(analysis): tighten the unit-companion comment
devin-ai-integration[bot] Sep 24, 2026
4b37fa1
fix(runtime): report an iteration output error on a MonteCarlo run
devin-ai-integration[bot] Sep 24, 2026
d0612e3
fix(analysis): settle member shapes across runs and fill record-numbe…
devin-ai-integration[bot] Sep 24, 2026
e60ef87
fix(repl): spell each recorded run in its own context and say why one…
devin-ai-integration[bot] Sep 24, 2026
2b43f3f
fix(cli): apply run bounds and flag refusals to recorded conversions
devin-ai-integration[bot] Sep 24, 2026
2b4262e
docs: note runs skipped from recording
devin-ai-integration[bot] Sep 24, 2026
3a9c5c0
fix(analysis): declare the unit companion when a Real member later ca…
devin-ai-integration[bot] Sep 24, 2026
65433c7
fix(cli): refuse -record-run with -compare-results
devin-ai-integration[bot] Sep 24, 2026
a2e76ab
fix(analysis): quote record names needing it and own each record defi…
devin-ai-integration[bot] Sep 24, 2026
624e348
fix(repl): name sibling cases' records from their owner and read the …
devin-ai-integration[bot] Sep 24, 2026
c5c0999
docs: record definitions carry the case and siblings are owner-named
devin-ai-integration[bot] Sep 24, 2026
cbba08f
refactor(repl): read a record definition's case through the notation'…
devin-ai-integration[bot] Sep 24, 2026
40e6478
fix(runtime): read a Monte Carlo run's outputs on demand, memoizing n…
devin-ai-integration[bot] Sep 24, 2026
4c6ce1e
fix(analysis): give a run's record its own kind
devin-ai-integration[bot] Sep 24, 2026
06606c0
fix(repl): record a sample's conclusion and say when nothing recorded
devin-ai-integration[bot] Sep 24, 2026
a32d4be
docs: record the Monte Carlo sample's conclusion
devin-ai-integration[bot] Sep 24, 2026
f5970ad
fix(runtime): capture a Monte Carlo run's outputs inside a probe
devin-ai-integration[bot] Sep 24, 2026
b704463
fix(analysis): let a record member declared ScalarValue settle to a c…
devin-ai-integration[bot] Sep 24, 2026
074d51a
fix(repl): adjudicate an iteration's unread outputs against the sample's
devin-ai-integration[bot] Sep 24, 2026
dafca08
fix(runtime): report an unassigned output by name in a typed error
devin-ai-integration[bot] Sep 24, 2026
e4d4976
fix(analysis): record what a verification run's body decided
devin-ai-integration[bot] Sep 24, 2026
1aea725
fix(repl): skip a run on its own failed outputs and count error repeats
devin-ai-integration[bot] Sep 24, 2026
d54c582
docs: what a verification case's record carries
devin-ai-integration[bot] Sep 24, 2026
747727b
docs(manual): state how sampled outputs and random draws are recorded
devin-ai-integration[bot] Sep 24, 2026
fe9c3d6
fix(analysis): record an inout as a member and an In companion
devin-ai-integration[bot] Sep 24, 2026
c6056fd
fix(repl): merge a record into the file holding its target package
devin-ai-integration[bot] Sep 24, 2026
c5da370
docs: the In companion an inout parameter records
devin-ai-integration[bot] Sep 24, 2026
b6adcd6
fix(analysis): keep enum literal identity and settle Integer and Real…
devin-ai-integration[bot] Sep 24, 2026
1a63423
fix(cli): refuse -record-into given without a package name
devin-ai-integration[bot] Sep 24, 2026
85844fa
docs: numeric settlement and enum literals in recorded runs
devin-ai-integration[bot] Sep 24, 2026
e2c3c6c
fix(analysis): keep a quantity member's unit when a plain number foll…
devin-ai-integration[bot] Sep 24, 2026
d69f37c
fix(analysis): settle a plain number beside a quantity to the quantit…
devin-ai-integration[bot] Sep 24, 2026
d9fd78b
fix(cli): record the run's session flags in the provenance command
devin-ai-integration[bot] Sep 24, 2026
e7a340a
fix(analysis): settle an inout's two sides before declaring unit comp…
devin-ai-integration[bot] Sep 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/unreleased/record-analysis-runs.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **Record analysis runs into the model.** `%record <case> [into <package>]` at the REPL and `-record-run <case>` on the command line run an analysis case as `%analysis`/`-analysis` does and write the run into the model as `AnalysisRecords` elements — a record definition per case, one part per run carrying the inputs bound and outputs produced, and `@AnalysisRecords::RecordedRun` provenance metadata. Sweeps (`-sweep`) and Monte Carlo samples (`-runs`/`-seed`) record one part per run, plus one for the sample's conclusion under kind `sample`; records compose with `-convert sysml -o` and `-render-document`, are found by document queries, and an `inout` records the value the run left and a `<name>In` companion for the value it was bound with, and values supplied as Integer and Real alike settle a member to Real and scalar-valued enum literals keep their literal, and a failed run records nothing. A verification case's record carries the verdict its body decided — the `verdict` attribute — and one `VerdictRecord` row apiece for it and each subcase's.
109 changes: 104 additions & 5 deletions cmd/sysml/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ type checks struct {
satisfy optionalNames
calcs stringSlice
analyses stringSlice
records stringSlice
recordInto string
sweeps stringSlice
samples sweepCount
seed sweepSeed
Expand Down Expand Up @@ -231,6 +233,7 @@ func (a *advanceTime) Set(value string) error {
func (c *checks) requested() bool {
return c.validate.given || c.jsonOut || c.advance.given || c.satisfy.given || len(c.instantiate) > 0 ||
len(c.constraints) > 0 || len(c.requirements) > 0 || len(c.calcs) > 0 || len(c.analyses) > 0 ||
len(c.records) > 0 ||
len(c.queries) > 0 || len(c.actions) > 0 || len(c.states) > 0 ||
c.sweeping() || c.running() || c.compare != "" || c.checker.given()
}
Expand Down Expand Up @@ -328,11 +331,11 @@ func (c *checks) runsMisuse() string {
switch {
case !c.runs.given:
return "-observe names what -runs reports; ask for the runs, as -runs <number>"
case len(c.actions) == 0 && len(c.analyses) == 0:
case len(c.actions) == 0 && len(c.analyses) == 0 && len(c.records) == 0:
return "-runs runs an action or a Simulation::MonteCarlo analysis case; name one, as -action <name> or -analysis <name>"
case len(c.actions)+len(c.analyses) > 1:
case len(c.actions)+len(c.analyses)+len(c.records) > 1:
return "-runs runs one action or analysis case; name a single -action or -analysis"
case len(c.analyses) > 0 && len(c.observe) > 0:
case len(c.analyses)+len(c.records) > 0 && len(c.observe) > 0:
return "-runs of an analysis case observes what the case declares as observed; -observe names the features of an -action"
case len(c.states) > 0:
return "-runs runs an action; a state machine is run once, as -state <name> without -runs"
Expand All @@ -355,7 +358,7 @@ func (c *checks) compareMisuse() string {
switch {
case len(c.states) > 0 || c.sweeping() || c.advance.given || c.checker.given() ||
c.validate.given || c.satisfy.given || len(c.instantiate) > 0 || len(c.constraints) > 0 ||
len(c.requirements) > 0 || len(c.calcs) > 0 || len(c.analyses) > 0 || len(c.queries) > 0:
len(c.requirements) > 0 || len(c.calcs) > 0 || len(c.analyses) > 0 || len(c.records) > 0 || len(c.queries) > 0:
return "-compare-results runs the migrated configurations the results index and compares the runs with the tool's; the other checks are made in a run of their own"
}
for _, pair := range c.observe {
Expand Down Expand Up @@ -407,7 +410,7 @@ func (c *checks) sweepMisuse() string {
if !c.sweeping() {
return ""
}
targets := len(c.calcs) + len(c.analyses)
targets := len(c.calcs) + len(c.analyses) + len(c.records)
switch {
case targets == 0:
return "-sweep runs an analysis case or a calc; name one, as -analysis <name> or -calc <name>"
Expand All @@ -426,14 +429,53 @@ func (c *checks) sweepMisuse() string {
func (c *checks) instantiatesOnly() bool {
return len(c.instantiate) > 0 && !c.validate.given && !c.jsonOut && !c.advance.given && !c.satisfy.given &&
len(c.constraints) == 0 && len(c.requirements) == 0 && len(c.calcs) == 0 && len(c.analyses) == 0 &&
len(c.records) == 0 &&
len(c.queries) == 0 && len(c.actions) == 0 && len(c.states) == 0 && !c.sweeping() && !c.running() && c.compare == "" && !c.checker.given()
}

// recordsOnly reports whether the run makes records and decides nothing else,
// so a document can be rendered over, or a file written from, what was recorded.
// The bounds the records run under — a sweep's ranges, a Monte Carlo's runs,
// seed and draws — and the objects -instantiate materializes for them, serve them.
func (c *checks) recordsOnly() bool {
return len(c.records) > 0 && !c.validate.given && !c.jsonOut && !c.advance.given && !c.satisfy.given &&
len(c.constraints) == 0 && len(c.requirements) == 0 && len(c.calcs) == 0 &&
len(c.analyses) == 0 && len(c.observe) == 0 &&
len(c.queries) == 0 && len(c.actions) == 0 && len(c.states) == 0 && c.compare == "" && !c.checker.given()
}

// recordMisuse reports why the flags the records were asked for with record
// none, and "" when they record one.
func (c *checks) recordMisuse() string {
if len(c.records) == 0 {
return ""
}
switch {
case c.samples.given:
return "-samples draws values for a sweep it does not run; -record-run records a run, a -sweep's rows or a -runs sample"
case len(c.records) > 1 && c.runs.given:
return "-runs runs one analysis case; name a single -record-run"
}
return ""
}

// boundsMisuse reports why the bounds a records run was asked for make no run:
// the same refusal runChecks gives for them.
func (c *checks) boundsMisuse() string {
for _, message := range []string{c.sweepMisuse(), c.runsMisuse(), c.recordMisuse()} {
if message != "" {
return message
}
}
return ""
}

// checksOnly reports whether anything was asked about the model itself, as
// against how to report the answer.
func (c *checks) checksOnly() bool {
return len(c.validate.targets) > 0 || len(c.instantiate) > 0 || len(c.constraints) > 0 ||
len(c.requirements) > 0 || len(c.satisfy.targets) > 0 || len(c.calcs) > 0 || len(c.analyses) > 0 ||
len(c.records) > 0 ||
len(c.queries) > 0 || len(c.actions) > 0 || len(c.states) > 0 || c.compare != ""
}

Expand Down Expand Up @@ -533,6 +575,10 @@ func runChecks(files []string, exprs []string, c checks) int {
rep.failed(message)
return rep.finish()
}
if message := c.recordMisuse(); message != "" {
rep.failed(message)
return rep.finish()
}
if message := c.checkerMisuse(engine.text); message != "" {
rep.failed(message)
return rep.finish()
Expand Down Expand Up @@ -708,6 +754,9 @@ func runChecks(files []string, exprs []string, c checks) int {
rep.verdict(sess.RunAnalysis(invocation))
}
}
for _, invocation := range c.records {
rep.verdict(c.record(sess, invocation))
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
}
// With -advance every behavior named is started first and the clock they share
// is moved once, so an action's signal reaches a machine that accepts it later;
// under the check engine it bounds the search of the invocation's schedules.
Expand Down Expand Up @@ -753,6 +802,56 @@ func behaviors(values []string) []repl.Behavior {
return out
}

// record runs one invocation as its -analysis twin does — swept or sampled over
// -runs when the flags say — then writes the run into the model as records.
func (c *checks) record(sess *repl.Session, invocation string) repl.Verdict {
command := c.recordCommand(invocation)
switch {
case c.sweeping():
return sess.RecordSweep(invocation, c.sweeps, c.recordInto, command)
case c.runs.given:
return sess.RecordMonteCarlo(invocation, c.runs.value, c.seed.seed(), c.recordInto, command)
default:
return sess.RecordAnalysis(invocation, c.recordInto, command)
}
}

// recordCommand is the invocation text a record's provenance carries: the flags
// the run was made with, as written.
func (c *checks) recordCommand(invocation string) string {
parts := []string{fmt.Sprintf("-record-run %q", invocation)}
for _, r := range c.sweeps {
parts = append(parts, fmt.Sprintf("-sweep %q", r))
}
if c.runs.given {
parts = append(parts, "-runs "+c.runs.text)
}
if c.seed.given {
parts = append(parts, "-seed "+c.seed.text)
}
if c.draws.text != "" {
parts = append(parts, "-draws "+c.draws.text)
}
// The flags the session runs under decide what the run computed and which
// objects it ran on, so the command records them as written too.
if schedule.text != "" {
parts = append(parts, "-schedule "+schedule.text)
}
if c.clockStep.given {
parts = append(parts, "-clock-step "+c.clockStep.text)
}
if engine.text != "" {
parts = append(parts, "-engine "+engine.text)
}
for _, name := range c.instantiate {
parts = append(parts, fmt.Sprintf("-instantiate %q", name))
}
if c.recordInto != "" {
parts = append(parts, "-record-into "+c.recordInto)
}
return strings.Join(parts, " ")
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
}

// sweep runs one invocation once per row of the ranges given: over every value
// of each range, or over values drawn from them when -samples was asked for.
func (c *checks) sweep(sess *repl.Session, invocation string) repl.Verdict {
Expand Down
1 change: 1 addition & 0 deletions cmd/sysml/compare_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ func TestMigrationResultsThroughCLI(t *testing.T) {
"missing sidecar": {[]string{model, "-compare-results", filepath.Join(dir, "none.json")}, "-compare-results: open"},
"sidecar not JSON": {[]string{model, "-compare-results", model}, "the results are not the JSON -migration-results writes"},
"with convert": {[]string{model, "-compare-results", sidecar, "-convert", "ttl"}, "cannot be combined with -convert"},
"with record-run": {[]string{model, "-compare-results", sidecar, "-record-run", "Group 0"}, "the other checks are made in a run of their own"},
"results without xmi": {[]string{model, "-convert", "ttl", "-migration-results", sidecar}, "-migration-results indexes the result snapshots of a SysML v1 migration"},
"results without convert": {[]string{model, "-migration-results", sidecar}, "-migration-results accompanies -convert"},
"results over the model": {[]string{simconfigXMI, "-convert", "sysml", "-o", sidecar, "-migration-results", sidecar}, "-migration-results and -o both name"},
Expand Down
136 changes: 124 additions & 12 deletions cmd/sysml/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"path/filepath"
"strings"

"github.com/Open-MBEE/OpenSysML/internal/frontend/repl"
"github.com/Open-MBEE/OpenSysML/internal/translate/convert"
"github.com/Open-MBEE/OpenSysML/internal/translate/export"
"github.com/Open-MBEE/OpenSysML/internal/translate/interop/flexo"
Expand Down Expand Up @@ -61,6 +62,15 @@ func runConvert(files []string) (int, error) {
}
input := files[0]

// A run asked to record puts the records in the session's buffer rather than
// in the file, so what is converted is that buffer's text, as %save writes it.
if len(modelChecks.records) > 0 {
if err := recordedConvertMisuse(input); err != nil {
return 0, err
}
return convertRecorded(input, to)
}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

inputRef, inputIsURL, err := flexo.ParseBranchURL(input)
if err != nil {
return 0, err
Expand Down Expand Up @@ -134,25 +144,18 @@ func runConvert(files []string) (int, error) {
// convertInput runs the conversion the input format asks for: a SysML v1 model
// is migrated and its report written, anything else converted.
func convertInput(name string, data []byte, from, to convert.Format) ([]byte, error) {
if idForm != "" && (from != convert.FormatSysML || (to != convert.FormatTurtle && to != convert.FormatAPIJSON)) {
return nil, fmt.Errorf("-id applies to -convert ttl or api-json from SysML notation")
opts, err := convertOptions(from, to)
if err != nil {
return nil, err
}
if from != convert.FormatXMI {
opts := convert.Options{}
if idForm != "" {
form, ok := export.ParseIDForm(idForm)
if !ok {
return nil, fmt.Errorf("-id wants qualified or uuid, not %q", idForm)
}
opts.ID = form
}
return convert.ConvertWith(name, data, from, to, opts)
}
opts, err := migrationOptions()
migOpts, err := migrationOptions()
if err != nil {
return nil, err
}
migrated, err := convert.Migrate(name, data, to, opts)
migrated, err := convert.Migrate(name, data, to, migOpts)
if err != nil {
return nil, err
}
Expand All @@ -165,6 +168,115 @@ func convertInput(name string, data []byte, from, to convert.Format) ([]byte, er
return migrated.Output, nil
}

// recordedConvertMisuse is why a flag cannot share the run -record-run
// converts: what is converted is the session the records join, not a file
// migrated or a branch read or pushed.
func recordedConvertMisuse(input string) error {
inRef, inputIsURL, err := flexo.ParseBranchURL(input)
if err != nil {
return err
}
if inputIsURL {
return fmt.Errorf("-record-run converts the recorded session model; a repository branch is not an input it reads (%s)", inRef)
}
if outputPath != "" {
outRef, outputIsURL, err := flexo.ParseBranchURL(outputPath)
if err != nil {
return err
}
if outputIsURL {
return fmt.Errorf("-record-run converts the recorded session model; -o cannot push it to a repository branch (%s)", outRef)
}
}
switch {
case syncState != "":
return errors.New("-record-run converts the recorded session model; -sync-state does not apply")
case migrationReport != "":
return errors.New("-record-run converts the recorded session model; -migration-report does not apply")
case migrationResults != "":
return errors.New("-record-run converts the recorded session model; -migration-results does not apply")
case layoutPath != "":
return errors.New("-record-run converts the recorded session model; -layout does not apply")
}
return nil
}

// convertRecorded loads the file, makes the runs -record-run names so the
// records join the session's buffer, and converts that text; a load that did
// not analyse or a run that failed converts nothing.
func convertRecorded(input string, to convert.Format) (int, error) {
if fromFormat != "" && fromFormat != "sysml" {
return 0, fmt.Errorf("-record-run records into SysML notation; -from %s does not apply", fromFormat)
}
sess := newSession()
report, err := sess.LoadPathsReport([]string{input})
if err != nil {
return 0, err
}
writeLines(os.Stderr, report.Loaded)
writeLines(os.Stderr, report.Found)
writeLines(os.Stderr, report.Declared)
if report.Errors {
return 0, fmt.Errorf("%s did not analyse cleanly; nothing was converted", input)
}
// The objects -instantiate names are materialized first, so a run named on
// one has it to record.
for _, name := range modelChecks.instantiate {
created, err := sess.InstantiateReport(name)
if err != nil {
return 0, err
}
writeLines(os.Stderr, created.Lines)
if len(created.FeatureValueErrors) > 0 {
writeLines(os.Stderr, created.FeatureValueErrors)
return 0, fmt.Errorf("%s did not materialize cleanly; nothing was converted", name)
}
}
for _, invocation := range modelChecks.records {
verdict := modelChecks.record(sess, invocation)
writeLines(os.Stderr, verdict.Lines)
if verdict.Status != repl.VerdictHolds {
return 0, fmt.Errorf("%s: the run was not recorded; nothing was converted", invocation)
}
}
opts, err := convertOptions(convert.FormatSysML, to)
if err != nil {
return 0, err
}
out, tolerated, err := convert.ConvertTolerantWith(repl.SessionOrigin, []byte(sess.Text()), convert.FormatSysML, to, opts)
if err != nil {
return 0, err
}
if tolerated != nil {
for _, line := range strings.Split("warning: "+tolerated.Error(), "\n") {
fmt.Fprintln(os.Stderr, line)
}
}
if outputPath != "" {
return exitHolds, writeConversion(outputPath, out, to)
}
_, err = os.Stdout.Write(out)
return exitHolds, err
}

// convertOptions are the conversion settings -id asks for, refusing it for a
// direction it does not apply to.
func convertOptions(from, to convert.Format) (convert.Options, error) {
opts := convert.Options{}
if idForm == "" {
return opts, nil
}
if from != convert.FormatSysML || (to != convert.FormatTurtle && to != convert.FormatAPIJSON) {
return opts, fmt.Errorf("-id applies to -convert ttl or api-json from SysML notation")
}
form, ok := export.ParseIDForm(idForm)
if !ok {
return opts, fmt.Errorf("-id wants qualified or uuid, not %q", idForm)
}
opts.ID = form
return opts, nil
}

// writeConversion writes converted output to a file and reports it.
func writeConversion(path string, out []byte, to convert.Format) error {
replaced, err := export.WriteFile(path, out)
Expand Down
Loading
Loading