From a4e1f6ef9cf2685f2f88f03707f092b971f46a18 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 02:44:14 +0000 Subject: [PATCH 1/7] fix(runtime): track body feature bindings Co-Authored-By: jason.han --- .../unreleased/body-feature-bindings.fixed.md | 1 + docs/project/spec-compliance.md | 45 +- internal/exec/runtime/action_executor.go | 90 +++- internal/exec/runtime/action_frame.go | 223 +++++++-- internal/exec/runtime/action_statements.go | 22 +- internal/exec/runtime/action_terminate.go | 4 +- internal/exec/runtime/analysis_run.go | 10 +- internal/exec/runtime/assign_chain.go | 8 +- internal/exec/runtime/body_bindings.go | 464 ++++++++++++++++++ internal/exec/runtime/calc_statements.go | 45 +- internal/exec/runtime/calc_usage.go | 91 +++- internal/exec/runtime/case_step.go | 2 +- internal/exec/runtime/check_state.go | 81 ++- internal/exec/runtime/classifier_behavior.go | 2 +- internal/exec/runtime/conformance_test.go | 9 +- internal/exec/runtime/context.go | 9 +- internal/exec/runtime/dependents.go | 46 +- internal/exec/runtime/eval.go | 46 +- internal/exec/runtime/frame.go | 84 +++- internal/exec/runtime/function_value.go | 8 +- internal/exec/runtime/held_image_behavior.go | 197 +++++++- internal/exec/runtime/instance.go | 11 + internal/exec/runtime/invoke_calc.go | 31 +- internal/exec/runtime/montecarlo_case.go | 16 +- ...obustness_action_step_multiplicity_test.go | 6 +- .../robustness_body_feature_bindings_test.go | 321 ++++++++++++ internal/exec/runtime/shared_default.go | 12 +- internal/exec/runtime/signal.go | 5 +- internal/exec/runtime/snapshot.go | 73 ++- internal/exec/runtime/state_executor.go | 212 ++++++-- internal/exec/runtime/state_space.go | 17 +- internal/exec/runtime/state_statements.go | 23 +- internal/exec/runtime/state_unit_front.go | 21 +- internal/exec/runtime/statements.go | 376 ++++++++++++-- .../runtime/testdata/conformance/README.md | 2 +- ..._block_body_feature_bindings.expected.json | 8 + .../action_block_body_feature_bindings.sysml | 28 ++ .../action_body_feature_binding.expected.json | 10 + .../action_body_feature_binding.sysml | 14 + .../action_body_feature_binding.trace.golden | 11 + ...body_feature_initial_binding.expected.json | 10 + .../action_body_feature_initial_binding.sysml | 18 + ...dy_feature_occurrence_mirror.expected.json | 9 + ...ction_body_feature_occurrence_mirror.sysml | 25 + ...ction_body_feature_pin_write.expected.json | 10 + .../action_body_feature_pin_write.sysml | 21 + ...ction_body_feature_read_only.expected.json | 5 + .../action_body_feature_read_only.sysml | 9 + .../action_body_feature_written.expected.json | 9 + .../action_body_feature_written.sysml | 17 + ...ction_inherited_default_root.expected.json | 4 +- .../action_inherited_default_root.sysml | 5 +- ...erited_default_root_fallback.expected.json | 9 + ...tion_inherited_default_root_fallback.sysml | 17 + ...inherited_supplied_parameter.expected.json | 7 + .../action_inherited_supplied_parameter.sysml | 25 + ..._nested_body_feature_binding.expected.json | 9 + .../action_nested_body_feature_binding.sysml | 21 + ...plicity_shared_writers.check.expected.json | 6 +- ..._multiplicity_shared_writers.expected.json | 2 - ...ion_step_multiplicity_shared_writers.sysml | 4 +- ...shared_writers_initial.check.expected.json | 5 + ...icity_shared_writers_initial.expected.json | 11 + ..._multiplicity_shared_writers_initial.sysml | 17 + ...rminate_names_exhibiting_part.trace.golden | 1 + .../calc_body_feature_binding.expected.json | 6 + .../calc_body_feature_binding.sysml | 13 + ...alc_usage_outputs_one_binding.trace.golden | 2 + ...straint_body_feature_binding.expected.json | 10 + .../constraint_body_feature_binding.sysml | 22 + ...d_state_guard_reads_attribute.trace.golden | 1 + .../exhibited_state_self_target.trace.golden | 2 + ...ormed_action_later_node_reads.trace.golden | 1 + ...tribute_body_feature_binding.expected.json | 10 + ...state_attribute_body_feature_binding.sysml | 19 + ...havior_body_feature_bindings.expected.json | 9 + ...state_behavior_body_feature_bindings.sysml | 31 ++ ...feature_binding_change_guard.expected.json | 10 + ...te_body_feature_binding_change_guard.sysml | 22 + ...ock_state_interleave.declared.trace.golden | 9 + ...clock_state_interleave.seed-1.trace.golden | 9 + ..._space_clock_state_interleave.trace.golden | 9 + ...ate_space_zero_crossing_state.trace.golden | 9 + internal/exec/runtime/tool.go | 5 +- internal/exec/runtime/via_reference.go | 7 +- internal/exec/runtime/write_conformance.go | 2 +- internal/ir/lower/action_graph.go | 25 +- internal/ir/lower/body_binding_test.go | 103 ++++ internal/ir/lower/calc_body.go | 4 +- internal/ir/lower/state_behavior.go | 2 +- internal/ir/lower/state_behavior_test.go | 16 + internal/ir/lower/state_graph.go | 2 +- internal/ir/lower/state_inheritance.go | 2 +- .../parse/body_feature_bindings.golden | 98 ++++ .../parse/body_feature_bindings.sysml | 36 ++ 95 files changed, 3142 insertions(+), 314 deletions(-) create mode 100644 changes/unreleased/body-feature-bindings.fixed.md create mode 100644 internal/exec/runtime/body_bindings.go create mode 100644 internal/exec/runtime/robustness_body_feature_bindings_test.go create mode 100644 internal/exec/runtime/testdata/conformance/action_block_body_feature_bindings.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_block_body_feature_bindings.sysml create mode 100644 internal/exec/runtime/testdata/conformance/action_body_feature_binding.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_body_feature_binding.sysml create mode 100644 internal/exec/runtime/testdata/conformance/action_body_feature_binding.trace.golden create mode 100644 internal/exec/runtime/testdata/conformance/action_body_feature_initial_binding.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_body_feature_initial_binding.sysml create mode 100644 internal/exec/runtime/testdata/conformance/action_body_feature_occurrence_mirror.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_body_feature_occurrence_mirror.sysml create mode 100644 internal/exec/runtime/testdata/conformance/action_body_feature_pin_write.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_body_feature_pin_write.sysml create mode 100644 internal/exec/runtime/testdata/conformance/action_body_feature_read_only.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_body_feature_read_only.sysml create mode 100644 internal/exec/runtime/testdata/conformance/action_body_feature_written.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_body_feature_written.sysml create mode 100644 internal/exec/runtime/testdata/conformance/action_inherited_default_root_fallback.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_inherited_default_root_fallback.sysml create mode 100644 internal/exec/runtime/testdata/conformance/action_inherited_supplied_parameter.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_inherited_supplied_parameter.sysml create mode 100644 internal/exec/runtime/testdata/conformance/action_nested_body_feature_binding.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_nested_body_feature_binding.sysml create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_shared_writers_initial.check.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_shared_writers_initial.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_shared_writers_initial.sysml create mode 100644 internal/exec/runtime/testdata/conformance/calc_body_feature_binding.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/calc_body_feature_binding.sysml create mode 100644 internal/exec/runtime/testdata/conformance/constraint_body_feature_binding.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/constraint_body_feature_binding.sysml create mode 100644 internal/exec/runtime/testdata/conformance/state_attribute_body_feature_binding.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/state_attribute_body_feature_binding.sysml create mode 100644 internal/exec/runtime/testdata/conformance/state_behavior_body_feature_bindings.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/state_behavior_body_feature_bindings.sysml create mode 100644 internal/exec/runtime/testdata/conformance/state_body_feature_binding_change_guard.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/state_body_feature_binding_change_guard.sysml create mode 100644 internal/ir/lower/body_binding_test.go create mode 100644 tests/parser/testdata/parse/body_feature_bindings.golden create mode 100644 tests/parser/testdata/parse/body_feature_bindings.sysml diff --git a/changes/unreleased/body-feature-bindings.fixed.md b/changes/unreleased/body-feature-bindings.fixed.md new file mode 100644 index 0000000000..dbf16dee10 --- /dev/null +++ b/changes/unreleased/body-feature-bindings.fixed.md @@ -0,0 +1 @@ +- **Track `=` values in action, calculation, statement-block, and state-machine bodies.** Their dependencies are re-evaluated when read after a source changes, while `:=`, `default`, and values assigned to a binding retain their existing write behavior. An unsupplied inherited action parameter such as `z = x * 2` now follows a later write to `x` (`action_inherited_default_root`: `z` and `r` change from 6 to 20); the supplied argument in `action_inherited_supplied_parameter` remains held. Body-local calc usages remain evaluated once per declaration. Explore/check now agrees on the shared-writers `=` case with `{3}` rather than `{1,2,3}`. diff --git a/docs/project/spec-compliance.md b/docs/project/spec-compliance.md index 943b049a15..644f7ebe5f 100644 --- a/docs/project/spec-compliance.md +++ b/docs/project/spec-compliance.md @@ -550,7 +550,7 @@ checked after the result is bound is not a form the runtime offers, and none is | A default reaching through a nested part reads that part's own derived values | `eval.go` `evalFeatureChain` (via `GetFeatureValue`) | `instance_derived_slots.sysml` (`total`) | ✅ Faithful | | A default expression resolves declarations in the scope that declared the feature, while instance feature values take precedence | `shape.go` `EffectiveFeature.DeclScope`, `eval.go` `EvalContext.self` | `instance_derived_slots.sysml` | ✅ Faithful | | Mutually dependent defaults report a cycle rather than recursing to the step budget | `context.go` `derivingSlots`, `errors.go` `ErrCyclicFeatureValue` | `robustness_test.go:cyclic_derived_slot` | ✅ Faithful | -| A value written with `=` holds at all times, not once: a `FeatureValue` with `isDefault = false` binds the feature to its expression's result (KerML 1.0 §7.3.4.5), and a binding requires the same values at both ends (KerML 1.0 §7.4.9), so once a feature the expression read changes, the derived value is derived again from what the object holds now — `attribute a : Integer default 3; attribute d : Integer = a * 2; attribute dd : Integer = d + 1;` reads `d` as `6` and `dd` as `7`, then `18` and `19` after `a` is written `9`, transitively and through a part or a binding the expression read through, and a `default null` collection a classifier's subsetter later supersedes re-derives the roll-up that read it folded. Recomputation is lazy — the write unmaterializes what read the feature, the next read derives it — and only a value nothing assigned tracks: one a run assigned keeps the assignment (`d := 100` then `a := 1` reads `d` as `100`, `dd` as `101`), a probe or transaction that wrote what a derived value read is undone with it, edges and materialization alike, and a value derived from itself is still `ErrCyclicFeatureValue` | `dependents.go` (`deriveFeatureValue` opens the recording frame around the `=` evaluation only, `noteRead` lists the derived value on each feature value it reads and the feature value among what it read, `forgetReads` delists it before it is derived again so a branch it no longer takes cannot unmaterialize it, `beforeWrite`/`afterWrite`/`invalidate` unmaterialize what read a feature that changed, `forgetEdgesOf` drops every edge into or out of an object a failed creation abandons), `instance.go` `SetFeatureValue`, `materializeFeatureValue`, `unfoldSubsettedDefaults`, `binding.go` `assignBindingEndpoint`, `classify.go` `refineFeatureValue`, `subsetting.go` `fillOptionalSubsetters`, `adopt.go` `commit`, `classifier_behavior.go` `forgetBehaviorWrites` | conformance `derived_value_follows_assignment`, `derived_invalidation_test.go:TestDerivedValueRecomputesWhenWhatItReadChanges`, `:TestWrittenDerivedValueKeepsWhatWasAssigned`, `:TestWriteOfTheSameValueLeavesDerivedValues`, `:TestClassifierSubsetterRecomputesWhatReadTheFoldedDefault`, `:TestDerivedValueFollowsAWriteThroughAPartAndABinding`, `:TestProbeRollbackRestoresDerivedValuesAndTheirDependencies`, `:TestProbeRollbackForgetsDependenciesRecordedInIt`, `:TestDerivedValueForgetsTheBranchItNoLongerReads`, `:TestAbandonedObjectsLeaveNoDependencyEdges`, `:TestWriteUnderADerivationDerivesItAgain`, `:TestSelfReferentialDerivedValueStaysACycle`, `:TestNoDependencyIsRecordedOutsideADerivation`, `robustness_test.go:write_into_cyclic_derived_feature_values` | ✅ Faithful (object-level `=` values only: the `in` parameters of a calc or action usage are bound once per invocation and stay bound while its outputs are read — the calc-usage rows under *Calculation (Calc)* above — and a compiled calc reads parameters and library constants alone, so it has no object feature value to track) | +| A value written with `=` holds at all times, not once: a `FeatureValue` with `isDefault = false` binds the feature to its expression's result (KerML 1.0 §7.3.4.5), and a binding requires the same values at both ends (KerML 1.0 §7.4.9), so once a feature the expression read changes, the derived value is derived again from what the object holds now — `attribute a : Integer default 3; attribute d : Integer = a * 2; attribute dd : Integer = d + 1;` reads `d` as `6` and `dd` as `7`, then `18` and `19` after `a` is written `9`, transitively and through a part or a binding the expression read through, and a `default null` collection a classifier's subsetter later supersedes re-derives the roll-up that read it folded. Recomputation is lazy — the write unmaterializes what read the feature, the next read derives it — and only a value nothing assigned tracks: one a run assigned keeps the assignment (`d := 100` then `a := 1` reads `d` as `100`, `dd` as `101`), a probe or transaction that wrote what a derived value read is undone with it, edges and materialization alike, and a value derived from itself is still `ErrCyclicFeatureValue` | `dependents.go` (`deriveFeatureValue` opens the recording frame around the `=` evaluation only, `noteRead` lists the derived value on each feature value it reads and the feature value among what it read, `forgetReads` delists it before it is derived again so a branch it no longer takes cannot unmaterialize it, `beforeWrite`/`afterWrite`/`invalidate` unmaterialize what read a feature that changed, `forgetEdgesOf` drops every edge into or out of an object a failed creation abandons), `instance.go` `SetFeatureValue`, `materializeFeatureValue`, `unfoldSubsettedDefaults`, `binding.go` `assignBindingEndpoint`, `classify.go` `refineFeatureValue`, `subsetting.go` `fillOptionalSubsetters`, `adopt.go` `commit`, `classifier_behavior.go` `forgetBehaviorWrites`; body/performance/state cells use the same dependency graph in `body_bindings.go` and `dependents.go` | conformance `derived_value_follows_assignment`, `derived_invalidation_test.go:TestDerivedValueRecomputesWhenWhatItReadChanges`, `:TestWrittenDerivedValueKeepsWhatWasAssigned`, `:TestWriteOfTheSameValueLeavesDerivedValues`, `:TestClassifierSubsetterRecomputesWhatReadTheFoldedDefault`, `:TestDerivedValueFollowsAWriteThroughAPartAndABinding`, `:TestProbeRollbackRestoresDerivedValuesAndTheirDependencies`, `:TestProbeRollbackForgetsDependenciesRecordedInIt`, `:TestDerivedValueForgetsTheBranchItNoLongerReads`, `:TestAbandonedObjectsLeaveNoDependencyEdges`, `:TestWriteUnderADerivationDerivesItAgain`, `:TestSelfReferentialDerivedValueStaysACycle`, `:TestNoDependencyIsRecordedOutsideADerivation`, `robustness_test.go:write_into_cyclic_derived_feature_values`; body binding fixtures and `TestRuntimeRobustnessBodyFeatureBindings` below | ✅ Faithful for object-level, action, calculation, statement-block and state-machine features; body-local calc usages remain one-shot as noted below | | A default over an undeclared feature fails naming the feature value | `instance.go` `evalFeatureValueDefault` | `robustness_test.go:derived_slot_over_missing_feature` | ✅ Faithful | | A multi-valued feature holds its default's contents; a single value written on it is the collection's one element | `instance.go` `GetFeatureValue` | `runtime/instance_test.go:TestMultiValuedDefaultMaterializes`, `repl/instance_test.go:TestCollectionFeatureValuesShowTheirContents` | ✅ Faithful | | A nested part usage with a body of its own is instantiated as that usage, so what its body declares wins over what its type declares, and an untyped nested part (`part engine { ... }`) still materializes | `instance.go` `compositeType`, `GetFeatureValue` | `instance_nested_usage_body.sysml`, `instance_unnamed_redefinition.sysml`, `runtime/instance_test.go:TestNestedUsageBodyOverridesItsType`, `TestUntypedNestedPartMaterializes` | ✅ Faithful (both the named form and an unnamed `:>> power = 250.0;`, which takes the name of what it redefines — see the KerML 7.3.4.5 row above) | @@ -586,6 +586,47 @@ checked after the result is bound is not a form the runtime offers, and none is | A usage declared with no kind keyword is a reference usage (`doubled = span * 2.0;` inside a `part def`), so it is a feature of the object that materializes and evaluates like any attribute — `Triangle::base { length = Triangle::length; }` values the base's `length` — while the same `name = value;` inside an `enum def` is an enumeration literal and no feature of anything (SysML v2 §7.6 Usages — `DefaultReferenceUsage` in SysML.xtext; §7.7 Enumerations) | `parser/defusage.go` `parseBodyMember` (a bare name is an enumerated value only under `inEnumBody`, an attribute-kinded usage elsewhere), `semantics/shape.go` `IsShapeFeature` | conformance `instance_default_reference_usage` (`beam.doubled` = `8.0`, `beam.end.offset` = `4.0` through `Beam::span`), `instance_library_geometry_triangle` (`tri.base.length` = `4 [m]`) | ✅ Faithful | | What `ShapeItems` declares beyond these rules fails as a typed error, never as an empty collection, a guessed value or a panic: `matingOccurrences` and `spaceBoundary` are Kernel Semantic frame features (`Occurrences.kerml`) and so not features of the object — `ErrNoSuchFeature` — which is also why the `assert constraint { isClosed == vertices->forAll{ … includes(p1.matingOccurrences, p2) } }` bodies of `Path`/`Polygon` are not evaluated as part of materializing a shape; `Cylinder`/`Cone` bind `cf : Surface` whose `edges` are the Kernel `StructuredSpaceObject`'s, so `be`/`ae` are `ErrBindingEnd` (`"cf.edges": feature edges not found`) and `edges = faces.edges`/`vertices = faces.vertices` are `ErrNoSuchFeature` over `cf`, while `base.edges` and `af.edges` — each `Disc`'s own `edges = shape`, related to `be` by the one link `binding [1] bind [0..*] base.edges = [0..*] be` declares — answer the ellipse (the row above), as do `faces` (three), `height` and a `Cylinder` nested as a `Box`'s `voids`; `RightTriangle::hypotenuse.length = (Triangle::length^2 + Triangle::width^2)` is the library's squared length and is reported as the dimension mismatch it is (`ErrTypeMismatch`, `25 [m**2]` into a `LengthValue`) rather than rewritten; an `Ellipse` has one edge and no vertices, both of which evaluate | `runtime/errors.go` `ErrNoSuchFeature` (a member read, chained read or write naming no feature of the object), `runtime/eval.go` `evalFeatureChain`, `runtime/instance.go` `GetFeatureValue`/`SetFeatureValue`, `runtime/library_frame.go` `frameFeature` | `runtime/shape_items_limits_test.go:TestShapeItemsUnsupportedExpressionsAreTypedErrors`, conformance `instance_library_geometry_triangle` (`tri.hypotenuse.length` error), `instance_library_geometry_box` (`box.self`, `box.timeSlices`, `box.startShot` errors) | ⚠️ Approximate (what the library determines from the dimensions — every `Box`/`Rectangle`/`Triangle`/`Ellipse` face, edge and vertex collection not routed through a `[0..1]` group, a `Cylinder`'s/`Cone`'s faces and their edges — evaluates; the vertex-mating constraints, `matingOccurrences`, `spaceBoundary`, the curved face's edge graph through `cf : Surface` and the `[0..1]`-bound groups are the typed errors named here, none an empty collection) | +### Body and Performance Feature Bindings + +The dependency-tracked `=` rule for object features also applies to run-owned +action, calculation, statement-block and state-machine features. A source write +invalidates a tracking cell; its next semantic read derives the value again +through the same dependency graph and declaration checks. `:=` initializes a +writable value once, `default` remains a fallback, and assigning a tracking cell +holds the assigned value and ends its tracking. Supplied action pins and call +arguments are writes. At the end of a performance, a tracking cell derives once +more if it has not materialized and then freezes; this end-of-performance value +is an OpenSysML policy because the language does not specify later reads of an +ended occurrence. State exit invalidates dependents of the exiting state's +attribute cells, so a later read observes the existing inactive-state behavior +rather than a cached value from before exit. + +The cells are journaled with their owning maps for snapshot/restore, carried in +held images (tracking values restore unmaterialized and rederive lazily), and +spelled in explore/check keys with both their held value or unset state and +their tracking, written or frozen status. Constraint-body declarations already +reevaluate through `evalNameGeneral`/`declaredValue`. A body-local calc usage +lowered as `DeclareUsage` remains one-shot: its input pins are captured on the +first read in an activation and do not track later changes to body `=` values. + +Implementation: `runtime/body_bindings.go` (`bindBodyCell`, `deriveBodyCell`, +`writeBodyCell`, `freezeBodyCells`), `runtime/dependents.go` (`deriveWith`), +`runtime/held_image_behavior.go`, `runtime/snapshot.go`, and +`runtime/check_state.go`. Tests: conformance `action_body_feature_binding`, +`action_body_feature_occurrence_mirror`, +`action_nested_body_feature_binding`, `calc_body_feature_binding`, +`action_block_body_feature_bindings`, `state_body_feature_binding_change_guard`, +`state_attribute_body_feature_binding`, `constraint_body_feature_binding`, +`action_body_feature_initial_binding`, `action_body_feature_written`, +`action_body_feature_read_only`, `state_behavior_body_feature_bindings`, +`action_step_multiplicity_shared_writers`, `action_inherited_default_root`, +`action_inherited_default_root_fallback`, and +`action_inherited_supplied_parameter`; trace +`action_body_feature_binding.trace.golden`; robustness +`TestRuntimeRobustnessBodyFeatureBindings`. Status: ✅ Faithful for action, +calculation, statement-block and state-machine cells; ⚠️ body-local calc usages +remain one-shot. + ### Redefinition in a Specialization (KerML §7.4.7 Redefinition, SysML v2 §7.6) | Semantic Rule | Implementation | Test Case | Status | @@ -959,7 +1000,7 @@ re-deriving one from `symbol.Decl` (AGENTS.md §4). | Semantic Rule | Implementation | Test Case | Status | |--------------|----------------|-----------|--------| | An attribute default in a behavior body is evaluated in that body's scope, so a unit an import brought in resolves (`attribute h : LengthValue = 500.0 [m];`) | `lower/action_graph.go` `lowerAttributes` + `ActionGraph.Scope`; `runtime/action_executor.go` `initializeAttributes`; `lower/state_graph.go` + `runtime/state_executor.go` `initializeAttributes` | `action_body_quantity_descent.sysml`, `state_body_quantity_scope.sysml`, `tests/parser/testdata/parse/action_body_quantity_statements.golden` | ✅ Faithful | -| A parameter or attribute default an action inherits from a generalization and does not redefine is seeded when the performance starts, as an owned default is — evaluated in the scope it was declared in, after the owned ones, so a parameter the action redefines is read as redefined (KerML 1.0 §7.4.7: an inherited feature is a feature of the specializing type, with its value); a default that fails to evaluate is reported when the action starts, naming the parameter | `runtime/action_executor.go` `performanceFeatures` (the graph's attributes, then `semantics.Model.MembersOf` less what the library base types contribute) → `initializeAttributes`/`declaresAttribute` | conformance `action_inherited_default_root` (a body reassigning `x` leaves the seeded `z = x * 2` alone), `action_node_inherited_default` (typed, invoked and performed nodes; a redefined `x` read by an inherited `z`); `robustness_test.go:node_inherited_default_that_cannot_be_evaluated` | ✅ Faithful | +| An inherited action parameter is initialized in its declaring scope; an unsupplied `in z = x * 2` binds to that expression for the performance, so a later assignment to `x` rederives `z`, while `default` remains a fallback and a supplied argument or redefinition is kept as a write (KerML 1.0 §7.4.7) | `runtime/action_executor.go` `performanceFeatures`/`initializeAttributes`; `runtime/action_frame.go` `seedPerformance`/`setFrameFeature`; `runtime/body_bindings.go` | conformance `action_inherited_default_root` (unsupplied binding follows `x`), `action_inherited_default_root_fallback` (`default` stays at its initial value), `action_inherited_supplied_parameter` (a supplied argument stays held), `action_node_inherited_default`; `robustness_test.go:node_inherited_default_that_cannot_be_evaluated` | ✅ Faithful | | A statement in a nested action node resolves in *that* node's scope; a statement in a loop body or an `if` branch in the block's own scope | `lower/action_graph.go` `lowerStatement`, `lowerBlock`, `childScope` (`lower/scope.go`); `runtime/action_statements.go` `evalIn` | `action_body_quantity_descent.sysml`, `action_body_shadows_enclosing_scope.sysml` | ✅ Faithful | | A decision guard and an inline expression resolve in the action's own scope, its feature values shadowing it | `runtime/action_executor.go` `stepDecisionNode`, `stepActionExecutionNode` | `action_body_shadows_enclosing_scope.sysml`, `action_control_flow.sysml` | ✅ Faithful | | A transition's guard, effect, time-event duration and change-event condition resolve in the scope the transition was written in; a call trigger's parameters and an accept trigger's payload are members of the transition, reachable by simple name from its guard, effect and body and inaccessible by simple name elsewhere; the exit of the state the transition leaves reads them qualified by the transition's name (row above) | `lower/state_graph.go` `Transition.Scope`/`BodyScope` (via `symbols.TriggerScope`); `symbols/bodyscopes.go` `triggerParameterDefiner`, `payloadParameterDefiner`; `passes/w8c_feature_reference.go`; `resolve/document.go`; `runtime/state_executor.go` `passesGuard`, `scheduleTransitionsForState`, `pollChangeEvents`, `executeAction`; `runtime/state_region_transition.go` `runEffect` | `state_body_quantity_scope.sysml`, `state_call_trigger_guard.sysml`, `state_call_trigger_regions.sysml`, `state_transition_accept_payload.sysml`, `model/behavior_body_resolve_test.go` accept-payload case | ✅ Faithful | diff --git a/internal/exec/runtime/action_executor.go b/internal/exec/runtime/action_executor.go index 6c8cb1f3c1..713e54dfa6 100644 --- a/internal/exec/runtime/action_executor.go +++ b/internal/exec/runtime/action_executor.go @@ -296,6 +296,7 @@ func (e *ActionExecutor) performanceFeatures() []lower.Attribute { features = append(features, lower.Attribute{ Name: name, Direction: usage.Direction, IsResult: usage.IsResult, Type: lower.TypeText(usage), Value: value, Node: usage, Scope: scope, Optional: e.ctx.admitsNoValue(member), + Binding: value != nil && !usage.ValueIsInitial && !usage.ValueIsDefault, }) } return features @@ -1160,8 +1161,9 @@ func (e *ActionExecutor) initializeAttributes() error { return fmt.Errorf("%w: read %s of object #%d: %w", ErrActionPerformanceOccurrence, attr.Name, e.occurrence.ID, err) } - if value := fv.HeldValue(); value.Kind != ValInvalid { - e.root.data[e.root.key(attr.Name)] = value + if value := fv.HeldValue(); value.Kind != ValInvalid && + (!attr.Binding || fv.Written || e.occurrenceRedefinesAttribute(attr, fv)) { + e.ctx.writeBodyValue(e.root.cells, e.root.data, e.root.key(attr.Name), value) if err := e.streamInitialOutput(attr.Name, value); err != nil { return err } @@ -1174,14 +1176,26 @@ func (e *ActionExecutor) initializeAttributes() error { } continue } - value, err := evalDefaults().evalIn(attr.Scope).Eval(attr.Value) + var value Value + var err error + if attr.Binding { + value, err = e.ctx.deriveBodyCell( + e.root.cells, e.root.key(attr.Name), e.root.cells.existingCell(e.root.key(attr.Name)), + ) + } else { + value, err = evalDefaults().evalIn(attr.Scope).Eval(attr.Value) + } if err != nil { return fmt.Errorf("eval attribute default %s: %w", attr.Name, err) } - if value, err = e.mirrorOccurrence(attr.Name, value); err != nil { - return err + if !attr.Binding { + if value, err = e.mirrorOccurrence(attr.Name, value); err != nil { + return err + } + } + if !attr.Binding { + e.ctx.writeBodyValue(e.root.cells, e.root.data, e.root.key(attr.Name), value) } - e.root.data[e.root.key(attr.Name)] = value if err := e.streamInitialOutput(attr.Name, value); err != nil { return err } @@ -1215,7 +1229,7 @@ func (e *ActionExecutor) bindContextDefault(attr lower.Attribute) bool { if err != nil { return false } - e.root.data[e.root.key(attr.Name)] = value + e.ctx.writeBodyValue(e.root.cells, e.root.data, e.root.key(attr.Name), value) return true } @@ -1261,16 +1275,23 @@ func (e *ActionExecutor) materializeOccurrence() (*Instance, error) { if err != nil { return nil, err } + e.occurrence = inst + e.performances.occurrence = inst for _, attr := range e.features { if value, held := e.root.data[e.root.key(attr.Name)]; held { + if cell := e.root.cells.existingCell(e.root.key(attr.Name)); cell != nil && cell.binding != nil && !cell.fv.Written { + if _, err := e.mirrorBindingOccurrence(attr.Name, value, cell); err != nil { + return nil, fmt.Errorf("%w: seed %s of object #%d: %w", + ErrActionPerformanceOccurrence, attr.Name, inst.ID, err) + } + continue + } if err := inst.BindFeatureValue(e.ctx, attr.Name, value); err != nil { return nil, fmt.Errorf("%w: seed %s of object #%d: %w", ErrActionPerformanceOccurrence, attr.Name, inst.ID, err) } } } - e.occurrence = inst - e.performances.occurrence = inst e.ctx.beginPerformanceLife(inst, e.ctx.newActivation()) return inst, nil } @@ -1308,6 +1329,22 @@ func (e *ActionExecutor) mirrorOccurrence(name string, value Value) (Value, erro return fv.HeldValue(), nil } +func (e *ActionExecutor) mirrorBindingOccurrence(name string, value Value, cell *bodyCell) (Value, error) { + if e.occurrence == nil || !e.declaresAttribute(name) { + return value, nil + } + mirrored, err := e.ctx.mirrorBodyCell(e.occurrence, name, cell, value) + if err != nil { + return value, fmt.Errorf("%w: write %s of object #%d: %w", + ErrActionPerformanceOccurrence, name, e.occurrence.ID, err) + } + return mirrored, nil +} + +func (e *ActionExecutor) occurrenceRedefinesAttribute(attr lower.Attribute, fv *FeatureValue) bool { + return occurrenceHasRedefinedDefault(fv, attr, e.graph.Scope) +} + // setFeature writes into the action's feature space, through the performance // occurrence for a feature the action declares: the occurrence is authoritative // for those, and data mirrors what it holds after the write. @@ -1322,7 +1359,7 @@ func (e *ActionExecutor) setFeature(name string, value Value) error { // rather than by the write to that occurrence. return err } - e.root.data[e.root.key(name)] = value + e.ctx.writeBodyValue(e.root.cells, e.root.data, e.root.key(name), value) e.moved = true return e.streamOutput(name, value) } @@ -1387,12 +1424,15 @@ func (e *ActionExecutor) completeWithoutFlow() error { if err := e.checkResultParameters(); err != nil { return err } - e.ctx.beginPerformanceLife(e.occurrence, e.ctx.newActivation()) + e.beginRootPerformance() if err := e.bindInputs(); err != nil { return err } + if err := e.ctx.freezeBodyCells(e.root.cells); err != nil { + return err + } e.state = StateCompleted - e.ctx.endPerformanceLife(e.occurrence) + e.endRootPerformance() return nil } @@ -1414,6 +1454,18 @@ func (e *ActionExecutor) bindInputs() error { return nil } +func (e *ActionExecutor) beginRootPerformance() { + if e.root.began == 0 { + e.root.began = e.ctx.newActivation() + } + e.ctx.beginPerformanceLife(e.occurrence, e.root.began) +} + +func (e *ActionExecutor) endRootPerformance() { + e.ctx.endPerformanceLife(e.occurrence) + e.ctx.endActivation(e.root.began) +} + type inputBindingError struct{ Err error } func (e inputBindingError) Error() string { return e.Err.Error() } @@ -1530,7 +1582,7 @@ func (e *ActionExecutor) initialize() error { return err } - e.ctx.beginPerformanceLife(e.occurrence, e.ctx.newActivation()) + e.beginRootPerformance() if err := e.bindInputs(); err != nil { return err } @@ -1541,8 +1593,11 @@ func (e *ActionExecutor) initialize() error { if err := checkStreamsReceived(e.root); err != nil { return err } + if err := e.ctx.freezeBodyCells(e.root.cells); err != nil { + return err + } e.state = StateCompleted - e.ctx.endPerformanceLife(e.occurrence) + e.endRootPerformance() return nil } e.seedTokens(e.root, starts, 0) @@ -2014,8 +2069,11 @@ func (e *ActionExecutor) retireToken(tokenIdx int) error { if err := checkStreamsReceived(e.root); err != nil { return err } + if err := e.ctx.freezeBodyCells(e.root.cells); err != nil { + return err + } e.state = StateCompleted - e.ctx.endPerformanceLife(e.occurrence) + e.endRootPerformance() } return nil } @@ -2347,7 +2405,7 @@ func (e *ActionExecutor) stepNestedAction(tokenIdx int) error { } } - perf, err := e.beginPerformance(token.frame, graph, usage, nil) + perf, err := e.beginPerformance(token.frame, graph, usage, nil, nil) if err != nil { return err } diff --git a/internal/exec/runtime/action_frame.go b/internal/exec/runtime/action_frame.go index 8920c8900c..8e6151ecb4 100644 --- a/internal/exec/runtime/action_frame.go +++ b/internal/exec/runtime/action_frame.go @@ -70,7 +70,8 @@ type actionFrame struct { body bool // locals are the block-local bindings entered around node in parent's body, // outermost first: a loop variable the node's declarations read. - locals []map[string]Value + locals []map[string]Value + localCells []*bodyCells // outer are the frames around a root performance its bodies read but no performance // holds, outermost first: a state machine's data and its states' attributes. outer []frame @@ -94,7 +95,8 @@ type actionFrame struct { // those of every flow around it, this one's own included. connections []lower.Connection // data holds the values the performance's own features hold. - data map[string]Value + data map[string]Value + cells *bodyCells // features are the parameters and attributes the performance holds, by name. features map[string]ast.FeatureDirection // aliases map each name a held feature redefines to the feature's own name. @@ -217,9 +219,34 @@ func (e *ActionExecutor) newRootFrame() *actionFrame { run: e.ctx.newRun(), } e.declareRootFeatures(root) + e.registerRootBindings(root) return root } +func (e *ActionExecutor) registerRootBindings(root *actionFrame) { + for _, attr := range e.features { + if !attr.Binding || attr.Value == nil { + continue + } + scope := attr.Scope + if scope == nil { + scope = root.graph.Scope + } + name := root.key(attr.Name) + cells := e.performances.bodyCells(root) + var cell *bodyCell + context := func(scope *symbols.Scope) *EvalContext { + return e.performances.evalBindingContext(root, scope, name, root.began) + } + onDerived := func(value *Value) error { + mirrored, err := e.mirrorBindingOccurrence(attr.Name, *value, cell) + *value = mirrored + return err + } + cell = e.ctx.registerBodyBindingInContext(cells, name, attr.Value, scope, nil, context, onDerived) + } +} + // declareRootFeatures gives root the attributes the graph declares and the // features the action holds, aliasing what each redefines. func (e *ActionExecutor) declareRootFeatures(root *actionFrame) { @@ -305,7 +332,7 @@ func (f *actionFrame) key(name string) string { // seeding its pins from deliveries, then the arguments it passes its callee, then input bindings, // then its own declared defaults. func (e *performances) beginPerformance( - parent *actionFrame, flow *lower.ActionGraph, node ast.Node, locals []map[string]Value, + parent *actionFrame, flow *lower.ActionGraph, node ast.Node, locals []map[string]Value, localCells []*bodyCells, ) (*actionFrame, error) { perf := &actionFrame{ node: node, @@ -313,6 +340,7 @@ func (e *performances) beginPerformance( scope: flow.Scopes[node], parent: parent, locals: locals, + localCells: localCells, connections: parent.connections, data: make(map[string]Value), features: make(map[string]ast.FeatureDirection), @@ -357,14 +385,28 @@ func (e *performances) beginPerformance( // seedPerformance seeds perf's pins from deliveries, then the arguments it passes its // callee, then input bindings, then its own declared defaults. func (e *performances) seedPerformance(parent *actionFrame, flow *lower.ActionGraph, node ast.Node, perf *actionFrame) error { + activation, endStep := e.ctx.beginStep() + defer endStep() + perf.began = activation + for _, feature := range flow.Features[node] { + if !feature.Binding || feature.Value == nil { + continue + } + check := func(value *Value) error { + return e.ctx.checkBodyDeclaration(feature.Scope, perf.describe(), feature.Name, value) + } + context := func(scope *symbols.Scope) *EvalContext { + return e.evalBindingContext(perf, scope, feature.Name, activation) + } + e.ctx.registerBodyBindingInContext( + e.bodyCells(perf), perf.key(feature.Name), feature.Value, feature.Scope, check, context, nil, + ) + } if err := e.takeDeliveries(parent, node, perf); err != nil { return err } // The arguments, bindings and defaults are one evaluation: a calc usage two of them // read answers once, and another performance evaluates it anew. - activation, endStep := e.ctx.beginStep() - defer endStep() - perf.began = activation if err := e.bindArguments(perf, activation); err != nil { return err } @@ -420,14 +462,36 @@ func (e *performances) seedDeclaredValues(perf *actionFrame, features []lower.Fe } ec := e.evalContextFor(perf, feature.Scope) ec.activation = activation - value, err := ec.Eval(feature.Value) + var value Value + var err error + if feature.Binding { + cells := e.bodyCells(perf) + check := func(value *Value) error { + return e.ctx.checkBodyDeclaration(feature.Scope, perf.describe(), feature.Name, value) + } + name := perf.key(feature.Name) + cell := cells.existingCell(name) + if cell == nil || cell.binding == nil { + context := func(scope *symbols.Scope) *EvalContext { + return e.evalBindingContext(perf, scope, feature.Name, activation) + } + cell = e.ctx.registerBodyBindingInContext( + cells, name, feature.Value, feature.Scope, check, context, nil, + ) + } + value, err = e.ctx.deriveBodyCell(cells, name, cell) + } else { + value, err = ec.Eval(feature.Value) + if err == nil { + err = e.ctx.checkBodyDeclaration(feature.Scope, perf.describe(), feature.Name, &value) + } + if err == nil { + e.ctx.writeBodyValue(perf.cells, perf.data, perf.key(feature.Name), value) + } + } if err != nil { return fmt.Errorf("eval %s of %s: %w", feature.Name, nodeDescription(perf.node), err) } - if err := e.ctx.checkBodyDeclaration(feature.Scope, perf.describe(), feature.Name, &value); err != nil { - return err - } - perf.data[perf.key(feature.Name)] = value if err := e.streamFrom(perf, perf.key(feature.Name), value); err != nil { return err } @@ -439,6 +503,9 @@ func (e *performances) seedDeclaredValues(perf *actionFrame, features []lower.Fe // return to same-named enclosing features, and the bindings at its output pins // carry what it produced to their other ends. func (e *performances) endPerformance(perf *actionFrame) error { + if err := e.ctx.freezeBodyCells(perf.cells); err != nil { + return err + } perf.ended = true if err := checkStreamsReceived(perf); err != nil { return err @@ -615,8 +682,12 @@ func (f *actionFrame) lexicalFrames() []frame { } else { frames = slices.Clone(f.outer) } - for _, local := range f.locals { - frames = append(frames, mapFrame(local)) + for i, local := range f.locals { + fr := mapFrame(local) + if i < len(f.localCells) { + fr.cells = f.localCells[i] + } + frames = append(frames, fr) } return append(frames, performanceFrame(f)) } @@ -733,6 +804,13 @@ func (f *actionFrame) unsupportedRepeatedRead(node ast.Node) error { // holds none reads as the empty sequence, as its declaration does. func (f *actionFrame) pin(name string) (Value, error) { value, ok := f.data[f.key(name)] + if f.perfs != nil && f.perfs.ctx != nil { + var err error + value, ok, err = f.perfs.ctx.readBodyValue(f.cells, f.data, f.key(name)) + if err != nil { + return Value{}, err + } + } if ok { return value, nil } @@ -850,7 +928,7 @@ func (e *performances) takeDeliveries(f *actionFrame, node ast.Node, perf *actio for pin, values := range queues { f.receiveStream(node, pin) f.shiftStaged(node, pin) - perf.data[pin] = values[0] + e.ctx.writeBodyValue(perf.cells, perf.data, pin, values[0]) taken[pin] = values[0] if len(values) == 1 { delete(queues, pin) @@ -924,7 +1002,7 @@ func (e *performances) setFrameFeature(f *actionFrame, name string, value Value) if err := e.ctx.checkNamedWrite(f.scope, f.describe(), name, &value); err != nil { return err } - f.data[f.key(name)] = value + e.ctx.writeBodyValue(f.cells, f.data, f.key(name), value) e.noteFrameWrite(f, name, value) return e.streamFrom(f, f.key(name), value) } @@ -1071,26 +1149,36 @@ func enclosingHolder(perf *actionFrame, name string) (local map[string]Value, ho // lookupEnclosing reads name from the innermost binding around perf that holds // a value for it, the frames around the root included. -func lookupEnclosing(perf *actionFrame, name string) (Value, bool) { +func (e *performances) lookupEnclosing(perf *actionFrame, name string) (Value, bool, error) { for f := perf; f != nil; f = f.parent { for i := len(f.locals) - 1; i >= 0; i-- { - if value, ok := f.locals[i][name]; ok { - return value, true + var cells *bodyCells + if i < len(f.localCells) { + cells = f.localCells[i] + } + if value, ok, err := e.ctx.readBodyValue(cells, f.locals[i], name); err != nil { + return Value{}, false, err + } else if ok { + return value, true, nil } } if f.parent != nil { - if value, ok := f.parent.data[f.parent.key(name)]; ok { - return value, true + if value, ok, err := e.ctx.readBodyValue(f.parent.cells, f.parent.data, f.parent.key(name)); err != nil { + return Value{}, false, err + } else if ok { + return value, true, nil } continue } for i := len(f.outer) - 1; i >= 0; i-- { - if value, ok := f.outer[i].lookup(name); ok { - return value, true + if value, ok, err := f.outer[i].read(e.ctx, name); err != nil { + return Value{}, false, err + } else if ok { + return value, true, nil } } } - return Value{}, false + return Value{}, false, nil } // evalContextFor returns a context evaluating in scope with the performance and @@ -1105,6 +1193,26 @@ func (e *performances) evalContextFor(perf *actionFrame, scope *symbols.Scope) * return ec } +func (e *performances) evalBindingContext( + perf *actionFrame, scope *symbols.Scope, name string, activation int64, +) *EvalContext { + ec := e.evalContextFor(perf, scope) + ec.activation = activation + if perf == nil { + return ec + } + for i := len(ec.frames) - 1; i >= 0; i-- { + if ec.frames[i].perf != perf { + continue + } + f := ec.frames[i] + f.masked = map[string]bool{perf.key(name): true} + ec.frames[i] = f + break + } + return ec +} + // evalContextAround returns a context evaluating in scope what is written at perf's // node: the enclosing performances and the block-locals around the node, not perf's own. // What is written there is a statement of the body, reaching the performer's features @@ -1119,8 +1227,12 @@ func (e *performances) evalContextAround(perf *actionFrame, scope *symbols.Scope ec.pushFrame(f) } } - for _, local := range perf.locals { - ec.pushFrame(mapFrame(local)) + for i, local := range perf.locals { + fr := mapFrame(local) + if i < len(perf.localCells) { + fr.cells = perf.localCells[i] + } + ec.pushFrame(fr) } return ec } @@ -1179,6 +1291,9 @@ func lexicalValues(perf *actionFrame) map[string]Value { // collect reports the values the performance and its non-repeated subactions hold, // under their paths (`p.v`), the latest performance of each name standing for it. func (f *actionFrame) collect(prefix string, into map[string]Value) { + if f.perfs != nil && f.perfs.ctx != nil { + _ = f.perfs.ctx.deriveBodyCells(f.cells) + } for name, value := range f.data { into[prefix+name] = value } @@ -1327,7 +1442,10 @@ func boundEndError(end boundEnd, err error) error { // outputPinValue is the value a pin of direction dir carries to the other end of // its binding, carried false when the pin holds nothing the binding moves. func (e *performances) outputPinValue(perf *actionFrame, end boundEnd, dir ast.FeatureDirection) (Value, bool, error) { - value, ok := perf.data[perf.key(end.Pin)] + value, ok, err := e.ctx.readBodyValue(perf.cells, perf.data, perf.key(end.Pin)) + if err != nil { + return Value{}, false, err + } switch dir { case ast.DirOut, ast.DirInOut: if !ok { @@ -1342,7 +1460,9 @@ func (e *performances) outputPinValue(perf *actionFrame, end boundEnd, dir ast.F if !ok { return Value{}, false, nil } - if other, held := e.otherEndHeld(perf, end); held && e.ctx.equalValues(other, value) { + if other, held, err := e.otherEndHeld(perf, end); err != nil { + return Value{}, false, err + } else if held && e.ctx.equalValues(other, value) { return Value{}, false, nil } return value, true, nil @@ -1520,23 +1640,22 @@ func (e *performances) bindingOtherValue(perf *actionFrame, end boundEnd, activa // otherEndHeld reads what the other end of a binding holds now: a performed node's // pin, an enclosing feature named outright, or the feature a chain reaches. -func (e *performances) otherEndHeld(perf *actionFrame, end boundEnd) (Value, bool) { +func (e *performances) otherEndHeld(perf *actionFrame, end boundEnd) (Value, bool, error) { if end.OtherNode != nil { other, performed := e.otherPerformance(perf, end) if !performed { - return Value{}, false + return Value{}, false, nil } - value, held := other.data[other.key(end.OtherPin)] - return value, held + return e.ctx.readBodyValue(other.cells, other.data, other.key(end.OtherPin)) } if name := simpleEndName(end.Other); name != "" { return e.bindingEndContext(end).Lookup(name) } if end.OtherChain != nil { value, err := e.bindingEndContext(end).Eval(end.Other) - return value, err == nil + return value, err == nil, err } - return Value{}, false + return Value{}, false, nil } // unheldEnd reports whether err, from reading the other end of a binding, says that end @@ -1550,7 +1669,10 @@ func (e *performances) unheldEnd(end boundEnd, err error) bool { if end.OtherNode != nil || name == "" { return false } - _, valued := e.bindingEndContext(end).Lookup(name) + _, valued, lookupErr := e.bindingEndContext(end).Lookup(name) + if lookupErr != nil { + return false + } _, _, holds := enclosingHolder(end.at, name) return !valued && holds } @@ -1638,7 +1760,9 @@ func (e *performances) beginInvocation(perf *actionFrame, inv actionInvocation) in, out := parameterNames(params) inputs := make(map[string]Value, len(in)) for _, name := range in { - if value, ok := perf.data[perf.key(name)]; ok { + if value, ok, err := e.ctx.readBodyValue(perf.cells, perf.data, perf.key(name)); err != nil { + return nil, err + } else if ok { inputs[name] = value } } @@ -1650,7 +1774,9 @@ func (e *performances) beginInvocation(perf *actionFrame, inv actionInvocation) return nil, err } } - e.bindPerformerInputs(perf, inv, params, in, inputs, performer) + if err := e.bindPerformerInputs(perf, inv, params, in, inputs, performer); err != nil { + return nil, err + } if err := checkInputsBound(inv, params, inputs); err != nil { return nil, err } @@ -1670,7 +1796,7 @@ func (e *performances) beginInvocation(perf *actionFrame, inv actionInvocation) // default resolved on it, which bindContextDefault resolves once it runs on // that object; a like-named feature of the caller is the argument only for a // `ref` input the performer cannot supply. -func (e *performances) bindPerformerInputs(perf *actionFrame, inv actionInvocation, params []actionParameter, in []string, inputs map[string]Value, performer *Instance) { +func (e *performances) bindPerformerInputs(perf *actionFrame, inv actionInvocation, params []actionParameter, in []string, inputs map[string]Value, performer *Instance) error { if inv.expr == nil { refInputs := make(map[string]bool) if inv.chain != nil { @@ -1684,13 +1810,15 @@ func (e *performances) bindPerformerInputs(perf *actionFrame, inv actionInvocati if _, bound := inputs[name]; bound || refInputs[name] { continue } - if value, ok := lookupEnclosing(perf, name); ok { + if value, ok, err := e.lookupEnclosing(perf, name); err != nil { + return err + } else if ok { inputs[name] = value } } } if inv.chain == nil { - return + return nil } for _, param := range params { if param.IsReference { @@ -1699,6 +1827,7 @@ func (e *performances) bindPerformerInputs(perf *actionFrame, inv actionInvocati } } } + return nil } // streamCalleeOutput is what the action a node performs writes its outputs through @@ -1713,7 +1842,7 @@ func (e *performances) streamCalleeOutput(perf *actionFrame, out []string) func( if !outputs[key] { return nil } - perf.data[key] = value + e.ctx.writeBodyValue(perf.cells, perf.data, key, value) return e.streamFrom(perf, key, value) } } @@ -1722,7 +1851,7 @@ func (e *performances) streamCalleeOutput(perf *actionFrame, out []string) func( // own: its features' values, and its subactions, read as `call.inner.v`. func (f *actionFrame) adopt(callee *ActionExecutor) { for name, value := range callee.root.data { - f.data[f.key(name)] = value + callee.ctx.writeBodyValue(f.cells, f.data, f.key(name), value) } f.performs = callee.graph if len(callee.root.subactions) > 0 && f.subactions == nil { @@ -1753,8 +1882,9 @@ func checkInputsBound(inv actionInvocation, params []actionParameter, inputs map // performanceFrame is the frame an evaluation reads a performance's values // through, which also answers for the nodes of its flow. func performanceFrame(f *actionFrame) frame { - fr := frame{vars: f.data, aliases: f.aliases, perf: f, run: f.run} + fr := frame{vars: f.data, aliases: f.aliases, perf: f, run: f.run, cells: f.cells} if f.perfs != nil { + fr.ensureCells = func() *bodyCells { return f.perfs.bodyCells(f) } // A qualified write lands on the run's own path: the declaration check, // the performance occurrence, and the flows streaming the written pin. fr.write = func(_ frame, name string, value Value) error { @@ -1764,6 +1894,15 @@ func performanceFrame(f *actionFrame) frame { return fr } +func (e *performances) bodyCells(perf *actionFrame) *bodyCells { + if perf.cells == nil { + perf.cells = newBodyCells(perf.data, func(scope *symbols.Scope) *EvalContext { + return e.evalContextFor(perf, scope) + }) + } + return perf.cells +} + // cloneUnreceived copies the unreceived streams of a frame, queues included. func cloneUnreceived(m map[ast.Node][]unreceivedStream) map[ast.Node][]unreceivedStream { if m == nil { diff --git a/internal/exec/runtime/action_statements.go b/internal/exec/runtime/action_statements.go index 7c123bcd6e..e8d42bb532 100644 --- a/internal/exec/runtime/action_statements.go +++ b/internal/exec/runtime/action_statements.go @@ -2,7 +2,6 @@ package runtime import ( "fmt" - "slices" "sort" "github.com/Open-MBEE/OpenSysML/internal/ir/lower" @@ -153,7 +152,11 @@ func (h *actionStmtHost) effect(engine *stmtEngine, s lower.Effect) error { } // The performed action reads the values in scope where it is performed and its // outputs come back to them, so a perform in a loop body sees that iteration. - _, outputs, err := invokeAction(h.exec.ctx, s.Scope, inv, env.values(), h.exec.self) + values, err := env.values(h.exec.ctx) + if err != nil { + return fmt.Errorf("%s: %w", h.describe(), err) + } + _, outputs, err := invokeAction(h.exec.ctx, s.Scope, inv, values, h.exec.self) if err != nil { return fmt.Errorf("%s: %w", h.describe(), err) } @@ -163,7 +166,7 @@ func (h *actionStmtHost) effect(engine *stmtEngine, s lower.Effect) error { } sort.Strings(names) for _, name := range names { - if env.assign(name, outputs[name]) { + if env.assign(h.exec.ctx, name, outputs[name]) { continue } if written, err := h.exec.returnEnclosing(h.perf, name, outputs[name], h.exec.owner.assignAround); written || err != nil { @@ -172,7 +175,7 @@ func (h *actionStmtHost) effect(engine *stmtEngine, s lower.Effect) error { } continue } - env.data.set(name, outputs[name]) + env.data.setBody(h.exec.ctx, name, outputs[name]) } return nil } @@ -212,7 +215,10 @@ func (e *performances) performNode(parent *actionFrame, engine *stmtEngine, grap } } if f.perf == nil { - if f.perf, err = e.beginPerformance(parent, graph, node, slices.Clone(engine.env.frames)); err != nil { + locals, localCells := engine.env.localFrames() + if f.perf, err = e.beginPerformance( + parent, graph, node, locals, localCells, + ); err != nil { return flowNext, err } } @@ -306,13 +312,15 @@ func (e *performances) performBlockFlow(parent *actionFrame, engine *stmtEngine, if scope == nil { scope = parent.scope } + locals, localCells := engine.env.localFrames() f.perf = &actionFrame{ node: block.Node, graph: block.Graph, flow: block.Graph, scope: scope, parent: parent, - locals: slices.Clone(engine.env.frames), + locals: locals, + localCells: localCells, connections: joinConnections(parent.connections, block.Graph.Connections), data: make(map[string]Value), features: make(map[string]ast.FeatureDirection), @@ -332,7 +340,7 @@ func (e *performances) performBlockFlow(parent *actionFrame, engine *stmtEngine, for _, attr := range block.Graph.Attributes { f.perf.features[attr.Name] = attr.Direction features = append(features, lower.Feature{ - Name: attr.Name, Direction: attr.Direction, IsResult: attr.IsResult, + Name: attr.Name, Direction: attr.Direction, IsResult: attr.IsResult, Binding: attr.Binding, Value: attr.Value, Node: attr.Node, Scope: block.Scope, }) } diff --git a/internal/exec/runtime/action_terminate.go b/internal/exec/runtime/action_terminate.go index 998bb06c8a..67c2fc04e1 100644 --- a/internal/exec/runtime/action_terminate.go +++ b/internal/exec/runtime/action_terminate.go @@ -226,7 +226,7 @@ func (e *ActionExecutor) beginPending(parent *actionFrame, node ast.Node) ([]*ac slices.Sort(parked) var begun []*actionFrame for _, id := range parked { - perf, err := e.beginPerformance(parent, e.graphOf(parent), node, nil) + perf, err := e.beginPerformance(parent, e.graphOf(parent), node, nil, nil) if err != nil { return nil, err } @@ -322,7 +322,7 @@ func (e *ActionExecutor) endAround(tokenIdx int, perf *actionFrame) error { if perf == e.root { e.removeToken(e.tokenIndex(id)) e.state = StateCompleted - e.ctx.endPerformanceLife(e.occurrence) + e.endRootPerformance() return nil } return e.leaveTerminated(e.tokenIndex(id), perf) diff --git a/internal/exec/runtime/analysis_run.go b/internal/exec/runtime/analysis_run.go index 6d1e4c6bdb..e464f2e738 100644 --- a/internal/exec/runtime/analysis_run.go +++ b/internal/exec/runtime/analysis_run.go @@ -100,21 +100,21 @@ func (shape *calcShape) subjectParameter() (*calcParameter, bool) { // enclosingSubject is the subject of the case whose body declares shape's usage, // which a nested case binding no subject of its own takes (SysML v2 §7.21.2): // read from the environment of the evaluation reading the usage. -func (ctx *Context) enclosingSubject(shape *calcShape, enclosing *EvalContext) (Value, bool) { +func (ctx *Context) enclosingSubject(shape *calcShape, enclosing *EvalContext) (Value, bool, error) { if enclosing == nil { - return Value{}, false + return Value{}, false, nil } owner := enclosingBehavior(shape.Sym) if owner == nil || !isCalcSymbol(owner) { - return Value{}, false + return Value{}, false, nil } outer, err := ctx.calcShapeOf(owner) if err != nil { - return Value{}, false + return Value{}, false, err } subject, ok := outer.subjectParameter() if !ok { - return Value{}, false + return Value{}, false, nil } return enclosing.Lookup(subject.Name) } diff --git a/internal/exec/runtime/assign_chain.go b/internal/exec/runtime/assign_chain.go index 5d56371996..dec2a21d56 100644 --- a/internal/exec/runtime/assign_chain.go +++ b/internal/exec/runtime/assign_chain.go @@ -68,11 +68,15 @@ func (ec *EvalContext) chainCarrier(target *lower.AssignTarget) (*Instance, erro // names: a part names an occurrence rather than a value, and a calc usage names // an evaluation, which holds no object a write could reach. func (ec *EvalContext) chainRoot(base ast.Node) (Value, error) { - if sym, ok := ec.calcUsageOperand(base); ok { + if sym, ok, err := ec.calcUsageOperand(base); err != nil { + return Value{}, err + } else if ok { return Value{}, fmt.Errorf("%w: calc usage %s computes output features and holds no object", ErrTypeMismatch, symbolText(sym)) } - if sym, ok := ec.occurrenceOperand(base); ok { + if sym, ok, err := ec.occurrenceOperand(base); err != nil { + return Value{}, err + } else if ok { // A collection reads as its objects; the step after it writes on one of them. if ec.ctx.namesObjects(sym) { return ec.ctx.denotedValue(sym) diff --git a/internal/exec/runtime/body_bindings.go b/internal/exec/runtime/body_bindings.go new file mode 100644 index 0000000000..c872fee620 --- /dev/null +++ b/internal/exec/runtime/body_bindings.go @@ -0,0 +1,464 @@ +package runtime + +import ( + "fmt" + "maps" + + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +type bodyBindingCheckError struct { + err error +} + +func (e bodyBindingCheckError) Error() string { return e.err.Error() } +func (e bodyBindingCheckError) Unwrap() error { return e.err } + +type bodyCells struct { + vars map[string]Value + cells map[string]*bodyCell + order []string + context func(*symbols.Scope) *EvalContext +} + +type bodyCell struct { + owner *bodyCells + name string + fv FeatureValue + binding *bodyBinding +} + +type bodyBinding struct { + value ast.Node + scope *symbols.Scope + check func(*Value) error + context func(*symbols.Scope) *EvalContext + onDerived func(*Value) error + visible []map[string]bool + limitFrames bool + frozen bool +} + +func occurrenceHasRedefinedDefault(fv *FeatureValue, attr lower.Attribute, fallback *symbols.Scope) bool { + if fv == nil || fv.Feature == nil || fv.Feature.DefaultDecl == nil { + return false + } + scope := attr.Scope + if scope == nil { + scope = fallback + } + declared := memberSymbol(scope, attr.Node) + return declared != nil && fv.Feature.DefaultDecl != declared +} + +type bodyCellState struct { + cell *bodyCell + written bool + frozen bool + materialized bool + tracking bool +} + +type bodyCellsCapture struct { + cells *bodyCells + vars mapState[string, Value] + members map[string]*bodyCell + bindings map[*bodyCell]*bodyBinding + order []string +} + +func captureBodyCells(cells *bodyCells) bodyCellsCapture { + capture := bodyCellsCapture{cells: cells} + if cells == nil { + return capture + } + capture.vars = captureMap(cells.vars) + capture.members = maps.Clone(cells.cells) + capture.order = append([]string(nil), cells.order...) + capture.bindings = make(map[*bodyCell]*bodyBinding, len(cells.cells)) + for _, cell := range cells.cells { + if cell.binding == nil { + continue + } + binding := *cell.binding + binding.visible = cloneBodyBindingVisibility(binding.visible) + capture.bindings[cell] = &binding + } + return capture +} + +func (c bodyCellsCapture) restore() { + if c.cells == nil { + return + } + c.cells.vars = c.vars.restore() + if c.cells.cells == nil { + c.cells.cells = make(map[string]*bodyCell, len(c.members)) + } + clear(c.cells.cells) + c.cells.order = append(c.cells.order[:0], c.order...) + for name, cell := range c.members { + cell.owner, cell.name = c.cells, name + cell.binding = c.bindings[cell] + c.cells.cells[name] = cell + } +} + +func newBodyCells(vars map[string]Value, context func(*symbols.Scope) *EvalContext) *bodyCells { + return &bodyCells{vars: vars, cells: make(map[string]*bodyCell), context: context} +} + +func (cells *bodyCells) cell(name string) *bodyCell { + if cells == nil { + return nil + } + if cell := cells.cells[name]; cell != nil { + return cell + } + cell := &bodyCell{owner: cells, name: name} + cell.fv.body = cell + if value, ok := cells.vars[name]; ok { + cell.fv.Value = value + cell.fv.Materialized = true + } + cells.cells[name] = cell + cells.order = append(cells.order, name) + return cell +} + +func (cells *bodyCells) existingCell(name string) *bodyCell { + if cells == nil { + return nil + } + return cells.cells[name] +} + +func (ctx *Context) registerBodyBinding( + cells *bodyCells, + name string, + value ast.Node, + scope *symbols.Scope, + check func(*Value) error, + onDerived func(*Value) error, +) *bodyCell { + return ctx.registerBodyBindingInContext(cells, name, value, scope, check, nil, onDerived) +} + +func (ctx *Context) registerBodyBindingInContext( + cells *bodyCells, + name string, + value ast.Node, + scope *symbols.Scope, + check func(*Value) error, + context func(*symbols.Scope) *EvalContext, + onDerived func(*Value) error, +) *bodyCell { + cell := cells.cell(name) + cell.binding = &bodyBinding{ + value: value, scope: scope, check: check, context: context, onDerived: onDerived, + } + return cell +} + +func (ctx *Context) deriveBodyCell(cells *bodyCells, name string, cell *bodyCell) (Value, error) { + if cell == nil || cell.binding == nil || cell.binding.frozen || cell.fv.Written { + value, ok := cells.vars[name] + if !ok { + return Value{}, &NoValueError{Feature: name} + } + return value, nil + } + if cell.fv.Materialized { + return cells.vars[name], nil + } + if ctx.derivingValue(&cell.fv) { + return Value{}, fmt.Errorf("%w: %s", ErrCyclicFeatureValue, name) + } + value, err := ctx.deriveWith(&cell.fv, func() (Value, error) { + if cells.context == nil { + return Value{}, fmt.Errorf("body binding %s has no evaluation context", name) + } + ec := cells.context(cell.binding.scope) + if cell.binding.context != nil { + ec = cell.binding.context(cell.binding.scope) + } + if ec == nil { + return Value{}, fmt.Errorf("body binding %s has no evaluation context", name) + } + if cell.binding.limitFrames { + frames := append([]frame(nil), ec.frames...) + if len(frames) > len(cell.binding.visible) { + frames = frames[:len(cell.binding.visible)] + } + for i := range frames { + if i < len(cell.binding.visible) { + frames[i].visible = cell.binding.visible[i] + } else { + frames[i].visible = map[string]bool{} + } + } + copy := *ec + copy.frames = frames + ec = © + } + value, err := ec.Eval(cell.binding.value) + if err != nil { + return Value{}, err + } + if cell.binding.check != nil { + if err := cell.binding.check(&value); err != nil { + return Value{}, bodyBindingCheckError{err: err} + } + } + if cell.binding.onDerived != nil { + if err := cell.binding.onDerived(&value); err != nil { + return Value{}, err + } + } + ctx.noteProbeWrite(&cell.fv) + cell.fv.Value, cell.fv.Values, cell.fv.Materialized = value, Value{}, true + cell.fv.intrinsic = false + cells.vars[name] = value + return value, nil + }) + return value, err +} + +func cloneBodyBindingVisibility(visible []map[string]bool) []map[string]bool { + if visible == nil { + return nil + } + cloned := make([]map[string]bool, len(visible)) + for i, frame := range visible { + cloned[i] = maps.Clone(frame) + } + return cloned +} + +func (ctx *Context) readBodyCell(cells *bodyCells, name string) (Value, bool, error) { + if cells == nil { + return Value{}, false, nil + } + cell := cells.cells[name] + if cell == nil && len(ctx.deriving) != 0 { + if _, ok := cells.vars[name]; ok { + cell = cells.cell(name) + } + } + if cell == nil { + value, ok := cells.vars[name] + return value, ok, nil + } + if cell.binding != nil && !cell.binding.frozen && !cell.fv.Written && !cell.fv.Materialized { + value, err := ctx.deriveBodyCell(cells, name, cell) + if err != nil { + return Value{}, false, err + } + ctx.noteRead(nil, &cell.fv) + return value, true, nil + } + value, ok := cells.vars[name] + if ok { + ctx.noteRead(nil, &cell.fv) + } + return value, ok, nil +} + +func (ctx *Context) writeBodyCell(cells *bodyCells, name string, value Value) { + if cells == nil { + return + } + cell := cells.cells[name] + if cell == nil { + cells.vars[name] = value + return + } + held := ctx.beforeWrite(&cell.fv) + if !held.taken { + ctx.noteProbeWrite(&cell.fv) + } + cells.vars[name] = value + cell.fv.Value, cell.fv.Values, cell.fv.Materialized = value, Value{}, true + cell.fv.intrinsic = false + if cell.binding != nil { + cell.fv.Written = true + ctx.forgetReads(&cell.fv) + } + ctx.afterWrite(&cell.fv, held) +} + +func (ctx *Context) writeBodyValue(cells *bodyCells, vars map[string]Value, name string, value Value) { + if cells == nil { + vars[name] = value + return + } + ctx.writeBodyCell(cells, name, value) +} + +func (ctx *Context) mirrorBodyCell(inst *Instance, name string, cell *bodyCell, value Value) (Value, error) { + if inst == nil { + return value, nil + } + fv, ok := inst.FeatureValues[name] + if !ok { + return value, nil + } + ctx.noteProbeWrite(fv) + fv.body = cell + deriving := ctx.derivingValue(fv) + if !deriving { + if err := inst.BindFeatureValue(ctx, name, value); err != nil { + return value, err + } + } + fv.Written = false + ctx.listRead(&cell.fv, fv) + if deriving { + return value, nil + } + return fv.HeldValue(), nil +} + +func (ctx *Context) clearBodyValue(cells *bodyCells, vars map[string]Value, name string) { + if cells == nil || cells.cells[name] == nil { + delete(vars, name) + return + } + cell := cells.cells[name] + held := ctx.beforeWrite(&cell.fv) + if !held.taken { + ctx.noteProbeWrite(&cell.fv) + } + delete(vars, name) + cell.fv.Value, cell.fv.Values, cell.fv.Materialized = Value{}, Value{}, false + cell.fv.intrinsic = false + if cell.binding != nil { + cell.fv.Written = true + ctx.forgetReads(&cell.fv) + } + ctx.afterWrite(&cell.fv, held) +} + +func bodyCellStateOf(cells *bodyCells, name string) bodyCellState { + if cells == nil || cells.cells[name] == nil { + return bodyCellState{} + } + cell := cells.cells[name] + state := bodyCellState{cell: cell, materialized: cell.fv.Materialized, written: cell.fv.Written} + if cell.binding != nil { + state.frozen = cell.binding.frozen + state.tracking = !state.written && !state.frozen + } + return state +} + +func (ctx *Context) restoreBodyCellState(cells *bodyCells, name string, state bodyCellState) { + if state.cell == nil { + return + } + cell := state.cell + cell.fv.Written = state.written + if cell.binding != nil { + cell.binding.frozen = state.frozen + } + if state.tracking { + ctx.forgetReads(&cell.fv) + delete(cells.vars, name) + cell.fv.Value, cell.fv.Values, cell.fv.Materialized = Value{}, Value{}, false + cell.fv.intrinsic = false + ctx.invalidateDependents(&cell.fv) + return + } + cell.fv.Materialized = state.materialized + if state.materialized { + cell.fv.Value = cells.vars[name] + } else { + cell.fv.Value = Value{} + } +} + +func (ctx *Context) readBodyValue(cells *bodyCells, vars map[string]Value, name string) (Value, bool, error) { + if cells == nil { + value, ok := vars[name] + return value, ok, nil + } + return ctx.readBodyCell(cells, name) +} + +func (ctx *Context) deriveBodyCells(cells *bodyCells) error { + if cells == nil { + return nil + } + for _, name := range cells.order { + cell := cells.cells[name] + if cell.binding == nil || cell.binding.frozen || cell.fv.Written || cell.fv.Materialized { + continue + } + if _, err := ctx.deriveBodyCell(cells, name, cell); err != nil { + return err + } + } + return nil +} + +func (ctx *Context) freezeBodyCells(cells *bodyCells) error { + if err := ctx.deriveBodyCells(cells); err != nil { + return err + } + if cells == nil { + return nil + } + for _, name := range cells.order { + cell := cells.cells[name] + if cell.binding == nil || cell.binding.frozen { + continue + } + ctx.noteProbeWrite(&cell.fv) + cell.binding.frozen = true + ctx.forgetReads(&cell.fv) + } + return nil +} + +func (ctx *Context) forgetBodyCells(cells *bodyCells) { + if cells == nil { + return + } + for _, name := range cells.order { + cell := cells.cells[name] + if cell.binding != nil { + ctx.forgetReads(&cell.fv) + cell.binding.frozen = true + } + } +} + +func (ctx *Context) exitBodyCells(cells *bodyCells) { + if cells == nil { + return + } + for _, name := range cells.order { + cell := cells.cells[name] + if cell.binding == nil { + continue + } + ctx.invalidateDependents(&cell.fv) + ctx.noteProbeWrite(&cell.fv) + cell.binding.frozen = true + ctx.forgetReads(&cell.fv) + } +} + +func (ctx *Context) resetBodyCells(cells *bodyCells) { + if cells == nil { + return + } + ctx.forgetBodyCells(cells) + cells.cells = make(map[string]*bodyCell) + cells.order = nil + for name := range cells.vars { + delete(cells.vars, name) + } +} diff --git a/internal/exec/runtime/calc_statements.go b/internal/exec/runtime/calc_statements.go index 78c3b17002..ae34fea71e 100644 --- a/internal/exec/runtime/calc_statements.go +++ b/internal/exec/runtime/calc_statements.go @@ -71,7 +71,7 @@ func (h *calcStmtHost) attachPerformances(engine *stmtEngine) { subactions: make(map[ast.Node]*actionFrame), nodes: h.shape.Nodes, label: h.shape.Label, - outer: append(append([]frame{}, engine.env.enclosing...), engine.env.data), + outer: append(append(append([]frame{}, engine.env.enclosing...), engine.env.data), engine.env.localFrame()), run: h.ctx.newRun(), } h.flow = &ActionExecutor{ @@ -130,6 +130,13 @@ func (h *calcStmtHost) declaredOutput(name string) bool { // assignOuter binds an output this calculation declares, and rejects any other // undeclared name: writing that would be an effect outside the calculation. func (h *calcStmtHost) assignOuter(env *stmtEnv, name string, value Value, s lower.Assign) error { + if env.rootDeclares(name) { + if err := h.ctx.checkBodyWrite(h, s, &value); err != nil { + return err + } + env.assignRootLocal(h.ctx, name, value) + return h.mirrorOccurrence(name, value) + } if !h.declaredOutput(name) { return fmt.Errorf("%w: %s is not declared by the calculation", ErrCalcExternalAssignment, name) } @@ -187,6 +194,22 @@ func (h *calcStmtHost) mirrorOccurrence(name string, value Value) error { return nil } +func (h *calcStmtHost) mirrorRootLocal(name string, value Value) error { + return h.mirrorOccurrence(name, value) +} + +func (h *calcStmtHost) mirrorBodyBinding(name string, cell *bodyCell, value *Value) error { + if h.occ == nil || h.occ.inst == nil { + return nil + } + mirrored, err := h.ctx.mirrorBodyCell(h.occ.inst, name, cell, *value) + if err != nil { + return err + } + *value = mirrored + return nil +} + // assignChain rejects a chained target: writing a feature of another object is // an effect outside the calculation, as writing an undeclared name is. func (h *calcStmtHost) assignChain(_ *EvalContext, s lower.Assign, _ Value) error { @@ -241,7 +264,11 @@ func (h *calcStmtHost) effect(_ *stmtEngine, s lower.Effect) error { if !ok { return fmt.Errorf("%s: 'perform' names no action to perform", h.describe()) } - _, outputs, err := invokeAction(h.ctx, s.Scope, inv, h.env.values(), h.self) + values, err := h.env.values(h.ctx) + if err != nil { + return fmt.Errorf("%s: %w", h.describe(), err) + } + _, outputs, err := invokeAction(h.ctx, s.Scope, inv, values, h.self) if err != nil { return fmt.Errorf("%s: %w", h.describe(), err) } @@ -251,8 +278,8 @@ func (h *calcStmtHost) effect(_ *stmtEngine, s lower.Effect) error { } sort.Strings(names) for _, name := range names { - if !h.env.assign(name, outputs[name]) { - h.env.data.set(name, outputs[name]) + if !h.env.assign(h.ctx, name, outputs[name]) { + h.env.data.setBody(h.ctx, name, outputs[name]) } } return nil @@ -277,8 +304,8 @@ func (h *calcStmtHost) performNode(engine *stmtEngine, graph *lower.ActionGraph, return flowNext, fmt.Errorf("%s: the flow %s states of its own in a body is not executable", h.describe(), nodeDescription(node)) } - engine.env.enter() - defer engine.env.leave() + engine.env.enter(engine) + defer engine.env.leave(engine.ctx, true) defer engine.enterActivation()() for _, feature := range graph.Features[node] { if feature.Value == nil { @@ -289,7 +316,7 @@ func (h *calcStmtHost) performNode(engine *stmtEngine, graph *lower.ActionGraph, if err != nil { return flowNext, fmt.Errorf("eval %s of %s: %w", feature.Name, nodeDescription(node), err) } - engine.env.declare(feature.Name, value) + engine.env.declare(engine.ctx, feature.Name, value) } return engine.run(graph.Bodies[node]) } @@ -314,14 +341,14 @@ func (h *calcStmtHost) setFeature(name string, value Value) error { // assignAround writes what a step's performance returns to the same-named value // of the body: an output the case declares, or a parameter or local it holds. func (h *calcStmtHost) assignAround(name string, value Value) (bool, error) { - if h.env.assignLocal(name, value) { + if h.env.assignLocal(h.ctx, name, value) { return true, nil } if h.declaredOutput(name) || h.env.data.has(name) { if err := h.ctx.checkNamedWrite(h.shape.bodyScope(), h.describe(), name, &value); err != nil { return true, err } - h.env.data.set(name, value) + h.env.data.setBody(h.ctx, name, value) return true, h.mirrorOccurrence(name, value) } return false, nil diff --git a/internal/exec/runtime/calc_usage.go b/internal/exec/runtime/calc_usage.go index b8e3c5a34d..9411fd5aaf 100644 --- a/internal/exec/runtime/calc_usage.go +++ b/internal/exec/runtime/calc_usage.go @@ -450,6 +450,9 @@ type calcRun struct { // activation is the execution this run is, so a usage its outputs read is // evaluated once for the whole run rather than once per output. activation int64 + // bodyFrames are the root statement locals left by the run, which output + // bindings read after its statements have completed. + bodyFrames []frame // perf is the case's performance, whose steps an output binding reads by name // (`step.pin`); nil for a calc, which performs none. perf *actionFrame @@ -461,6 +464,15 @@ type calcRun struct { occurrence *calcOccurrence } +func (run *calcRun) lookup(name string) (Value, bool) { + for i := len(run.bodyFrames) - 1; i >= 0; i-- { + if value, ok := run.bodyFrames[i].lookup(name); ok { + return value, true + } + } + return run.env.lookup(name) +} + // boundInputs are the values each non-subject input parameter of shape was // bound to in env, in declaration order. func boundInputs(shape *calcShape, env frame) []InputBinding { @@ -502,6 +514,7 @@ func (run *calcRun) detached() *calcRun { } out := *run out.env = run.env.snapshot() + out.bodyFrames = snapshotFrames(run.bodyFrames) if run.outer != nil { out.outer = run.outer.closure() } @@ -1002,6 +1015,8 @@ func (ctx *Context) runCalcUsage(start *calcUsageStart) (*calcRun, error) { steps, _ = shape.observationSteps() } result, returned, err := runCalcSteps(engine, host, steps) + run.env.cells = engine.env.data.cells + run.bodyFrames = []frame{engine.env.localFrame().snapshot()} if err != nil { if paused(err) { return nil, err @@ -1050,6 +1065,11 @@ func (run *calcRun) endOccurrence(ctx *Context) { // its binding the first time it is read. func (run *calcRun) output(ctx *Context, name string) (Value, error) { if value, ok := run.outputs[name]; ok { + if out, found := run.shape.output(name); found && (out.Value == nil || out.IsInitial) { + if _, held := run.env.lookup(out.Name); held { + return run.readBodyOutput(ctx, out.Name) + } + } return value, nil } out, ok := run.shape.output(name) @@ -1069,6 +1089,20 @@ func (run *calcRun) output(ctx *Context, name string) (Value, error) { return value, nil } +func (run *calcRun) readBodyOutput(ctx *Context, name string) (Value, error) { + if run.env.cells == nil && len(ctx.deriving) != 0 { + run.env.cells = newBodyCells(run.env.vars, nil) + } + value, ok, err := run.env.read(ctx, name) + if err != nil { + return Value{}, err + } + if !ok { + return Value{}, &NoValueError{Feature: name} + } + return value, nil +} + // value evaluates one output feature's binding in the scope of the calc that // declares it, with the calc's parameters and locals in scope, and memoizes it // for this evaluation. An output that names another output has it evaluated on @@ -1086,7 +1120,11 @@ func (run *calcRun) value(ctx *Context, out calcOutput) (Value, error) { if out.Value == nil || out.IsInitial { // An output the body assigned, and an `inout` the invocation bound, are values // the activation left behind rather than bindings to evaluate. - if value, ok := run.env.lookup(out.Name); ok && out.Name != "" { + if _, ok := run.env.lookup(out.Name); ok && out.Name != "" { + value, err := run.readBodyOutput(ctx, out.Name) + if err != nil { + return Value{}, err + } run.outputs[out.Name] = value return value, nil } @@ -1161,6 +1199,9 @@ func (run *calcRun) bindingEnv(ctx *Context, owner *symbols.Symbol) *EvalContext if run.perf != nil { ec.pushFrame(performanceFrame(run.perf)) } + for _, frame := range run.bodyFrames { + ec.pushFrame(frame) + } ec.calcRun = run return ec } @@ -1269,60 +1310,72 @@ func (run *calcRun) nestedUsage(ctx *Context, name string) (*calcRun, bool, erro // usage, whose members are computed rather than declared values. A local binding // or valued feature of the same name is the value the expression names, so it // masks the declaration. -func (ec *EvalContext) calcUsageOperand(operand ast.Node) (*symbols.Symbol, bool) { +func (ec *EvalContext) calcUsageOperand(operand ast.Node) (*symbols.Symbol, bool, error) { ref, ok := operand.(*ast.FeatureReference) if !ok || ref.Name == nil || len(ref.Name.Parts) == 0 || ec.ctx.model.resolver == nil { - return nil, false + return nil, false, nil } - if len(ref.Name.Parts) == 1 && ec.namesValue(ref.Name.Parts[0].Text) { - return nil, false + if len(ref.Name.Parts) == 1 { + names, err := ec.namesValue(ref.Name.Parts[0].Text) + if err != nil { + return nil, false, err + } + if names { + return nil, false, nil + } } sym, ok := ec.ctx.resolveQualified(ec.scope, ref.Name) if !ok || !isCalcUsageSymbol(sym) { - return nil, false + return nil, false, nil } - return sym, true + return sym, true, nil } // namesValue reports whether name is a local binding or a valued feature of the // element being evaluated, which the expression names rather than a declaration. -func (ec *EvalContext) namesValue(name string) bool { - if _, bound := ec.Lookup(name); bound { - return true +func (ec *EvalContext) namesValue(name string) (bool, error) { + if _, bound, err := ec.Lookup(name); err != nil { + return false, err + } else if bound { + return true, nil } _, valued := ec.valuedFeature(name) - return valued + return valued, nil } // occurrenceOperand reports whether the operand of a feature chain names occurrences of // its own — a part or item, or a namespace's collection of them — that no local binding // or valued feature, and no feature value of the object being evaluated, already answers with. -func (ec *EvalContext) occurrenceOperand(operand ast.Node) (*symbols.Symbol, bool) { +func (ec *EvalContext) occurrenceOperand(operand ast.Node) (*symbols.Symbol, bool, error) { ref, ok := operand.(*ast.FeatureReference) if !ok || ref.Name == nil || len(ref.Name.Parts) == 0 || ec.ctx.model.resolver == nil { - return nil, false + return nil, false, nil } if len(ref.Name.Parts) == 1 { name := ref.Name.Parts[0].Text - if ec.namesValue(name) { - return nil, false + names, err := ec.namesValue(name) + if err != nil { + return nil, false, err + } + if names { + return nil, false, nil } if ec.self != nil { if _, carried := ec.self.FeatureValues[name]; carried { - return nil, false + return nil, false, nil } } } sym, ok := ec.ctx.resolveQualified(ec.scope, ref.Name) if !ok || !ec.ctx.namesOneObject(sym) && !ec.ctx.namesObjects(sym) { - return nil, false + return nil, false, nil } if isReferenceUsage(sym) { // A reference member of a behavior — `Raise::context` — holds the value // its run bound, not an occurrence of its own: read it as a value. - return nil, false + return nil, false, nil } - return sym, true + return sym, true, nil } // calcUsageMemberValue reads parts from a calc usage a part declares, running it diff --git a/internal/exec/runtime/case_step.go b/internal/exec/runtime/case_step.go index f2c8241c95..43654220f1 100644 --- a/internal/exec/runtime/case_step.go +++ b/internal/exec/runtime/case_step.go @@ -99,7 +99,7 @@ func (e *performances) performCase(perf *actionFrame) error { } for _, out := range outputs { perf.features[out.Name] = ast.DirOut - perf.data[perf.key(out.Name)] = out.Value + e.ctx.writeBodyValue(perf.cells, perf.data, perf.key(out.Name), out.Value) } if out := run.shape.resultOutput(); out != nil { perf.result = out.Name diff --git a/internal/exec/runtime/check_state.go b/internal/exec/runtime/check_state.go index 8e4a68b06d..14fab47f1d 100644 --- a/internal/exec/runtime/check_state.go +++ b/internal/exec/runtime/check_state.go @@ -325,6 +325,32 @@ func (s *stateSpeller) joinsArrived(e *StateExecutor) { } s.out.WriteString("}") } + fmt.Fprintf(&s.out, " data{%s}", s.bodyValues(e.stateData, e.stateCells)) + for _, state := range sortedStates(e.stateAttrs) { + fmt.Fprintf(&s.out, " attrs{%s: %s}", e.statePath(state), s.bodyValues(e.stateAttrs[state], e.stateAttrCells[state])) + } + fmt.Fprintf(&s.out, " visits{%s}", strings.Join(e.stateVisits, ", ")) + if e.eventQueue != nil { + events := slices.Clone(e.eventQueue.events) + sort.SliceStable(events, func(i, j int) bool { return events.Less(i, j) }) + for _, event := range events { + fmt.Fprintf(&s.out, " event{%s}", s.event(e, event)) + } + } + for _, trans := range sortedTransitions(e.timerScheduled) { + fmt.Fprintf(&s.out, " timer{%s}", s.transition(e, trans)) + } + for _, trans := range sortedTransitions(e.changeFired) { + fmt.Fprintf(&s.out, " latched{%s}", s.transition(e, trans)) + } + for _, act := range e.doActions { + fmt.Fprintf(&s.out, " do{%s: %d pending", e.statePath(act.state), len(act.pending)) + if act.run != nil { + fmt.Fprintf(&s.out, ", paused{%s}", s.body(act.run.body)) + } + s.out.WriteString("}") + } + s.out.WriteByte('\n') } // stateName spells a state by its path in the machine, nothing for none. @@ -500,9 +526,13 @@ func (s *stateSpeller) frame(perf *actionFrame) string { fmt.Fprintf(&b, " %s", group) } fmt.Fprintf(&b, " live=%d", perf.live) - fmt.Fprintf(&b, " data{%s}", s.values(perf.data)) - for _, local := range perf.locals { - fmt.Fprintf(&b, " local{%s}", s.values(local)) + fmt.Fprintf(&b, " data{%s}", s.bodyValues(perf.data, perf.cells)) + for i, local := range perf.locals { + var cells *bodyCells + if i < len(perf.localCells) { + cells = perf.localCells[i] + } + fmt.Fprintf(&b, " local{%s}", s.bodyValues(local, cells)) } s.deliveries(&b, perf) for _, node := range sortedNodes(perf.subactions) { @@ -698,6 +728,51 @@ func (s *stateSpeller) values(m map[string]Value) string { return strings.Join(parts, ", ") } +func (s *stateSpeller) bodyValues(values map[string]Value, cells *bodyCells) string { + if cells == nil { + return s.values(values) + } + names := make(map[string]bool, len(values)+len(cells.cells)) + for name := range values { + names[name] = true + } + for name, cell := range cells.cells { + if cell.binding != nil { + names[name] = true + } + } + ordered := make([]string, 0, len(names)) + for name := range names { + ordered = append(ordered, name) + } + sort.Strings(ordered) + parts := make([]string, 0, len(ordered)) + for _, name := range ordered { + cell := cells.cells[name] + value, held := values[name] + if cell != nil && cell.binding != nil { + state := "tracking" + switch { + case cell.fv.Written: + state = "written" + case cell.binding.frozen: + state = "frozen" + } + if !cell.fv.Materialized { + held = false + } + text := UnsetText + if held { + text = s.value(value) + } + parts = append(parts, fmt.Sprintf("%s = %s [%s]", name, text, state)) + } else if held { + parts = append(parts, name+" = "+s.value(value)) + } + } + return strings.Join(parts, ", ") +} + func (s *stateSpeller) elements(elements []Value) string { parts := make([]string, len(elements)) for i, element := range elements { diff --git a/internal/exec/runtime/classifier_behavior.go b/internal/exec/runtime/classifier_behavior.go index 774e92ce3c..31475d8eb2 100644 --- a/internal/exec/runtime/classifier_behavior.go +++ b/internal/exec/runtime/classifier_behavior.go @@ -1048,7 +1048,7 @@ func (ctx *Context) attachClassifierBehavior(inst *Instance, decl classifierBeha return nil, fmt.Errorf("exhibited state machine %s of %s: %w", decl.behavior.Name, symbolText(inst.Type), err) } for name, value := range arguments { - exec.stateData[name] = value + exec.ctx.writeBodyValue(exec.stateCells, exec.stateData, name, value) } if err := exec.initialize(); err != nil { exec.Release() diff --git a/internal/exec/runtime/conformance_test.go b/internal/exec/runtime/conformance_test.go index 8c030cec9a..0c22bc2d6c 100644 --- a/internal/exec/runtime/conformance_test.go +++ b/internal/exec/runtime/conformance_test.go @@ -735,7 +735,7 @@ func (b *ExpectedExploreBudget) budget() ExploreBudget { // admissibleSchemaProblems reports how a case misuses outcomes and admissible: // the two go together, replace the single outcome rather than sit beside it, -// list at least two distinct results, and cite a section the oracle has. +// list distinct results, and cite a section the oracle has. func admissibleSchemaProblems(expected ExpectedOutcome, oracleTitles map[string]bool) []string { var problems []string if len(expected.Outcomes) == 0 { @@ -759,9 +759,6 @@ func admissibleSchemaProblems(expected ExpectedOutcome, oracleTitles map[string] if len(expected.Performers) > 0 { problems = append(problems, "outcomes and performers are stated together") } - if len(expected.Outcomes) < 2 { - problems = append(problems, "outcomes lists one result; state it as the single outcome") - } for i, outcome := range expected.Outcomes { if outcome.Outputs == nil && outcome.FinalState == "" && outcome.StateVisits == nil && !outcome.Terminated { problems = append(problems, fmt.Sprintf("outcome %d states nothing", i+1)) @@ -2419,7 +2416,7 @@ func validateElements(t reporter, ctx *Context, name string, expected []Expected } // TestAdmissibleOutcomesSchema pins that an admissible set cannot hide a bug: it -// replaces the single outcome, lists at least two, and cites an oracle section. +// replaces the single outcome, lists distinct results, and cites an oracle section. func TestAdmissibleOutcomesSchema(t *testing.T) { titles := oracleSectionTitles(t) const cited = "Concurrent branches writing one feature: the value is open, the writes are not" @@ -2446,7 +2443,7 @@ func TestAdmissibleOutcomesSchema(t *testing.T) { {"missing admissible", ExpectedOutcome{Type: "action", Outcomes: outcomes}, 1}, {"admissible cites no section", ExpectedOutcome{Type: "action", Outcomes: outcomes, Admissible: "the value is open"}, 1}, {"admissible without outcomes", ExpectedOutcome{Type: "action", Outputs: outcomes[0].Outputs, Admissible: cited}, 1}, - {"one outcome listed", ExpectedOutcome{Type: "action", Outcomes: outcomes[:1], Admissible: cited}, 1}, + {"one outcome listed", ExpectedOutcome{Type: "action", Outcomes: outcomes[:1], Admissible: cited}, 0}, {"empty outcome", ExpectedOutcome{Type: "action", Outcomes: []AdmittedOutcome{outcomes[0], {}}, Admissible: cited}, 1}, {"calc case", ExpectedOutcome{Type: "calc", Outcomes: outcomes, Admissible: cited}, 1}, } diff --git a/internal/exec/runtime/context.go b/internal/exec/runtime/context.go index 91920fa77f..95ca6bf699 100644 --- a/internal/exec/runtime/context.go +++ b/internal/exec/runtime/context.go @@ -1754,7 +1754,11 @@ func (ctx *Context) ExecuteStatePerformedBy(stateMachine *symbols.Symbol, self * return nil, nil, err } // Return state machine data and the real ordered visit trace - return exec.StateData(), exec.GetStateVisits(), nil + data, err := exec.StateDataWithError() + if err != nil { + return nil, nil, err + } + return data, exec.GetStateVisits(), nil } // StateOutcomeWithEvents runs a state machine as ExecuteStateWithEvents does and @@ -1770,6 +1774,9 @@ func (ctx *Context) StateOutcomePerformedBy(stateMachine *symbols.Symbol, self * if err != nil { return Outcome{}, err } + if _, err := exec.StateDataWithError(); err != nil { + return Outcome{}, err + } return (&Invocation{States: []*StateExecutor{exec}}).Outcome(), nil } diff --git a/internal/exec/runtime/dependents.go b/internal/exec/runtime/dependents.go index 8718cfd6ba..10a1a419be 100644 --- a/internal/exec/runtime/dependents.go +++ b/internal/exec/runtime/dependents.go @@ -6,7 +6,7 @@ package runtime // derivable reports whether fv's value is derived from what it reads: bound by `=`, // neither a fallback default nor assigned by a run nor propagated by a binding. func (ctx *Context) derivable(fv *FeatureValue) bool { - return !fv.Written && !fv.BindingDerived && !fv.Feature.DefaultIsFallback() + return !fv.Written && !fv.BindingDerived && (fv.Feature == nil || !fv.Feature.DefaultIsFallback()) } // derivation is a `=` value being derived; stale once a value it read was written @@ -26,15 +26,15 @@ func (ctx *Context) deriveFeatureValue(inst *Instance, fv *FeatureValue, name st clean, reads := ctx.endTrace(top) return val, clean, reads, err } - for { - ctx.forgetReads(fv) + var clean bool + var reads []sharedRead + val, err := ctx.deriveWith(fv, func() (Value, error) { top := ctx.beginTrace(inst, fv) - val, stale, err := ctx.deriveOnce(inst, fv, name) - clean, reads := ctx.endTrace(top) - if err != nil || !stale { - return val, clean, reads, err - } - } + value, err := ctx.evalFeatureValueDefault(inst, fv, name) + clean, reads = ctx.endTrace(top) + return value, err + }) + return val, clean, reads, err } // forgetReads delists fv from what its last derivation read: this one reads afresh, @@ -98,18 +98,37 @@ func (ctx *Context) forgetEdgesOf(objects []*Instance) { } } -// deriveOnce derives fv under a frame of its own, reporting whether it went stale. -func (ctx *Context) deriveOnce(inst *Instance, fv *FeatureValue, name string) (val Value, stale bool, err error) { +// deriveWith evaluates fv under a frame of its own, retrying when it went stale. +func (ctx *Context) deriveWith(fv *FeatureValue, eval func() (Value, error)) (Value, error) { + for { + ctx.forgetReads(fv) + val, stale, err := ctx.deriveOnceWith(fv, eval) + if err != nil || !stale { + return val, err + } + } +} + +func (ctx *Context) deriveOnceWith(fv *FeatureValue, eval func() (Value, error)) (val Value, stale bool, err error) { top := len(ctx.deriving) ctx.deriving = append(ctx.deriving, derivation{fv: fv}) defer func() { stale = ctx.deriving[top].stale ctx.deriving = ctx.deriving[:top] }() - val, err = ctx.evalFeatureValueDefault(inst, fv, name) + val, err = eval() return val, false, err } +func (ctx *Context) derivingValue(fv *FeatureValue) bool { + for i := range ctx.deriving { + if ctx.deriving[i].fv == fv { + return true + } + } + return false +} + // noteRead lists the value being derived, if any, as a dependent of the fv just read, // held by inst, and fv among what it reads; a derivation being observed sees the read. func (ctx *Context) noteRead(inst *Instance, fv *FeatureValue) { @@ -307,6 +326,9 @@ func (ctx *Context) invalidate(dependents []*FeatureValue) (deriving []*FeatureV default: ctx.noteProbeWrite(dep) dep.Value, dep.Values, dep.Materialized, dep.intrinsic = Value{}, Value{}, false, false + if dep.body != nil && dep.body.owner != nil { + delete(dep.body.owner.vars, dep.body.name) + } ctx.invalidateDependents(dep) } } diff --git a/internal/exec/runtime/eval.go b/internal/exec/runtime/eval.go index 1a9a01acfb..4758b3257b 100644 --- a/internal/exec/runtime/eval.go +++ b/internal/exec/runtime/eval.go @@ -317,13 +317,15 @@ func (ec *EvalContext) Pop() { } // Lookup searches for a name in the frame stack (innermost first). -func (ec *EvalContext) Lookup(name string) (Value, bool) { +func (ec *EvalContext) Lookup(name string) (Value, bool, error) { for i := len(ec.frames) - 1; i >= 0; i-- { - if val, ok := ec.frames[i].lookup(name); ok { - return val, true + if val, ok, err := ec.frames[i].read(ec.ctx, name); err != nil { + return Value{}, false, err + } else if ok { + return val, true, nil } } - return Value{}, false + return Value{}, false, nil } // Eval evaluates an expression node. Returns a Value or an error. @@ -600,7 +602,9 @@ func (ec *EvalContext) evalName(qn *ast.QualifiedName) (Value, error) { // Outside an expression body no body-local declaration can shadow a bound // name, so a frame binding is the answer: the common case, kept small. if qn != nil && len(qn.Parts) == 1 && (ec.scope == nil || !ec.scope.BodyLocal()) { - if val, ok := ec.Lookup(qn.Parts[0].Text); ok { + if val, ok, err := ec.Lookup(qn.Parts[0].Text); err != nil { + return Value{}, err + } else if ok { return val, nil } } @@ -642,7 +646,9 @@ func (ec *EvalContext) evalNameGeneral(qn *ast.QualifiedName) (Value, error) { } } // Try frame stack first (local bindings from calc/lambda params) - if val, ok := ec.Lookup(name); ok { + if val, ok, err := ec.Lookup(name); err != nil { + return Value{}, err + } else if ok { return val, nil } // Then a node of an action performance in the frame stack, read as a value. @@ -816,7 +822,9 @@ func (ec *EvalContext) evalNameGeneral(qn *ast.QualifiedName) (Value, error) { // A feature of a behavior whose run is on the stack (`MassCase::result` in its // objective or assertion) reads the value that run bound to it. if qualifier, ok := reading.Part(len(qn.Parts) - 2); ok { - if val, ok := ec.frameFeatureValue(qualifier, currentSym); ok { + if val, ok, err := ec.frameFeatureValue(qualifier, currentSym); err != nil { + return Value{}, err + } else if ok { return val, nil } } @@ -892,7 +900,7 @@ func (ec *EvalContext) evalNameGeneral(qn *ast.QualifiedName) (Value, error) { // frameFeatureValue reads the resolved member sym, qualified by qualifier, from the innermost // frame whose owner is (or specializes) the qualifier, under the name that owner's run binds it by. -func (ec *EvalContext) frameFeatureValue(qualifier, sym *symbols.Symbol) (Value, bool) { +func (ec *EvalContext) frameFeatureValue(qualifier, sym *symbols.Symbol) (Value, bool, error) { for i := len(ec.frames) - 1; i >= 0; i-- { f := ec.frames[i] if f.owner != nil { @@ -903,8 +911,10 @@ func (ec *EvalContext) frameFeatureValue(qualifier, sym *symbols.Symbol) (Value, if !ok { continue } - if val, ok := f.lookup(name); ok { - return val, true + if val, ok, err := f.read(ec.ctx, name); err != nil { + return Value{}, false, err + } else if ok { + return val, true, nil } continue } @@ -914,11 +924,13 @@ func (ec *EvalContext) frameFeatureValue(qualifier, sym *symbols.Symbol) (Value, if !f.runs(ec.ctx, qualifier) { continue } - if val, ok := f.lookup(sym.Name); ok { - return val, true + if val, ok, err := f.read(ec.ctx, sym.Name); err != nil { + return Value{}, false, err + } else if ok { + return val, true, nil } } - return Value{}, false + return Value{}, false, nil } // writeFrameFeature is frameFeatureValue for a write: value goes into the innermost @@ -1314,13 +1326,17 @@ func (ec *EvalContext) evalFeatureChain(n *ast.FeatureChainExpr) (Value, error) // A calc usage carries no value of its own: its output features are computed // by evaluating it, so `c.a` runs the usage — once — and reads the output // from that evaluation rather than from a feature value. - if sym, ok := ec.calcUsageOperand(base); ok { + if sym, ok, err := ec.calcUsageOperand(base); err != nil { + return Value{}, err + } else if ok { return ec.evalCalcUsageMembers(sym, parts) } // A part carries no value of its own: it denotes an occurrence, whose features // `lander.mass.mDry` reads, so the chain is read from that object. - if sym, ok := ec.occurrenceOperand(base); ok { + if sym, ok, err := ec.occurrenceOperand(base); err != nil { + return Value{}, err + } else if ok { // A usage of an enclosing object is read from that object, so a sibling // chain `e1.length` inside `e3` reads the containing rectangle's e1. if val, ok, err := ec.outerFeatureValue(sym); ok { diff --git a/internal/exec/runtime/frame.go b/internal/exec/runtime/frame.go index 298690206e..ed75d2dcf7 100644 --- a/internal/exec/runtime/frame.go +++ b/internal/exec/runtime/frame.go @@ -8,8 +8,12 @@ import ( // frame is one level of local bindings an evaluation reads: a calc invocation's // parameter slots, a map of named values, or both. type frame struct { - slots *slotFrame - vars map[string]Value + slots *slotFrame + vars map[string]Value + cells *bodyCells + ensureCells func() *bodyCells + masked map[string]bool + visible map[string]bool // aliases map the name of a redefined feature to the name of the feature // redefining it, which the frame binds it under (`in g :>> x` holds x as g). aliases map[string]string @@ -118,12 +122,15 @@ func (f frame) performs() *symbols.Symbol { // withVars is the frame holding vars in place of its own, still answering for // the same run and performance. func (f frame) withVars(vars map[string]Value) frame { - return frame{vars: vars, aliases: f.aliases, perf: f.perf, owner: f.owner, write: f.write, performed: f.performed, run: f.run, merged: f.merged, firing: f.firing} + return frame{vars: vars, masked: f.masked, visible: f.visible, aliases: f.aliases, perf: f.perf, owner: f.owner, write: f.write, performed: f.performed, run: f.run, merged: f.merged, firing: f.firing} } // lookup finds name in the frame: a slot binding it, else the map. func (f frame) lookup(name string) (Value, bool) { name = canonical(f.aliases, name) + if f.visible != nil && !f.visible[name] { + return Value{}, false + } if f.slots != nil { if value, ok := f.slots.lookup(name); ok { return value, true @@ -133,6 +140,26 @@ func (f frame) lookup(name string) (Value, bool) { return value, ok } +func (f frame) read(ctx *Context, name string) (Value, bool, error) { + name = canonical(f.aliases, name) + if f.masked[name] || (f.visible != nil && !f.visible[name]) { + return Value{}, false, nil + } + if f.slots != nil { + if value, ok := f.slots.lookup(name); ok { + return value, true, nil + } + } + if f.cells == nil && len(ctx.deriving) != 0 && f.ensureCells != nil { + f.cells = f.ensureCells() + } + if f.cells == nil { + value, ok := f.vars[name] + return value, ok, nil + } + return ctx.readBodyCell(f.cells, name) +} + // has reports whether the frame binds name. func (f frame) has(name string) bool { _, ok := f.lookup(name) @@ -148,6 +175,18 @@ func (f frame) set(name string, value Value) { f.vars[name] = value } +func (f frame) setBody(ctx *Context, name string, value Value) { + name = canonical(f.aliases, name) + if f.slots != nil && f.slots.set(name, value) { + return + } + if f.cells != nil { + ctx.writeBodyCell(f.cells, name, value) + return + } + f.vars[name] = value +} + // bindParam binds parameter i, named name: by position in the slots, else by name. func (f frame) bindParam(i int, name string, value Value) { if f.slots != nil { @@ -160,13 +199,42 @@ func (f frame) bindParam(i int, name string, value Value) { // each calls fn for every name the frame binds. func (f frame) each(fn func(name string, value Value)) { if f.slots != nil { - f.slots.each(fn) + f.slots.each(func(name string, value Value) { + name = canonical(f.aliases, name) + if f.visible == nil || f.visible[name] { + fn(name, value) + } + }) } for name, value := range f.vars { - fn(name, value) + name = canonical(f.aliases, name) + if f.visible == nil || f.visible[name] { + fn(name, value) + } } } +func (f frame) visibleNames() map[string]bool { + names := make(map[string]bool, len(f.vars)) + f.each(func(name string, _ Value) { names[canonical(f.aliases, name)] = true }) + if f.cells != nil { + for _, name := range f.cells.order { + names[canonical(f.aliases, name)] = true + } + } + return names +} + +func (f frame) eachCurrent(ctx *Context, fn func(name string, value Value)) error { + if f.cells != nil { + if err := ctx.deriveBodyCells(f.cells); err != nil { + return err + } + } + f.each(fn) + return nil +} + // snapshot copies the frame's bindings, and the aliases they are read through, // into storage of its own, unchanged by whatever later reuses the frame's. The // copy still answers for the run it was taken from, though not for its flow's nodes. @@ -175,6 +243,12 @@ func (f frame) snapshot() frame { f.each(func(name string, value Value) { vars[name] = value }) out := ownedFrame(f.owner, vars) out.performed, out.run, out.merged = f.performs(), f.run, f.merged + if f.visible != nil { + out.visible = make(map[string]bool, len(f.visible)) + for name, visible := range f.visible { + out.visible[name] = visible + } + } if len(f.aliases) > 0 { out.aliases = make(map[string]string, len(f.aliases)) for name, alias := range f.aliases { diff --git a/internal/exec/runtime/function_value.go b/internal/exec/runtime/function_value.go index 0b19ed5732..3688f84a77 100644 --- a/internal/exec/runtime/function_value.go +++ b/internal/exec/runtime/function_value.go @@ -160,7 +160,9 @@ func (ec *EvalContext) boundFunction(callee *symbols.Symbol, qn *ast.QualifiedNa return Value{}, false, nil } name := qn.Parts[0].Text - if val, ok := ec.Lookup(name); ok { + if val, ok, err := ec.Lookup(name); err != nil { + return Value{}, false, err + } else if ok { return val, true, nil } // A calc-typed feature the element being evaluated binds (`in calc :>> f = g`) @@ -208,7 +210,9 @@ func (ec *EvalContext) declaredFunction(callee *symbols.Symbol) (Value, bool, er // else the calc the callee's own value names. func (ec *EvalContext) qualifiedBoundFunction(callee *symbols.Symbol, qn *ast.QualifiedName) (Value, bool, error) { if qualifier, ok := ec.ctx.readQualified(ec.scope, qn).Part(len(qn.Parts) - 2); ok { - if val, ok := ec.frameFeatureValue(qualifier, callee); ok { + if val, ok, err := ec.frameFeatureValue(qualifier, callee); err != nil { + return Value{}, true, err + } else if ok { return val, true, nil } } diff --git a/internal/exec/runtime/held_image_behavior.go b/internal/exec/runtime/held_image_behavior.go index f2e66595b2..7d48356bd4 100644 --- a/internal/exec/runtime/held_image_behavior.go +++ b/internal/exec/runtime/held_image_behavior.go @@ -60,7 +60,9 @@ type imagedFrame struct { saved actionFrame parent int locals []map[string]Value + localCells [][]imagedBodyCell data map[string]Value + cells []imagedBodyCell outer []imagedOuter subactions map[ast.Node]int repeats map[repetitionGroupID]imagedRepetition @@ -69,6 +71,16 @@ type imagedFrame struct { nested map[ast.Node][]nestedDelivery } +type imagedBodyCell struct { + name string + value ast.Node + scope *symbols.Scope + written bool + frozen bool + visible []map[string]bool + limitFrames bool +} + type imagedRepetition struct { node ast.Node remaining int64 @@ -101,7 +113,9 @@ type imagedState struct { nextEventID int64 events []Event stateData map[string]Value + stateCells []imagedBodyCell stateAttrs map[*ast.StateNode]map[string]Value + stateAttrCells map[*ast.StateNode][]imagedBodyCell stateVisits []string stateStack []*ast.StateNode fired []FiredTransition @@ -227,6 +241,7 @@ func (t *imaging) frame(perf *actionFrame, at func(*actionFrame) int) (imagedFra } f := imagedFrame{saved: *perf, parent: at(perf.parent)} f.saved.parent, f.saved.locals, f.saved.outer, f.saved.data = nil, nil, nil, nil + f.saved.cells, f.saved.localCells = nil, nil f.saved.subactions, f.saved.repeats, f.saved.pending, f.saved.staged, f.saved.nested = nil, nil, nil, nil, nil f.saved.connections = slices.Clone(perf.connections) f.saved.features = maps.Clone(perf.features) @@ -235,16 +250,23 @@ func (t *imaging) frame(perf *actionFrame, at func(*actionFrame) int) (imagedFra f.saved.nodes = slices.Clone(perf.nodes) f.saved.streamed = maps.Clone(perf.streamed) f.saved.unreceived = cloneUnreceived(perf.unreceived) - for _, local := range perf.locals { - if err := t.values(local); err != nil { + for i, local := range perf.locals { + cloned := maps.Clone(local) + var cells []imagedBodyCell + if i < len(perf.localCells) { + cells = imageBodyCells(perf.localCells[i], cloned) + } + if err := t.values(cloned); err != nil { return imagedFrame{}, err } - f.locals = append(f.locals, maps.Clone(local)) + f.locals = append(f.locals, cloned) + f.localCells = append(f.localCells, cells) } - if err := t.values(perf.data); err != nil { + f.data = maps.Clone(perf.data) + f.cells = imageBodyCells(perf.cells, f.data) + if err := t.values(f.data); err != nil { return imagedFrame{}, err } - f.data = maps.Clone(perf.data) outer, err := t.outerFrames(perf, at) if err != nil { return imagedFrame{}, err @@ -351,6 +373,7 @@ func (t *imaging) stateExecutor(e *StateExecutor) (*imagedState, error) { graph: e.graph, state: e.state, activeConfig: cloneConfiguration(e.activeConfig), nextEventID: e.nextEventID, stateData: maps.Clone(e.stateData), + stateAttrCells: make(map[*ast.StateNode][]imagedBodyCell, len(e.stateAttrCells)), stateAttrs: make(map[*ast.StateNode]map[string]Value, len(e.stateAttrs)), stateVisits: slices.Clone(e.stateVisits), stateStack: slices.Clone(e.stateStack), @@ -385,7 +408,8 @@ func (t *imaging) stateExecutor(e *StateExecutor) (*imagedState, error) { if img.run, err = t.run(e.driven.state); err != nil { return nil, err } - if err := t.values(e.stateData); err != nil { + img.stateCells = imageBodyCells(e.stateCells, img.stateData) + if err := t.values(img.stateData); err != nil { return nil, err } if call := e.pendingCall; call != nil { @@ -397,10 +421,12 @@ func (t *imaging) stateExecutor(e *StateExecutor) (*imagedState, error) { } } for node, attrs := range e.stateAttrs { - if err := t.values(attrs); err != nil { + cloned := maps.Clone(attrs) + img.stateAttrCells[node] = imageBodyCells(e.stateAttrCells[node], cloned) + if err := t.values(cloned); err != nil { return nil, fmt.Errorf("state %s: %w", StateVertexName(node), err) } - img.stateAttrs[node] = maps.Clone(attrs) + img.stateAttrs[node] = cloned } for node, record := range e.history { img.history[node] = historyRecord{child: record.child, regions: maps.Clone(record.regions)} @@ -590,7 +616,9 @@ func (m *materializing) actionExecutor(e *ActionExecutor, img *imagedAction) err // frame fills one performance of dst's from its image. func (m *materializing) frame(perf *actionFrame, img imagedFrame, frameAt func(int) *actionFrame) error { + perfs := perf.perfs *perf = img.saved + perf.perfs = perfs perf.parent = frameAt(img.parent) perf.connections = slices.Clone(img.saved.connections) perf.features = maps.Clone(img.saved.features) @@ -601,16 +629,21 @@ func (m *materializing) frame(perf *actionFrame, img imagedFrame, frameAt func(i perf.unreceived = cloneUnreceived(img.saved.unreceived) var err error perf.locals = nil - for _, local := range img.locals { + perf.localCells = make([]*bodyCells, len(img.locals)) + for i, local := range img.locals { carried, err := m.values(local) if err != nil { return err } perf.locals = append(perf.locals, carried) + if i < len(img.localCells) { + perf.localCells[i] = m.frameCells(perf, carried, img.localCells[i], i+1) + } } if perf.data, err = m.values(img.data); err != nil { return err } + perf.cells = m.frameCells(perf, perf.data, img.cells, 0) perf.outer = nil for _, outer := range img.outer { vars, err := m.values(outer.vars) @@ -645,9 +678,142 @@ func (m *materializing) frame(perf *actionFrame, img imagedFrame, frameAt func(i if err := m.nested(perf, img.nested); err != nil { return err } + for _, cells := range perf.localCells { + if err := m.dst.deriveBodyCells(cells); err != nil { + return err + } + } + if err := m.dst.deriveBodyCells(perf.cells); err != nil { + return err + } return nil } +func imageBodyCells(cells *bodyCells, values map[string]Value) []imagedBodyCell { + if cells == nil { + return nil + } + // Tracking cells omit dependency edges from images and derive again on first read. + var image []imagedBodyCell + for _, name := range cells.order { + cell := cells.cells[name] + if cell.binding == nil { + continue + } + image = append(image, imagedBodyCell{ + name: name, value: cell.binding.value, scope: cell.binding.scope, + written: cell.fv.Written, frozen: cell.binding.frozen, + visible: cloneBodyBindingVisibility(cell.binding.visible), limitFrames: cell.binding.limitFrames, + }) + if !cell.fv.Written && !cell.binding.frozen { + delete(values, name) + } + } + return image +} + +func restoreStateBodyCells( + ctx *Context, + cells *bodyCells, + attributes []lower.Attribute, + image []imagedBodyCell, + owner string, + defaultScope *symbols.Scope, +) { + features := make(map[string]lower.Attribute, len(attributes)) + for _, attr := range attributes { + features[attr.Name] = attr + } + for _, state := range image { + attr, ok := features[state.name] + if !ok || !attr.Binding || attr.Value == nil { + continue + } + if cell := cells.cells[state.name]; cell != nil && cell.binding != nil { + restoreBodyCellValue(cells, state, cell) + continue + } + scope := attr.Scope + if scope == nil { + scope = defaultScope + } + check := func(value *Value) error { + return ctx.checkBodyDeclaration(scope, owner, state.name, value) + } + cell := ctx.registerBodyBinding(cells, state.name, attr.Value, scope, check, nil) + cell.binding.visible = cloneBodyBindingVisibility(state.visible) + cell.binding.limitFrames = state.limitFrames + restoreBodyCellValue(cells, state, cell) + } +} + +func restoreBodyCellValue(cells *bodyCells, state imagedBodyCell, cell *bodyCell) { + if value, held := cells.vars[state.name]; held { + cell.fv.Value, cell.fv.Materialized = value, true + } + if state.written { + cell.fv.Written = true + } else if state.frozen { + cell.binding.frozen = true + } else { + delete(cells.vars, state.name) + cell.fv.Value, cell.fv.Values, cell.fv.Materialized = Value{}, Value{}, false + } +} + +func (m *materializing) frameCells( + perf *actionFrame, + values map[string]Value, + image []imagedBodyCell, + localDepth int, +) *bodyCells { + if len(image) == 0 { + return nil + } + cells := newBodyCells(values, func(scope *symbols.Scope) *EvalContext { + ec := perf.perfs.evalContextFor(perf, scope) + if localDepth > 0 { + localStart := len(ec.frames) - len(perf.locals) - 1 + ec.frames = ec.frames[:localStart+localDepth] + } + return ec + }) + for _, state := range image { + if state.value == nil { + continue + } + check := func(value *Value) error { + return m.dst.checkBodyDeclaration(state.scope, perf.describe(), state.name, value) + } + var onDerived func(*Value) error + if perf.node == nil && perf.parent == nil { + if owner, ok := perf.perfs.owner.(*ActionExecutor); ok { + cell := cells.cell(state.name) + onDerived = func(value *Value) error { + mirrored, err := owner.mirrorBindingOccurrence(state.name, *value, cell) + if err != nil { + return err + } + *value = mirrored + return nil + } + } + } + cell := m.dst.registerBodyBinding(cells, state.name, state.value, state.scope, check, onDerived) + cell.binding.visible = cloneBodyBindingVisibility(state.visible) + cell.binding.limitFrames = state.limitFrames + if state.written { + cell.fv.Written = true + } else if state.frozen { + cell.binding.frozen = true + } else { + delete(values, state.name) + cell.fv.Value, cell.fv.Materialized = Value{}, false + } + } + return cells +} + // pending fills perf's pending table from img's. func (m *materializing) pending(perf *actionFrame, pending map[ast.Node]map[string][]Value) error { if pending == nil { @@ -715,10 +881,23 @@ func (m *materializing) stateExecutor(e *StateExecutor, img *imagedState) error if e.stateData == nil { e.stateData = make(map[string]Value) } + if e.stateCells != nil { + e.stateCells.vars = e.stateData + restoreStateBodyCells(m.dst, e.stateCells, e.graph.Attributes, img.stateCells, + "state machine "+symbolText(e.stateMachine), e.graph.Scope) + } for node, attrs := range img.stateAttrs { if e.stateAttrs[node], err = m.values(attrs); err != nil { return fmt.Errorf("state %s: %w", StateVertexName(node), err) } + if image := img.stateAttrCells[node]; len(image) != 0 { + cells := newBodyCells(e.stateAttrs[node], func(scope *symbols.Scope) *EvalContext { + return e.stateAttributeContext(node, scope) + }) + restoreStateBodyCells(m.dst, cells, e.graph.StateAttributes[node], image, + "state "+node.Name, e.graph.Scope) + e.stateAttrCells[node] = cells + } } e.stateVisits, e.stateStack = slices.Clone(img.stateVisits), slices.Clone(img.stateStack) e.fired, e.firedBase, e.keepFired = slices.Clone(img.fired), img.firedBase, img.keepFired diff --git a/internal/exec/runtime/instance.go b/internal/exec/runtime/instance.go index a409c16a29..dded5dac47 100644 --- a/internal/exec/runtime/instance.go +++ b/internal/exec/runtime/instance.go @@ -86,6 +86,7 @@ func (inst *Instance) keepConnector(fv *FeatureValue, id int64) { // Feature value holds the runtime value(s) for one feature. type FeatureValue struct { Feature *EffectiveFeature + body *bodyCell Value Value // scalar feature value (multiplicity [1]) Values Value // collection feature value (Sequence or Set) Materialized bool // lazy flag: has this feature value been instantiated? @@ -1423,6 +1424,16 @@ func (ctx *Context) evalFeatureValueDefault(inst *Instance, fv *FeatureValue, na } ctx.derivingFeatureValues[key] = true defer delete(ctx.derivingFeatureValues, key) + if fv.body != nil { + value, ok, err := ctx.readBodyCell(fv.body.owner, fv.body.name) + if err != nil { + return Value{}, err + } + if !ok { + return Value{}, &NoValueError{Feature: name} + } + return value, nil + } scope := fv.Feature.DefaultScope() if scope == nil { diff --git a/internal/exec/runtime/invoke_calc.go b/internal/exec/runtime/invoke_calc.go index ba8433e039..62ed7f0232 100644 --- a/internal/exec/runtime/invoke_calc.go +++ b/internal/exec/runtime/invoke_calc.go @@ -585,6 +585,7 @@ type invocationFrame struct { // slots hold the parameters; bindings the locals the body declares beside them. slots slotFrame bindings map[string]Value + cells *bodyCells aliases map[string]string owner *calcShape // the calc invoked, whose members the locals bind run int64 // the run this invocation is (Context.newRun) @@ -595,7 +596,7 @@ type invocationFrame struct { // locals is the frame the invocation's parameters and body locals are bound in. func (f *invocationFrame) locals() frame { - return frame{slots: &f.slots, vars: f.bindings, aliases: f.aliases, owner: f.owner, run: f.run} + return frame{slots: &f.slots, vars: f.bindings, cells: f.cells, aliases: f.aliases, owner: f.owner, run: f.run} } // maxFreeInvocationFrames bounds the frames kept, so one deep recursion does not @@ -624,6 +625,7 @@ func (ctx *Context) acquireInvocationFrame() *invocationFrame { // caller has ended the activation, so nothing memoized still reads the bindings. func (ctx *Context) releaseInvocationFrame(frame *invocationFrame) { bindings, slots := frame.bindings, frame.slots + ctx.forgetBodyCells(frame.env.data.cells) if frame.locals().width() > maxPooledBindings { bindings, slots = nil, slotFrame{} } else { @@ -868,7 +870,9 @@ func (ctx *Context) bindCalcParameters( // activation, which the caller ends after it. func (ctx *Context) runCalcBody(shape *calcShape, frame *invocationFrame, callerScope *symbols.Scope, self *Instance, activation int64, enclosing []frame, occurrence *calcOccurrence) (Value, error) { frame.host = calcStmtHost{ctx: ctx, shape: shape, self: self, occ: occurrence} - frame.env = stmtEnv{data: frame.locals(), enclosing: shape.bodyEnclosing(enclosing)} + frame.env = stmtEnv{ + data: frame.locals(), enclosing: shape.bodyEnclosing(enclosing), locals: make(map[string]Value), + } thisOccurrence := frame.engine.thisOccurrence if thisOccurrence == nil { thisOccurrence = frame.host.materializeOccurrence @@ -879,7 +883,11 @@ func (ctx *Context) runCalcBody(shape *calcShape, frame *invocationFrame, caller if err != nil { return Value{}, err } + frame.cells = frame.env.data.cells if returned { + if err := ctx.freezeBodyCells(frame.cells); err != nil { + return Value{}, err + } return result, nil } out, err := shape.designatedOutput() @@ -891,12 +899,20 @@ func (ctx *Context) runCalcBody(shape *calcShape, frame *invocationFrame, caller // through the same run bookkeeping a calc usage's outputs use. run := newCalcRun(shape, callerScope, self, frame.locals()) run.activation, run.perf, run.occurrence = activation, frame.host.performance(), occurrence + run.bodyFrames = append(run.bodyFrames, frame.env.localFrame().snapshot()) if len(enclosing) > 0 { run.outer = &EvalContext{ctx: ctx, scope: callerScope, self: self, frames: enclosing, trace: ctx.trace, activation: activation} } // The invocation already holds this evaluation's nesting feature value. run.onStack = true - return run.value(ctx, out) + result, err = run.value(ctx, out) + if err != nil { + return Value{}, err + } + if err := ctx.freezeBodyCells(frame.cells); err != nil { + return Value{}, err + } + return result, nil } // toolCalcResult resolves what an invocation of a tool-computed calc yields when @@ -957,6 +973,9 @@ func (shape *calcShape) designatedToolOutput(outputs map[string]Value) (calcOutp // calc's parameters on the way in and its locals on the way out, reporting // the value host took from a `return` and whether the body returned one. func runCalcSteps(engine *stmtEngine, host *calcStmtHost, steps []lower.Statement) (Value, bool, error) { + deriving := engine.ctx.deriving + engine.ctx.deriving = nil + defer func() { engine.ctx.deriving = deriving }() flow, err := engine.run(steps) if err != nil { return Value{}, false, err @@ -1064,7 +1083,11 @@ func (ec *EvalContext) bindCalcParameter( } if param.Default == nil { if param.IsSubject { - if value, ok := ec.ctx.enclosingSubject(shape, enclosing); ok { + value, ok, err := ec.ctx.enclosingSubject(shape, enclosing) + if err != nil { + return Value{}, "", err + } + if ok { return value, "enclosing subject", nil } return Value{}, "", shape.unboundSubject(param) diff --git a/internal/exec/runtime/montecarlo_case.go b/internal/exec/runtime/montecarlo_case.go index 3b0a958983..532ccdba74 100644 --- a/internal/exec/runtime/montecarlo_case.go +++ b/internal/exec/runtime/montecarlo_case.go @@ -235,7 +235,7 @@ func (ctx *Context) monteCarloObserved(run *calcRun) (Value, error) { return Value{}, fmt.Errorf("%w: %s declares no %s::%s to observe", ErrMonteCarloObserved, run.shape.Label, MonteCarloCaseFQN, monteCarloObserved) } - if value, ok := run.env.lookup(name); ok { + if value, ok := run.lookup(name); ok { return value, nil } return Value{Kind: ValNull}, nil @@ -523,9 +523,23 @@ func (r *MonteCarloRun) returnResults() error { enclosing = run.shape.bodyEnclosing(run.outer.enclosingRun(run.shape)) } engine := newStmtEngineIn(ctx, host, run.env, enclosing) + for _, body := range run.bodyFrames { + body.each(func(name string, value Value) { + if engine.env.locals == nil { + engine.env.locals = make(map[string]Value) + } + engine.env.locals[name] = value + }) + } defer engine.finish() host.readPerformance(engine, run.perf) result, returned, err := runCalcSteps(engine, host, r.results) + body := engine.env.localFrame().snapshot() + if len(run.bodyFrames) == 0 { + run.bodyFrames = []frame{body} + } else { + run.bodyFrames[len(run.bodyFrames)-1] = body + } if err != nil { return calcFrame(run.shape.Kind, run.shape.Name, fmt.Errorf("result: %w", err)) } diff --git a/internal/exec/runtime/robustness_action_step_multiplicity_test.go b/internal/exec/runtime/robustness_action_step_multiplicity_test.go index c2ce0828ad..5206725cb5 100644 --- a/internal/exec/runtime/robustness_action_step_multiplicity_test.go +++ b/internal/exec/runtime/robustness_action_step_multiplicity_test.go @@ -621,10 +621,10 @@ func TestActionStepMultiplicityExploreOmitsRepeatedLocals(t *testing.T) { if err != nil { t.Fatalf("explore shared writers: %v", err) } - if !exploration.Complete() || len(exploration.Outcomes) != 3 { - t.Fatalf("exploration = %s with %d outcomes, want three complete outcomes", exploration.Status(), len(exploration.Outcomes)) + if !exploration.Complete() || len(exploration.Outcomes) != 1 { + t.Fatalf("exploration = %s with %d outcomes, want one complete outcome", exploration.Status(), len(exploration.Outcomes)) } - want := map[string]bool{"c = 1": true, "c = 2": true, "c = 3": true} + want := map[string]bool{"c = 3": true} for _, explored := range exploration.Outcomes { got := explored.Outcome.String() if !want[got] { diff --git a/internal/exec/runtime/robustness_body_feature_bindings_test.go b/internal/exec/runtime/robustness_body_feature_bindings_test.go new file mode 100644 index 0000000000..332d58e9e0 --- /dev/null +++ b/internal/exec/runtime/robustness_body_feature_bindings_test.go @@ -0,0 +1,321 @@ +package runtime + +import ( + "context" + "errors" + "testing" +) + +func TestRuntimeRobustnessBodyFeatureBindings(t *testing.T) { + t.Run("cycle", func(t *testing.T) { + ctx, action := loadAction(t, `package test { + private import ScalarValues::*; + action run { + attribute left : Integer = right + 1; + attribute right : Integer = left + 1; + first start; + then action read { assign left := right; } + } + }`, "run") + exec, err := ctx.CreateActionExecutor(action) + if err == nil { + err = exec.RunToCompletion() + } + if !errors.Is(err, ErrCyclicFeatureValue) { + t.Fatalf("RunToCompletion = %v, want ErrCyclicFeatureValue", err) + } + }) + + t.Run("destroyed_object_read", func(t *testing.T) { + ctx, action := loadAction(t, `package test { + private import ScalarValues::*; + part def Target { attribute n : Integer := 2; } + action run { + in ref target : Target; + attribute live : Integer = target.n; + first start; + then action read { assign live := live + 1; } + } + }`, "run") + idx := ctx.model.resolver.Index() + target, err := ctx.Instantiate(oneSymbol(t, idx, "test::Target")) + if err != nil { + t.Fatalf("Instantiate Target: %v", err) + } + exec, err := ctx.CreateActionExecutorWithInputs(action, nil, map[string]Value{"target": objectValue(target)}) + if err != nil { + t.Fatalf("CreateActionExecutorWithInputs: %v", err) + } + if err := ctx.destroy(target); err != nil { + t.Fatalf("destroy Target: %v", err) + } + if _, _, err := ctx.readBodyValue(exec.root.cells, exec.root.data, "live"); !errors.Is(err, ErrOccurrenceDestroyed) { + t.Fatalf("read live after destroying target = %v, want ErrOccurrenceDestroyed", err) + } + }) + + t.Run("state_exit_invalidates_dependents", func(t *testing.T) { + ctx, machine := loadState(t, `package test { + private import ScalarValues::*; + state machine { + attribute source : Integer := 1; + attribute top : Integer = active.value; + entry; then active; + state active { attribute value : Integer = source; } + state done; + transition first active accept when source > 0 do assign source := 2 then done; + } + }`, "machine") + exec, err := ctx.CreateStateExecutor(machine) + if err != nil { + t.Fatalf("CreateStateExecutor: %v", err) + } + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("RunToCompletion: %v", err) + } + data, err := exec.StateDataWithError() + if err != nil { + if !errors.Is(err, ErrNoValue) && !errors.Is(err, ErrUninitializedFeatureValue) && !errors.Is(err, ErrUnresolvedReference) { + t.Fatalf("read top after state exit = %v, want a typed inactive-state value error", err) + } + return + } + if got := FormatValue(data["top"]); got != "" { + t.Fatalf("top after state exit = %s, want ", got) + } + }) + + t.Run("write_stops_tracking", func(t *testing.T) { + ctx, action := loadAction(t, `package test { + private import ScalarValues::*; + action run { + attribute source : Integer := 1; + attribute live : Integer = source * 2; + out attribute result : Integer; + first start; + then action write { + assign live := 99; + assign source := 5; + assign result := live; + } + } + }`, "run") + exec, err := ctx.CreateActionExecutor(action) + if err != nil { + t.Fatalf("CreateActionExecutor: %v", err) + } + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("RunToCompletion: %v", err) + } + if got := FormatValue(exec.Results()["result"]); got != "99" { + t.Fatalf("result = %s, want 99 after the binding is written", got) + } + }) + + t.Run("read_only_body_features", func(t *testing.T) { + for _, tc := range []struct { + name string + decl string + want string + }{ + {"constant", "constant attribute locked : Integer = 1;", "locked is constant"}, + {"derived", "attribute source : Integer := 1; derived attribute locked : Integer = source * 2;", "locked is derived"}, + } { + t.Run(tc.name, func(t *testing.T) { + ctx, action := loadAction(t, `package test { + private import ScalarValues::*; + action run { + `+tc.decl+` + first start; + then action write { assign locked := 2; } + } + }`, "run") + exec, err := ctx.CreateActionExecutor(action) + if err != nil { + t.Fatalf("CreateActionExecutor: %v", err) + } + if err := exec.RunToCompletion(); !errors.Is(err, ErrReadOnlyFeature) { + t.Fatalf("RunToCompletion = %v, want ErrReadOnlyFeature (%s)", err, tc.want) + } + }) + } + }) + + t.Run("rederivation_obeys_step_budget", func(t *testing.T) { + ctx, action := loadAction(t, `package test { + private import ScalarValues::*; + action run { + attribute source : Integer := 1; + attribute live : Integer = source + 1; + first start; + then action done; + } + }`, "run") + exec, err := ctx.CreateActionExecutor(action) + if err != nil { + t.Fatalf("CreateActionExecutor: %v", err) + } + ctx.writeBodyValue(exec.root.cells, exec.root.data, "source", integerValue(2)) + ctx.run.steps = ctx.maxSteps + if _, _, err := ctx.readBodyValue(exec.root.cells, exec.root.data, "live"); !errors.Is(err, ErrStepLimitExceeded) { + t.Fatalf("read live at exhausted budget = %v, want ErrStepLimitExceeded", err) + } + }) + + t.Run("snapshot_restore", func(t *testing.T) { + ctx, action := loadAction(t, `package test { + private import ScalarValues::*; + action run { + attribute source : Integer := 1; + attribute live : Integer = source * 2; + out attribute result : Integer; + first start; + then action prepare { + assign source := 4; + assign result := live; + } + then action finish { + assign source := 5; + assign result := live; + } + } + }`, "run") + exec, err := ctx.CreateActionExecutor(action) + if err != nil { + t.Fatalf("CreateActionExecutor: %v", err) + } + exec.SetBreakpoint("finish") + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("RunToCompletion to breakpoint: %v", err) + } + if got := exec.PausedAt(); got != "finish" { + t.Fatalf("PausedAt = %q, want finish", got) + } + snapshot, err := exec.Snapshot() + if err != nil { + t.Fatalf("Snapshot: %v", err) + } + defer snapshot.Release() + finish := func() { + t.Helper() + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("RunToCompletion: %v", err) + } + if got := FormatValue(exec.Results()["result"]); got != "10" { + t.Fatalf("result = %s, want 10 after source := 5", got) + } + } + finish() + snapshot.Restore() + if got := FormatValue(exec.Results()["live"]); got != "8" { + t.Fatalf("live after restore = %s, want the captured 8", got) + } + finish() + }) + + t.Run("held_image", func(t *testing.T) { + idx, _, ctx := buildRuntimeWithLibraries(t, "body-bindings-image.sysml", parseAndBuild(t, `package test { + private import ScalarValues::*; + action def Run { + attribute source : Integer := 1; + attribute live : Integer = source * 2; + out attribute result : Integer; + first start; + then action prepare { + assign source := 4; + assign result := live; + } + then action finish { + assign source := 5; + assign result := live; + } + } + part def Host { perform action run : Run; } + }`)) + host, err := ctx.Instantiate(oneSymbol(t, idx, "test::Host")) + if err != nil { + t.Fatalf("Instantiate Host: %v", err) + } + behavior, ok := host.Behavior("run") + if !ok || behavior.Action == nil { + t.Fatal("Host has no run action") + } + behavior.Action.SetBreakpoint("finish") + if err := behavior.Action.RunToCompletion(); err != nil { + t.Fatalf("RunToCompletion to breakpoint: %v", err) + } + image := imageInto(t, ctx, host) + copied, ok := image.Instance(host.ID) + if !ok { + t.Fatalf("image has no Host #%d", host.ID) + } + copiedBehavior, ok := copied.Behavior("run") + if !ok || copiedBehavior.Action == nil { + t.Fatal("image has no run action") + } + if err := copiedBehavior.Action.RunToCompletion(); err != nil { + t.Fatalf("RunToCompletion of imaged action: %v", err) + } + if got := FormatValue(copiedBehavior.Action.Results()["result"]); got != "10" { + t.Fatalf("imaged result = %s, want 10 after lazy rederivation", got) + } + }) + + t.Run("replay", func(t *testing.T) { + ctx, action := loadAction(t, `package test { + private import ScalarValues::*; + action run { + attribute source : Integer := 1; + attribute live : Integer = source * 2; + out attribute result : Integer; + first start; + then action write { + assign source := 5; + assign result := live; + } + } + }`, "run") + fresh := func() (*Context, error) { return NewContext(ctx.Model(), 100000), nil } + replayed, err := ReplaySchedule(context.Background(), fresh, starterOf(action), Witness{}, ScheduleEnd) + if err != nil { + t.Fatalf("ReplaySchedule: %v", err) + } + if replayed.Err != nil { + t.Fatalf("replay run: %v", replayed.Err) + } + if got, err := replayed.FinalValue("result"); err != nil || got != "10" { + t.Fatalf("replayed result = %s, %v, want 10", got, err) + } + }) + + t.Run("calc_usage_remains_one_shot", func(t *testing.T) { + ctx, action := loadAction(t, `package test { + private import ScalarValues::*; + calc def Twice { + in x : Integer; + out y : Integer = x * 2; + } + action run { + attribute source : Integer := 2; + calc value : Twice { in x = source; } + out attribute result : Integer; + first start; + then action write { + assign result := value.y; + assign source := 5; + assign result := value.y; + } + } + }`, "run") + exec, err := ctx.CreateActionExecutor(action) + if err != nil { + t.Fatalf("CreateActionExecutor: %v", err) + } + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("RunToCompletion: %v", err) + } + if got := FormatValue(exec.Results()["result"]); got != "4" { + t.Fatalf("calc usage result = %s, want its one-shot pin value 4", got) + } + }) +} diff --git a/internal/exec/runtime/shared_default.go b/internal/exec/runtime/shared_default.go index e724d07df0..37d24b7169 100644 --- a/internal/exec/runtime/shared_default.go +++ b/internal/exec/runtime/shared_default.go @@ -189,7 +189,7 @@ func (ctx *Context) sharesDefault(inst *Instance, fv *FeatureValue) bool { // sharedShape is the shape fv's derived default on inst is shared under, nil when // sharesDefault does not hold. func (ctx *Context) sharedShape(inst *Instance, fv *FeatureValue) *shapeNode { - if !ctx.sharing() || !fv.Feature.Scalar() || fv.Written || fv.BindingDerived || + if fv.Feature == nil || !ctx.sharing() || !fv.Feature.Scalar() || fv.Written || fv.BindingDerived || ctx.behaviorRunDepth != 0 || ctx.defaultYieldsToSubsetters(inst, fv.Feature) { return nil } @@ -265,6 +265,10 @@ func (ctx *Context) observeRead(inst *Instance, fv *FeatureValue) { if !t.clean || t.fv == fv { continue } + if inst == nil || fv.Feature == nil { + t.clean = false + continue + } if !ctx.declaredWithin(inst, t.inst) { t.clean = false continue @@ -309,6 +313,9 @@ func (ctx *Context) sharedRecordOf(inst *Instance, fv *FeatureValue) (*sharedDef // materialize under it: held, at every step, by a declared composite value of an // unclassified object. func (ctx *Context) declaredWithin(inst, root *Instance) bool { + if inst == nil || root == nil { + return inst == root + } for inst != root { if len(inst.classifiers) != 0 { return false @@ -328,6 +335,9 @@ func (ctx *Context) declaredWithin(inst, root *Instance) bool { // singlyWithin reports whether inst is root itself or held under it by single-valued // features at every step, so a path of feature names from root names inst alone. func (ctx *Context) singlyWithin(inst, root *Instance) bool { + if inst == nil || root == nil { + return inst == root + } for inst != root { owner, feature := inst.Owner() if owner == nil { diff --git a/internal/exec/runtime/signal.go b/internal/exec/runtime/signal.go index 8d7d718d62..d3282d790d 100644 --- a/internal/exec/runtime/signal.go +++ b/internal/exec/runtime/signal.go @@ -953,7 +953,10 @@ func (ec *EvalContext) boundTargetAddresses(send lower.Send) ([]messageAddress, if root == thisName { return nil, false, nil } - value, bound := ec.Lookup(root) + value, bound, err := ec.Lookup(root) + if err != nil { + return nil, false, err + } if !bound { return nil, false, nil } diff --git a/internal/exec/runtime/snapshot.go b/internal/exec/runtime/snapshot.go index 2b37f08ba8..f4f55d1fbc 100644 --- a/internal/exec/runtime/snapshot.go +++ b/internal/exec/runtime/snapshot.go @@ -252,6 +252,8 @@ type bodyCapture struct { run *bodyRun saved bodyRun frames []bodyFrame + cells []bodyCellsCapture + locals []mapState[string, Value] } // captureBody captures the run and, into the set, the executors its paused work @@ -260,6 +262,23 @@ func (s *executorCaptures) captureBody(run *bodyRun) *bodyCapture { c := &bodyCapture{run: run, saved: *run} c.saved.work, c.saved.cursor, c.saved.resuming = run.work.clone(), nil, nil for _, f := range run.cursor { + switch f := f.(type) { + case *engineFrame: + if locals := f.engine.env.locals; locals != nil { + c.locals = append(c.locals, captureMap(locals)) + } + if cells := f.engine.env.localCells; cells != nil && !bodyCellsCaptured(c.cells, cells) { + c.cells = append(c.cells, captureBodyCells(cells)) + } + case *blockFrame: + if f.cells != nil && !bodyCellsCaptured(c.cells, f.cells) { + c.cells = append(c.cells, captureBodyCells(f.cells)) + } + case *loopFrame: + if f.cells != nil && !bodyCellsCaptured(c.cells, f.cells) { + c.cells = append(c.cells, captureBodyCells(f.cells)) + } + } c.frames = append(c.frames, f.clone()) switch f := f.(type) { case *calleeFrame: @@ -280,6 +299,12 @@ func (s *executorCaptures) captureBody(run *bodyRun) *bodyCapture { } func (c *bodyCapture) restore() { + for _, locals := range c.locals { + locals.restore() + } + for _, cells := range c.cells { + cells.restore() + } *c.run = c.saved c.run.work = c.saved.work.clone() c.run.cursor = make([]bodyFrame, len(c.frames)) @@ -288,6 +313,15 @@ func (c *bodyCapture) restore() { } } +func bodyCellsCaptured(captures []bodyCellsCapture, cells *bodyCells) bool { + for _, capture := range captures { + if capture.cells == cells { + return true + } + } + return false +} + // Release ends the snapshot: it is no longer restorable, and the journal keeps // nothing for it. Releasing twice does nothing. func (s *Snapshot) Release() { @@ -536,17 +570,25 @@ func (e *ActionExecutor) reachableFrames() []*actionFrame { // frameCapture is one performance's state by value; the maps it holds are // restored in place, so a frame reading one of them as its own sees the restored values. type frameCapture struct { - perf *actionFrame - saved actionFrame - locals []mapState[string, Value] - data mapState[string, Value] + perf *actionFrame + saved actionFrame + locals []mapState[string, Value] + data mapState[string, Value] + cells bodyCellsCapture + localCells []bodyCellsCapture } func captureFrame(perf *actionFrame) frameCapture { - c := frameCapture{perf: perf, saved: *perf, data: captureMap(perf.data)} + c := frameCapture{ + perf: perf, saved: *perf, data: captureMap(perf.data), + cells: captureBodyCells(perf.cells), + } for _, local := range perf.locals { c.locals = append(c.locals, captureMap(local)) } + for _, cells := range perf.localCells { + c.localCells = append(c.localCells, captureBodyCells(cells)) + } c.saved.outer = slices.Clone(perf.outer) c.saved.connections = slices.Clone(perf.connections) c.saved.features = maps.Clone(perf.features) @@ -571,6 +613,13 @@ func (c frameCapture) restore() { perf.locals = append(perf.locals, local.restore()) } perf.data = c.data.restore() + c.cells.restore() + perf.cells = c.cells.cells + perf.localCells = make([]*bodyCells, len(c.localCells)) + for i, cells := range c.localCells { + cells.restore() + perf.localCells[i] = cells.cells + } perf.outer = slices.Clone(c.saved.outer) perf.connections = slices.Clone(c.saved.connections) perf.features = maps.Clone(c.saved.features) @@ -651,6 +700,8 @@ type stateCapture struct { events eventHeap stateData mapState[string, Value] stateAttrs map[*ast.StateNode]mapState[string, Value] + stateCells bodyCellsCapture + stateAttrCells map[*ast.StateNode]bodyCellsCapture stateVisits []string stateStack []*ast.StateNode fired []FiredTransition @@ -708,6 +759,8 @@ func (e *StateExecutor) capture() stateCapture { nextEventID: e.nextEventID, stateData: captureMap(e.stateData), stateAttrs: make(map[*ast.StateNode]mapState[string, Value], len(e.stateAttrs)), + stateCells: captureBodyCells(e.stateCells), + stateAttrCells: make(map[*ast.StateNode]bodyCellsCapture, len(e.stateAttrCells)), stateVisits: slices.Clone(e.stateVisits), stateStack: slices.Clone(e.stateStack), fired: slices.Clone(e.fired), @@ -742,6 +795,9 @@ func (e *StateExecutor) capture() stateCapture { for node, attrs := range e.stateAttrs { c.stateAttrs[node] = captureMap(attrs) } + for node, cells := range e.stateAttrCells { + c.stateAttrCells[node] = captureBodyCells(cells) + } for node, record := range e.history { c.history[node] = historyRecord{child: record.child, regions: maps.Clone(record.regions)} } @@ -758,12 +814,19 @@ func (c stateCapture) restore() { e.eventQueue.events = slices.Clone(c.events) } e.stateData = c.stateData.restore() + c.stateCells.restore() + e.stateCells = c.stateCells.cells if e.stateAttrs != nil { clear(e.stateAttrs) for node, attrs := range c.stateAttrs { e.stateAttrs[node] = attrs.restore() } } + e.stateAttrCells = make(map[*ast.StateNode]*bodyCells, len(c.stateAttrCells)) + for node, cells := range c.stateAttrCells { + cells.restore() + e.stateAttrCells[node] = cells.cells + } e.stateVisits, e.stateStack = slices.Clone(c.stateVisits), slices.Clone(c.stateStack) e.fired, e.firedBase = slices.Clone(c.fired), c.firedBase e.breakpointHit, e.pausedAt, e.completionDue = c.breakpointHit, c.pausedAt, c.completionDue diff --git a/internal/exec/runtime/state_executor.go b/internal/exec/runtime/state_executor.go index 30c28efb84..bd1cc011ab 100644 --- a/internal/exec/runtime/state_executor.go +++ b/internal/exec/runtime/state_executor.go @@ -53,11 +53,13 @@ type StateExecutor struct { // timer set for later is this machine's wait on the clock. eventQueue *EventQueue stateData map[string]Value // State machine local variables + stateCells *bodyCells // stateAttrs holds the attributes each state owns, one map per state node, so // two usages of one state definition keep separate values. - stateAttrs map[*ast.StateNode]map[string]Value - stateVisits []string // Ordered list of visited state names - stateStack []*ast.StateNode // Active state configuration (for nested states) + stateAttrs map[*ast.StateNode]map[string]Value + stateAttrCells map[*ast.StateNode]*bodyCells + stateVisits []string // Ordered list of visited state names + stateStack []*ast.StateNode // Active state configuration (for nested states) // history records, per composite state, the configuration that state had when // it was last exited. A history pseudostate re-enters that configuration @@ -251,6 +253,9 @@ func newStateExecutorForOccurrence( if err := exec.initializeStateAttributes(); err != nil { return nil, err } + if err := ctx.deriveBodyCells(exec.stateCells); err != nil { + return nil, fmt.Errorf("derive state machine attributes: %w", err) + } ctx.clock.attach(exec) return exec, nil @@ -275,6 +280,7 @@ func newStateExecutorOn( eventQueue: NewEventQueue(), stateData: make(map[string]Value), stateAttrs: make(map[*ast.StateNode]map[string]Value), + stateAttrCells: make(map[*ast.StateNode]*bodyCells), stateVisits: make([]string, 0), stateStack: make([]*ast.StateNode, 0), history: make(map[*ast.StateNode]*historyRecord), @@ -290,6 +296,25 @@ func newStateExecutorOn( entering: make(map[*ast.StateNode]bool), } exec.driven.exec = exec + for _, attr := range graph.Attributes { + if !attr.Binding || attr.Value == nil { + continue + } + scope := attr.Scope + if scope == nil { + scope = graph.Scope + } + var cell *bodyCell + onDerived := func(value *Value) error { + mirrored, err := exec.mirrorBindingOccurrence(attr.Name, *value, cell) + if err != nil { + return err + } + *value = mirrored + return nil + } + cell = ctx.registerBodyBinding(exec.ensureStateCells(), attr.Name, attr.Value, scope, nil, onDerived) + } return exec } @@ -323,8 +348,9 @@ func (e *StateExecutor) initializeAttributes() error { return fmt.Errorf("%w: read %s of object #%d: %w", ErrStatePerformanceOccurrence, attr.Name, e.occurrence.ID, err) } - if value := fv.HeldValue(); value.Kind != ValInvalid { - e.stateData[attr.Name] = value + if value := fv.HeldValue(); value.Kind != ValInvalid && + (!attr.Binding || fv.Written || occurrenceHasRedefinedDefault(fv, attr, e.graph.Scope)) { + e.ctx.writeBodyValue(e.stateCells, e.stateData, attr.Name, value) continue } } @@ -334,6 +360,9 @@ func (e *StateExecutor) initializeAttributes() error { } continue } + if attr.Binding { + continue + } value, err := evalDefaults().evalIn(attr.Scope).Eval(attr.Value) if err != nil { return fmt.Errorf("eval attribute default %s: %w", attr.Name, err) @@ -341,7 +370,7 @@ func (e *StateExecutor) initializeAttributes() error { if value, err = e.mirrorOccurrence(attr.Name, value); err != nil { return err } - e.stateData[attr.Name] = value + e.ctx.writeBodyValue(e.stateCells, e.stateData, attr.Name, value) } return nil @@ -370,14 +399,39 @@ func (e *StateExecutor) bindContextDefault(attr lower.Attribute) bool { if err != nil { return false } - e.stateData[attr.Name] = value + e.ctx.writeBodyValue(e.stateCells, e.stateData, attr.Name, value) return true } // dataFrame is the machine's data as a run frame of the machine, so `Ctl::context` // written in a member the machine owns reads the binding the running machine gave it. func (e *StateExecutor) dataFrame() frame { - return frame{vars: e.stateData, performed: e.stateMachine} + return frame{ + vars: e.stateData, cells: e.stateCells, performed: e.stateMachine, + ensureCells: e.ensureStateCells, + } +} + +func (e *StateExecutor) ensureStateCells() *bodyCells { + if e.stateCells == nil { + e.stateCells = newBodyCells(e.stateData, func(scope *symbols.Scope) *EvalContext { + ec := NewEvalContextIn(e.ctx, scope, e.self) + ec.thisOccurrence = e.materializeOccurrence + ec.pushFrame(e.dataFrame()) + return ec + }) + } + return e.stateCells +} + +func (e *StateExecutor) stateAttributeContext(state *ast.StateNode, scope *symbols.Scope) *EvalContext { + ec := NewEvalContextIn(e.ctx, scope, e.self) + ec.thisOccurrence = e.materializeOccurrence + ec.pushFrame(e.dataFrame()) + for _, fr := range e.attrFramesFor(state) { + ec.pushFrame(fr) + } + return ec } // initializeStateAttributes gives every state that owns attributes its own @@ -389,6 +443,22 @@ func (e *StateExecutor) initializeStateAttributes() error { } data := make(map[string]Value, len(attrs)) e.stateAttrs[state] = data + var cells *bodyCells + for _, attr := range attrs { + if attr.Binding && attr.Value != nil { + if cells == nil { + cells = newBodyCells(data, func(scope *symbols.Scope) *EvalContext { + return e.stateAttributeContext(state, scope) + }) + e.stateAttrCells[state] = cells + } + scope := attr.Scope + if scope == nil { + scope = e.graph.Scope + } + e.ctx.registerBodyBinding(cells, attr.Name, attr.Value, scope, nil, nil) + } + } for _, attr := range attrs { if attr.Value == nil { continue @@ -397,17 +467,25 @@ func (e *StateExecutor) initializeStateAttributes() error { if scope == nil { scope = e.graph.Scope } - ec := NewEvalContextIn(e.ctx, scope, e.self) - ec.occurrence = e.occurrence - ec.thisOccurrence = e.materializeOccurrence - ec.pushFrame(e.dataFrame()) - end := ec.beginStep() - value, err := ec.Eval(attr.Value) - end() + var value Value + var err error + if attr.Binding { + value, err = e.ctx.deriveBodyCell(cells, attr.Name, cells.existingCell(attr.Name)) + } else { + ec := NewEvalContextIn(e.ctx, scope, e.self) + ec.occurrence = e.occurrence + ec.thisOccurrence = e.materializeOccurrence + ec.pushFrame(e.dataFrame()) + end := ec.beginStep() + value, err = ec.Eval(attr.Value) + end() + } if err != nil { return fmt.Errorf("eval attribute default %s of state %s: %w", attr.Name, state.Name, err) } - data[attr.Name] = value + if !attr.Binding { + e.ctx.writeBodyValue(cells, data, attr.Name, value) + } } } return nil @@ -415,15 +493,15 @@ func (e *StateExecutor) initializeStateAttributes() error { // attrFramesFor are the attribute values a behavior of state reads, outermost // state first so an inner state's attribute shadows an enclosing one's. -func (e *StateExecutor) attrFramesFor(state *ast.StateNode) []map[string]Value { +func (e *StateExecutor) attrFramesFor(state *ast.StateNode) []frame { if state == nil || len(e.stateAttrs) == 0 { return nil } - var frames []map[string]Value + var frames []frame chain := e.getParentChain(state) for i := len(chain) - 1; i >= 0; i-- { if data := e.stateAttrs[chain[i]]; data != nil { - frames = append(frames, data) + frames = append(frames, frame{vars: data, cells: e.stateAttrCells[chain[i]]}) } } return frames @@ -432,9 +510,9 @@ func (e *StateExecutor) attrFramesFor(state *ast.StateNode) []map[string]Value { // stateAttributeValues is the value map of the innermost state at or enclosing // state that owns an attribute of this name, with the scope that attribute is // declared in, so a write to it answers to its declaration. -func (e *StateExecutor) stateAttributeValues(state *ast.StateNode, name string) (map[string]Value, *symbols.Scope, bool) { +func (e *StateExecutor) stateAttributeValues(state *ast.StateNode, name string) (map[string]Value, *symbols.Scope, *bodyCells, bool) { if state == nil { - return nil, nil, false + return nil, nil, nil, false } for _, ancestor := range e.getParentChain(state) { data, ok := e.stateAttrs[ancestor] @@ -449,10 +527,10 @@ func (e *StateExecutor) stateAttributeValues(state *ast.StateNode, name string) if scope == nil { scope = e.graph.Scope } - return data, scope, true + return data, scope, e.stateAttrCells[ancestor], true } } - return nil, nil, false + return nil, nil, nil, false } func (e *StateExecutor) declaresAttribute(name string) bool { @@ -479,15 +557,22 @@ func (e *StateExecutor) materializeOccurrence() (*Instance, error) { if err != nil { return nil, err } + e.occurrence = inst for _, attr := range e.graph.Attributes { if value, held := e.stateData[attr.Name]; held { + if cell := e.stateCells.existingCell(attr.Name); cell != nil && cell.binding != nil && !cell.fv.Written { + if _, err := e.mirrorBindingOccurrence(attr.Name, value, cell); err != nil { + return nil, fmt.Errorf("%w: seed %s of object #%d: %w", + ErrStatePerformanceOccurrence, attr.Name, inst.ID, err) + } + continue + } if err := inst.BindFeatureValue(e.ctx, attr.Name, value); err != nil { return nil, fmt.Errorf("%w: seed %s of object #%d: %w", ErrStatePerformanceOccurrence, attr.Name, inst.ID, err) } } } - e.occurrence = inst e.ctx.beginPerformanceLife(inst, e.ctx.newActivation()) return inst, nil } @@ -511,6 +596,18 @@ func (e *StateExecutor) mirrorOccurrence(name string, value Value) (Value, error return fv.HeldValue(), nil } +func (e *StateExecutor) mirrorBindingOccurrence(name string, value Value, cell *bodyCell) (Value, error) { + if e.occurrence == nil || !e.declaresAttribute(name) { + return value, nil + } + mirrored, err := e.ctx.mirrorBodyCell(e.occurrence, name, cell, value) + if err != nil { + return value, fmt.Errorf("%w: write %s of object #%d: %w", + ErrStatePerformanceOccurrence, name, e.occurrence.ID, err) + } + return mirrored, nil +} + func (e *StateExecutor) assignAttribute(name string, value Value) error { if e.occurrence != nil { if err := e.occurrence.SetFeatureValue(e.ctx, name, value); err != nil { @@ -534,7 +631,7 @@ func (e *StateExecutor) assignAttribute(name string, value Value) error { return err } } - e.stateData[name] = value + e.ctx.writeBodyValue(e.stateCells, e.stateData, name, value) return nil } @@ -573,7 +670,7 @@ func (e *StateExecutor) evalStepWithin(owner ast.Node, f *firing, node ast.Node, ec.pushFrame(data) if state, ok := owner.(*ast.StateNode); ok { for _, frame := range e.attrFramesFor(state) { - ec.Push(frame) + ec.pushFrame(frame) } } defer ec.beginStep()() @@ -2079,17 +2176,20 @@ func orAnonymousSignal(signalType string) string { func (e *StateExecutor) restoreSharedData(names []string) func() { saved := make(map[string]Value, len(names)) held := make(map[string]bool, len(names)) + cells := make(map[string]bodyCellState, len(names)) for _, name := range names { value, ok := e.stateData[name] saved[name], held[name] = value, ok + cells[name] = bodyCellStateOf(e.stateCells, name) } return func() { for name, wasHeld := range held { if wasHeld { - e.stateData[name] = saved[name] + e.ctx.writeBodyValue(e.stateCells, e.stateData, name, saved[name]) } else { - delete(e.stateData, name) + e.ctx.clearBodyValue(e.stateCells, e.stateData, name) } + e.ctx.restoreBodyCellState(e.stateCells, name, cells[name]) } } } @@ -2355,6 +2455,9 @@ func (e *StateExecutor) completeMachine() error { if err := e.exitMachine(); err != nil { return err } + if err := e.ctx.freezeBodyCells(e.stateCells); err != nil { + return err + } e.state = StateCompleted e.ctx.endPerformanceLife(e.occurrence) return nil @@ -2364,6 +2467,9 @@ func (e *StateExecutor) completeMachine() error { // transition reached (SysML v2 §7.18.3): no state is exited and no exit behavior // runs; the do behaviors under way are abandoned, and no state stays active. func (e *StateExecutor) terminateMachine(fromName string, trigger ast.Node, stop *ast.Usage) error { + if err := e.ctx.freezeBodyCells(e.stateCells); err != nil { + return err + } name, _ := ast.EffectiveName(stop) if e.trace() != nil { e.trace().RecordStateTransition(e.traceOrigin(), fromName, name, triggerName(trigger)) @@ -5301,6 +5407,10 @@ func (e *StateExecutor) exitState(state *ast.StateNode) error { if err := e.executeBehaviors(e.behaviorsOf(state).Exit); err != nil { return fmt.Errorf("exit action: %w", err) } + if err := e.ctx.freezeBodyCells(e.stateAttrCells[state]); err != nil { + return fmt.Errorf("freeze state %s attributes: %w", state.Name, err) + } + e.ctx.exitBodyCells(e.stateAttrCells[state]) // Clear simple state e.activeConfig.simpleState = nil @@ -5336,6 +5446,9 @@ func (e *StateExecutor) exitRegionsBelow(owner *ast.StateNode, regions []*ast.St // passing state data in through the callee's input parameters and merging its // output parameters back into state data. func (e *StateExecutor) invokeNested(inv actionInvocation) error { + if err := e.ctx.deriveBodyCells(e.stateCells); err != nil { + return err + } _, outputs, err := invokeAction(e.ctx, e.stateMachine.Scope, inv, e.stateData, e.self) if err != nil { return err @@ -5356,7 +5469,7 @@ func (e *StateExecutor) writeStateValue(name string, value Value) error { if e.declaresAttribute(name) { return e.assignAttribute(name, value) } - e.stateData[name] = value + e.ctx.writeBodyValue(e.stateCells, e.stateData, name, value) return nil } @@ -5470,17 +5583,44 @@ func (e *StateExecutor) StateStack() []*ast.StateNode { // attributes each state owns under that state's path (`nested.hits`), which two // usages of one state definition hold separately. func (e *StateExecutor) StateData() map[string]Value { - data := make(map[string]Value, len(e.stateData)) - for k, v := range e.stateData { - data[k] = v + data, _ := e.StateDataWithError() + return data +} + +// StateDataWithError returns state data after deriving current tracking cells. +// A value whose inactive-state reference cannot be read is omitted and reported. +func (e *StateExecutor) StateDataWithError() (map[string]Value, error) { + var deriveErr error + if err := e.ctx.deriveBodyCells(e.stateCells); err != nil { + deriveErr = err } + data := copyStateValues(e.stateData, e.stateCells, "") for state, attrs := range e.stateAttrs { - prefix := e.statePath(state) + "." - for name, value := range attrs { - data[prefix+name] = value + cells := e.stateAttrCells[state] + if err := e.ctx.deriveBodyCells(cells); err != nil && deriveErr == nil { + deriveErr = err } + data = copyStateValuesInto(data, attrs, cells, e.statePath(state)+".") } - return data + return data, deriveErr +} + +func copyStateValues(values map[string]Value, cells *bodyCells, prefix string) map[string]Value { + return copyStateValuesInto(make(map[string]Value, len(values)), values, cells, prefix) +} + +func copyStateValuesInto(target, values map[string]Value, cells *bodyCells, prefix string) map[string]Value { + for name, value := range values { + target[prefix+name] = value + } + if cells != nil { + for name, cell := range cells.cells { + if cell.binding != nil && !cell.fv.Written && !cell.binding.frozen && !cell.fv.Materialized { + delete(target, prefix+name) + } + } + } + return target } // statePath is a state's name qualified by the states enclosing it. diff --git a/internal/exec/runtime/state_space.go b/internal/exec/runtime/state_space.go index 3a47bf981f..6c14970236 100644 --- a/internal/exec/runtime/state_space.go +++ b/internal/exec/runtime/state_space.go @@ -132,7 +132,10 @@ func (e *ActionExecutor) initializeDynamics() error { func (e *ActionExecutor) readStep(run *stateSpaceRun) error { dyn := run.dyn what := fmt.Sprintf("%s of action %s", lower.TimeStepFeature, symbolText(e.action)) - step, ok := e.root.data[e.root.key(lower.TimeStepFeature)] + step, ok, err := e.ctx.readBodyValue(e.root.cells, e.root.data, e.root.key(lower.TimeStepFeature)) + if err != nil { + return fmt.Errorf("%w: %s: %w", ErrStateSpaceStep, what, err) + } if dyn.TimeStep == nil || !ok { return fmt.Errorf("%w: action %s states no %s; specialize %s or declare one", ErrStateSpaceStep, symbolText(e.action), lower.TimeStepFeature, lower.FixedStepDynamicsFQN) @@ -165,7 +168,11 @@ func (e *ActionExecutor) readStep(run *stateSpaceRun) error { // stateVector is the state the action holds, checked to be a vector. func (e *ActionExecutor) stateVector() (Value, error) { - state, ok := e.root.data[e.root.key(lower.StateSpaceFeature)] + state, ok, err := e.ctx.readBodyValue(e.root.cells, e.root.data, e.root.key(lower.StateSpaceFeature)) + if err != nil { + return Value{}, fmt.Errorf("%w: action %s: read %s: %w", + ErrStateSpaceValue, symbolText(e.action), lower.StateSpaceFeature, err) + } if !ok || state.Kind == ValNull { return Value{}, fmt.Errorf("%w: action %s binds no %s; give it an initial value", ErrStateSpaceValue, symbolText(e.action), lower.StateSpaceFeature) @@ -184,7 +191,11 @@ func (e *ActionExecutor) dynamicsSteps() int { // inputVector is the input the action holds; nil when it binds none, so a calc // that does not need one runs and one that does reports its parameter unbound. func (e *ActionExecutor) inputVector() (*Value, error) { - input, ok := e.root.data[e.root.key(lower.InputFeature)] + input, ok, err := e.ctx.readBodyValue(e.root.cells, e.root.data, e.root.key(lower.InputFeature)) + if err != nil { + return nil, fmt.Errorf("%w: action %s: read %s: %w", + ErrStateSpaceValue, symbolText(e.action), lower.InputFeature, err) + } if !ok || input.Kind == ValNull { return nil, nil } diff --git a/internal/exec/runtime/state_statements.go b/internal/exec/runtime/state_statements.go index 1c79a19269..233feda427 100644 --- a/internal/exec/runtime/state_statements.go +++ b/internal/exec/runtime/state_statements.go @@ -22,7 +22,7 @@ type stateStmtHost struct { perfs *performances // attrs are the attributes of the state the behavior belongs to and of the // states enclosing it, innermost first. - attrs []map[string]Value + attrs []frame // firing is the transition the behavior reads as being taken: the one under way // for an entry, exit or effect, the one that entered the state for a do behavior. firing *firing @@ -145,7 +145,9 @@ func (e *StateExecutor) firingOf(t *lower.Transition) *firing { // name — `Track::context` inside a behavior of a state of Track's — finds its value // here. func (h *stateStmtHost) dataFrame() frame { - return frame{vars: h.exec.stateData, performed: h.exec.stateMachine, firing: h.firing} + fr := h.exec.dataFrame() + fr.firing = h.firing + return fr } // run executes the behavior's statements; a do behavior's pause where they wait @@ -298,7 +300,7 @@ func (run *doRun) visibleArmedWaits() []ClockWait { // rootFrame is the performance of the behavior itself: it holds no feature of its // own, and reads the machine's data and the enclosing states' attributes around it. -func (h *stateStmtHost) rootFrame(attrs []map[string]Value) *actionFrame { +func (h *stateStmtHost) rootFrame(attrs []frame) *actionFrame { root := &actionFrame{ scope: h.behavior.Scope, connections: h.exec.graph.Connections, @@ -314,9 +316,7 @@ func (h *stateStmtHost) rootFrame(attrs []map[string]Value) *actionFrame { if root.scope == nil { root.scope = h.exec.stateMachine.Scope } - for _, attr := range attrs { - root.outer = append(root.outer, mapFrame(attr)) - } + root.outer = append(root.outer, attrs...) return root } @@ -391,14 +391,14 @@ func (h *stateStmtHost) assignForeign(ec *EvalContext, s lower.Assign, value Val // behavior, or by one enclosing it, and reports whether it did. The value // answers to the attribute's declaration as every other write does. func (h *stateStmtHost) assignStateAttribute(name string, value Value) (bool, error) { - data, scope, ok := h.exec.stateAttributeValues(h.behavior.Owner, name) + data, scope, cells, ok := h.exec.stateAttributeValues(h.behavior.Owner, name) if !ok { return false, nil } if err := h.exec.ctx.checkNamedWrite(scope, h.describe(), name, &value); err != nil { return true, err } - data[name] = value + h.exec.ctx.writeBodyValue(cells, data, name, value) return true, nil } @@ -482,6 +482,7 @@ func (h *stateStmtHost) runFlow(block lower.Block) (stmtFlow, error) { root.scope = block.Graph.Scope } h.flow.declareRootFeatures(root) + h.flow.registerRootBindings(root) h.flow.declareAcceptPayloads(root) if err := h.flow.initializeAttributes(); err != nil { return flowNext, fmt.Errorf("%s: %w", h.describe(), err) @@ -496,7 +497,7 @@ func (h *stateStmtHost) setFeature(name string, value Value) error { if err := h.exec.ctx.checkNamedWrite(root.scope, h.describe(), name, &value); err != nil { return err } - root.data[root.key(name)] = value + h.exec.ctx.writeBodyValue(root.cells, root.data, root.key(name), value) return nil } if written, err := h.assignAround(name, value); written || err != nil { @@ -515,7 +516,7 @@ func (h *stateStmtHost) assignAround(name string, value Value) (bool, error) { return true, h.exec.assignAttribute(name, value) } if _, ok := h.exec.stateData[name]; ok { - h.exec.stateData[name] = value + h.exec.ctx.writeBodyValue(h.exec.stateCells, h.exec.stateData, name, value) return true, nil } return assignPerformerFeature(h.exec.ctx, h.exec.self, h.behavior.Scope, name, value) @@ -524,7 +525,7 @@ func (h *stateStmtHost) assignAround(name string, value Value) (bool, error) { // returnAround writes a returned output as assignAround does and keeps it for the // caller when a call event's transition is firing. func (h *stateStmtHost) returnAround(name string, value Value) (bool, error) { - if _, scope, ok := h.exec.stateAttributeValues(h.behavior.Owner, name); ok { + if _, scope, _, ok := h.exec.stateAttributeValues(h.behavior.Owner, name); ok { what := func() string { return fmt.Sprintf("%s: output %s returned", h.describe(), name) } if err := h.exec.ctx.checkMutable(scope, what, name); err != nil { return true, err diff --git a/internal/exec/runtime/state_unit_front.go b/internal/exec/runtime/state_unit_front.go index 63cd57ef79..547ea3234c 100644 --- a/internal/exec/runtime/state_unit_front.go +++ b/internal/exec/runtime/state_unit_front.go @@ -180,6 +180,7 @@ type firingScope struct { type dataSlot struct { value Value held bool + cell bodyCellState } // firing captures the firing the executor is in the middle of. @@ -203,8 +204,10 @@ func (e *StateExecutor) setFiring(s *firingScope) { func (s *firingScope) install(e *StateExecutor) { for name, value := range s.bound { prior, held := e.stateData[name] - s.shadowed[name] = dataSlot{value: prior, held: held} - e.stateData[name] = value + s.shadowed[name] = dataSlot{ + value: prior, held: held, cell: bodyCellStateOf(e.stateCells, name), + } + e.ctx.writeBodyValue(e.stateCells, e.stateData, name, value) } } @@ -218,11 +221,13 @@ func (s *firingScope) uninstall(e *StateExecutor) { // restore puts back what the data held under the firing's binding of name. func (s *firingScope) restore(e *StateExecutor, name string) { - if slot := s.shadowed[name]; slot.held { - e.stateData[name] = slot.value + slot := s.shadowed[name] + if slot.held { + e.ctx.writeBodyValue(e.stateCells, e.stateData, name, slot.value) } else { - delete(e.stateData, name) + e.ctx.clearBodyValue(e.stateCells, e.stateData, name) } + e.ctx.restoreBodyCellState(e.stateCells, name, slot.cell) } // runningQueue is the queue whose units are running, nil outside a front. @@ -235,7 +240,7 @@ func (e *StateExecutor) runningQueue() *unitQueue { // bindData binds one of the machine's data for the firing under way to read. func (e *StateExecutor) bindData(name string, value Value) { - e.stateData[name] = value + e.ctx.writeBodyValue(e.stateCells, e.stateData, name, value) if q := e.runningQueue(); q != nil { q.firing.bound[name] = value } @@ -254,7 +259,9 @@ func (e *StateExecutor) restoreData(names []string) func() { continue } prior, held := e.stateData[name] - s.shadowed[name] = dataSlot{value: prior, held: held} + s.shadowed[name] = dataSlot{ + value: prior, held: held, cell: bodyCellStateOf(e.stateCells, name), + } s.bound[name] = prior } return func() { diff --git a/internal/exec/runtime/statements.go b/internal/exec/runtime/statements.go index db1ee696e6..e5d1f46485 100644 --- a/internal/exec/runtime/statements.go +++ b/internal/exec/runtime/statements.go @@ -1,6 +1,7 @@ package runtime import ( + "errors" "fmt" "maps" "slices" @@ -11,9 +12,8 @@ import ( "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) -// stmtEnv is the environment a body's statements execute in: the behavior's own -// data, plus one frame per body-local block entered. A frame is discarded when -// its block exits, so a name it declares never leaks outward. +// stmtEnv is the environment a body's statements execute in: behavior data, root +// locals, and one frame per body-local block entered. type stmtEnv struct { data frame // enclosing are the frames the behavior's data shadows but its statements @@ -21,27 +21,46 @@ type stmtEnv struct { enclosing []frame // outer are value maps the body reads but does not declare into, innermost // last: the attributes the states enclosing the behavior own. - outer []map[string]Value + outer []frame // perf is the performance the body runs as where data is not its frame — a state // behavior's — whose nodes a read of `p.v` names. nil where data is the frame. - perf *actionFrame - frames []map[string]Value + perf *actionFrame + locals map[string]Value + localCells *bodyCells + unvaluedLocal map[string]bool + frames []map[string]Value + cells []*bodyCells // unvalued names, per frame, the features a frame declares but holds no value // for yet (`out v : Integer;`), which an assignment writes into that frame. unvalued []map[string]bool } // enter pushes a frame for a block about to run and returns it. -func (env *stmtEnv) enter() map[string]Value { +func (env *stmtEnv) enter(e *stmtEngine) map[string]Value { frame := make(map[string]Value) env.frames = append(env.frames, frame) + env.cells = append(env.cells, nil) env.unvalued = append(env.unvalued, nil) return frame } +func (e *stmtEngine) ensureLocalCells(index int) *bodyCells { + if e.env.cells[index] == nil { + depth := index + 1 + e.env.cells[index] = newBodyCells(e.env.frames[index], func(scope *symbols.Scope) *EvalContext { + return e.evalInDepth(scope, depth) + }) + } + return e.env.cells[index] +} + // leave discards the frame the innermost entered block declares into. -func (env *stmtEnv) leave() { +func (env *stmtEnv) leave(ctx *Context, forget bool) { if len(env.frames) > 0 { + if forget { + ctx.forgetBodyCells(env.cells[len(env.cells)-1]) + } + env.cells = env.cells[:len(env.cells)-1] env.frames = env.frames[:len(env.frames)-1] env.unvalued = env.unvalued[:len(env.unvalued)-1] } @@ -51,6 +70,10 @@ func (env *stmtEnv) leave() { func (env *stmtEnv) declareUnvalued(name string) { depth := len(env.frames) if depth == 0 { + if env.unvaluedLocal == nil { + env.unvaluedLocal = make(map[string]bool) + } + env.unvaluedLocal[name] = true return } if env.unvalued[depth-1] == nil { @@ -69,12 +92,60 @@ func (env *stmtEnv) frameDeclares(i int, name string) bool { // declare binds a name the innermost entered block declares, or a name of the // behavior's own data when no block is entered. -func (env *stmtEnv) declare(name string, value Value) { +func (env *stmtEnv) declare(ctx *Context, name string, value Value) { if depth := len(env.frames); depth > 0 { - env.frames[depth-1][name] = value + if cells := env.cells[depth-1]; cells != nil { + ctx.writeBodyCell(cells, name, value) + } else { + env.frames[depth-1][name] = value + } return } - env.data.set(name, value) + if env.localCells != nil { + ctx.writeBodyCell(env.localCells, name, value) + } else { + env.locals[name] = value + } +} + +func (e *stmtEngine) ensureRootCells() *bodyCells { + if e.env.localCells == nil { + e.env.localCells = newBodyCells(e.env.locals, func(scope *symbols.Scope) *EvalContext { + return e.evalInDepth(scope, 0) + }) + } + return e.env.localCells +} + +func (env *stmtEnv) rootDeclares(name string) bool { + if _, ok := env.locals[name]; ok { + return true + } + return env.unvaluedLocal[name] +} + +func (env *stmtEnv) rootLocal(name string) bool { + if !env.rootDeclares(name) { + return false + } + for i := len(env.frames) - 1; i >= 0; i-- { + if env.frameDeclares(i, name) { + return false + } + } + return true +} + +func (env *stmtEnv) assignRootLocal(ctx *Context, name string, value Value) bool { + if !env.rootDeclares(name) { + return false + } + if env.localCells != nil { + ctx.writeBodyCell(env.localCells, name, value) + } else { + env.locals[name] = value + } + return true } // holdsLocal reports whether an entered block declares name. @@ -84,45 +155,82 @@ func (env *stmtEnv) holdsLocal(name string) bool { return true } } - return false + return env.rootDeclares(name) } -// assignLocal writes to the innermost entered block that declares name and -// reports whether one did: a block-local declaration shadows a name of the -// behavior's own, including one of its output features. -func (env *stmtEnv) assignLocal(name string, value Value) bool { +// assignLocal writes to the innermost local declaration of name and reports +// whether one did: a local shadows a name of the behavior's own. +func (env *stmtEnv) assignLocal(ctx *Context, name string, value Value) bool { for i := len(env.frames) - 1; i >= 0; i-- { if env.frameDeclares(i, name) { - env.frames[i][name] = value + if cells := env.cells[i]; cells != nil { + ctx.writeBodyCell(cells, name, value) + } else { + env.frames[i][name] = value + } return true } } + if env.rootDeclares(name) { + if env.localCells != nil { + ctx.writeBodyCell(env.localCells, name, value) + } else { + env.locals[name] = value + } + return true + } return false } -// values is the values a statement reads: the enclosing frames, overridden by the -// behavior's own, overridden by the blocks entered around it, innermost last. -func (env *stmtEnv) values() map[string]Value { +// values is the values a statement reads, with locals overriding behavior data. +func (env *stmtEnv) values(ctx *Context) (map[string]Value, error) { merged := make(map[string]Value, env.data.width()) for _, f := range env.enclosing { - f.each(func(name string, value Value) { merged[name] = value }) + if err := f.eachCurrent(ctx, func(name string, value Value) { merged[name] = value }); err != nil { + return nil, err + } } - env.data.each(func(name string, value Value) { merged[name] = value }) - for _, frame := range env.frames { + if err := env.data.eachCurrent(ctx, func(name string, value Value) { merged[name] = value }); err != nil { + return nil, err + } + if err := ctx.deriveBodyCells(env.localCells); err != nil { + return nil, err + } + maps.Copy(merged, env.locals) + for i, frame := range env.frames { + if err := ctx.deriveBodyCells(env.cells[i]); err != nil { + return nil, err + } maps.Copy(merged, frame) } - return merged + return merged, nil +} + +func (env *stmtEnv) localFrames() ([]map[string]Value, []*bodyCells) { + locals := make([]map[string]Value, 0, len(env.frames)+1) + cells := make([]*bodyCells, 0, len(env.frames)+1) + locals = append(locals, env.locals) + cells = append(cells, env.localCells) + locals = append(locals, env.frames...) + cells = append(cells, env.cells...) + return locals, cells +} + +func (env *stmtEnv) localFrame() frame { + local := mapFrame(env.locals) + local.cells = env.localCells + return local } // assign writes to the innermost entered block that declares name, or to the // behavior's data when that holds the name, and reports whether it found one. // A name neither declares is the host's to decide on. -func (env *stmtEnv) assign(name string, value Value) bool { - if env.assignLocal(name, value) { +func (env *stmtEnv) assign(ctx *Context, name string, value Value) bool { + if env.assignLocal(ctx, name, value) { return true } if env.data.has(name) { - env.data.set(name, value) + env.data.setBody(ctx, name, value) return true } return false @@ -137,6 +245,14 @@ const ( flowReturn ) +type bodyBindingMirror interface { + mirrorBodyBinding(name string, cell *bodyCell, value *Value) error +} + +type rootLocalMirror interface { + mirrorRootLocal(name string, value Value) error +} + // stmtHost is the behavior a statement engine runs statements for: it names // itself in diagnostics and decides the statements only it can state — sends, // returns, assignments reaching outside the body, effects. @@ -205,11 +321,11 @@ type stmtEngine struct { // newStmtEngineOver returns an engine running statements against data, which also // read outer value maps — the attributes of the enclosing states — innermost last. -func newStmtEngineOver(ctx *Context, host stmtHost, data frame, outer []map[string]Value) *stmtEngine { +func newStmtEngineOver(ctx *Context, host stmtHost, data frame, outer []frame) *stmtEngine { return &stmtEngine{ ctx: ctx, host: host, - env: &stmtEnv{data: data, outer: outer}, + env: &stmtEnv{data: data, outer: outer, locals: make(map[string]Value)}, activation: ctx.newActivation(), thisOccurrence: host.materializeOccurrence, } @@ -221,7 +337,7 @@ func newStmtEngineIn(ctx *Context, host stmtHost, data frame, enclosing []frame) return &stmtEngine{ ctx: ctx, host: host, - env: &stmtEnv{data: data, enclosing: enclosing}, + env: &stmtEnv{data: data, enclosing: enclosing, locals: make(map[string]Value)}, activation: ctx.newActivation(), thisOccurrence: host.materializeOccurrence, } @@ -230,24 +346,39 @@ func newStmtEngineIn(ctx *Context, host stmtHost, data frame, enclosing []frame) // finish ends the activation the engine's statements ran in, discarding what the // calc usages read in them computed. func (e *stmtEngine) finish() { + e.ctx.forgetBodyCells(e.env.localCells) e.ctx.endActivation(e.activation) } +func (e *stmtEngine) complete() error { + err := e.ctx.freezeBodyCells(e.env.localCells) + e.ctx.endActivation(e.activation) + return err +} + // evalIn returns an evaluation context resolving names in the scope the // statement was written in, reading the behavior's data and the frames entered, // innermost last so a block-local name shadows an outer one. func (e *stmtEngine) evalIn(scope *symbols.Scope) *EvalContext { frames := append(e.frameBuf[:0], e.env.enclosing...) frames = append(frames, e.env.data) - for _, outer := range e.env.outer { - frames = append(frames, mapFrame(outer)) - } + frames = append(frames, e.env.outer...) if e.env.perf != nil { frames = append(frames, performanceFrame(e.env.perf)) } + root := mapFrame(e.env.locals) + root.cells = e.env.localCells + root.ensureCells = func() *bodyCells { return e.ensureRootCells() } + frames = append(frames, root) for _, local := range e.env.frames { frames = append(frames, mapFrame(local)) } + for i := range e.env.frames { + fr := len(frames) - len(e.env.frames) + i + frames[fr].cells = e.env.cells[i] + index := i + frames[fr].ensureCells = func() *bodyCells { return e.ensureLocalCells(index) } + } e.frameBuf = frames ec := &e.scratch *ec = EvalContext{ @@ -264,6 +395,35 @@ func (e *stmtEngine) evalIn(scope *symbols.Scope) *EvalContext { return ec } +func (e *stmtEngine) evalInDepth(scope *symbols.Scope, depth int) *EvalContext { + frames := make([]frame, 0, len(e.env.enclosing)+1+len(e.env.outer)+depth+1) + frames = append(frames, e.env.enclosing...) + frames = append(frames, e.env.data) + frames = append(frames, e.env.outer...) + if e.env.perf != nil { + frames = append(frames, performanceFrame(e.env.perf)) + } + root := mapFrame(e.env.locals) + root.cells = e.env.localCells + root.ensureCells = func() *bodyCells { return e.ensureRootCells() } + frames = append(frames, root) + if depth > len(e.env.frames) { + depth = len(e.env.frames) + } + for i, local := range e.env.frames[:depth] { + fr := mapFrame(local) + fr.cells = e.env.cells[i] + index := i + fr.ensureCells = func() *bodyCells { return e.ensureLocalCells(index) } + frames = append(frames, fr) + } + return &EvalContext{ + ctx: e.ctx, scope: scope, self: e.host.performer(), + occurrence: e.host.occurrence(), thisOccurrence: e.thisOccurrence, + frames: frames, trace: e.ctx.trace, inBehaviorBody: true, activation: e.activation, + } +} + // engineFrame is the engine of a body that paused, kept with the values and // activation it runs in until the body is resumed and ends. type engineFrame struct{ engine *stmtEngine } @@ -272,7 +432,8 @@ func (f *engineFrame) abandon(*Context) { f.engine.finish() } func (f *engineFrame) clone() bodyFrame { engine, env := *f.engine, *f.engine.env - env.frames, env.unvalued = slices.Clone(env.frames), slices.Clone(env.unvalued) + env.frames, env.cells, env.unvalued = slices.Clone(env.frames), slices.Clone(env.cells), slices.Clone(env.unvalued) + env.unvaluedLocal = maps.Clone(env.unvaluedLocal) engine.env, engine.scratch, engine.frameBuf = &env, EvalContext{}, nil return &engineFrame{engine: &engine} } @@ -293,8 +454,11 @@ func (ctx *Context) runStatements(build func() *stmtEngine, stmts []lower.Statem ctx.pushPaused(f) return flow, err } - f.engine.finish() - return flow, err + if err != nil { + f.engine.finish() + return flow, err + } + return flow, f.engine.complete() } // stmtListFrame is where a statement list paused: at its i-th statement, which @@ -388,6 +552,9 @@ func (e *stmtEngine) execute(stmt lower.Statement) (stmtFlow, error) { // The qualifier denoting this body's own run makes the write the // unqualified one: the host writes and streams it as `assign n := 3`. if e.env.data.runs(ec.ctx, s.Owner) { + if !e.host.declaredOutput(s.Target) && e.env.rootDeclares(s.Target) { + return flowNext, e.host.assignOuter(e.env, s.Target, value, s) + } if !e.host.declaredOutput(s.Target) && e.env.data.has(s.Target) { return flowNext, e.host.assignData(e.env, s.Target, value, s) } @@ -401,7 +568,13 @@ func (e *stmtEngine) execute(stmt lower.Statement) (stmtFlow, error) { if err := e.ctx.checkBodyWrite(e.host, s, &value); err != nil { return flowNext, err } - e.env.assignLocal(s.Target, value) + rootLocal := e.env.rootLocal(s.Target) + e.env.assignLocal(e.ctx, s.Target, value) + if rootLocal { + if mirror, ok := e.host.(rootLocalMirror); ok { + return flowNext, mirror.mirrorRootLocal(s.Target, value) + } + } return flowNext, nil } if !e.host.declaredOutput(s.Target) { @@ -411,6 +584,73 @@ func (e *stmtEngine) execute(stmt lower.Statement) (stmtFlow, error) { } return flowNext, e.host.assignOuter(e.env, s.Target, value, s) case lower.Declare: + if s.Binding && s.Value != nil { + var cells *bodyCells + name := s.Name + if depth := len(e.env.frames); depth > 0 { + cells = e.ensureLocalCells(depth - 1) + } else { + cells = e.ensureRootCells() + } + check := func(value *Value) error { + return e.ctx.checkBodyDeclaration(s.Scope, e.host.describe(), name, value) + } + bindingContext := e.evalIn(s.Scope) + visible := make([]map[string]bool, len(bindingContext.frames)) + for i, frame := range bindingContext.frames { + visible[i] = frame.visibleNames() + } + var cell *bodyCell + var onDerived func(*Value) error + if len(e.env.frames) == 0 { + if mirror, ok := e.host.(bodyBindingMirror); ok { + onDerived = func(value *Value) error { + return mirror.mirrorBodyBinding(name, cell, value) + } + } + } + cell = e.ctx.registerBodyBinding(cells, name, s.Value, s.Scope, check, onDerived) + cell.binding.visible = visible + cell.binding.limitFrames = true + if _, err := e.ctx.deriveBodyCell(cells, name, cell); err != nil { + var checkErr bodyBindingCheckError + if errors.As(err, &checkErr) { + return flowNext, checkErr.err + } + return flowNext, fmt.Errorf("eval declaration %s: %w", s.Name, err) + } + return flowNext, nil + } + if s.BodyData { + value := Value{Kind: ValNull} + if s.Value != nil { + evaluated, err := e.evalIn(s.Scope).Eval(s.Value) + if err != nil { + return flowNext, fmt.Errorf("eval declaration %s: %w", s.Name, err) + } + if err := e.ctx.checkBodyDeclaration(s.Scope, e.host.describe(), s.Name, &evaluated); err != nil { + return flowNext, err + } + value = evaluated + } + e.env.data.setBody(e.ctx, s.Name, value) + return flowNext, nil + } + if e.host.declaredOutput(s.Name) { + value := Value{Kind: ValNull} + if s.Value != nil { + evaluated, err := e.evalIn(s.Scope).Eval(s.Value) + if err != nil { + return flowNext, fmt.Errorf("eval declaration %s: %w", s.Name, err) + } + if err := e.ctx.checkBodyDeclaration(s.Scope, e.host.describe(), s.Name, &evaluated); err != nil { + return flowNext, err + } + value = evaluated + } + e.env.data.setBody(e.ctx, s.Name, value) + return flowNext, nil + } value := Value{Kind: ValNull} if s.Value != nil { evaluated, err := e.evalIn(s.Scope).Eval(s.Value) @@ -422,7 +662,7 @@ func (e *stmtEngine) execute(stmt lower.Statement) (stmtFlow, error) { } value = evaluated } - e.env.declare(s.Name, value) + e.env.declare(e.ctx, s.Name, value) return flowNext, nil case lower.DeclareUsage: return flowNext, e.declareUsage(s) @@ -461,6 +701,7 @@ func (e *stmtEngine) execute(stmt lower.Statement) (stmtFlow, error) { // this execution of the body reads starts here, so an evaluation of the same // usage from before the declaration was reached is discarded. func (e *stmtEngine) declareUsage(stmt lower.DeclareUsage) error { + // Body-local calc usages remain one-shot; their pins do not track body `=` bindings. sym, err := e.ctx.bodyUsageSymbol(stmt) if err != nil { return fmt.Errorf("%s: %w", e.host.describe(), err) @@ -507,33 +748,43 @@ func (e *stmtEngine) ifStatement(stmt lower.If) (stmtFlow, error) { // its activation, and the one around it. type blockFrame struct { locals map[string]Value + cells *bodyCells unvalued map[string]bool activation int64 outer int64 } -func (f *blockFrame) abandon(ctx *Context) { ctx.endActivation(f.activation) } +func (f *blockFrame) abandon(ctx *Context) { + ctx.forgetBodyCells(f.cells) + ctx.endActivation(f.activation) +} func (f *blockFrame) clone() bodyFrame { c := *f - c.locals, c.unvalued = maps.Clone(f.locals), maps.Clone(f.unvalued) + if f.cells == nil { + c.locals = maps.Clone(f.locals) + } + c.unvalued = maps.Clone(f.unvalued) return &c } // enterBlock enters a frame and an activation for a block about to run, or the // ones a paused block ran in; leave restores what was around them. -func (e *stmtEngine) enterBlock(f *blockFrame) (leave func()) { +func (e *stmtEngine) enterBlock(f *blockFrame) (leave func(bool)) { if f.locals == nil { - f.locals = e.env.enter() + f.locals = e.env.enter(e) + f.cells = e.env.cells[len(e.env.cells)-1] f.activation, f.outer = e.ctx.newActivation(), e.activation } else { e.env.frames = append(e.env.frames, f.locals) + e.env.cells = append(e.env.cells, f.cells) e.env.unvalued = append(e.env.unvalued, f.unvalued) } e.activation = f.activation - return func() { + return func(forget bool) { f.unvalued = e.env.unvalued[len(e.env.unvalued)-1] - e.env.leave() + f.cells = e.env.cells[len(e.env.cells)-1] + e.env.leave(e.ctx, forget) e.activation = f.outer } } @@ -550,11 +801,12 @@ func (e *stmtEngine) block(block lower.Block) (stmtFlow, error) { } leave := e.enterBlock(f) flow, err := e.runBlock(block) - leave() if paused(err) { + leave(false) e.ctx.pushPaused(f) return flow, err } + leave(true) e.ctx.endActivation(f.activation) return flow, err } @@ -692,6 +944,7 @@ func (e *stmtEngine) enterActivation() func() { // iteration under way with its activation, and the activation around the loop. type loopFrame struct { locals map[string]Value + cells *bodyCells unvalued map[string]bool iteration int activation int64 @@ -700,27 +953,36 @@ type loopFrame struct { elements []Value } -func (f *loopFrame) abandon(ctx *Context) { ctx.endActivation(f.activation) } +func (f *loopFrame) abandon(ctx *Context) { + ctx.forgetBodyCells(f.cells) + ctx.endActivation(f.activation) +} func (f *loopFrame) clone() bodyFrame { c := *f - c.locals, c.unvalued = maps.Clone(f.locals), maps.Clone(f.unvalued) + if f.cells == nil { + c.locals = maps.Clone(f.locals) + } + c.unvalued = maps.Clone(f.unvalued) return &c } // enterLoop enters the frame the loop's body declares into, or re-enters the one a // paused loop ran in; leave restores what was around it. -func (e *stmtEngine) enterLoop(f *loopFrame) (leave func()) { +func (e *stmtEngine) enterLoop(f *loopFrame) (leave func(bool)) { if f.locals == nil { - f.locals = e.env.enter() + f.locals = e.env.enter(e) + f.cells = e.env.cells[len(e.env.cells)-1] f.outer = e.activation } else { e.env.frames = append(e.env.frames, f.locals) + e.env.cells = append(e.env.cells, f.cells) e.env.unvalued = append(e.env.unvalued, f.unvalued) } - return func() { + return func(forget bool) { f.unvalued = e.env.unvalued[len(e.env.unvalued)-1] - e.env.leave() + f.cells = e.env.cells[len(e.env.cells)-1] + e.env.leave(e.ctx, forget) e.activation = f.outer } } @@ -768,10 +1030,12 @@ func (e *stmtEngine) loop(stmt lower.Loop) (stmtFlow, error) { } resumed = resumed && !e.ctx.yieldedHere() leave := e.enterLoop(f) - defer leave() + preserve := false + defer func() { leave(!preserve) }() for { if err := e.ctx.yieldBody(); err != nil { + preserve = true return flowNext, e.ctx.pausing(f, err) } if !resumed { @@ -782,6 +1046,7 @@ func (e *stmtEngine) loop(stmt lower.Loop) (stmtFlow, error) { flow, done, err := e.iteration(stmt, f, resumed) resumed = false if err != nil || done || flow == flowReturn { + preserve = paused(err) return flow, e.ctx.pausing(f, err) } e.ctx.bodyPerformed() @@ -805,6 +1070,7 @@ func (e *stmtEngine) iteration(stmt lower.Loop, f *loopFrame, resumed bool) (flo } } clear(f.locals) + e.ctx.resetBodyCells(f.cells) } flow, err = e.runBlock(stmt.Body) if err != nil || flow == flowReturn { @@ -855,10 +1121,12 @@ func (e *stmtEngine) forLoop(stmt lower.Loop) (stmtFlow, error) { } resumed = resumed && !e.ctx.yieldedHere() leave := e.enterLoop(f) - defer leave() + preserve := false + defer func() { leave(!preserve) }() for f.iteration < len(f.elements) || resumed { if err := e.ctx.yieldBody(); err != nil { + preserve = true return flowNext, e.ctx.pausing(f, err) } if !resumed { @@ -869,6 +1137,7 @@ func (e *stmtEngine) forLoop(stmt lower.Loop) (stmtFlow, error) { flow, err := e.forIteration(stmt, f, resumed) resumed = false if err != nil || flow == flowReturn { + preserve = paused(err) return flow, e.ctx.pausing(f, err) } e.ctx.bodyPerformed() @@ -883,6 +1152,7 @@ func (e *stmtEngine) forIteration(stmt lower.Loop, f *loopFrame, resumed bool) ( defer func() { e.endIteration(f, err) }() if !resumed { clear(f.locals) + e.ctx.resetBodyCells(f.cells) f.locals[stmt.Variable] = f.elements[f.iteration-1] } return e.runBlock(stmt.Body) diff --git a/internal/exec/runtime/testdata/conformance/README.md b/internal/exec/runtime/testdata/conformance/README.md index 370a3d5b47..553c0e4490 100644 --- a/internal/exec/runtime/testdata/conformance/README.md +++ b/internal/exec/runtime/testdata/conformance/README.md @@ -128,7 +128,7 @@ one: } ``` -- `outcomes`: at least two complete results. Each entry carries the `outputs` of +- `outcomes`: one or more complete results. Each entry carries the `outputs` of an action case, or the `finalState` / `stateVisits` / `outputs` of a state case, with the meaning those keys have above, plus an optional `probability`: the exact model-draw probability when every matched explored outcome has an diff --git a/internal/exec/runtime/testdata/conformance/action_block_body_feature_bindings.expected.json b/internal/exec/runtime/testdata/conformance/action_block_body_feature_bindings.expected.json new file mode 100644 index 0000000000..39cf0cb8fe --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_block_body_feature_bindings.expected.json @@ -0,0 +1,8 @@ +{ + "type": "action", + "evaluate": "test::run", + "outputs": { + "source": {"type": "Integer", "value": 8}, + "total": {"type": "Integer", "value": 30} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_block_body_feature_bindings.sysml b/internal/exec/runtime/testdata/conformance/action_block_body_feature_bindings.sysml new file mode 100644 index 0000000000..352227f23c --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_block_body_feature_bindings.sysml @@ -0,0 +1,28 @@ +package test { + private import ScalarValues::*; + + action run { + attribute source : Integer := 0; + out attribute total : Integer := 0; + + first start; + then action blocks { + while source < 3 { + attribute local : Integer = source; + assign source := source + 1; + assign total := total + local; + } + if source == 3 { + attribute branch : Integer = source; + assign source := 6; + assign total := total + branch; + } + for i in 1..2 { + attribute each : Integer = source + i; + assign source := source + 1; + assign total := total + each; + } + } + then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_body_feature_binding.expected.json b/internal/exec/runtime/testdata/conformance/action_body_feature_binding.expected.json new file mode 100644 index 0000000000..24156042a3 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_body_feature_binding.expected.json @@ -0,0 +1,10 @@ +{ + "type": "action", + "evaluate": "test::act", + "trace": true, + "outputs": { + "a": {"type": "Integer", "value": 5}, + "s": {"type": "Integer", "value": 10}, + "r": {"type": "Integer", "value": 10} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_body_feature_binding.sysml b/internal/exec/runtime/testdata/conformance/action_body_feature_binding.sysml new file mode 100644 index 0000000000..119e9cb668 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_body_feature_binding.sysml @@ -0,0 +1,14 @@ +package test { + private import ScalarValues::*; + + action act { + attribute a : Integer := 0; + attribute s : Integer = a * 2; + out attribute r : Integer; + + first start; + then action write { assign a := 5; } + then action read { assign r := s; } + then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_body_feature_binding.trace.golden b/internal/exec/runtime/testdata/conformance/action_body_feature_binding.trace.golden new file mode 100644 index 0000000000..3e668ea1f7 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_body_feature_binding.trace.golden @@ -0,0 +1,11 @@ +step 1: token 1@write +stmt assign a + eval literal 5 -> 5 +step 2: token 1@read +stmt assign r + eval feature a -> 5 + eval literal 2 -> 2 + eval operator * -> 10 + eval feature s -> 10 +step 3: token 1@done +step 4: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/action_body_feature_initial_binding.expected.json b/internal/exec/runtime/testdata/conformance/action_body_feature_initial_binding.expected.json new file mode 100644 index 0000000000..38ae5248f9 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_body_feature_initial_binding.expected.json @@ -0,0 +1,10 @@ +{ + "type": "action", + "evaluate": "test::run", + "outputs": { + "source": {"type": "Integer", "value": 5}, + "snapshot": {"type": "Integer", "value": 2}, + "fallback": {"type": "Integer", "value": 9}, + "result": {"type": "Integer", "value": 29} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_body_feature_initial_binding.sysml b/internal/exec/runtime/testdata/conformance/action_body_feature_initial_binding.sysml new file mode 100644 index 0000000000..dcc0830687 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_body_feature_initial_binding.sysml @@ -0,0 +1,18 @@ +package test { + private import ScalarValues::*; + + action run { + attribute source : Integer := 2; + attribute snapshot : Integer := source; + attribute fallback : Integer default source; + out attribute result : Integer; + + first start; + then action change { + assign source := 5; + assign fallback := 9; + assign result := snapshot * 10 + fallback; + } + then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_body_feature_occurrence_mirror.expected.json b/internal/exec/runtime/testdata/conformance/action_body_feature_occurrence_mirror.expected.json new file mode 100644 index 0000000000..b4e9ed7916 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_body_feature_occurrence_mirror.expected.json @@ -0,0 +1,9 @@ +{ + "type": "instance", + "libraries": true, + "instantiate": "test::Host", + "slots": { + "mirrored": {"type": "Integer", "value": 10}, + "result": {"type": "Integer", "value": 10} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_body_feature_occurrence_mirror.sysml b/internal/exec/runtime/testdata/conformance/action_body_feature_occurrence_mirror.sysml new file mode 100644 index 0000000000..47c1bb5219 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_body_feature_occurrence_mirror.sysml @@ -0,0 +1,25 @@ +package test { + private import ScalarValues::*; + + part def Host { + attribute mirrored : Integer := 0; + attribute result : Integer := 0; + + action def Bind { + in ref context : Host[1]; + attribute source : Integer := 0; + attribute live : Integer = source * 2; + + first start; + then action observe { assign context.mirrored := this.live; } + then action write { assign source := 5; } + then action read { + assign context.mirrored := this.live; + assign context.result := live; + } + then done; + } + + perform action bind : Bind { in ref :>> context = this; } + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_body_feature_pin_write.expected.json b/internal/exec/runtime/testdata/conformance/action_body_feature_pin_write.expected.json new file mode 100644 index 0000000000..894dd478b9 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_body_feature_pin_write.expected.json @@ -0,0 +1,10 @@ +{ + "type": "action", + "evaluate": "test::run", + "outputs": { + "source": {"type": "Integer", "value": 8}, + "result": {"type": "Integer", "value": 4}, + "consume.value": {"type": "Integer", "value": 4}, + "consume.result": {"type": "Integer", "value": 4} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_body_feature_pin_write.sysml b/internal/exec/runtime/testdata/conformance/action_body_feature_pin_write.sysml new file mode 100644 index 0000000000..191b0ff06c --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_body_feature_pin_write.sysml @@ -0,0 +1,21 @@ +package test { + private import ScalarValues::*; + + action def Consumer { + in value : Integer = 1; + out result : Integer; + assign result := value; + } + + action run { + attribute source : Integer := 4; + out attribute result : Integer; + first start; + then action consume : Consumer { in value = source; } + then action update { + assign source := 8; + assign result := consume.result; + } + then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_body_feature_read_only.expected.json b/internal/exec/runtime/testdata/conformance/action_body_feature_read_only.expected.json new file mode 100644 index 0000000000..9869622047 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_body_feature_read_only.expected.json @@ -0,0 +1,5 @@ +{ + "type": "action", + "evaluate": "test::run", + "error": "feature is read-only: locked is constant" +} diff --git a/internal/exec/runtime/testdata/conformance/action_body_feature_read_only.sysml b/internal/exec/runtime/testdata/conformance/action_body_feature_read_only.sysml new file mode 100644 index 0000000000..ab27f36cec --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_body_feature_read_only.sysml @@ -0,0 +1,9 @@ +package test { + private import ScalarValues::*; + + action run { + constant attribute locked : Integer = 1; + first start; + then action write { assign locked := 2; } + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_body_feature_written.expected.json b/internal/exec/runtime/testdata/conformance/action_body_feature_written.expected.json new file mode 100644 index 0000000000..460c196e52 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_body_feature_written.expected.json @@ -0,0 +1,9 @@ +{ + "type": "action", + "evaluate": "test::run", + "outputs": { + "source": {"type": "Integer", "value": 5}, + "live": {"type": "Integer", "value": 99}, + "result": {"type": "Integer", "value": 99} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_body_feature_written.sysml b/internal/exec/runtime/testdata/conformance/action_body_feature_written.sysml new file mode 100644 index 0000000000..1dc8f8ff81 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_body_feature_written.sysml @@ -0,0 +1,17 @@ +package test { + private import ScalarValues::*; + + action run { + attribute source : Integer := 1; + attribute live : Integer = source * 2; + out attribute result : Integer; + + first start; + then action update { + assign live := 99; + assign source := 5; + assign result := live; + } + then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_inherited_default_root.expected.json b/internal/exec/runtime/testdata/conformance/action_inherited_default_root.expected.json index 6a05291470..9dd5b3b548 100644 --- a/internal/exec/runtime/testdata/conformance/action_inherited_default_root.expected.json +++ b/internal/exec/runtime/testdata/conformance/action_inherited_default_root.expected.json @@ -3,8 +3,8 @@ "evaluate": "test::Derived", "outputs": { "x": {"type": "Integer", "value": 10}, - "z": {"type": "Integer", "value": 6}, + "z": {"type": "Integer", "value": 20}, "y": {"type": "Integer", "value": 4}, - "r": {"type": "Integer", "value": 6} + "r": {"type": "Integer", "value": 20} } } diff --git a/internal/exec/runtime/testdata/conformance/action_inherited_default_root.sysml b/internal/exec/runtime/testdata/conformance/action_inherited_default_root.sysml index 9f95bcea42..55f1f2a830 100644 --- a/internal/exec/runtime/testdata/conformance/action_inherited_default_root.sysml +++ b/internal/exec/runtime/testdata/conformance/action_inherited_default_root.sysml @@ -1,9 +1,8 @@ package test { private import ScalarValues::*; - // The root performance seeds the defaults inherited from the general action - // when it starts, as it seeds its own: z holds x * 2 as x was then, not as - // the body later reassigns it. + // The unsupplied `in z = x * 2` parameter binds z to the expression for the + // performance, so it follows the value assigned to x later in the body. action def Base { in x : Integer = 3; in z : Integer = x * 2; diff --git a/internal/exec/runtime/testdata/conformance/action_inherited_default_root_fallback.expected.json b/internal/exec/runtime/testdata/conformance/action_inherited_default_root_fallback.expected.json new file mode 100644 index 0000000000..b5503500d2 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_inherited_default_root_fallback.expected.json @@ -0,0 +1,9 @@ +{ + "type": "action", + "evaluate": "test::Derived", + "outputs": { + "x": {"type": "Integer", "value": 10}, + "z": {"type": "Integer", "value": 6}, + "r": {"type": "Integer", "value": 6} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_inherited_default_root_fallback.sysml b/internal/exec/runtime/testdata/conformance/action_inherited_default_root_fallback.sysml new file mode 100644 index 0000000000..8e39bf1d06 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_inherited_default_root_fallback.sysml @@ -0,0 +1,17 @@ +package test { + private import ScalarValues::*; + + // `default` supplies z only when no value is passed; the value does not track x. + action def Base { + in x : Integer = 3; + in z : Integer default x * 2; + out r : Integer; + } + + action def Derived :> Base { + first start; + then assign x := 10; + then assign r := z; + then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_inherited_supplied_parameter.expected.json b/internal/exec/runtime/testdata/conformance/action_inherited_supplied_parameter.expected.json new file mode 100644 index 0000000000..a6b2e11799 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_inherited_supplied_parameter.expected.json @@ -0,0 +1,7 @@ +{ + "type": "action", + "evaluate": "test::caller", + "outputs": { + "result": {"type": "Integer", "value": 7} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_inherited_supplied_parameter.sysml b/internal/exec/runtime/testdata/conformance/action_inherited_supplied_parameter.sysml new file mode 100644 index 0000000000..d0ac57626c --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_inherited_supplied_parameter.sysml @@ -0,0 +1,25 @@ +package test { + private import ScalarValues::*; + + action def Base { + in x : Integer = 3; + in z : Integer = x * 2; + out r : Integer; + + first start; + then assign x := 10; + then assign r := z; + then done; + } + + action caller { + out attribute result : Integer; + + first start; + then action run = Base(x = 3, z = 7); + then action keep { + assign result := run.r; + } + then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_nested_body_feature_binding.expected.json b/internal/exec/runtime/testdata/conformance/action_nested_body_feature_binding.expected.json new file mode 100644 index 0000000000..15fbb643fa --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_nested_body_feature_binding.expected.json @@ -0,0 +1,9 @@ +{ + "type": "action", + "evaluate": "test::outer", + "outputs": { + "result": {"type": "Integer", "value": 12}, + "child.x": {"type": "Integer", "value": 12}, + "child.y": {"type": "Integer", "value": 12} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_nested_body_feature_binding.sysml b/internal/exec/runtime/testdata/conformance/action_nested_body_feature_binding.sysml new file mode 100644 index 0000000000..2ae65fcb97 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_nested_body_feature_binding.sysml @@ -0,0 +1,21 @@ +package test { + private import ScalarValues::*; + + action outer { + attribute a : Integer := 2; + out attribute result : Integer; + + first start; + then action child { + attribute x : Integer = a * 3; + out attribute y : Integer; + + first start; + then action change { assign a := 4; } + then action read { assign y := x; } + then done; + } + then action collect { assign result := child.y; } + then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_shared_writers.check.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_shared_writers.check.expected.json index d75f916d89..34adf1121d 100644 --- a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_shared_writers.check.expected.json +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_shared_writers.check.expected.json @@ -1,5 +1,5 @@ { - "verdict": "divergent", - "divergent": {"c": ["1", "2", "3"]}, - "agreed": {} + "verdict": "no violation, exhaustive", + "divergent": {}, + "agreed": {"c": "3"} } diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_shared_writers.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_shared_writers.expected.json index e241fb4ccc..b81e533660 100644 --- a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_shared_writers.expected.json +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_shared_writers.expected.json @@ -3,8 +3,6 @@ "exploreBudget": {"runs": 65536}, "libraries": true, "outcomes": [ - {"outputs": {"c": {"type": "Integer", "value": 1}}}, - {"outputs": {"c": {"type": "Integer", "value": 2}}}, {"outputs": {"c": {"type": "Integer", "value": 3}}} ], "admissible": "Repeated action steps and shared writes" diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_shared_writers.sysml b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_shared_writers.sysml index 73a91a0a09..0a04ec5338 100644 --- a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_shared_writers.sysml +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_shared_writers.sysml @@ -4,8 +4,8 @@ package test { action def SharedWriters { attribute c : Integer = 0; first start then a; - // Each performance snapshots shared c in its own l; the final value depends on - // how many snapshots happen before the last write. + // Each performance binds l to the current c; the repeated writes therefore + // advance the shared value to 3 under every interleaving. action a[3] { attribute l : Integer = c; first start then w; diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_shared_writers_initial.check.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_shared_writers_initial.check.expected.json new file mode 100644 index 0000000000..d75f916d89 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_shared_writers_initial.check.expected.json @@ -0,0 +1,5 @@ +{ + "verdict": "divergent", + "divergent": {"c": ["1", "2", "3"]}, + "agreed": {} +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_shared_writers_initial.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_shared_writers_initial.expected.json new file mode 100644 index 0000000000..e241fb4ccc --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_shared_writers_initial.expected.json @@ -0,0 +1,11 @@ +{ + "type": "action", + "exploreBudget": {"runs": 65536}, + "libraries": true, + "outcomes": [ + {"outputs": {"c": {"type": "Integer", "value": 1}}}, + {"outputs": {"c": {"type": "Integer", "value": 2}}}, + {"outputs": {"c": {"type": "Integer", "value": 3}}} + ], + "admissible": "Repeated action steps and shared writes" +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_shared_writers_initial.sysml b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_shared_writers_initial.sysml new file mode 100644 index 0000000000..1304022c51 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_shared_writers_initial.sysml @@ -0,0 +1,17 @@ +package test { + private import ScalarValues::*; + + action def SharedWriters { + attribute c : Integer = 0; + first start then a; + // Each performance snapshots shared c in its own l; the final value depends on + // how many snapshots happen before the last write. + action a[3] { + attribute l : Integer := c; + first start then w; + action w { assign c := l + 1; } + then done; + } + then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_names_exhibiting_part.trace.golden b/internal/exec/runtime/testdata/conformance/action_terminate_names_exhibiting_part.trace.golden index 352b1f1705..07a7bdb4f4 100644 --- a/internal/exec/runtime/testdata/conformance/action_terminate_names_exhibiting_part.trace.golden +++ b/internal/exec/runtime/testdata/conformance/action_terminate_names_exhibiting_part.trace.golden @@ -4,6 +4,7 @@ materialize: patient #1 materialize: Heart #2 start: exhibited state machine rhythm of #2 materialize: Beating #3 + eval literal 0 -> 0 enter: alive run: exhibited state machine rhythm of #2 do: alive diff --git a/internal/exec/runtime/testdata/conformance/calc_body_feature_binding.expected.json b/internal/exec/runtime/testdata/conformance/calc_body_feature_binding.expected.json new file mode 100644 index 0000000000..5b8846a56b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/calc_body_feature_binding.expected.json @@ -0,0 +1,6 @@ +{ + "type": "calcUsage", + "evaluate": "test::c", + "outputs": {}, + "result": {"type": "Integer", "value": 12} +} diff --git a/internal/exec/runtime/testdata/conformance/calc_body_feature_binding.sysml b/internal/exec/runtime/testdata/conformance/calc_body_feature_binding.sysml new file mode 100644 index 0000000000..2d7f2d1a7a --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/calc_body_feature_binding.sysml @@ -0,0 +1,13 @@ +package test { + private import ScalarValues::*; + + calc def Recompute { + in initial : Integer; + attribute current : Integer := initial; + attribute doubled : Integer = current * 2; + assign current := 6; + return : Integer = doubled; + } + + calc c : Recompute { in initial = 3; } +} diff --git a/internal/exec/runtime/testdata/conformance/calc_usage_outputs_one_binding.trace.golden b/internal/exec/runtime/testdata/conformance/calc_usage_outputs_one_binding.trace.golden index 7b8d228dcb..70c8552638 100644 --- a/internal/exec/runtime/testdata/conformance/calc_usage_outputs_one_binding.trace.golden +++ b/internal/exec/runtime/testdata/conformance/calc_usage_outputs_one_binding.trace.golden @@ -20,6 +20,8 @@ enter calc test::Interleaved output test::Interleaved::p.b = 10.0 eval chain b -> 10.0 stmt return + reuse calc test::Interleaved::p + eval chain a -> 1.0 eval feature ra -> 1.0 eval literal 1000.0 -> 1000.0 eval operator * -> 1000.0 diff --git a/internal/exec/runtime/testdata/conformance/constraint_body_feature_binding.expected.json b/internal/exec/runtime/testdata/conformance/constraint_body_feature_binding.expected.json new file mode 100644 index 0000000000..e3dc60c671 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/constraint_body_feature_binding.expected.json @@ -0,0 +1,10 @@ +{ + "type": "instance", + "instantiate": "test::Subject", + "slots": { + "amount": {"type": "Integer", "value": 5} + }, + "constraints": { + "positive": true + } +} diff --git a/internal/exec/runtime/testdata/conformance/constraint_body_feature_binding.sysml b/internal/exec/runtime/testdata/conformance/constraint_body_feature_binding.sysml new file mode 100644 index 0000000000..e0071c7078 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/constraint_body_feature_binding.sysml @@ -0,0 +1,22 @@ +package test { + private import ScalarValues::*; + + // Constraint declarations are evaluated on each read in the current subject context. + constraint def PositiveAfterUpdate { + attribute twice : Integer = amount * 2; + twice > 3 + } + + part def Subject { + attribute amount : Integer := 0; + + action def Raise { + first start; + then action update { assign amount := 5; } + then done; + } + + perform action raise : Raise; + constraint positive : PositiveAfterUpdate; + } +} diff --git a/internal/exec/runtime/testdata/conformance/exhibited_state_guard_reads_attribute.trace.golden b/internal/exec/runtime/testdata/conformance/exhibited_state_guard_reads_attribute.trace.golden index aee31851f5..79a8e2cf07 100644 --- a/internal/exec/runtime/testdata/conformance/exhibited_state_guard_reads_attribute.trace.golden +++ b/internal/exec/runtime/testdata/conformance/exhibited_state_guard_reads_attribute.trace.golden @@ -1,6 +1,7 @@ materialize: Controller #1 start: exhibited state machine modes of #1 materialize: Modes #2 +eval literal 0 -> 0 enter: armed (entry action) stmt action body stmt assign attempts diff --git a/internal/exec/runtime/testdata/conformance/exhibited_state_self_target.trace.golden b/internal/exec/runtime/testdata/conformance/exhibited_state_self_target.trace.golden index 8b06be640e..edf476075d 100644 --- a/internal/exec/runtime/testdata/conformance/exhibited_state_self_target.trace.golden +++ b/internal/exec/runtime/testdata/conformance/exhibited_state_self_target.trace.golden @@ -4,6 +4,8 @@ materialize: modes #2 materialize: CmdPort #3 eval feature engineControl -> instance#3 materialize: EngineCommand #4 +eval chain cmd -> instance#4 + eval feature engineControl -> instance#3 eval chain cmd -> instance#4 eval feature engineControl -> instance#3 eval chain cmd -> instance#4 diff --git a/internal/exec/runtime/testdata/conformance/performed_action_later_node_reads.trace.golden b/internal/exec/runtime/testdata/conformance/performed_action_later_node_reads.trace.golden index 898dfff92d..568ebd766e 100644 --- a/internal/exec/runtime/testdata/conformance/performed_action_later_node_reads.trace.golden +++ b/internal/exec/runtime/testdata/conformance/performed_action_later_node_reads.trace.golden @@ -1,6 +1,7 @@ materialize: Adder #1 start: performed action sum of #1 materialize: Accumulate #2 +eval literal 0 -> 0 run: performed action sum of #1 stmt assign total eval literal 5 -> 5 diff --git a/internal/exec/runtime/testdata/conformance/state_attribute_body_feature_binding.expected.json b/internal/exec/runtime/testdata/conformance/state_attribute_body_feature_binding.expected.json new file mode 100644 index 0000000000..4b62f3563f --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_attribute_body_feature_binding.expected.json @@ -0,0 +1,10 @@ +{ + "type": "state", + "finalState": "done", + "stateVisits": ["active", "done"], + "outputs": { + "source": {"type": "Integer", "value": 5}, + "observed": {"type": "Integer", "value": 10}, + "active.live": {"type": "Integer", "value": 10} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_attribute_body_feature_binding.sysml b/internal/exec/runtime/testdata/conformance/state_attribute_body_feature_binding.sysml new file mode 100644 index 0000000000..d595676999 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_attribute_body_feature_binding.sysml @@ -0,0 +1,19 @@ +package test { + private import ScalarValues::*; + + state Machine { + attribute source : Integer := 1; + attribute observed : Integer := 0; + + entry; then active; + state active { + attribute live : Integer = source * 2; + entry { + assign source := 5; + assign observed := live; + } + } + state done; + transition first active accept when observed > 0 then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_behavior_body_feature_bindings.expected.json b/internal/exec/runtime/testdata/conformance/state_behavior_body_feature_bindings.expected.json new file mode 100644 index 0000000000..ee1bc4817e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_behavior_body_feature_bindings.expected.json @@ -0,0 +1,9 @@ +{ + "type": "state", + "finalState": "done", + "stateVisits": ["active", "done"], + "outputs": { + "source": {"type": "Integer", "value": 4}, + "log": {"type": "Integer", "value": 234} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_behavior_body_feature_bindings.sysml b/internal/exec/runtime/testdata/conformance/state_behavior_body_feature_bindings.sysml new file mode 100644 index 0000000000..e5b52223f1 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_behavior_body_feature_bindings.sysml @@ -0,0 +1,31 @@ +package test { + private import ScalarValues::*; + + state Machine { + attribute source : Integer := 1; + attribute log : Integer := 0; + attribute complete : Boolean := false; + + entry; then active; + state active { + entry { + attribute entryValue : Integer = source; + assign source := 2; + assign log := entryValue; + } + do { + attribute doValue : Integer = source; + assign source := 3; + assign log := log * 10 + doValue; + assign complete := true; + } + exit { + attribute exitValue : Integer = source; + assign source := 4; + assign log := log * 10 + exitValue; + } + } + state done; + transition first active accept when complete then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_body_feature_binding_change_guard.expected.json b/internal/exec/runtime/testdata/conformance/state_body_feature_binding_change_guard.expected.json new file mode 100644 index 0000000000..9cd5ab6971 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_body_feature_binding_change_guard.expected.json @@ -0,0 +1,10 @@ +{ + "type": "state", + "finalState": "done", + "stateVisits": ["waiting", "done"], + "outputs": { + "a": {"type": "Integer", "value": 6}, + "s": {"type": "Integer", "value": 12}, + "observed": {"type": "Integer", "value": 12} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_body_feature_binding_change_guard.sysml b/internal/exec/runtime/testdata/conformance/state_body_feature_binding_change_guard.sysml new file mode 100644 index 0000000000..7e809a0613 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_body_feature_binding_change_guard.sysml @@ -0,0 +1,22 @@ +// The machine's transition polling observes a change in the live data binding. +package test { + private import ScalarValues::*; + + state Machine { + attribute a : Integer := 0; + attribute s : Integer = a * 2; + attribute observed : Integer := 0; + + entry; then waiting; + state waiting { + do action arm { assign a := 5; } + } + state done; + + transition first waiting accept when s > 3 do { + attribute effectValue : Integer = a * 2; + assign a := 6; + assign observed := effectValue; + } then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_space_clock_state_interleave.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_space_clock_state_interleave.declared.trace.golden index 132a9d4ae9..abd6b107a3 100644 --- a/internal/exec/runtime/testdata/conformance/state_space_clock_state_interleave.declared.trace.golden +++ b/internal/exec/runtime/testdata/conformance/state_space_clock_state_interleave.declared.trace.golden @@ -12,9 +12,18 @@ materialize: cool #2 enter calc VectorFunctions::VectorOf bind components = 1.0 [argument] exit calc VectorFunctions::VectorOf -> ⟨1.0⟩ + eval invoke VectorOf -> ⟨1.0⟩ + eval literal 1.0 -> 1.0 + enter calc VectorFunctions::VectorOf + bind components = 1.0 [argument] + exit calc VectorFunctions::VectorOf -> ⟨1.0⟩ eval invoke VectorOf -> ⟨1.0⟩ eval literal 0.25 -> 0.25 eval index -> 0.25 [s] + eval literal 0.25 -> 0.25 + eval index -> 0.25 [s] + eval literal 1 -> 1 + eval index -> 1 [s] eval literal 1 -> 1 eval index -> 1 [s] start: exhibited state machine monitor of #1 diff --git a/internal/exec/runtime/testdata/conformance/state_space_clock_state_interleave.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_space_clock_state_interleave.seed-1.trace.golden index 8a89a3274b..c102f2e98f 100644 --- a/internal/exec/runtime/testdata/conformance/state_space_clock_state_interleave.seed-1.trace.golden +++ b/internal/exec/runtime/testdata/conformance/state_space_clock_state_interleave.seed-1.trace.golden @@ -12,9 +12,18 @@ materialize: cool #2 enter calc VectorFunctions::VectorOf bind components = 1.0 [argument] exit calc VectorFunctions::VectorOf -> ⟨1.0⟩ + eval invoke VectorOf -> ⟨1.0⟩ + eval literal 1.0 -> 1.0 + enter calc VectorFunctions::VectorOf + bind components = 1.0 [argument] + exit calc VectorFunctions::VectorOf -> ⟨1.0⟩ eval invoke VectorOf -> ⟨1.0⟩ eval literal 0.25 -> 0.25 eval index -> 0.25 [s] + eval literal 0.25 -> 0.25 + eval index -> 0.25 [s] + eval literal 1 -> 1 + eval index -> 1 [s] eval literal 1 -> 1 eval index -> 1 [s] start: exhibited state machine monitor of #1 diff --git a/internal/exec/runtime/testdata/conformance/state_space_clock_state_interleave.trace.golden b/internal/exec/runtime/testdata/conformance/state_space_clock_state_interleave.trace.golden index 8a89a3274b..c102f2e98f 100644 --- a/internal/exec/runtime/testdata/conformance/state_space_clock_state_interleave.trace.golden +++ b/internal/exec/runtime/testdata/conformance/state_space_clock_state_interleave.trace.golden @@ -12,9 +12,18 @@ materialize: cool #2 enter calc VectorFunctions::VectorOf bind components = 1.0 [argument] exit calc VectorFunctions::VectorOf -> ⟨1.0⟩ + eval invoke VectorOf -> ⟨1.0⟩ + eval literal 1.0 -> 1.0 + enter calc VectorFunctions::VectorOf + bind components = 1.0 [argument] + exit calc VectorFunctions::VectorOf -> ⟨1.0⟩ eval invoke VectorOf -> ⟨1.0⟩ eval literal 0.25 -> 0.25 eval index -> 0.25 [s] + eval literal 0.25 -> 0.25 + eval index -> 0.25 [s] + eval literal 1 -> 1 + eval index -> 1 [s] eval literal 1 -> 1 eval index -> 1 [s] start: exhibited state machine monitor of #1 diff --git a/internal/exec/runtime/testdata/conformance/state_space_zero_crossing_state.trace.golden b/internal/exec/runtime/testdata/conformance/state_space_zero_crossing_state.trace.golden index 11d982ca0e..fe944a57d2 100644 --- a/internal/exec/runtime/testdata/conformance/state_space_zero_crossing_state.trace.golden +++ b/internal/exec/runtime/testdata/conformance/state_space_zero_crossing_state.trace.golden @@ -13,6 +13,15 @@ eval invoke VectorOf -> ⟨10.0⟩ bind components = (19.0, 0.0) [argument] exit calc VectorFunctions::VectorOf -> ⟨19.0, 0.0⟩ eval invoke VectorOf -> ⟨19.0, 0.0⟩ + eval literal 19.0 -> 19.0 + eval literal 0.0 -> 0.0 + eval sequence of 2 -> (19.0, 0.0) + enter calc VectorFunctions::VectorOf + bind components = (19.0, 0.0) [argument] + exit calc VectorFunctions::VectorOf -> ⟨19.0, 0.0⟩ +eval invoke VectorOf -> ⟨19.0, 0.0⟩ + eval literal 0.5 -> 0.5 +eval index -> 0.5 [s] eval literal 0.5 -> 0.5 eval index -> 0.5 [s] run: performed action fall of #1 diff --git a/internal/exec/runtime/tool.go b/internal/exec/runtime/tool.go index 878fc5b380..60f189ac66 100644 --- a/internal/exec/runtime/tool.go +++ b/internal/exec/runtime/tool.go @@ -409,7 +409,10 @@ func (e *ActionExecutor) toolCall(execution *toolExecution) (*ToolCall, error) { reads := param.Direction == ast.DirIn || param.Direction == ast.DirInOut writes := param.Direction == ast.DirOut || param.Direction == ast.DirInOut if reads { - held, bound := e.root.data[e.root.key(name)] + held, bound, err := e.ctx.readBodyValue(e.root.cells, e.root.data, e.root.key(name)) + if err != nil { + return nil, err + } if !bound && !e.ctx.model.semantics.OptionalParameter(param.Symbol) { return nil, fmt.Errorf("%w: action %s: input parameter %s is bound by no argument", ErrUnboundParameter, symbolText(e.performed), name) diff --git a/internal/exec/runtime/via_reference.go b/internal/exec/runtime/via_reference.go index fcc7f67d27..4d95b4322e 100644 --- a/internal/exec/runtime/via_reference.go +++ b/internal/exec/runtime/via_reference.go @@ -41,7 +41,7 @@ func (ec *EvalContext) viaHolder(path string, viaSelf bool, self *Instance) (*In return self, path, err } if len(held) != 1 { - value, _ := ec.Lookup(root) + value, _, _ := ec.Lookup(root) return self, path, &SendTargetValueError{Target: path, Name: root, Value: FormatValue(value)} } holder := held[0] @@ -117,7 +117,10 @@ func (ec *EvalContext) boundEndDeliveries(end string) ([]ownerDelivery, bool, er // boundHolders returns the objects a binding of ec holds and whether ec binds the name; // a binding holding anything but objects is that error, naming the path it leads. func (ec *EvalContext) boundHolders(name, path string) ([]*Instance, bool, error) { - value, bound := ec.Lookup(name) + value, bound, err := ec.Lookup(name) + if err != nil { + return nil, false, err + } if !bound { return nil, false, nil } diff --git a/internal/exec/runtime/write_conformance.go b/internal/exec/runtime/write_conformance.go index 607fe91246..95b4aafa64 100644 --- a/internal/exec/runtime/write_conformance.go +++ b/internal/exec/runtime/write_conformance.go @@ -215,7 +215,7 @@ func storeBodyValue(ctx *Context, host stmtHost, env *stmtEnv, name string, valu if err := ctx.checkBodyWrite(host, s, &value); err != nil { return err } - env.data.set(name, value) + env.data.setBody(ctx, name, value) return nil } diff --git a/internal/ir/lower/action_graph.go b/internal/ir/lower/action_graph.go index 4c1d2c51ab..c27b8e38f2 100644 --- a/internal/ir/lower/action_graph.go +++ b/internal/ir/lower/action_graph.go @@ -318,10 +318,12 @@ func flattenChain(chain *ast.FeatureChainExpr) (ast.Node, []string) { // that block, so the executor binds it in the block's own frame and discards it // when the block exits. Value is nil when the declaration carried none. type Declare struct { - Name string - Value ast.Node - Node ast.Node // the declaration itself, for diagnostics - Scope *symbols.Scope // the scope the declaration was written in + Name string + Value ast.Node + Binding bool + BodyData bool // expression-valued state action execution stored as state data + Node ast.Node // the declaration itself, for diagnostics + Scope *symbols.Scope // the scope the declaration was written in } func (Declare) statement() { /* marker: closed Statement set */ } @@ -607,9 +609,10 @@ type Attribute struct { Direction ast.FeatureDirection IsResult bool // a `return` parameter, what the behavior yields // Type is the declared type as written (`Natural`, `Vehicle::Mode`), "" without one. - Type string - Value ast.Node - Node ast.Node // the declaration itself, for diagnostics + Type string + Value ast.Node + Binding bool + Node ast.Node // the declaration itself, for diagnostics // Scope is the scope the declaration was written in, in which its default // resolves; nil where the owner's own scope resolves it. Scope *symbols.Scope @@ -655,10 +658,15 @@ type Feature struct { Direction ast.FeatureDirection IsResult bool // a `return` parameter, what the node stands for read as a value Value ast.Node + Binding bool Node ast.Node // the declaration, for diagnostics Scope *symbols.Scope } +func valueIsBinding(value ast.Node, isInitial, isDefault bool) bool { + return value != nil && !isInitial && !isDefault +} + // Output reports whether the feature is written back rather than read: an `out` // or `return` parameter. func (f Feature) Output() bool { @@ -1272,6 +1280,7 @@ func lowerFeatures(graph *ActionGraph, node *ast.Usage, scope *symbols.Scope) { Direction: m.Direction, IsResult: m.IsResult, Value: m.Value, + Binding: valueIsBinding(m.Value, m.ValueIsInitial, m.ValueIsDefault), Node: m, Scope: scope, }) @@ -1616,7 +1625,7 @@ func lowerAttributes(members []ast.Node) []Attribute { if name == "" { continue } - attrs = append(attrs, Attribute{Name: name, Direction: usage.Direction, IsResult: usage.IsResult, Type: TypeText(usage), Value: usage.Value, Node: usage}) + attrs = append(attrs, Attribute{Name: name, Direction: usage.Direction, IsResult: usage.IsResult, Type: TypeText(usage), Value: usage.Value, Binding: valueIsBinding(usage.Value, usage.ValueIsInitial, usage.ValueIsDefault), Node: usage}) } return attrs } diff --git a/internal/ir/lower/body_binding_test.go b/internal/ir/lower/body_binding_test.go new file mode 100644 index 0000000000..c793fd7cfa --- /dev/null +++ b/internal/ir/lower/body_binding_test.go @@ -0,0 +1,103 @@ +package lower + +import ( + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +func TestLoweredBodyFeaturesPreserveValueOperators(t *testing.T) { + graph := actionGraphFor(t, ` + action act { + attribute bound : Integer = 1; + attribute initial : Integer := 2; + attribute fallback : Integer default 3; + action nested { + attribute localBound : Integer = 4; + attribute localInitial : Integer := 5; + attribute localFallback : Integer default 6; + } + if true { + attribute blockBound : Integer = 7; + attribute blockInitial : Integer := 8; + attribute blockFallback : Integer default 9; + } + } + `) + + root := make(map[string]bool, len(graph.Attributes)) + for _, attr := range graph.Attributes { + root[attr.Name] = attr.Binding + } + if root["bound"] != true || root["initial"] || root["fallback"] { + t.Errorf("action attribute bindings = %v", root) + } + + var nested ast.Node + for _, node := range graph.Nodes { + if getNodeName(node) == "nested" { + nested = node + break + } + } + features := graph.Features[nested] + nestedBindings := make(map[string]bool, len(features)) + for _, feature := range features { + nestedBindings[feature.Name] = feature.Binding + } + if nestedBindings["localBound"] != true || nestedBindings["localInitial"] || nestedBindings["localFallback"] { + t.Errorf("nested action feature bindings = %v", nestedBindings) + } + + var local Declare + for _, node := range graph.Nodes { + for _, statement := range graph.Bodies[node] { + branch, ok := statement.(If) + if !ok { + continue + } + for _, nestedStatement := range branch.Then.Statements { + if declared, ok := nestedStatement.(Declare); ok && declared.Name == "blockBound" { + local = declared + } + } + } + } + if local.Name != "blockBound" || !local.Binding { + t.Fatalf("block binding declaration = %#v", local) + } +} + +func TestStateGraphPreservesAttributeValueOperators(t *testing.T) { + graph, err := ToStateGraph(stateUsageIn(t, ` + state Machine { + attribute bound : Integer = 1; + attribute initial : Integer := 2; + attribute fallback : Integer default 3; + entry; then active; + state active { + attribute stateBound : Integer = 4; + } + } + `), nil) + if err != nil { + t.Fatalf("ToStateGraph: %v", err) + } + root := make(map[string]bool, len(graph.Attributes)) + for _, attr := range graph.Attributes { + root[attr.Name] = attr.Binding + } + if root["bound"] != true || root["initial"] || root["fallback"] { + t.Errorf("state machine attribute bindings = %v", root) + } + for state, attrs := range graph.StateAttributes { + if state.Name != "active" { + continue + } + if len(attrs) != 1 || attrs[0].Name != "stateBound" || !attrs[0].Binding { + t.Errorf("state attributes = %#v, want a binding stateBound", attrs) + } + return + } + t.Fatal("state attributes for active were not lowered") +} diff --git a/internal/ir/lower/calc_body.go b/internal/ir/lower/calc_body.go index cb53efb25a..a07c7c142e 100644 --- a/internal/ir/lower/calc_body.go +++ b/internal/ir/lower/calc_body.go @@ -90,12 +90,12 @@ func usageStatement(u *ast.Usage, scope *symbols.Scope) (Statement, bool) { // An initial value (`:=`) is what the output holds when the body starts, // for its assignments to replace; a binding (`=`) is the value it returns. if u.ValueIsInitial && name != "" { - return Declare{Name: name, Value: u.Value, Node: u, Scope: scope}, true + return Declare{Name: name, Value: u.Value, Binding: valueIsBinding(u.Value, u.ValueIsInitial, u.ValueIsDefault), Node: u, Scope: scope}, true } return Return{Value: u.Value, Node: u, Scope: scope}, true } if u.Kind == ast.UsageAttribute && name != "" { - return Declare{Name: name, Value: u.Value, Node: u, Scope: scope}, true + return Declare{Name: name, Value: u.Value, Binding: valueIsBinding(u.Value, u.ValueIsInitial, u.ValueIsDefault), Node: u, Scope: scope}, true } if (u.Kind == ast.UsageCalc || u.Kind == ast.UsageAnalysisCase || u.Kind == ast.UsageVerificationCase) && name != "" { return DeclareUsage{Name: name, Node: u, Scope: scope}, true diff --git a/internal/ir/lower/state_behavior.go b/internal/ir/lower/state_behavior.go index d5b1b5d853..a37d02fa57 100644 --- a/internal/ir/lower/state_behavior.go +++ b/internal/ir/lower/state_behavior.go @@ -143,7 +143,7 @@ func lowerBehaviorBody(node *ast.Usage, scope *symbols.Scope, resolver *resolve. func lowerActionExecution(node *ast.ActionExecutionNode, scope *symbols.Scope) []Statement { switch { case node.Expression != nil: - return []Statement{Declare{Name: node.Name, Value: node.Expression, Node: node, Scope: scope}} + return []Statement{Declare{Name: node.Name, Value: node.Expression, BodyData: true, Node: node, Scope: scope}} case node.ActionRef != nil: return []Statement{Effect{Kind: EffectPerform, Node: node, Scope: scope}} default: diff --git a/internal/ir/lower/state_behavior_test.go b/internal/ir/lower/state_behavior_test.go index 71d72b3cf0..8a3e4e7cf8 100644 --- a/internal/ir/lower/state_behavior_test.go +++ b/internal/ir/lower/state_behavior_test.go @@ -3,6 +3,8 @@ package lower import ( "strings" "testing" + + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // The inline body of an entry, do or exit behavior stating successions or @@ -169,3 +171,17 @@ func TestStateBehaviorBodyWithAmbiguousStartKeepsNoInitial(t *testing.T) { } } } + +func TestStateBehaviorActionExecutionIsStateData(t *testing.T) { + body := lowerActionExecution(&ast.ActionExecutionNode{ + Name: "calculated", + Expression: &ast.LiteralInteger{Value: "2"}, + }, nil) + if len(body) != 1 { + t.Fatalf("action execution = %d statements, want a declaration", len(body)) + } + decl, ok := body[0].(Declare) + if !ok || !decl.BodyData || decl.Name != "calculated" { + t.Errorf("action execution = %#v, want calculated declared as state data", body[0]) + } +} diff --git a/internal/ir/lower/state_graph.go b/internal/ir/lower/state_graph.go index 843dd5a7f1..4f9cef8ef8 100644 --- a/internal/ir/lower/state_graph.go +++ b/internal/ir/lower/state_graph.go @@ -474,7 +474,7 @@ func lowerStateAttributes(graph *StateGraph, members []inheritedMember) []Attrib continue } graph.attributeScope[usage] = member.scope - attrs = append(attrs, Attribute{Name: name, Direction: usage.Direction, IsResult: usage.IsResult, Type: TypeText(usage), Value: usage.Value, Node: usage, Scope: member.scope}) + attrs = append(attrs, Attribute{Name: name, Direction: usage.Direction, IsResult: usage.IsResult, Type: TypeText(usage), Value: usage.Value, Binding: valueIsBinding(usage.Value, usage.ValueIsInitial, usage.ValueIsDefault), Node: usage, Scope: member.scope}) } return attrs } diff --git a/internal/ir/lower/state_inheritance.go b/internal/ir/lower/state_inheritance.go index 8df68a70a3..2b9f816c2b 100644 --- a/internal/ir/lower/state_inheritance.go +++ b/internal/ir/lower/state_inheritance.go @@ -397,7 +397,7 @@ func (g *StateGraph) addMember(content *stateContent, member ast.Node, parallel // A restated run-to-completion default is what the executor implements, not a slot. case isStateDatum(m): if name, _ := ast.EffectiveName(m); name != "" { - content.attrs = append(content.attrs, Attribute{Name: name, Direction: m.Direction, IsResult: m.IsResult, Type: TypeText(m), Value: m.Value, Node: m, Scope: scope}) + content.attrs = append(content.attrs, Attribute{Name: name, Direction: m.Direction, IsResult: m.IsResult, Type: TypeText(m), Value: m.Value, Binding: valueIsBinding(m.Value, m.ValueIsInitial, m.ValueIsDefault), Node: m, Scope: scope}) g.attributeScope[m] = scope } default: diff --git a/tests/parser/testdata/parse/body_feature_bindings.golden b/tests/parser/testdata/parse/body_feature_bindings.golden new file mode 100644 index 0000000000..e4b66ad7f4 --- /dev/null +++ b/tests/parser/testdata/parse/body_feature_bindings.golden @@ -0,0 +1,98 @@ +(RootNamespace + (Membership visibility="default" + (Package name="BodyFeatureBindings" library=false standard=false + (Import visibility="private" all=false kind=namespace recursive=false imported="ScalarValues" filtered=false) + (Membership visibility="default" + (Definition kind="action" abstract=false variation=false name="Work" + (Membership visibility="default" + (Usage kind="attribute" name="source" ref=false direction="none" composite=false derived=false ordered=false nonunique=false initial=true + (Relationship kind="typing" target=Integer + (*ast.QualifiedName)) + (LiteralInteger value="1"))) + (Membership visibility="default" + (Usage kind="attribute" name="live" ref=false direction="none" composite=false derived=false ordered=false nonunique=false + (Relationship kind="typing" target=Integer + (*ast.QualifiedName)) + (OperatorExpr operator="*" + (FeatureReference name="source") + (LiteralInteger value="2")))) + (Membership visibility="default" + (Usage kind="attribute" name="fallback" ref=false direction="none" composite=false derived=false ordered=false nonunique=false default=true + (Relationship kind="typing" target=Integer + (*ast.QualifiedName)) + (FeatureReference name="source"))) + (Membership visibility="default" + (Usage kind="action" name="nested" ref=false direction="none" composite=false derived=false ordered=false nonunique=false + (Membership visibility="default" + (Usage kind="attribute" name="nestedLive" ref=false direction="none" composite=false derived=false ordered=false nonunique=false + (Relationship kind="typing" target=Integer + (*ast.QualifiedName)) + (OperatorExpr operator="+" + (FeatureReference name="source") + (LiteralInteger value="1")))))) + (IfActionNode + (OperatorExpr operator=">" + (FeatureReference name="source") + (LiteralInteger value="0")) + (IfBranchNode kind="then" + (Membership visibility="default" + (Usage kind="attribute" name="blockLive" ref=false direction="none" composite=false derived=false ordered=false nonunique=false + (Relationship kind="typing" target=Integer + (*ast.QualifiedName)) + (OperatorExpr operator="*" + (FeatureReference name="source") + (LiteralInteger value="3")))) + (*ast.AssignmentActionNode))))) + (Membership visibility="default" + (Definition kind="state" abstract=false variation=false name="Machine" + (Membership visibility="default" + (Usage kind="attribute" name="source" ref=false direction="none" composite=false derived=false ordered=false nonunique=false initial=true + (Relationship kind="typing" target=Integer + (*ast.QualifiedName)) + (LiteralInteger value="1"))) + (Membership visibility="default" + (Usage kind="attribute" name="live" ref=false direction="none" composite=false derived=false ordered=false nonunique=false + (Relationship kind="typing" target=Integer + (*ast.QualifiedName)) + (OperatorExpr operator="*" + (FeatureReference name="source") + (LiteralInteger value="2")))) + (Membership visibility="default" + (Usage kind="state" name="active" ref=false direction="none" composite=false derived=false ordered=false nonunique=false + (Membership visibility="default" + (Usage kind="attribute" name="stateLive" ref=false direction="none" composite=false derived=false ordered=false nonunique=false + (Relationship kind="typing" target=Integer + (*ast.QualifiedName)) + (OperatorExpr operator="+" + (FeatureReference name="source") + (LiteralInteger value="1")))) + (EntryMember + (Membership visibility="default" + (Usage kind="action" name="" ref=false direction="none" composite=false derived=false ordered=false nonunique=false prefix="entry" + (Membership visibility="default" + (Usage kind="attribute" name="entryLive" ref=false direction="none" composite=false derived=false ordered=false nonunique=false + (Relationship kind="typing" target=Integer + (*ast.QualifiedName)) + (OperatorExpr operator="*" + (FeatureReference name="source") + (LiteralInteger value="3"))))))) + (DoMember + (Membership visibility="default" + (Usage kind="action" name="" ref=false direction="none" composite=false derived=false ordered=false nonunique=false prefix="do" + (Membership visibility="default" + (Usage kind="attribute" name="doLive" ref=false direction="none" composite=false derived=false ordered=false nonunique=false + (Relationship kind="typing" target=Integer + (*ast.QualifiedName)) + (OperatorExpr operator="*" + (FeatureReference name="source") + (LiteralInteger value="4"))))))) + (ExitMember + (Membership visibility="default" + (Usage kind="action" name="" ref=false direction="none" composite=false derived=false ordered=false nonunique=false prefix="exit" + (Membership visibility="default" + (Usage kind="attribute" name="exitLive" ref=false direction="none" composite=false derived=false ordered=false nonunique=false + (Relationship kind="typing" target=Integer + (*ast.QualifiedName)) + (OperatorExpr operator="*" + (FeatureReference name="source") + (LiteralInteger value="5")))))))))))))) \ No newline at end of file diff --git a/tests/parser/testdata/parse/body_feature_bindings.sysml b/tests/parser/testdata/parse/body_feature_bindings.sysml new file mode 100644 index 0000000000..31ceb9d3f6 --- /dev/null +++ b/tests/parser/testdata/parse/body_feature_bindings.sysml @@ -0,0 +1,36 @@ +package BodyFeatureBindings { + private import ScalarValues::*; + + action def Work { + attribute source : Integer := 1; + attribute live : Integer = source * 2; + attribute fallback : Integer default source; + + action nested { + attribute nestedLive : Integer = source + 1; + } + + if source > 0 { + attribute blockLive : Integer = source * 3; + assign source := 2; + } + } + + state def Machine { + attribute source : Integer := 1; + attribute live : Integer = source * 2; + + state active { + attribute stateLive : Integer = source + 1; + entry { + attribute entryLive : Integer = source * 3; + } + do { + attribute doLive : Integer = source * 4; + } + exit { + attribute exitLive : Integer = source * 5; + } + } + } +} From 74f9e965219e3f5439d7f790ae71f63439e53020 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 03:29:53 +0000 Subject: [PATCH 2/7] fix(runtime): reuse materialized body binding values Co-Authored-By: jason.han --- internal/exec/runtime/action_executor.go | 16 ++++ internal/exec/runtime/action_frame.go | 3 + internal/exec/runtime/body_bindings.go | 73 ++++++++++++++++++- internal/exec/runtime/calc_statements.go | 2 + internal/exec/runtime/calc_usage.go | 2 + internal/exec/runtime/check_state.go | 1 + internal/exec/runtime/conformance_test.go | 24 +++++- internal/exec/runtime/dependents.go | 2 + internal/exec/runtime/frame.go | 4 + internal/exec/runtime/held_image_behavior.go | 5 ++ .../robustness_body_feature_bindings_test.go | 36 ++++++++- internal/exec/runtime/snapshot.go | 1 + internal/exec/runtime/state_executor.go | 14 ++++ internal/exec/runtime/statements.go | 15 +++- .../runtime/testdata/conformance/README.md | 21 +++--- ..._multiplicity_shared_writers.expected.json | 5 +- ...rminate_names_exhibiting_part.trace.golden | 1 - ...d_state_guard_reads_attribute.trace.golden | 1 - .../exhibited_state_self_target.trace.golden | 2 - ...ormed_action_later_node_reads.trace.golden | 1 - ...ock_state_interleave.declared.trace.golden | 9 --- ...clock_state_interleave.seed-1.trace.golden | 9 --- ..._space_clock_state_interleave.trace.golden | 9 --- ...ate_space_zero_crossing_state.trace.golden | 9 --- internal/ir/lower/action_graph.go | 1 + internal/ir/lower/body_binding_test.go | 2 + internal/ir/lower/state_behavior_test.go | 1 + 27 files changed, 206 insertions(+), 63 deletions(-) diff --git a/internal/exec/runtime/action_executor.go b/internal/exec/runtime/action_executor.go index 713e54dfa6..f0537f6bbf 100644 --- a/internal/exec/runtime/action_executor.go +++ b/internal/exec/runtime/action_executor.go @@ -1161,6 +1161,18 @@ func (e *ActionExecutor) initializeAttributes() error { return fmt.Errorf("%w: read %s of object #%d: %w", ErrActionPerformanceOccurrence, attr.Name, e.occurrence.ID, err) } + if attr.Binding && !fv.Written && !e.occurrenceRedefinesAttribute(attr, fv) { + value, seeded, err := e.ctx.seedBodyBindingFromFeatureValue(e.root.cells, e.root.key(attr.Name), fv) + if err != nil { + return fmt.Errorf("eval attribute default %s: %w", attr.Name, err) + } + if seeded { + if err := e.streamInitialOutput(attr.Name, value); err != nil { + return err + } + continue + } + } if value := fv.HeldValue(); value.Kind != ValInvalid && (!attr.Binding || fv.Written || e.occurrenceRedefinesAttribute(attr, fv)) { e.ctx.writeBodyValue(e.root.cells, e.root.data, e.root.key(attr.Name), value) @@ -1329,6 +1341,7 @@ func (e *ActionExecutor) mirrorOccurrence(name string, value Value) (Value, erro return fv.HeldValue(), nil } +// mirrorBindingOccurrence exposes a tracked body feature through its occurrence. func (e *ActionExecutor) mirrorBindingOccurrence(name string, value Value, cell *bodyCell) (Value, error) { if e.occurrence == nil || !e.declaresAttribute(name) { return value, nil @@ -1341,6 +1354,7 @@ func (e *ActionExecutor) mirrorBindingOccurrence(name string, value Value, cell return mirrored, nil } +// occurrenceRedefinesAttribute reports whether the occurrence supplies another declaration's default. func (e *ActionExecutor) occurrenceRedefinesAttribute(attr lower.Attribute, fv *FeatureValue) bool { return occurrenceHasRedefinedDefault(fv, attr, e.graph.Scope) } @@ -1454,6 +1468,7 @@ func (e *ActionExecutor) bindInputs() error { return nil } +// beginRootPerformance starts the root performance's dependency lifetime. func (e *ActionExecutor) beginRootPerformance() { if e.root.began == 0 { e.root.began = e.ctx.newActivation() @@ -1461,6 +1476,7 @@ func (e *ActionExecutor) beginRootPerformance() { e.ctx.beginPerformanceLife(e.occurrence, e.root.began) } +// endRootPerformance ends the root performance's dependency lifetime. func (e *ActionExecutor) endRootPerformance() { e.ctx.endPerformanceLife(e.occurrence) e.ctx.endActivation(e.root.began) diff --git a/internal/exec/runtime/action_frame.go b/internal/exec/runtime/action_frame.go index 8e6151ecb4..0702294913 100644 --- a/internal/exec/runtime/action_frame.go +++ b/internal/exec/runtime/action_frame.go @@ -223,6 +223,7 @@ func (e *ActionExecutor) newRootFrame() *actionFrame { return root } +// registerRootBindings installs tracked cells for the root's declared bindings. func (e *ActionExecutor) registerRootBindings(root *actionFrame) { for _, attr := range e.features { if !attr.Binding || attr.Value == nil { @@ -1193,6 +1194,7 @@ func (e *performances) evalContextFor(perf *actionFrame, scope *symbols.Scope) * return ec } +// evalBindingContext resolves a binding in its performance while masking its own name. func (e *performances) evalBindingContext( perf *actionFrame, scope *symbols.Scope, name string, activation int64, ) *EvalContext { @@ -1894,6 +1896,7 @@ func performanceFrame(f *actionFrame) frame { return fr } +// bodyCells lazily gives a performance data map its dependency cells. func (e *performances) bodyCells(perf *actionFrame) *bodyCells { if perf.cells == nil { perf.cells = newBodyCells(perf.data, func(scope *symbols.Scope) *EvalContext { diff --git a/internal/exec/runtime/body_bindings.go b/internal/exec/runtime/body_bindings.go index c872fee620..f891747545 100644 --- a/internal/exec/runtime/body_bindings.go +++ b/internal/exec/runtime/body_bindings.go @@ -9,13 +9,18 @@ import ( "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) +// bodyBindingCheckError identifies a value rejected by its body's declaration. type bodyBindingCheckError struct { err error } +// Error returns the underlying declaration-check error. func (e bodyBindingCheckError) Error() string { return e.err.Error() } + +// Unwrap exposes the declaration-check error to errors.Is and errors.As. func (e bodyBindingCheckError) Unwrap() error { return e.err } +// bodyCells holds one runtime store and its dependency-graph cells. type bodyCells struct { vars map[string]Value cells map[string]*bodyCell @@ -23,6 +28,7 @@ type bodyCells struct { context func(*symbols.Scope) *EvalContext } +// bodyCell pairs a stored value with its dependency-graph cell. type bodyCell struct { owner *bodyCells name string @@ -30,6 +36,7 @@ type bodyCell struct { binding *bodyBinding } +// bodyBinding records the expression and declaration context a cell tracks. type bodyBinding struct { value ast.Node scope *symbols.Scope @@ -41,6 +48,7 @@ type bodyBinding struct { frozen bool } +// occurrenceHasRedefinedDefault reports whether an occurrence holds an overriding declaration's default. func occurrenceHasRedefinedDefault(fv *FeatureValue, attr lower.Attribute, fallback *symbols.Scope) bool { if fv == nil || fv.Feature == nil || fv.Feature.DefaultDecl == nil { return false @@ -53,6 +61,7 @@ func occurrenceHasRedefinedDefault(fv *FeatureValue, attr lower.Attribute, fallb return declared != nil && fv.Feature.DefaultDecl != declared } +// bodyCellState is the observable tracking state of one body cell. type bodyCellState struct { cell *bodyCell written bool @@ -61,6 +70,7 @@ type bodyCellState struct { tracking bool } +// bodyCellsCapture preserves a store's maps and binding metadata for restoration. type bodyCellsCapture struct { cells *bodyCells vars mapState[string, Value] @@ -69,6 +79,7 @@ type bodyCellsCapture struct { order []string } +// captureBodyCells snapshots the maps and binding metadata without deriving values. func captureBodyCells(cells *bodyCells) bodyCellsCapture { capture := bodyCellsCapture{cells: cells} if cells == nil { @@ -89,6 +100,7 @@ func captureBodyCells(cells *bodyCells) bodyCellsCapture { return capture } +// restore reinstates the maps and bindings held by the capture. func (c bodyCellsCapture) restore() { if c.cells == nil { return @@ -106,10 +118,12 @@ func (c bodyCellsCapture) restore() { } } +// newBodyCells wraps a value store with a lazy dependency-cell map. func newBodyCells(vars map[string]Value, context func(*symbols.Scope) *EvalContext) *bodyCells { return &bodyCells{vars: vars, cells: make(map[string]*bodyCell), context: context} } +// cell returns the named cell, creating a source cell for an existing value. func (cells *bodyCells) cell(name string) *bodyCell { if cells == nil { return nil @@ -128,6 +142,7 @@ func (cells *bodyCells) cell(name string) *bodyCell { return cell } +// existingCell returns the named cell without creating one. func (cells *bodyCells) existingCell(name string) *bodyCell { if cells == nil { return nil @@ -135,6 +150,7 @@ func (cells *bodyCells) existingCell(name string) *bodyCell { return cells.cells[name] } +// registerBodyBinding adds an expression-backed cell using the store's context. func (ctx *Context) registerBodyBinding( cells *bodyCells, name string, @@ -146,6 +162,7 @@ func (ctx *Context) registerBodyBinding( return ctx.registerBodyBindingInContext(cells, name, value, scope, check, nil, onDerived) } +// registerBodyBindingInContext adds an expression-backed cell with a custom context. func (ctx *Context) registerBodyBindingInContext( cells *bodyCells, name string, @@ -162,6 +179,7 @@ func (ctx *Context) registerBodyBindingInContext( return cell } +// deriveBodyCell evaluates an unmaterialized binding and records its dependencies. func (ctx *Context) deriveBodyCell(cells *bodyCells, name string, cell *bodyCell) (Value, error) { if cell == nil || cell.binding == nil || cell.binding.frozen || cell.fv.Written { value, ok := cells.vars[name] @@ -199,9 +217,9 @@ func (ctx *Context) deriveBodyCell(cells *bodyCells, name string, cell *bodyCell frames[i].visible = map[string]bool{} } } - copy := *ec - copy.frames = frames - ec = © + contextCopy := *ec + contextCopy.frames = frames + ec = &contextCopy } value, err := ec.Eval(cell.binding.value) if err != nil { @@ -226,6 +244,42 @@ func (ctx *Context) deriveBodyCell(cells *bodyCells, name string, cell *bodyCell return value, err } +// seedBodyBindingFromFeatureValue reuses a matching occurrence value and its read edges. +func (ctx *Context) seedBodyBindingFromFeatureValue(cells *bodyCells, name string, source *FeatureValue) (Value, bool, error) { + cell := cells.existingCell(name) + if cell == nil || cell.binding == nil || source == nil || !source.Materialized || source.Written || source.body != nil { + return Value{}, false, nil + } + value := source.HeldValue() + if value.Kind == ValInvalid { + return Value{}, false, nil + } + if cell.binding.check != nil { + if err := cell.binding.check(&value); err != nil { + return Value{}, false, bodyBindingCheckError{err: err} + } + } + reads := append([]*FeatureValue(nil), source.reads...) + ctx.forgetReads(&cell.fv) + ctx.noteProbeWrite(&cell.fv) + for _, read := range reads { + ctx.listRead(read, &cell.fv) + } + cell.fv.Value, cell.fv.Values, cell.fv.Materialized = value, Value{}, true + cell.fv.Written, cell.fv.BindingDerived, cell.fv.intrinsic = false, false, false + cells.vars[name] = value + if cell.binding.onDerived != nil { + if err := cell.binding.onDerived(&value); err != nil { + return Value{}, false, err + } + cell.fv.Value = value + cells.vars[name] = value + } + ctx.noteRead(nil, &cell.fv) + return value, true, nil +} + +// cloneBodyBindingVisibility copies the lexical visibility captured by a binding. func cloneBodyBindingVisibility(visible []map[string]bool) []map[string]bool { if visible == nil { return nil @@ -237,6 +291,7 @@ func cloneBodyBindingVisibility(visible []map[string]bool) []map[string]bool { return cloned } +// readBodyCell returns the current value and records it as a dependency when needed. func (ctx *Context) readBodyCell(cells *bodyCells, name string) (Value, bool, error) { if cells == nil { return Value{}, false, nil @@ -266,6 +321,7 @@ func (ctx *Context) readBodyCell(cells *bodyCells, name string) (Value, bool, er return value, ok, nil } +// writeBodyCell stores a value and ends tracking when the cell is a binding. func (ctx *Context) writeBodyCell(cells *bodyCells, name string, value Value) { if cells == nil { return @@ -289,6 +345,7 @@ func (ctx *Context) writeBodyCell(cells *bodyCells, name string, value Value) { ctx.afterWrite(&cell.fv, held) } +// writeBodyValue stores a semantic value through its dependency cell when present. func (ctx *Context) writeBodyValue(cells *bodyCells, vars map[string]Value, name string, value Value) { if cells == nil { vars[name] = value @@ -297,6 +354,7 @@ func (ctx *Context) writeBodyValue(cells *bodyCells, vars map[string]Value, name ctx.writeBodyCell(cells, name, value) } +// mirrorBodyCell exposes a body's tracked value through the matching occurrence feature. func (ctx *Context) mirrorBodyCell(inst *Instance, name string, cell *bodyCell, value Value) (Value, error) { if inst == nil { return value, nil @@ -321,6 +379,7 @@ func (ctx *Context) mirrorBodyCell(inst *Instance, name string, cell *bodyCell, return fv.HeldValue(), nil } +// clearBodyValue clears a stored value and invalidates its dependents. func (ctx *Context) clearBodyValue(cells *bodyCells, vars map[string]Value, name string) { if cells == nil || cells.cells[name] == nil { delete(vars, name) @@ -341,6 +400,7 @@ func (ctx *Context) clearBodyValue(cells *bodyCells, vars map[string]Value, name ctx.afterWrite(&cell.fv, held) } +// bodyCellStateOf reports the stored and tracking state of a cell. func bodyCellStateOf(cells *bodyCells, name string) bodyCellState { if cells == nil || cells.cells[name] == nil { return bodyCellState{} @@ -354,6 +414,7 @@ func bodyCellStateOf(cells *bodyCells, name string) bodyCellState { return state } +// restoreBodyCellState reinstates a captured cell state and its dependency edges. func (ctx *Context) restoreBodyCellState(cells *bodyCells, name string, state bodyCellState) { if state.cell == nil { return @@ -379,6 +440,7 @@ func (ctx *Context) restoreBodyCellState(cells *bodyCells, name string, state bo } } +// readBodyValue reads a value from its dependency-aware store. func (ctx *Context) readBodyValue(cells *bodyCells, vars map[string]Value, name string) (Value, bool, error) { if cells == nil { value, ok := vars[name] @@ -387,6 +449,7 @@ func (ctx *Context) readBodyValue(cells *bodyCells, vars map[string]Value, name return ctx.readBodyCell(cells, name) } +// deriveBodyCells settles the still-tracking cells of a store. func (ctx *Context) deriveBodyCells(cells *bodyCells) error { if cells == nil { return nil @@ -403,6 +466,7 @@ func (ctx *Context) deriveBodyCells(cells *bodyCells) error { return nil } +// freezeBodyCells derives and freezes every still-tracking cell in a store. func (ctx *Context) freezeBodyCells(cells *bodyCells) error { if err := ctx.deriveBodyCells(cells); err != nil { return err @@ -422,6 +486,7 @@ func (ctx *Context) freezeBodyCells(cells *bodyCells) error { return nil } +// forgetBodyCells removes the dependencies of every binding in a store. func (ctx *Context) forgetBodyCells(cells *bodyCells) { if cells == nil { return @@ -435,6 +500,7 @@ func (ctx *Context) forgetBodyCells(cells *bodyCells) { } } +// exitBodyCells invalidates values owned by an exiting state. func (ctx *Context) exitBodyCells(cells *bodyCells) { if cells == nil { return @@ -451,6 +517,7 @@ func (ctx *Context) exitBodyCells(cells *bodyCells) { } } +// resetBodyCells removes the stored cells and their dependency edges. func (ctx *Context) resetBodyCells(cells *bodyCells) { if cells == nil { return diff --git a/internal/exec/runtime/calc_statements.go b/internal/exec/runtime/calc_statements.go index ae34fea71e..d7bf959117 100644 --- a/internal/exec/runtime/calc_statements.go +++ b/internal/exec/runtime/calc_statements.go @@ -194,10 +194,12 @@ func (h *calcStmtHost) mirrorOccurrence(name string, value Value) error { return nil } +// mirrorRootLocal mirrors a calc's root local into its performance occurrence. func (h *calcStmtHost) mirrorRootLocal(name string, value Value) error { return h.mirrorOccurrence(name, value) } +// mirrorBodyBinding keeps an occurrence feature linked to a calc body binding. func (h *calcStmtHost) mirrorBodyBinding(name string, cell *bodyCell, value *Value) error { if h.occ == nil || h.occ.inst == nil { return nil diff --git a/internal/exec/runtime/calc_usage.go b/internal/exec/runtime/calc_usage.go index 9411fd5aaf..190a433b80 100644 --- a/internal/exec/runtime/calc_usage.go +++ b/internal/exec/runtime/calc_usage.go @@ -464,6 +464,7 @@ type calcRun struct { occurrence *calcOccurrence } +// lookup searches the calc's body frames before its enclosing evaluation frame. func (run *calcRun) lookup(name string) (Value, bool) { for i := len(run.bodyFrames) - 1; i >= 0; i-- { if value, ok := run.bodyFrames[i].lookup(name); ok { @@ -1089,6 +1090,7 @@ func (run *calcRun) output(ctx *Context, name string) (Value, error) { return value, nil } +// readBodyOutput returns a calc output after deriving its body-local dependencies. func (run *calcRun) readBodyOutput(ctx *Context, name string) (Value, error) { if run.env.cells == nil && len(ctx.deriving) != 0 { run.env.cells = newBodyCells(run.env.vars, nil) diff --git a/internal/exec/runtime/check_state.go b/internal/exec/runtime/check_state.go index 14fab47f1d..1de829e450 100644 --- a/internal/exec/runtime/check_state.go +++ b/internal/exec/runtime/check_state.go @@ -728,6 +728,7 @@ func (s *stateSpeller) values(m map[string]Value) string { return strings.Join(parts, ", ") } +// bodyValues spells stored values and the tracking state of body bindings. func (s *stateSpeller) bodyValues(values map[string]Value, cells *bodyCells) string { if cells == nil { return s.values(values) diff --git a/internal/exec/runtime/conformance_test.go b/internal/exec/runtime/conformance_test.go index 0c22bc2d6c..d98810610a 100644 --- a/internal/exec/runtime/conformance_test.go +++ b/internal/exec/runtime/conformance_test.go @@ -735,16 +735,25 @@ func (b *ExpectedExploreBudget) budget() ExploreBudget { // admissibleSchemaProblems reports how a case misuses outcomes and admissible: // the two go together, replace the single outcome rather than sit beside it, -// list distinct results, and cite a section the oracle has. +// list at least two distinct results, and cite a section the oracle has. func admissibleSchemaProblems(expected ExpectedOutcome, oracleTitles map[string]bool) []string { var problems []string if len(expected.Outcomes) == 0 { if expected.Admissible != "" { problems = append(problems, "admissible is stated without outcomes to admit") } - if expected.ExploreBudget != nil { + hasSingleOutcome := expected.Outputs != nil || expected.FinalState != "" || expected.StateVisits != nil || expected.Terminated + if expected.ExploreBudget != nil && !hasSingleOutcome { problems = append(problems, "exploreBudget is stated without outcomes to explore") } + if expected.ExploreBudget != nil && hasSingleOutcome { + if expected.Type != "action" && expected.Type != "state" { + problems = append(problems, fmt.Sprintf("exploreBudget applies to action and state cases, not %q", expected.Type)) + } + if _, err := ExplorePolicy(expected.ExploreBudget.budget()); err != nil { + problems = append(problems, "exploreBudget: "+err.Error()) + } + } return problems } if _, err := ExplorePolicy(expected.ExploreBudget.budget()); err != nil { @@ -759,6 +768,9 @@ func admissibleSchemaProblems(expected ExpectedOutcome, oracleTitles map[string] if len(expected.Performers) > 0 { problems = append(problems, "outcomes and performers are stated together") } + if len(expected.Outcomes) < 2 { + problems = append(problems, "outcomes lists one result; state it as the single outcome") + } for i, outcome := range expected.Outcomes { if outcome.Outputs == nil && outcome.FinalState == "" && outcome.StateVisits == nil && !outcome.Terminated { problems = append(problems, fmt.Sprintf("outcome %d states nothing", i+1)) @@ -2416,7 +2428,7 @@ func validateElements(t reporter, ctx *Context, name string, expected []Expected } // TestAdmissibleOutcomesSchema pins that an admissible set cannot hide a bug: it -// replaces the single outcome, lists distinct results, and cites an oracle section. +// replaces the single outcome, lists at least two, and cites an oracle section. func TestAdmissibleOutcomesSchema(t *testing.T) { titles := oracleSectionTitles(t) const cited = "Concurrent branches writing one feature: the value is open, the writes are not" @@ -2429,12 +2441,16 @@ func TestAdmissibleOutcomesSchema(t *testing.T) { {Outputs: map[string]ExpectedValue{"x": one}}, {Outputs: map[string]ExpectedValue{"x": two}}, } + runs := 65536 tests := []struct { name string expected ExpectedOutcome problems int }{ {"single outcome", ExpectedOutcome{Type: "action", Outputs: outcomes[0].Outputs}, 0}, + {"single outcome explore budget", ExpectedOutcome{ + Type: "action", Outputs: outcomes[0].Outputs, ExploreBudget: &ExpectedExploreBudget{Runs: &runs}, + }, 0}, {"admissible set", ExpectedOutcome{Type: "action", Outcomes: outcomes, Admissible: cited}, 0}, {"state admissible set", ExpectedOutcome{Type: "state", Outcomes: []AdmittedOutcome{{FinalState: "A"}, {FinalState: "B"}}, Admissible: cited}, 0}, {"outcomes beside outputs", ExpectedOutcome{Type: "action", Outputs: outcomes[0].Outputs, Outcomes: outcomes, Admissible: cited}, 1}, @@ -2443,7 +2459,7 @@ func TestAdmissibleOutcomesSchema(t *testing.T) { {"missing admissible", ExpectedOutcome{Type: "action", Outcomes: outcomes}, 1}, {"admissible cites no section", ExpectedOutcome{Type: "action", Outcomes: outcomes, Admissible: "the value is open"}, 1}, {"admissible without outcomes", ExpectedOutcome{Type: "action", Outputs: outcomes[0].Outputs, Admissible: cited}, 1}, - {"one outcome listed", ExpectedOutcome{Type: "action", Outcomes: outcomes[:1], Admissible: cited}, 0}, + {"one outcome listed", ExpectedOutcome{Type: "action", Outcomes: outcomes[:1], Admissible: cited}, 1}, {"empty outcome", ExpectedOutcome{Type: "action", Outcomes: []AdmittedOutcome{outcomes[0], {}}, Admissible: cited}, 1}, {"calc case", ExpectedOutcome{Type: "calc", Outcomes: outcomes, Admissible: cited}, 1}, } diff --git a/internal/exec/runtime/dependents.go b/internal/exec/runtime/dependents.go index 10a1a419be..03105607fe 100644 --- a/internal/exec/runtime/dependents.go +++ b/internal/exec/runtime/dependents.go @@ -109,6 +109,7 @@ func (ctx *Context) deriveWith(fv *FeatureValue, eval func() (Value, error)) (Va } } +// deriveOnceWith evaluates one dependency-tracked attempt and reports invalidation. func (ctx *Context) deriveOnceWith(fv *FeatureValue, eval func() (Value, error)) (val Value, stale bool, err error) { top := len(ctx.deriving) ctx.deriving = append(ctx.deriving, derivation{fv: fv}) @@ -120,6 +121,7 @@ func (ctx *Context) deriveOnceWith(fv *FeatureValue, eval func() (Value, error)) return val, false, err } +// derivingValue reports whether fv is already on the active derivation stack. func (ctx *Context) derivingValue(fv *FeatureValue) bool { for i := range ctx.deriving { if ctx.deriving[i].fv == fv { diff --git a/internal/exec/runtime/frame.go b/internal/exec/runtime/frame.go index ed75d2dcf7..48670ef17c 100644 --- a/internal/exec/runtime/frame.go +++ b/internal/exec/runtime/frame.go @@ -140,6 +140,7 @@ func (f frame) lookup(name string) (Value, bool) { return value, ok } +// read resolves a name and derives its body cell when the stored value is stale. func (f frame) read(ctx *Context, name string) (Value, bool, error) { name = canonical(f.aliases, name) if f.masked[name] || (f.visible != nil && !f.visible[name]) { @@ -175,6 +176,7 @@ func (f frame) set(name string, value Value) { f.vars[name] = value } +// setBody writes through the frame's dependency cell when one is present. func (f frame) setBody(ctx *Context, name string, value Value) { name = canonical(f.aliases, name) if f.slots != nil && f.slots.set(name, value) { @@ -214,6 +216,7 @@ func (f frame) each(fn func(name string, value Value)) { } } +// visibleNames returns the visible names bound by the frame and its cells. func (f frame) visibleNames() map[string]bool { names := make(map[string]bool, len(f.vars)) f.each(func(name string, _ Value) { names[canonical(f.aliases, name)] = true }) @@ -225,6 +228,7 @@ func (f frame) visibleNames() map[string]bool { return names } +// eachCurrent visits frame values after settling its tracked bindings. func (f frame) eachCurrent(ctx *Context, fn func(name string, value Value)) error { if f.cells != nil { if err := ctx.deriveBodyCells(f.cells); err != nil { diff --git a/internal/exec/runtime/held_image_behavior.go b/internal/exec/runtime/held_image_behavior.go index 7d48356bd4..43baec039d 100644 --- a/internal/exec/runtime/held_image_behavior.go +++ b/internal/exec/runtime/held_image_behavior.go @@ -71,6 +71,7 @@ type imagedFrame struct { nested map[ast.Node][]nestedDelivery } +// imagedBodyCell stores a body binding's value and tracking state for an image. type imagedBodyCell struct { name string value ast.Node @@ -689,6 +690,7 @@ func (m *materializing) frame(perf *actionFrame, img imagedFrame, frameAt func(i return nil } +// imageBodyCells captures binding declarations and flags without dependency edges. func imageBodyCells(cells *bodyCells, values map[string]Value) []imagedBodyCell { if cells == nil { return nil @@ -712,6 +714,7 @@ func imageBodyCells(cells *bodyCells, values map[string]Value) []imagedBodyCell return image } +// restoreStateBodyCells reinstates the binding cells owned by a state image. func restoreStateBodyCells( ctx *Context, cells *bodyCells, @@ -747,6 +750,7 @@ func restoreStateBodyCells( } } +// restoreBodyCellValue restores a body's value and written or tracking state. func restoreBodyCellValue(cells *bodyCells, state imagedBodyCell, cell *bodyCell) { if value, held := cells.vars[state.name]; held { cell.fv.Value, cell.fv.Materialized = value, true @@ -761,6 +765,7 @@ func restoreBodyCellValue(cells *bodyCells, state imagedBodyCell, cell *bodyCell } } +// frameCells rebuilds the dependency cells for a materialized performance frame. func (m *materializing) frameCells( perf *actionFrame, values map[string]Value, diff --git a/internal/exec/runtime/robustness_body_feature_bindings_test.go b/internal/exec/runtime/robustness_body_feature_bindings_test.go index 332d58e9e0..d139b345b6 100644 --- a/internal/exec/runtime/robustness_body_feature_bindings_test.go +++ b/internal/exec/runtime/robustness_body_feature_bindings_test.go @@ -6,6 +6,7 @@ import ( "testing" ) +// TestRuntimeRobustnessBodyFeatureBindings pins body-binding errors and lifetimes. func TestRuntimeRobustnessBodyFeatureBindings(t *testing.T) { t.Run("cycle", func(t *testing.T) { ctx, action := loadAction(t, `package test { @@ -288,7 +289,7 @@ func TestRuntimeRobustnessBodyFeatureBindings(t *testing.T) { } }) - t.Run("calc_usage_remains_one_shot", func(t *testing.T) { + t.Run("action_body_calc_usage_remains_one_shot", func(t *testing.T) { ctx, action := loadAction(t, `package test { private import ScalarValues::*; calc def Twice { @@ -318,4 +319,37 @@ func TestRuntimeRobustnessBodyFeatureBindings(t *testing.T) { t.Fatalf("calc usage result = %s, want its one-shot pin value 4", got) } }) + + t.Run("block_body_calc_usage_remains_one_shot", func(t *testing.T) { + ctx, action := loadAction(t, `package test { + private import ScalarValues::*; + calc def Rhs { + in x : Integer; + out y : Integer = x * 2; + } + action run { + attribute x : Integer := 2; + out attribute result : Integer; + first start; + then action write { + if true { + calc k1 : Rhs { in x = x; } + assign result := k1.y; + assign x := 5; + assign result := k1.y; + } + } + } + }`, "run") + exec, err := ctx.CreateActionExecutor(action) + if err != nil { + t.Fatalf("CreateActionExecutor: %v", err) + } + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("RunToCompletion: %v", err) + } + if got := FormatValue(exec.Results()["result"]); got != "4" { + t.Fatalf("block calc usage result = %s, want its one-shot pin value 4", got) + } + }) } diff --git a/internal/exec/runtime/snapshot.go b/internal/exec/runtime/snapshot.go index f4f55d1fbc..75b524ae0d 100644 --- a/internal/exec/runtime/snapshot.go +++ b/internal/exec/runtime/snapshot.go @@ -313,6 +313,7 @@ func (c *bodyCapture) restore() { } } +// bodyCellsCaptured reports whether a snapshot journals the given cell store. func bodyCellsCaptured(captures []bodyCellsCapture, cells *bodyCells) bool { for _, capture := range captures { if capture.cells == cells { diff --git a/internal/exec/runtime/state_executor.go b/internal/exec/runtime/state_executor.go index bd1cc011ab..0d30efc220 100644 --- a/internal/exec/runtime/state_executor.go +++ b/internal/exec/runtime/state_executor.go @@ -348,6 +348,15 @@ func (e *StateExecutor) initializeAttributes() error { return fmt.Errorf("%w: read %s of object #%d: %w", ErrStatePerformanceOccurrence, attr.Name, e.occurrence.ID, err) } + if attr.Binding && !fv.Written && !occurrenceHasRedefinedDefault(fv, attr, e.graph.Scope) { + _, seeded, err := e.ctx.seedBodyBindingFromFeatureValue(e.stateCells, attr.Name, fv) + if err != nil { + return fmt.Errorf("derive state machine attribute %s: %w", attr.Name, err) + } + if seeded { + continue + } + } if value := fv.HeldValue(); value.Kind != ValInvalid && (!attr.Binding || fv.Written || occurrenceHasRedefinedDefault(fv, attr, e.graph.Scope)) { e.ctx.writeBodyValue(e.stateCells, e.stateData, attr.Name, value) @@ -412,6 +421,7 @@ func (e *StateExecutor) dataFrame() frame { } } +// ensureStateCells lazily creates dependency cells for machine data. func (e *StateExecutor) ensureStateCells() *bodyCells { if e.stateCells == nil { e.stateCells = newBodyCells(e.stateData, func(scope *symbols.Scope) *EvalContext { @@ -424,6 +434,7 @@ func (e *StateExecutor) ensureStateCells() *bodyCells { return e.stateCells } +// stateAttributeContext resolves an attribute in machine data and its state frames. func (e *StateExecutor) stateAttributeContext(state *ast.StateNode, scope *symbols.Scope) *EvalContext { ec := NewEvalContextIn(e.ctx, scope, e.self) ec.thisOccurrence = e.materializeOccurrence @@ -596,6 +607,7 @@ func (e *StateExecutor) mirrorOccurrence(name string, value Value) (Value, error return fv.HeldValue(), nil } +// mirrorBindingOccurrence exposes a tracked state feature through its occurrence. func (e *StateExecutor) mirrorBindingOccurrence(name string, value Value, cell *bodyCell) (Value, error) { if e.occurrence == nil || !e.declaresAttribute(name) { return value, nil @@ -5605,10 +5617,12 @@ func (e *StateExecutor) StateDataWithError() (map[string]Value, error) { return data, deriveErr } +// copyStateValues copies a state's materialized values under its qualified path. func copyStateValues(values map[string]Value, cells *bodyCells, prefix string) map[string]Value { return copyStateValuesInto(make(map[string]Value, len(values)), values, cells, prefix) } +// copyStateValuesInto adds a state's materialized values to an output map. func copyStateValuesInto(target, values map[string]Value, cells *bodyCells, prefix string) map[string]Value { for name, value := range values { target[prefix+name] = value diff --git a/internal/exec/runtime/statements.go b/internal/exec/runtime/statements.go index e5d1f46485..ba06ef26fc 100644 --- a/internal/exec/runtime/statements.go +++ b/internal/exec/runtime/statements.go @@ -44,6 +44,7 @@ func (env *stmtEnv) enter(e *stmtEngine) map[string]Value { return frame } +// ensureLocalCells lazily creates dependency cells for an entered block. func (e *stmtEngine) ensureLocalCells(index int) *bodyCells { if e.env.cells[index] == nil { depth := index + 1 @@ -108,6 +109,7 @@ func (env *stmtEnv) declare(ctx *Context, name string, value Value) { } } +// ensureRootCells lazily creates dependency cells for behavior-local declarations. func (e *stmtEngine) ensureRootCells() *bodyCells { if e.env.localCells == nil { e.env.localCells = newBodyCells(e.env.locals, func(scope *symbols.Scope) *EvalContext { @@ -117,6 +119,7 @@ func (e *stmtEngine) ensureRootCells() *bodyCells { return e.env.localCells } +// rootDeclares reports whether the behavior's root scope declares name. func (env *stmtEnv) rootDeclares(name string) bool { if _, ok := env.locals[name]; ok { return true @@ -124,6 +127,7 @@ func (env *stmtEnv) rootDeclares(name string) bool { return env.unvaluedLocal[name] } +// rootLocal reports whether name belongs to the behavior root rather than a block. func (env *stmtEnv) rootLocal(name string) bool { if !env.rootDeclares(name) { return false @@ -136,6 +140,7 @@ func (env *stmtEnv) rootLocal(name string) bool { return true } +// assignRootLocal writes a name declared in the behavior's root scope. func (env *stmtEnv) assignRootLocal(ctx *Context, name string, value Value) bool { if !env.rootDeclares(name) { return false @@ -206,6 +211,7 @@ func (env *stmtEnv) values(ctx *Context) (map[string]Value, error) { return merged, nil } +// localFrames returns the entered block maps and their dependency-cell stores. func (env *stmtEnv) localFrames() ([]map[string]Value, []*bodyCells) { locals := make([]map[string]Value, 0, len(env.frames)+1) cells := make([]*bodyCells, 0, len(env.frames)+1) @@ -216,6 +222,7 @@ func (env *stmtEnv) localFrames() ([]map[string]Value, []*bodyCells) { return locals, cells } +// localFrame exposes the behavior's root locals as an evaluation frame. func (env *stmtEnv) localFrame() frame { local := mapFrame(env.locals) local.cells = env.localCells @@ -245,10 +252,12 @@ const ( flowReturn ) +// bodyBindingMirror lets a statement host mirror tracked local bindings. type bodyBindingMirror interface { mirrorBodyBinding(name string, cell *bodyCell, value *Value) error } +// rootLocalMirror lets a statement host mirror root-local values. type rootLocalMirror interface { mirrorRootLocal(name string, value Value) error } @@ -350,6 +359,7 @@ func (e *stmtEngine) finish() { e.ctx.endActivation(e.activation) } +// complete freezes the behavior's remaining bindings and ends its activation. func (e *stmtEngine) complete() error { err := e.ctx.freezeBodyCells(e.env.localCells) e.ctx.endActivation(e.activation) @@ -395,6 +405,7 @@ func (e *stmtEngine) evalIn(scope *symbols.Scope) *EvalContext { return ec } +// evalInDepth resolves a declaration with only the lexical frames visible at depth. func (e *stmtEngine) evalInDepth(scope *symbols.Scope, depth int) *EvalContext { frames := make([]frame, 0, len(e.env.enclosing)+1+len(e.env.outer)+depth+1) frames = append(frames, e.env.enclosing...) @@ -701,7 +712,7 @@ func (e *stmtEngine) execute(stmt lower.Statement) (stmtFlow, error) { // this execution of the body reads starts here, so an evaluation of the same // usage from before the declaration was reached is discarded. func (e *stmtEngine) declareUsage(stmt lower.DeclareUsage) error { - // Body-local calc usages remain one-shot; their pins do not track body `=` bindings. + // Known limitation: the `=` pins of a body-local calc usage are evaluated once per declaration and do not track later writes. sym, err := e.ctx.bodyUsageSymbol(stmt) if err != nil { return fmt.Errorf("%s: %w", e.host.describe(), err) @@ -754,6 +765,7 @@ type blockFrame struct { outer int64 } +// abandon releases dependencies and activation state for a discarded block. func (f *blockFrame) abandon(ctx *Context) { ctx.forgetBodyCells(f.cells) ctx.endActivation(f.activation) @@ -953,6 +965,7 @@ type loopFrame struct { elements []Value } +// abandon releases dependencies and activation state for a discarded loop. func (f *loopFrame) abandon(ctx *Context) { ctx.forgetBodyCells(f.cells) ctx.endActivation(f.activation) diff --git a/internal/exec/runtime/testdata/conformance/README.md b/internal/exec/runtime/testdata/conformance/README.md index 553c0e4490..63640e62e8 100644 --- a/internal/exec/runtime/testdata/conformance/README.md +++ b/internal/exec/runtime/testdata/conformance/README.md @@ -177,15 +177,18 @@ a case that hits it fails with a message telling the author to raise it: } ``` -- `exploreBudget`: optional beside `outcomes`; each of `runs` and `depth` defaults - to the default budget's when omitted. A budget without `outcomes`, a `runs` - below 1 or a `depth` below 0 is a schema error the test reports. Raising a - budget is the answer to a model with more linearizations than the default - covers, never to an outcome the set is missing: an unlisted outcome is a - derivation to add to the oracle or a bug to fix. - -Cases without `outcomes` are not explored by the harness. The default schedule is -deterministic, so a case with an admissible set still keeps its exact golden trace. +- `exploreBudget`: optional for an action or state case with a single result or + `outcomes`; each of `runs` and `depth` defaults to the default budget's when + omitted. A budget without a result to check, a `runs` below 1 or a `depth` + below 0 is a schema error the test reports. Raising a budget is the answer to + a model with more linearizations than the default covers, never to an outcome + the set is missing: an unlisted outcome is a derivation to add to the oracle + or a bug to fix. + +`TestExecutionConformance` explores cases with `outcomes`; the check corpus can +also explore a single-result case with a `.check.expected.json` entry. The +default schedule is deterministic, so a case with an admissible set still keeps +its exact golden trace. ### Scheduling Policy diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_shared_writers.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_shared_writers.expected.json index b81e533660..a1c7149f79 100644 --- a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_shared_writers.expected.json +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_shared_writers.expected.json @@ -2,8 +2,5 @@ "type": "action", "exploreBudget": {"runs": 65536}, "libraries": true, - "outcomes": [ - {"outputs": {"c": {"type": "Integer", "value": 3}}} - ], - "admissible": "Repeated action steps and shared writes" + "outputs": {"c": {"type": "Integer", "value": 3}} } diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_names_exhibiting_part.trace.golden b/internal/exec/runtime/testdata/conformance/action_terminate_names_exhibiting_part.trace.golden index 07a7bdb4f4..352b1f1705 100644 --- a/internal/exec/runtime/testdata/conformance/action_terminate_names_exhibiting_part.trace.golden +++ b/internal/exec/runtime/testdata/conformance/action_terminate_names_exhibiting_part.trace.golden @@ -4,7 +4,6 @@ materialize: patient #1 materialize: Heart #2 start: exhibited state machine rhythm of #2 materialize: Beating #3 - eval literal 0 -> 0 enter: alive run: exhibited state machine rhythm of #2 do: alive diff --git a/internal/exec/runtime/testdata/conformance/exhibited_state_guard_reads_attribute.trace.golden b/internal/exec/runtime/testdata/conformance/exhibited_state_guard_reads_attribute.trace.golden index 79a8e2cf07..aee31851f5 100644 --- a/internal/exec/runtime/testdata/conformance/exhibited_state_guard_reads_attribute.trace.golden +++ b/internal/exec/runtime/testdata/conformance/exhibited_state_guard_reads_attribute.trace.golden @@ -1,7 +1,6 @@ materialize: Controller #1 start: exhibited state machine modes of #1 materialize: Modes #2 -eval literal 0 -> 0 enter: armed (entry action) stmt action body stmt assign attempts diff --git a/internal/exec/runtime/testdata/conformance/exhibited_state_self_target.trace.golden b/internal/exec/runtime/testdata/conformance/exhibited_state_self_target.trace.golden index edf476075d..8b06be640e 100644 --- a/internal/exec/runtime/testdata/conformance/exhibited_state_self_target.trace.golden +++ b/internal/exec/runtime/testdata/conformance/exhibited_state_self_target.trace.golden @@ -4,8 +4,6 @@ materialize: modes #2 materialize: CmdPort #3 eval feature engineControl -> instance#3 materialize: EngineCommand #4 -eval chain cmd -> instance#4 - eval feature engineControl -> instance#3 eval chain cmd -> instance#4 eval feature engineControl -> instance#3 eval chain cmd -> instance#4 diff --git a/internal/exec/runtime/testdata/conformance/performed_action_later_node_reads.trace.golden b/internal/exec/runtime/testdata/conformance/performed_action_later_node_reads.trace.golden index 568ebd766e..898dfff92d 100644 --- a/internal/exec/runtime/testdata/conformance/performed_action_later_node_reads.trace.golden +++ b/internal/exec/runtime/testdata/conformance/performed_action_later_node_reads.trace.golden @@ -1,7 +1,6 @@ materialize: Adder #1 start: performed action sum of #1 materialize: Accumulate #2 -eval literal 0 -> 0 run: performed action sum of #1 stmt assign total eval literal 5 -> 5 diff --git a/internal/exec/runtime/testdata/conformance/state_space_clock_state_interleave.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_space_clock_state_interleave.declared.trace.golden index abd6b107a3..132a9d4ae9 100644 --- a/internal/exec/runtime/testdata/conformance/state_space_clock_state_interleave.declared.trace.golden +++ b/internal/exec/runtime/testdata/conformance/state_space_clock_state_interleave.declared.trace.golden @@ -12,18 +12,9 @@ materialize: cool #2 enter calc VectorFunctions::VectorOf bind components = 1.0 [argument] exit calc VectorFunctions::VectorOf -> ⟨1.0⟩ - eval invoke VectorOf -> ⟨1.0⟩ - eval literal 1.0 -> 1.0 - enter calc VectorFunctions::VectorOf - bind components = 1.0 [argument] - exit calc VectorFunctions::VectorOf -> ⟨1.0⟩ eval invoke VectorOf -> ⟨1.0⟩ eval literal 0.25 -> 0.25 eval index -> 0.25 [s] - eval literal 0.25 -> 0.25 - eval index -> 0.25 [s] - eval literal 1 -> 1 - eval index -> 1 [s] eval literal 1 -> 1 eval index -> 1 [s] start: exhibited state machine monitor of #1 diff --git a/internal/exec/runtime/testdata/conformance/state_space_clock_state_interleave.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_space_clock_state_interleave.seed-1.trace.golden index c102f2e98f..8a89a3274b 100644 --- a/internal/exec/runtime/testdata/conformance/state_space_clock_state_interleave.seed-1.trace.golden +++ b/internal/exec/runtime/testdata/conformance/state_space_clock_state_interleave.seed-1.trace.golden @@ -12,18 +12,9 @@ materialize: cool #2 enter calc VectorFunctions::VectorOf bind components = 1.0 [argument] exit calc VectorFunctions::VectorOf -> ⟨1.0⟩ - eval invoke VectorOf -> ⟨1.0⟩ - eval literal 1.0 -> 1.0 - enter calc VectorFunctions::VectorOf - bind components = 1.0 [argument] - exit calc VectorFunctions::VectorOf -> ⟨1.0⟩ eval invoke VectorOf -> ⟨1.0⟩ eval literal 0.25 -> 0.25 eval index -> 0.25 [s] - eval literal 0.25 -> 0.25 - eval index -> 0.25 [s] - eval literal 1 -> 1 - eval index -> 1 [s] eval literal 1 -> 1 eval index -> 1 [s] start: exhibited state machine monitor of #1 diff --git a/internal/exec/runtime/testdata/conformance/state_space_clock_state_interleave.trace.golden b/internal/exec/runtime/testdata/conformance/state_space_clock_state_interleave.trace.golden index c102f2e98f..8a89a3274b 100644 --- a/internal/exec/runtime/testdata/conformance/state_space_clock_state_interleave.trace.golden +++ b/internal/exec/runtime/testdata/conformance/state_space_clock_state_interleave.trace.golden @@ -12,18 +12,9 @@ materialize: cool #2 enter calc VectorFunctions::VectorOf bind components = 1.0 [argument] exit calc VectorFunctions::VectorOf -> ⟨1.0⟩ - eval invoke VectorOf -> ⟨1.0⟩ - eval literal 1.0 -> 1.0 - enter calc VectorFunctions::VectorOf - bind components = 1.0 [argument] - exit calc VectorFunctions::VectorOf -> ⟨1.0⟩ eval invoke VectorOf -> ⟨1.0⟩ eval literal 0.25 -> 0.25 eval index -> 0.25 [s] - eval literal 0.25 -> 0.25 - eval index -> 0.25 [s] - eval literal 1 -> 1 - eval index -> 1 [s] eval literal 1 -> 1 eval index -> 1 [s] start: exhibited state machine monitor of #1 diff --git a/internal/exec/runtime/testdata/conformance/state_space_zero_crossing_state.trace.golden b/internal/exec/runtime/testdata/conformance/state_space_zero_crossing_state.trace.golden index fe944a57d2..11d982ca0e 100644 --- a/internal/exec/runtime/testdata/conformance/state_space_zero_crossing_state.trace.golden +++ b/internal/exec/runtime/testdata/conformance/state_space_zero_crossing_state.trace.golden @@ -13,15 +13,6 @@ eval invoke VectorOf -> ⟨10.0⟩ bind components = (19.0, 0.0) [argument] exit calc VectorFunctions::VectorOf -> ⟨19.0, 0.0⟩ eval invoke VectorOf -> ⟨19.0, 0.0⟩ - eval literal 19.0 -> 19.0 - eval literal 0.0 -> 0.0 - eval sequence of 2 -> (19.0, 0.0) - enter calc VectorFunctions::VectorOf - bind components = (19.0, 0.0) [argument] - exit calc VectorFunctions::VectorOf -> ⟨19.0, 0.0⟩ -eval invoke VectorOf -> ⟨19.0, 0.0⟩ - eval literal 0.5 -> 0.5 -eval index -> 0.5 [s] eval literal 0.5 -> 0.5 eval index -> 0.5 [s] run: performed action fall of #1 diff --git a/internal/ir/lower/action_graph.go b/internal/ir/lower/action_graph.go index c27b8e38f2..6e5358d9d4 100644 --- a/internal/ir/lower/action_graph.go +++ b/internal/ir/lower/action_graph.go @@ -663,6 +663,7 @@ type Feature struct { Scope *symbols.Scope } +// valueIsBinding reports whether the declaration uses a live `=` value. func valueIsBinding(value ast.Node, isInitial, isDefault bool) bool { return value != nil && !isInitial && !isDefault } diff --git a/internal/ir/lower/body_binding_test.go b/internal/ir/lower/body_binding_test.go index c793fd7cfa..812036c7da 100644 --- a/internal/ir/lower/body_binding_test.go +++ b/internal/ir/lower/body_binding_test.go @@ -6,6 +6,7 @@ import ( "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) +// TestLoweredBodyFeaturesPreserveValueOperators checks lowered action bindings and defaults. func TestLoweredBodyFeaturesPreserveValueOperators(t *testing.T) { graph := actionGraphFor(t, ` action act { @@ -68,6 +69,7 @@ func TestLoweredBodyFeaturesPreserveValueOperators(t *testing.T) { } } +// TestStateGraphPreservesAttributeValueOperators checks state attribute binding flags. func TestStateGraphPreservesAttributeValueOperators(t *testing.T) { graph, err := ToStateGraph(stateUsageIn(t, ` state Machine { diff --git a/internal/ir/lower/state_behavior_test.go b/internal/ir/lower/state_behavior_test.go index 8a3e4e7cf8..20c4f50454 100644 --- a/internal/ir/lower/state_behavior_test.go +++ b/internal/ir/lower/state_behavior_test.go @@ -172,6 +172,7 @@ func TestStateBehaviorBodyWithAmbiguousStartKeepsNoInitial(t *testing.T) { } } +// TestStateBehaviorActionExecutionIsStateData checks lowered action results in state data. func TestStateBehaviorActionExecutionIsStateData(t *testing.T) { body := lowerActionExecution(&ast.ActionExecutionNode{ Name: "calculated", From 9b73a77b0e54adcd5489ff15c29507933ae9694b Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 05:01:09 +0000 Subject: [PATCH 3/7] fix(runtime): address body binding review findings Co-Authored-By: jason.han --- docs/project/spec-compliance.md | 2 + internal/exec/runtime/action_executor.go | 41 ++-- internal/exec/runtime/action_frame.go | 23 +- internal/exec/runtime/action_terminate.go | 4 +- internal/exec/runtime/body_bindings.go | 21 ++ internal/exec/runtime/context.go | 8 +- internal/exec/runtime/held_image_behavior.go | 16 +- .../robustness_body_feature_bindings_test.go | 203 ++++++++++++++++++ internal/exec/runtime/state_executor.go | 1 + internal/exec/runtime/state_space.go | 7 +- ...te_attribute_binding_reentry.expected.json | 12 ++ .../state_attribute_binding_reentry.sysml | 26 +++ ...tate_space_stop_time_binding.expected.json | 14 ++ .../state_space_stop_time_binding.sysml | 34 +++ 14 files changed, 379 insertions(+), 33 deletions(-) create mode 100644 internal/exec/runtime/testdata/conformance/state_attribute_binding_reentry.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/state_attribute_binding_reentry.sysml create mode 100644 internal/exec/runtime/testdata/conformance/state_space_stop_time_binding.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/state_space_stop_time_binding.sysml diff --git a/docs/project/spec-compliance.md b/docs/project/spec-compliance.md index 644f7ebe5f..667302bdc9 100644 --- a/docs/project/spec-compliance.md +++ b/docs/project/spec-compliance.md @@ -600,6 +600,8 @@ is an OpenSysML policy because the language does not specify later reads of an ended occurrence. State exit invalidates dependents of the exiting state's attribute cells, so a later read observes the existing inactive-state behavior rather than a cached value from before exit. +On state re-entry, each frozen unwritten `=` attribute restarts for lazy +rederivation; written values remain held. The cells are journaled with their owning maps for snapshot/restore, carried in held images (tracking values restore unmaterialized and rederive lazily), and diff --git a/internal/exec/runtime/action_executor.go b/internal/exec/runtime/action_executor.go index f0537f6bbf..f567954ad7 100644 --- a/internal/exec/runtime/action_executor.go +++ b/internal/exec/runtime/action_executor.go @@ -1432,6 +1432,16 @@ func (e *ActionExecutor) hasFlow() bool { e.dynamicsKind != lower.NotStateSpace) } +// completeRoot freezes the root bindings and ends its performance. +func (e *ActionExecutor) completeRoot() error { + if err := e.ctx.freezeBodyCells(e.root.cells); err != nil { + return err + } + e.state = StateCompleted + e.endRootPerformance() + return nil +} + // completeWithoutFlow completes an action stating no flow: it performs no step, // so its performance begins, takes its inputs, and ends at once. func (e *ActionExecutor) completeWithoutFlow() error { @@ -1442,12 +1452,7 @@ func (e *ActionExecutor) completeWithoutFlow() error { if err := e.bindInputs(); err != nil { return err } - if err := e.ctx.freezeBodyCells(e.root.cells); err != nil { - return err - } - e.state = StateCompleted - e.endRootPerformance() - return nil + return e.completeRoot() } // bindInputs writes the supplied inputs into the performance, then the @@ -1609,12 +1614,7 @@ func (e *ActionExecutor) initialize() error { if err := checkStreamsReceived(e.root); err != nil { return err } - if err := e.ctx.freezeBodyCells(e.root.cells); err != nil { - return err - } - e.state = StateCompleted - e.endRootPerformance() - return nil + return e.completeRoot() } e.seedTokens(e.root, starts, 0) @@ -2085,11 +2085,7 @@ func (e *ActionExecutor) retireToken(tokenIdx int) error { if err := checkStreamsReceived(e.root); err != nil { return err } - if err := e.ctx.freezeBodyCells(e.root.cells); err != nil { - return err - } - e.state = StateCompleted - e.endRootPerformance() + return e.completeRoot() } return nil } @@ -3053,10 +3049,17 @@ func (e *ActionExecutor) State() ExecutionState { // each nested non-repeated node's latest performance and under `part.attribute` what the one // object each of its own parts denotes holds; a performed usage's mirror its occurrence. func (e *ActionExecutor) Results() map[string]Value { + results, _ := e.ResultsWithError() + return results +} + +// ResultsWithError returns the values the action's features hold and the first binding +// derivation error; a feature whose binding fails to derive is omitted. +func (e *ActionExecutor) ResultsWithError() (map[string]Value, error) { results := make(map[string]Value, len(e.root.data)) - e.root.collect("", results) + err := e.root.collect("", results) e.collectPartsHeld(results) - return results + return results, err } // collectPartsHeld adds the attributes held by the object each part or item usage the diff --git a/internal/exec/runtime/action_frame.go b/internal/exec/runtime/action_frame.go index 0702294913..397c2f244b 100644 --- a/internal/exec/runtime/action_frame.go +++ b/internal/exec/runtime/action_frame.go @@ -245,6 +245,7 @@ func (e *ActionExecutor) registerRootBindings(root *actionFrame) { return err } cell = e.ctx.registerBodyBindingInContext(cells, name, attr.Value, scope, nil, context, onDerived) + cell.binding.masked = attr.Name } } @@ -399,9 +400,10 @@ func (e *performances) seedPerformance(parent *actionFrame, flow *lower.ActionGr context := func(scope *symbols.Scope) *EvalContext { return e.evalBindingContext(perf, scope, feature.Name, activation) } - e.ctx.registerBodyBindingInContext( + cell := e.ctx.registerBodyBindingInContext( e.bodyCells(perf), perf.key(feature.Name), feature.Value, feature.Scope, check, context, nil, ) + cell.binding.masked = feature.Name } if err := e.takeDeliveries(parent, node, perf); err != nil { return err @@ -479,6 +481,7 @@ func (e *performances) seedDeclaredValues(perf *actionFrame, features []lower.Fe cell = e.ctx.registerBodyBindingInContext( cells, name, feature.Value, feature.Scope, check, context, nil, ) + cell.binding.masked = feature.Name } value, err = e.ctx.deriveBodyCell(cells, name, cell) } else { @@ -1292,19 +1295,31 @@ func lexicalValues(perf *actionFrame) map[string]Value { // collect reports the values the performance and its non-repeated subactions hold, // under their paths (`p.v`), the latest performance of each name standing for it. -func (f *actionFrame) collect(prefix string, into map[string]Value) { +func (f *actionFrame) collect(prefix string, into map[string]Value) error { + var deriveErr error if f.perfs != nil && f.perfs.ctx != nil { - _ = f.perfs.ctx.deriveBodyCells(f.cells) + deriveErr = f.perfs.ctx.deriveBodyCells(f.cells) } for name, value := range f.data { + var cell *bodyCell + if f.cells != nil { + cell = f.cells.cells[name] + } + if cell != nil && cell.binding != nil && + !cell.fv.Written && !cell.binding.frozen && !cell.fv.Materialized { + continue + } into[prefix+name] = value } for name, sub := range f.latestSubactions() { if sub.repetition > 0 { continue } - sub.collect(prefix+name+".", into) + if err := sub.collect(prefix+name+".", into); deriveErr == nil { + deriveErr = err + } } + return deriveErr } // latestSubactions is the latest performance of each named node under f, by name. diff --git a/internal/exec/runtime/action_terminate.go b/internal/exec/runtime/action_terminate.go index 67c2fc04e1..8ec857098a 100644 --- a/internal/exec/runtime/action_terminate.go +++ b/internal/exec/runtime/action_terminate.go @@ -321,9 +321,7 @@ func (e *ActionExecutor) endAround(tokenIdx int, perf *actionFrame) error { e.dropTokensIn(perf, id) if perf == e.root { e.removeToken(e.tokenIndex(id)) - e.state = StateCompleted - e.endRootPerformance() - return nil + return e.completeRoot() } return e.leaveTerminated(e.tokenIndex(id), perf) } diff --git a/internal/exec/runtime/body_bindings.go b/internal/exec/runtime/body_bindings.go index f891747545..9f368ef478 100644 --- a/internal/exec/runtime/body_bindings.go +++ b/internal/exec/runtime/body_bindings.go @@ -40,6 +40,7 @@ type bodyCell struct { type bodyBinding struct { value ast.Node scope *symbols.Scope + masked string check func(*Value) error context func(*symbols.Scope) *EvalContext onDerived func(*Value) error @@ -486,6 +487,26 @@ func (ctx *Context) freezeBodyCells(cells *bodyCells) error { return nil } +// restartBodyCells makes frozen, unwritten bindings eligible to derive again. +func (ctx *Context) restartBodyCells(cells *bodyCells) { + if cells == nil { + return + } + for _, name := range cells.order { + cell := cells.cells[name] + if cell.binding == nil || !cell.binding.frozen || cell.fv.Written { + continue + } + ctx.invalidateDependents(&cell.fv) + ctx.noteProbeWrite(&cell.fv) + ctx.forgetReads(&cell.fv) + delete(cells.vars, name) + cell.fv.Value, cell.fv.Values, cell.fv.Materialized = Value{}, Value{}, false + cell.fv.BindingDerived, cell.fv.intrinsic = false, false + cell.binding.frozen = false + } +} + // forgetBodyCells removes the dependencies of every binding in a store. func (ctx *Context) forgetBodyCells(cells *bodyCells) { if cells == nil { diff --git a/internal/exec/runtime/context.go b/internal/exec/runtime/context.go index 95ca6bf699..c73d500b9c 100644 --- a/internal/exec/runtime/context.go +++ b/internal/exec/runtime/context.go @@ -1553,7 +1553,7 @@ func (ctx *Context) ExecuteActionPerformedBy(action *symbols.Symbol, self *Insta return nil, err } // Return the values the action's features hold once it completed - return exec.Results(), nil + return exec.ResultsWithError() } // ActionOutcomePerformedBy runs an action as ExecuteActionPerformedBy does and reports @@ -1573,7 +1573,11 @@ func (ctx *Context) ExecuteActionReportingPerformer(action *symbols.Symbol, self if err != nil { return nil, nil, err } - return exec.Results(), (&Invocation{Actions: []*ActionExecutor{exec}}).PerformerAttributes(), nil + outputs, err = exec.ResultsWithError() + if err != nil { + return nil, nil, err + } + return outputs, (&Invocation{Actions: []*ActionExecutor{exec}}).PerformerAttributes(), nil } // performAction runs action to completion, performed by self, and returns the diff --git a/internal/exec/runtime/held_image_behavior.go b/internal/exec/runtime/held_image_behavior.go index 43baec039d..4d6fdf5262 100644 --- a/internal/exec/runtime/held_image_behavior.go +++ b/internal/exec/runtime/held_image_behavior.go @@ -76,6 +76,7 @@ type imagedBodyCell struct { name string value ast.Node scope *symbols.Scope + masked string written bool frozen bool visible []map[string]bool @@ -704,6 +705,7 @@ func imageBodyCells(cells *bodyCells, values map[string]Value) []imagedBodyCell } image = append(image, imagedBodyCell{ name: name, value: cell.binding.value, scope: cell.binding.scope, + masked: cell.binding.masked, written: cell.fv.Written, frozen: cell.binding.frozen, visible: cloneBodyBindingVisibility(cell.binding.visible), limitFrames: cell.binding.limitFrames, }) @@ -775,14 +777,15 @@ func (m *materializing) frameCells( if len(image) == 0 { return nil } - cells := newBodyCells(values, func(scope *symbols.Scope) *EvalContext { + defaultContext := func(scope *symbols.Scope) *EvalContext { ec := perf.perfs.evalContextFor(perf, scope) if localDepth > 0 { localStart := len(ec.frames) - len(perf.locals) - 1 ec.frames = ec.frames[:localStart+localDepth] } return ec - }) + } + cells := newBodyCells(values, defaultContext) for _, state := range image { if state.value == nil { continue @@ -804,8 +807,15 @@ func (m *materializing) frameCells( } } } - cell := m.dst.registerBodyBinding(cells, state.name, state.value, state.scope, check, onDerived) + context := func(scope *symbols.Scope) *EvalContext { + if state.masked != "" { + return perf.perfs.evalBindingContext(perf, scope, state.masked, perf.began) + } + return defaultContext(scope) + } + cell := m.dst.registerBodyBindingInContext(cells, state.name, state.value, state.scope, check, context, onDerived) cell.binding.visible = cloneBodyBindingVisibility(state.visible) + cell.binding.masked = state.masked cell.binding.limitFrames = state.limitFrames if state.written { cell.fv.Written = true diff --git a/internal/exec/runtime/robustness_body_feature_bindings_test.go b/internal/exec/runtime/robustness_body_feature_bindings_test.go index d139b345b6..93714b2da4 100644 --- a/internal/exec/runtime/robustness_body_feature_bindings_test.go +++ b/internal/exec/runtime/robustness_body_feature_bindings_test.go @@ -4,6 +4,8 @@ import ( "context" "errors" "testing" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" ) // TestRuntimeRobustnessBodyFeatureBindings pins body-binding errors and lifetimes. @@ -55,6 +57,140 @@ func TestRuntimeRobustnessBodyFeatureBindings(t *testing.T) { } }) + t.Run("results_report_destroyed_binding_error", func(t *testing.T) { + ctx, action := loadAction(t, `package test { + private import ScalarValues::*; + part def Target { attribute n : Integer := 2; } + action run { + in ref target : Target; + attribute live : Integer = target.n; + first start; + then action read { assign live := live + 1; } + } + }`, "run") + idx := ctx.model.resolver.Index() + target, err := ctx.Instantiate(oneSymbol(t, idx, "test::Target")) + if err != nil { + t.Fatalf("Instantiate Target: %v", err) + } + exec, err := ctx.CreateActionExecutorWithInputs(action, nil, map[string]Value{"target": objectValue(target)}) + if err != nil { + t.Fatalf("CreateActionExecutorWithInputs: %v", err) + } + exec.SetBreakpoint("read") + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("RunToCompletion: %v", err) + } + if got := exec.PausedAt(); got != "read" { + t.Fatalf("PausedAt = %q, want read", got) + } + if err := ctx.destroy(target); err != nil { + t.Fatalf("destroy Target: %v", err) + } + results, err := exec.ResultsWithError() + if !errors.Is(err, ErrOccurrenceDestroyed) { + t.Fatalf("ResultsWithError error = %v, want ErrOccurrenceDestroyed", err) + } + if _, ok := results["live"]; ok { + t.Fatalf("ResultsWithError returned failed live binding: %s", FormatValue(results["live"])) + } + if _, ok := exec.Results()["live"]; ok { + t.Fatal("Results returned a failed live binding") + } + }) + + t.Run("terminate_freezes_root_binding", func(t *testing.T) { + ctx, action := loadAction(t, `package test { + private import ScalarValues::*; + part def Target { attribute n : Integer := 2; } + action run { + in ref target : Target; + attribute live : Integer = target.n; + first start; + then terminate; + } + action update { + in ref target : Target; + first start; + then action write { assign target.n := 5; } + } + }`, "run") + idx := ctx.model.resolver.Index() + target, err := ctx.Instantiate(oneSymbol(t, idx, "test::Target")) + if err != nil { + t.Fatalf("Instantiate Target: %v", err) + } + exec, err := ctx.CreateActionExecutorWithInputs(action, nil, map[string]Value{"target": objectValue(target)}) + if err != nil { + t.Fatalf("CreateActionExecutorWithInputs: %v", err) + } + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("RunToCompletion: %v", err) + } + if _, err := ctx.ExecuteActionWithInputs( + oneSymbol(t, idx, "test::update"), + map[string]Value{"target": objectValue(target)}, + ); err != nil { + t.Fatalf("ExecuteActionWithInputs update: %v", err) + } + if got := FormatValue(exec.Results()["live"]); got != "2" { + t.Fatalf("live after target.n := 5 = %s, want termination-time value 2", got) + } + }) + + t.Run("state_space_stop_time_reads_tracking_binding", func(t *testing.T) { + idx, _, ctx := buildRuntimeWithLibraries(t, "stop-time-binding.sysml", parseAndBuild(t, `package test { + private import ScalarValues::*; + private import SI::*; + private import VectorFunctions::*; + private import StateSpaceRepresentation::*; + private import StateSpaceIntegration::*; + action dyn : ContinuousStateSpaceDynamics, FixedStepDynamics { + in stop : DurationValue = 2 [s]; + in :>> input : Input = VectorOf((0.0)); + :>> stateSpace = VectorOf((1.0)); + :>> timeStep = 0.1 [s]; + :>> stopTime = stop; + calc :>> getDerivative { + in input : Input; + in stateSpace : StateSpace; + return : StateDerivative = (0.0 - 0.5) * stateSpace / 1 [s]; + } + calc :>> getOutput { + in input : Input; + in stateSpace : StateSpace; + return : Output = stateSpace; + } + } + }`)) + exec, err := ctx.CreateActionExecutor(oneSymbol(t, idx, "test::dyn")) + if err != nil { + t.Fatalf("CreateActionExecutor: %v", err) + } + stop, ok := exec.root.data[exec.root.key("stop")] + if !ok { + t.Fatal("initialized stop parameter is missing") + } + if stop.Kind != ValQuantity { + t.Fatalf("stop = %s, want a quantity", FormatValue(stop)) + } + step, ok := exec.root.data[exec.root.key("timeStep")] + if !ok { + t.Fatal("initialized timeStep is missing") + } + ctx.writeBodyValue(exec.root.cells, exec.root.data, exec.root.key("stop"), step) + if _, ok := exec.root.data[exec.root.key("stopTime")]; ok { + t.Fatal("writing stop did not invalidate its derived stopTime") + } + run := &stateSpaceRun{dyn: exec.dynamics.dyn} + if err := exec.readStep(run); err != nil { + t.Fatalf("readStep after writing stop: %v", err) + } + if !run.stops || run.stop != 0.1 { + t.Fatalf("readStep stop = %v (stops=%v), want 0.1 after binding rederivation", run.stop, run.stops) + } + }) + t.Run("state_exit_invalidates_dependents", func(t *testing.T) { ctx, machine := loadState(t, `package test { private import ScalarValues::*; @@ -262,6 +398,73 @@ func TestRuntimeRobustnessBodyFeatureBindings(t *testing.T) { } }) + t.Run("held_image_restores_binding_self_name_mask", func(t *testing.T) { + idx, _, ctx := buildRuntimeWithLibraries(t, "body-binding-mask-image.sysml", parseAndBuild(t, `package test { + private import ScalarValues::*; + action def Work { + in z : Integer; + first start; + then action reader { + in z : Integer = z; + out attribute result : Integer; + first start; + action heard accept g : Integer; + action finish { assign result := z; } + done; + succession first start then heard; + succession first heard then finish; + succession first finish then done; + } + } + part def Host { + attribute z : Integer := 4; + perform action work : Work { in z = this.z; } + } + }`)) + host, err := ctx.Instantiate(oneSymbol(t, idx, "test::Host")) + if err != nil { + t.Fatalf("Instantiate Host: %v", err) + } + behavior, ok := host.Behavior("work") + if !ok || behavior.Action == nil { + t.Fatal("Host has no work action") + } + nine := Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValInt, Int: 9}} + ctx.PostMessage(Message{SignalType: "Integer", Object: host.ID, Value: &nine}) + behavior.Action.SetBreakpoint("finish") + if err := behavior.Action.RunToCompletion(); err != nil { + t.Fatalf("RunToCompletion to the nested finish: %v", err) + } + if got := behavior.Action.PausedAt(); got != "finish" { + t.Fatalf("PausedAt = %q, state = %v, want the nested reader's finish (behavior error: %v)", + got, behavior.Action.State(), behavior.Err) + } + image := imageInto(t, ctx, host) + copied, ok := image.Instance(host.ID) + if !ok { + t.Fatalf("image has no Host #%d", host.ID) + } + copiedBehavior, ok := copied.Behavior("work") + if !ok || copiedBehavior.Action == nil { + t.Fatal("image has no work action") + } + if err := copiedBehavior.Action.RunToCompletion(); err != nil { + t.Fatalf("RunToCompletion of imaged action: %v", err) + } + results, err := copiedBehavior.Action.ResultsWithError() + if err != nil { + t.Fatalf("ResultsWithError: %v", err) + } + result := results["reader.result"] + if result.Kind != ValSequence || result.Sequence() == nil || result.Sequence().Size() != 1 { + t.Fatalf("imaged reader result = %s, want one value from performer z", FormatValue(result)) + } + got, err := result.Sequence().At(0) + if err != nil || FormatValue(got) != "4" { + t.Fatalf("imaged reader result element = %s, %v; want 4", FormatValue(got), err) + } + }) + t.Run("replay", func(t *testing.T) { ctx, action := loadAction(t, `package test { private import ScalarValues::*; diff --git a/internal/exec/runtime/state_executor.go b/internal/exec/runtime/state_executor.go index 0d30efc220..4fa90bc02f 100644 --- a/internal/exec/runtime/state_executor.go +++ b/internal/exec/runtime/state_executor.go @@ -5288,6 +5288,7 @@ func (e *StateExecutor) resetEntering() { // performEntry records the entry of state and performs its entry behaviors. func (e *StateExecutor) performEntry(state *ast.StateNode) error { + e.ctx.restartBodyCells(e.stateAttrCells[state]) if !e.activeAtEntry[state] { e.entering[state] = true } diff --git a/internal/exec/runtime/state_space.go b/internal/exec/runtime/state_space.go index 6c14970236..8306612cce 100644 --- a/internal/exec/runtime/state_space.go +++ b/internal/exec/runtime/state_space.go @@ -151,11 +151,14 @@ func (e *ActionExecutor) readStep(run *stateSpaceRun) error { if dyn.StopTime == nil { return nil } - stop, ok := e.root.data[e.root.key(lower.StopTimeFeature)] + what = fmt.Sprintf("%s of action %s", lower.StopTimeFeature, symbolText(e.action)) + stop, ok, err := e.ctx.readBodyValue(e.root.cells, e.root.data, e.root.key(lower.StopTimeFeature)) + if err != nil { + return fmt.Errorf("%w: %s: %w", ErrStateSpaceStep, what, err) + } if !ok || stop.Kind == ValNull { return nil } - what = fmt.Sprintf("%s of action %s", lower.StopTimeFeature, symbolText(e.action)) if run.stop, err = e.ctx.timeMagnitude(stop, what); err != nil { return fmt.Errorf("%w: %w", ErrStateSpaceStep, err) } diff --git a/internal/exec/runtime/testdata/conformance/state_attribute_binding_reentry.expected.json b/internal/exec/runtime/testdata/conformance/state_attribute_binding_reentry.expected.json new file mode 100644 index 0000000000..d057904b77 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_attribute_binding_reentry.expected.json @@ -0,0 +1,12 @@ +{ + "type": "state", + "libraries": true, + "finalState": "done", + "stateVisits": ["active", "active", "done"], + "outputs": { + "source": {"type": "Integer", "value": 5}, + "seen1": {"type": "Integer", "value": 2}, + "seen2": {"type": "Integer", "value": 10}, + "visits": {"type": "Integer", "value": 2} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_attribute_binding_reentry.sysml b/internal/exec/runtime/testdata/conformance/state_attribute_binding_reentry.sysml new file mode 100644 index 0000000000..b6eb615f45 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_attribute_binding_reentry.sysml @@ -0,0 +1,26 @@ +package test { + private import ScalarValues::*; + + state machine { + attribute source : Integer := 1; + attribute seen1 : Integer := 0; + attribute seen2 : Integer := 0; + attribute visits : Integer := 0; + + entry; then active; + state active { + attribute live : Integer = source * 2; + entry { + if visits == 0 { + assign seen1 := live; + } else { + assign seen2 := live; + } + assign visits := visits + 1; + } + } + state done; + transition first active accept when visits == 1 do assign source := 5 then active; + transition first active accept when visits == 2 then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_space_stop_time_binding.expected.json b/internal/exec/runtime/testdata/conformance/state_space_stop_time_binding.expected.json new file mode 100644 index 0000000000..687bcd736a --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_space_stop_time_binding.expected.json @@ -0,0 +1,14 @@ +{ + "type": "action", + "libraries": true, + "evaluate": "test::decay", + "outputs": { + "stateSpace": {"type": "VectorQuantity", "elements": [ + {"type": "Quantity", "value": 0.3584859224085422, "unit": "m"} + ]}, + "output": {"type": "VectorQuantity", "elements": [ + {"type": "Quantity", "value": 0.7169718448170844, "unit": "m"} + ]}, + "time": {"type": "Quantity", "value": 2.0, "unit": "s"} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_space_stop_time_binding.sysml b/internal/exec/runtime/testdata/conformance/state_space_stop_time_binding.sysml new file mode 100644 index 0000000000..60c964bdb6 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_space_stop_time_binding.sysml @@ -0,0 +1,34 @@ +package test { + private import ScalarValues::*; + private import SI::*; + private import VectorFunctions::*; + private import StateSpaceRepresentation::*; + private import StateSpaceIntegration::*; + + attribute def DecayState :> StateSpace; + attribute def DecayInput :> Input; + attribute def DecayOutput :> Output; + + action decay : ContinuousStateSpaceDynamics, FixedStepDynamics { + attribute rate : Real = 0.5; + in stop : DurationValue = 2 [s]; + in :>> input : DecayInput = 0 [m] * VectorOf((0.0)); + :>> stateSpace : DecayState = 1 [m] * VectorOf((1.0)); + :>> timeStep = 0.1 [s]; + :>> stopTime = stop; + + calc :>> getDerivative { + in input : DecayInput; + in stateSpace : DecayState; + return : StateDerivative = (0.0 - rate) * stateSpace / 1 [s]; + } + calc :>> getOutput { + in input : DecayInput; + in stateSpace : DecayState; + return : DecayOutput = 2.0 * stateSpace; + } + calc :>> getNextState { + calc :>> integrate : Euler; + } + } +} From c40d4a58d84fc5d16310f2ec04d5799f003046a6 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:23:52 +0000 Subject: [PATCH 4/7] fix(runtime): preserve body binding frame state Co-Authored-By: jason.han --- internal/exec/runtime/body_bindings.go | 8 +++++- internal/exec/runtime/eval.go | 6 +++++ internal/exec/runtime/frame.go | 6 +++-- internal/exec/runtime/statements.go | 3 ++- ...member_under_shadowing_local.expected.json | 11 ++++++++ ...alified_member_under_shadowing_local.sysml | 25 +++++++++++++++++++ 6 files changed, 55 insertions(+), 4 deletions(-) create mode 100644 internal/exec/runtime/testdata/conformance/action_qualified_member_under_shadowing_local.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_qualified_member_under_shadowing_local.sysml diff --git a/internal/exec/runtime/body_bindings.go b/internal/exec/runtime/body_bindings.go index 9f368ef478..e24e409971 100644 --- a/internal/exec/runtime/body_bindings.go +++ b/internal/exec/runtime/body_bindings.go @@ -114,7 +114,13 @@ func (c bodyCellsCapture) restore() { c.cells.order = append(c.cells.order[:0], c.order...) for name, cell := range c.members { cell.owner, cell.name = c.cells, name - cell.binding = c.bindings[cell] + if saved := c.bindings[cell]; saved != nil { + binding := *saved + binding.visible = cloneBodyBindingVisibility(saved.visible) + cell.binding = &binding + } else { + cell.binding = nil + } c.cells.cells[name] = cell } } diff --git a/internal/exec/runtime/eval.go b/internal/exec/runtime/eval.go index b8885a47a6..334a6badc3 100644 --- a/internal/exec/runtime/eval.go +++ b/internal/exec/runtime/eval.go @@ -922,6 +922,9 @@ func (ec *EvalContext) evalNameGeneral(qn *ast.QualifiedName) (Value, error) { func (ec *EvalContext) frameFeatureValue(qualifier, sym *symbols.Symbol) (Value, bool, error) { for i := len(ec.frames) - 1; i >= 0; i-- { f := ec.frames[i] + if f.lexical { + continue + } if f.owner != nil { if !f.owner.qualifiedBy(ec.ctx, qualifier) { continue @@ -960,6 +963,9 @@ func (ec *EvalContext) frameFeatureValue(qualifier, sym *symbols.Symbol) (Value, func (ec *EvalContext) writeFrameFeature(qualifier, sym *symbols.Symbol, value Value) (bool, error) { for i := len(ec.frames) - 1; i >= 0; i-- { f := ec.frames[i] + if f.lexical { + continue + } if f.owner != nil { if !f.owner.qualifiedBy(ec.ctx, qualifier) { continue diff --git a/internal/exec/runtime/frame.go b/internal/exec/runtime/frame.go index 48670ef17c..aae35cf287 100644 --- a/internal/exec/runtime/frame.go +++ b/internal/exec/runtime/frame.go @@ -23,6 +23,8 @@ type frame struct { // owner is the calc whose parameters, locals and outputs the frame binds, so a // qualified name of one of its members (`MassCase::result`) reads the binding. owner *calcShape + // lexical marks local statement bindings, which are not qualified members. + lexical bool // write is the run's own write path for a feature the frame binds — checked // against its declaration and mirrored or streamed the way the run's // statements write it — nil where the frame is a snapshot or plain bindings, @@ -122,7 +124,7 @@ func (f frame) performs() *symbols.Symbol { // withVars is the frame holding vars in place of its own, still answering for // the same run and performance. func (f frame) withVars(vars map[string]Value) frame { - return frame{vars: vars, masked: f.masked, visible: f.visible, aliases: f.aliases, perf: f.perf, owner: f.owner, write: f.write, performed: f.performed, run: f.run, merged: f.merged, firing: f.firing} + return frame{vars: vars, masked: f.masked, visible: f.visible, aliases: f.aliases, perf: f.perf, owner: f.owner, lexical: f.lexical, write: f.write, performed: f.performed, run: f.run, merged: f.merged, firing: f.firing} } // lookup finds name in the frame: a slot binding it, else the map. @@ -246,7 +248,7 @@ func (f frame) snapshot() frame { vars := make(map[string]Value, f.width()) f.each(func(name string, value Value) { vars[name] = value }) out := ownedFrame(f.owner, vars) - out.performed, out.run, out.merged = f.performs(), f.run, f.merged + out.performed, out.run, out.merged, out.lexical = f.performs(), f.run, f.merged, f.lexical if f.visible != nil { out.visible = make(map[string]bool, len(f.visible)) for name, visible := range f.visible { diff --git a/internal/exec/runtime/statements.go b/internal/exec/runtime/statements.go index a46b494a9f..06c279184d 100644 --- a/internal/exec/runtime/statements.go +++ b/internal/exec/runtime/statements.go @@ -228,13 +228,14 @@ func (env *stmtEnv) localFrame() frame { return local } -// bodyFrame marks local values as part of the behavior run that owns the data frame. +// bodyFrame marks local values as lexical bindings in the run that owns the data frame. func (env *stmtEnv) bodyFrame(vars map[string]Value, cells *bodyCells) frame { local := mapFrame(vars) local.owner, local.perf = env.data.owner, env.data.perf local.run, local.performed = env.data.run, env.data.performed local.merged, local.firing = env.data.merged, env.data.firing local.cells = cells + local.lexical = true return local } diff --git a/internal/exec/runtime/testdata/conformance/action_qualified_member_under_shadowing_local.expected.json b/internal/exec/runtime/testdata/conformance/action_qualified_member_under_shadowing_local.expected.json new file mode 100644 index 0000000000..d7a769ea9c --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_qualified_member_under_shadowing_local.expected.json @@ -0,0 +1,11 @@ +{ + "type": "action", + "evaluate": "test::Act", + "schedule": "reverse", + "outputs": { + "a": {"type": "Integer", "value": 4}, + "r": {"type": "Integer", "value": 1}, + "w": {"type": "Integer", "value": 1}, + "calcResult": {"type": "Integer", "value": 1} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_qualified_member_under_shadowing_local.sysml b/internal/exec/runtime/testdata/conformance/action_qualified_member_under_shadowing_local.sysml new file mode 100644 index 0000000000..f91836ffa4 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_qualified_member_under_shadowing_local.sysml @@ -0,0 +1,25 @@ +package test { + private import ScalarValues::*; + + calc def C { + attribute a : Integer := 1; + calc inner { + attribute a : Integer := 9; + C::a + } + } + + action def Act { + attribute a : Integer := 1; + out attribute r : Integer; + out attribute w : Integer; + out attribute calcResult : Integer; + if true { + attribute a : Integer := 9; + assign r := Act::a; + assign Act::a := 4; + } + assign w := a; + assign calcResult := C::inner(); + } +} From cd182c35860deffa227b343f18aacf1c5b4ae2df Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:26:15 +0000 Subject: [PATCH 5/7] test(runtime): make qualified member fixture schedule-independent Co-Authored-By: jason.han --- .../action_qualified_member_under_shadowing_local.expected.json | 2 -- .../action_qualified_member_under_shadowing_local.sysml | 2 -- 2 files changed, 4 deletions(-) diff --git a/internal/exec/runtime/testdata/conformance/action_qualified_member_under_shadowing_local.expected.json b/internal/exec/runtime/testdata/conformance/action_qualified_member_under_shadowing_local.expected.json index d7a769ea9c..a56e93f975 100644 --- a/internal/exec/runtime/testdata/conformance/action_qualified_member_under_shadowing_local.expected.json +++ b/internal/exec/runtime/testdata/conformance/action_qualified_member_under_shadowing_local.expected.json @@ -1,11 +1,9 @@ { "type": "action", "evaluate": "test::Act", - "schedule": "reverse", "outputs": { "a": {"type": "Integer", "value": 4}, "r": {"type": "Integer", "value": 1}, - "w": {"type": "Integer", "value": 1}, "calcResult": {"type": "Integer", "value": 1} } } diff --git a/internal/exec/runtime/testdata/conformance/action_qualified_member_under_shadowing_local.sysml b/internal/exec/runtime/testdata/conformance/action_qualified_member_under_shadowing_local.sysml index f91836ffa4..dac7f998d3 100644 --- a/internal/exec/runtime/testdata/conformance/action_qualified_member_under_shadowing_local.sysml +++ b/internal/exec/runtime/testdata/conformance/action_qualified_member_under_shadowing_local.sysml @@ -12,14 +12,12 @@ package test { action def Act { attribute a : Integer := 1; out attribute r : Integer; - out attribute w : Integer; out attribute calcResult : Integer; if true { attribute a : Integer := 9; assign r := Act::a; assign Act::a := 4; } - assign w := a; assign calcResult := C::inner(); } } From 98423fecd9c68d8098136ac1e1c6088bcaaf7213 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:48:53 +0000 Subject: [PATCH 6/7] fix(runtime): keep an unvalued mark on a frame whose map is not yet allocated markUnvalued allocated the map on a copy of the frame, so the mark was lost whenever the frame held no unvalued name yet; calcRun.bindingsFrame then read a case-local the run left unvalued through to the enclosing binding of the same name. Mark through a pointer, and drop the pre-allocation the constraint result no longer needs. Co-Authored-By: jason.han --- .../case-bindings-unvalued-mark.fixed.md | 4 ++ internal/exec/runtime/frame.go | 2 +- .../runtime/robustness_case_bindings_test.go | 69 +++++++++++++++++++ internal/exec/runtime/statements.go | 9 +-- 4 files changed, 76 insertions(+), 8 deletions(-) create mode 100644 changes/unreleased/case-bindings-unvalued-mark.fixed.md create mode 100644 internal/exec/runtime/robustness_case_bindings_test.go diff --git a/changes/unreleased/case-bindings-unvalued-mark.fixed.md b/changes/unreleased/case-bindings-unvalued-mark.fixed.md new file mode 100644 index 0000000000..65a9812cb3 --- /dev/null +++ b/changes/unreleased/case-bindings-unvalued-mark.fixed.md @@ -0,0 +1,4 @@ +- **A case-local a run left declared without a value stays unvalued in the frame its conditions + read.** `frame.markUnvalued` allocated its map on a copy of the frame, so a mark on a frame that + held no unvalued name yet was lost, and an objective or assertion naming such a local could read + through to a binding of the same name instead of being undecided for the missing value. diff --git a/internal/exec/runtime/frame.go b/internal/exec/runtime/frame.go index 0c241b9ca1..2be53170a6 100644 --- a/internal/exec/runtime/frame.go +++ b/internal/exec/runtime/frame.go @@ -192,7 +192,7 @@ func (f frame) has(name string) bool { // markUnvalued records a name the frame declares but holds no value for, which // a read answers as missing and a write binds. -func (f frame) markUnvalued(name string) { +func (f *frame) markUnvalued(name string) { if f.unvalued == nil { f.unvalued = map[string]bool{} } diff --git a/internal/exec/runtime/robustness_case_bindings_test.go b/internal/exec/runtime/robustness_case_bindings_test.go new file mode 100644 index 0000000000..bd4b7f467f --- /dev/null +++ b/internal/exec/runtime/robustness_case_bindings_test.go @@ -0,0 +1,69 @@ +package runtime + +import ( + "errors" + "strings" + "testing" +) + +// TestRuntimeRobustnessCaseBindings covers the frame a case's conditions read its +// run's bindings through: a case-local the run left declared without a value +// shadows the binding of the same name, so a condition naming it is undecided for +// the missing value rather than read through to the enclosing one. +func TestRuntimeRobustnessCaseBindings(t *testing.T) { + t.Run("a mark on a frame holding no unvalued names is kept", func(t *testing.T) { + var f frame + f.markUnvalued("x") + if !f.has("x") { + t.Fatal("frame does not declare x after markUnvalued") + } + if _, ok, err := f.read(&Context{}, "x"); ok || !errors.Is(err, ErrNoValue) { + t.Fatalf("read of unvalued x = %v, %v, want ErrNoValue", ok, err) + } + }) + + t.Run("an unvalued case-local shadowing a bound parameter is undecided, not read through", func(t *testing.T) { + src := `package test { + private import ScalarValues::*; + part def Ship { attribute cost : Real = 5.0; } + part ship : Ship; + analysis bounded { + subject s = ship; + in limit : Real = 10.0; + objective fits { require constraint { c < limit } } + out c : Real = s.cost; + } + }` + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, src)) + sym := oneSymbol(t, idx, "test::bounded") + run, err := ctx.calcUsageRun(NewEvalContextIn(ctx, nil, nil), sym) + if err != nil { + t.Fatal(err) + } + if _, err := run.outputValues(ctx); err != nil { + t.Fatal(err) + } + if value, ok := run.env.lookup("limit"); !ok || FormatValue(value) != "10.0" { + t.Fatalf("limit bound to %s, %v; want 10.0", FormatValue(value), ok) + } + // The body left limit declared and unvalued, as a constraint body's + // performance leaves a root declaration no step wrote. + run.bodyFrames = append(run.bodyFrames, frame{unvalued: map[string]bool{"limit": true}}) + + bindings := run.bindingsFrame(ctx) + if !bindings.has("limit") { + t.Error("bindings frame does not declare the unvalued limit") + } + if _, ok, err := bindings.read(ctx, "limit"); ok || !errors.Is(err, ErrNoValue) { + t.Errorf("read of unvalued limit = %v, %v, want ErrNoValue", ok, err) + } + + verdicts := ctx.analysisVerdicts(run, sym, nil) + if len(verdicts) != 1 || verdicts[0].Name != "fits" { + t.Fatalf("verdicts = %+v, want the one objective fits", verdicts) + } + if got := verdicts[0]; got.Status != VerdictUndecided || !strings.Contains(got.Detail, "no value for feature limit") { + t.Fatalf("objective fits = %s (%q), want undecided for the unvalued limit", got.Status, got.Detail) + } + }) +} diff --git a/internal/exec/runtime/statements.go b/internal/exec/runtime/statements.go index 26bb3c7f27..78fff53a07 100644 --- a/internal/exec/runtime/statements.go +++ b/internal/exec/runtime/statements.go @@ -233,13 +233,8 @@ func (env *stmtEnv) constraintResult(ctx *Context) (frame, error) { for name := range env.locals { delete(result.unvalued, name) } - if len(env.unvaluedLocal) > 0 { - if result.unvalued == nil { - result.unvalued = make(map[string]bool, len(env.unvaluedLocal)) - } - for name := range env.unvaluedLocal { - result.markUnvalued(name) - } + for name := range env.unvaluedLocal { + result.markUnvalued(name) } var cells *bodyCells if env.localCells != nil { From 109e0486ad300f93e97c2da9b5b0126046164b7a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:55:51 +0000 Subject: [PATCH 7/7] ci: re-run the race suite after the runtime shard hit its 45m limit on a slow runner Co-Authored-By: jason.han