Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
9f0dc1c
feat(runtime): calc definitions, usages and calc parameters as functi…
devin-ai-integration[bot] Sep 8, 2026
9a37674
fix(runtime): close a nested calc over its enclosing body only where …
devin-ai-integration[bot] Sep 8, 2026
718d1f4
test(runtime): compare a function value's key across two computations
devin-ai-integration[bot] Sep 8, 2026
65250be
docs(wire-contract): point the function arm at the capabilities section
devin-ai-integration[bot] Sep 8, 2026
58ba8eb
test(node): label a function's self_id as the instance it names in co…
devin-ai-integration[bot] Sep 8, 2026
fcff0e4
fix(runtime): scope function-value capture, qualified calc calls and …
devin-ai-integration[bot] Sep 8, 2026
333e6fa
fix(runtime): scope nested calc environments to the run of their owni…
devin-ai-integration[bot] Sep 8, 2026
1994651
merge: main into feature/function-values
devin-ai-integration[bot] Sep 8, 2026
ae947a5
fix(runtime): keep frame aliases in closures and type-check chain calls
devin-ai-integration[bot] Sep 8, 2026
300729b
Merge branch 'main' into feature/function-values
devin-ai-integration[bot] Sep 8, 2026
e2ca057
fix(runtime): keep a snapshot's action run and resolve chain-call hol…
devin-ai-integration[bot] Sep 8, 2026
9e158a3
fix(runtime): apply an object's calc in call position whatever its in…
devin-ai-integration[bot] Sep 8, 2026
c1ca190
fix(runtime): identify a body-closing function by the run it closes over
devin-ai-integration[bot] Sep 8, 2026
2f3cad4
chore(merge): merge origin/main into feature/function-values
devin-ai-integration[bot] Sep 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
632 changes: 362 additions & 270 deletions api/proto/sysml.pb.go

Large diffs are not rendered by default.

23 changes: 23 additions & 0 deletions api/proto/sysml.proto
Original file line number Diff line number Diff line change
Expand Up @@ -704,9 +704,27 @@ message Value {
// every finite magnitude and refused by arithmetic. Always true when set,
// as DocumentValue.infinity is.
bool infinity = 16;
Function function = 17; // a calc as a value, named by its declaration
}
}

// Function is a calc held as a value: a calc definition, or a calc usage with
// an input no read could supply, as `Sq` in `Fn(Sq, 3.0)` or the `f` of
// `in calc f {...}`. It crosses as the declaration it is a value of, which is
// its identity: two functions are the same exactly when calc_id and self_id
// are. A function closing over the bindings of the behavior body it is
// declared in has no wire form and crosses as the null arm.
message Function {
// FQN of the calc declaration ("Analysis::Sq"). Its identity.
string calc_id = 1;
// ID of the object the calc's feature names resolve against, for a calc
// usage read off a part (`holder.scale`); 0 for a function closing over no
// object. An instance id, living only within the response that sent it: a
// request function with a non-zero self_id is rejected, since no later call
// holds that object.
int64 self_id = 2;
}

// Array is a Collections::Array: its elements flattened in row-major order
// under its dimensions, compared by content rather than by the object read.
message Array {
Expand Down Expand Up @@ -882,6 +900,11 @@ message ServerInfoResponse {
// refused with UNIMPLEMENTED rather than read as another
// value. Separate from structured_values, which a client
// built before this arm existed may already claim.
// "function_values" - a Value carries a calc held as a value as function,
// named by its declaration, rather than reporting it as an
// unsupported null, and one is accepted as an action input
// or calc argument; without it, one is refused with
// UNIMPLEMENTED rather than read as another value.
// "apply_edits" - the ApplyEdits RPC edits a parsed model's own source,
// preserving everything the edit did not touch.
// "document_query" - the RunDocumentQuery RPC runs a named document query
Expand Down
2 changes: 2 additions & 0 deletions changes/unreleased/function-values.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
- **A calc is a value.** A calc definition, a calc usage awaiting an input, or an `in calc` parameter named where a value is expected is a function value: the calc together with the scope and object it was read in, invoked through a calc-typed parameter (`calc def Fn { in calc f { in v : Real; return : Real; } in a : Real; return : Real = f(a); }` makes `Fn(Sq, 3.0)` `9.0`), passed positionally or by name, read off a part, returned from a calc, compared and adopted. `SampledFunctions::Sample` now samples a user calc, and a library function the runtime implements (`RealFunctions::sqrt`) is a value too. A calc declared in a behavior body closes over the innermost active run of that behavior alone — never a caller's parameters, and nothing when no such run is active. Calling a non-function, an arity mismatch and an unbound calc parameter are typed errors; `in calc` parameters parse in action bodies as they do in calc bodies. A call through a feature chain (`holder.scale(3.0)`) is now checked statically as a direct call is — arguments against the calc feature's inputs, the result against the declared type it binds to — applies the calc even when defaults supply every input or it has none (`holder.scaled()`, where the bare read `holder.scaled` computes its result), and a typed feature valued by such a call (`item t : Tallied = picker.pick(lead, trail)`) classifies the argument the calc returns as one valued by a direct call does.
- **Function values cross the API.** `Value.function` carries the calc's qualified name and the id of the object it was read off, under the new `function_values` capability, which the Go, Python, Node, Rust and Java clients expose as a typed value and refuse to send to a service without the capability. A function closing over a behavior body's bindings crosses as an unsupported null, since no name reconstructs it; one read off an object is refused as an argument to a later call, since that object lived only within the response that sent it. Native compilation refuses a calc that binds or applies a function value with a typed error.
11 changes: 6 additions & 5 deletions client/opensysml/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,13 +202,14 @@ answering implementation supports, and `ServerInfo.Has` checks one. A request
that asks for an unavailable capability is refused with `CodeUnimplemented`;
capabilities that describe response population instead omit the fields they
name. Check the list first for an operation-specific error (the `Capability*`
constants name the known ones). Three capabilities are checked for you: a `Complex`
constants name the known ones). Four capabilities are checked for you: a `Complex`
among `ExecuteAction` inputs or `EvaluateCalc`/`RunAnalysis` arguments needs
`complex_values`, an `Array`, `Vector` or `VectorQuantity` needs
`structured_values`, and a `MeasurementRef` needs `measurement_refs` — each at
the top level or nested in a sequence or array; a service without them would read
the value as null, so the client refuses with `CodeUnimplemented` before sending
anything.
`structured_values`, a `MeasurementRef` needs `measurement_refs`, and a `Function`
(a calc held as a value, sent back to bind a calc-typed parameter) needs
`function_values` — each at the top level or nested in a sequence or array; a
service without them would read the value as null, so the client refuses with
`CodeUnimplemented` before sending anything.

## Stability

Expand Down
27 changes: 21 additions & 6 deletions client/opensysml/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,9 @@ type Client interface {
// ExecuteAction executes the named action with the inputs given, bound by
// parameter name, and reports the outputs it produced. A Complex input
// requires the complex_values capability, an Array, Vector or
// VectorQuantity input the structured_values one and a MeasurementRef
// input the measurement_refs one, checked before anything is sent.
// VectorQuantity input the structured_values one, a MeasurementRef input
// the measurement_refs one and a Function input the function_values one,
// checked before anything is sent.
ExecuteAction(ctx context.Context, model *Model, actionSymbolID string, inputs map[string]Value) (*ActionRun, error)

// ExecuteState runs the named state machine, feeding it the events in
Expand All @@ -85,9 +86,10 @@ type Client interface {

// EvaluateCalc invokes the named calculation with positional arguments, or,
// given none, evaluates a calc usage from its own members. Requires the
// verification capability, and the complex_values, structured_values or
// measurement_refs capability for a Complex, a structured or a
// MeasurementRef argument, checked before anything is sent.
// verification capability, and the complex_values, structured_values,
// measurement_refs or function_values capability for a Complex, a
// structured, a MeasurementRef or a Function argument, checked before
// anything is sent.
EvaluateCalc(ctx context.Context, model *Model, symbolID string, arguments ...Value) (*Calculation, error)

// RunAnalysis runs the named analysis case — a definition or a usage — and
Expand Down Expand Up @@ -505,7 +507,8 @@ func (c *client) call(model *Model) (string, error) {
// requireValueCapabilities refuses to send a value of a kind whose capability
// the service lacks — a Complex without complex_values, an Array, Vector or
// VectorQuantity without structured_values, a MeasurementRef without
// measurement_refs — which would read it as null rather than refuse it.
// measurement_refs, a Function without function_values — which would read it
// as null rather than refuse it.
func (c *client) requireValueCapabilities(ctx context.Context, values ...Value) error {
var needed []string
if slices.ContainsFunc(values, carriesComplex) {
Expand All @@ -517,6 +520,9 @@ func (c *client) requireValueCapabilities(ctx context.Context, values ...Value)
if slices.ContainsFunc(values, carriesMeasurementRef) {
needed = append(needed, CapabilityMeasurementRefs)
}
if slices.ContainsFunc(values, carriesFunction) {
needed = append(needed, CapabilityFunctionValues)
}
if len(needed) == 0 {
return nil
}
Expand Down Expand Up @@ -586,6 +592,15 @@ func carriesMeasurementRef(value Value) bool {
return slices.ContainsFunc(nestedValues(value), carriesMeasurementRef)
}

// carriesFunction reports whether a value, or any value nested in it, is a
// Function.
func carriesFunction(value Value) bool {
if _, ok := value.(Function); ok {
return true
}
return slices.ContainsFunc(nestedValues(value), carriesFunction)
}

// nestedValues are the values a value holds: a sequence's elements, an array's.
func nestedValues(value Value) []Value {
switch v := value.(type) {
Expand Down
10 changes: 10 additions & 0 deletions client/opensysml/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,11 @@ func valueFromProto(value *pb.Value) Value {
return Null("unsupported: measurement reference without its reduction")
}
return MeasurementRef{Unit: ref.GetUnit(), Term: unitTermFromProto(ref.GetUnitTerm()), UnitID: ref.GetUnitId()}
case *pb.Value_Function:
if kind.Function.GetCalcId() == "" {
return Null("unsupported: function naming no calc")
}
return Function{CalcID: kind.Function.GetCalcId(), Self: InstanceID(kind.Function.GetSelfId())}
default:
// A newer service's arm parses as an unknown field: no kind at all.
return Null("unsupported: a value arm this client does not know")
Expand Down Expand Up @@ -245,6 +250,11 @@ func valueToProto(value Value) (*pb.Value, error) {
UnitTerm: unitTermToProto(v.Term),
UnitId: v.UnitID,
}}}, nil
case Function:
if v.CalcID == "" {
return nil, &StatusError{Code: CodeInvalidArgument, Message: "a function names no calc"}
}
return &pb.Value{Kind: &pb.Value_Function{Function: &pb.Function{CalcId: v.CalcID, SelfId: int64(v.Self)}}}, nil
case Unset:
return nil, &StatusError{
Code: CodeInvalidArgument,
Expand Down
202 changes: 202 additions & 0 deletions client/opensysml/function_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
package opensysml_test

import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/Open-MBEE/OpenSysML/api/proto/protoconnect"
"github.com/Open-MBEE/OpenSysML/client/opensysml"
sysmlgrpc "github.com/Open-MBEE/OpenSysML/internal/grpc"
)

const functionSource = `package F {
private import ScalarValues::*;
calc def Sq { in v : Real; return : Real = v * v; }
calc def Cube { in v : Real; return : Real = v * v * v; }
calc def Apply { in calc f { in v : Real; return : Real; } in a : Real; return : Real = f(a); }
calc apply : Apply;
calc def Identity { in calc f { in v : Real; return : Real; } return r = f; }
attribute pick = Identity(Sq);
attribute nine = Apply(Sq, 3.0);
part def Scaler {
attribute k : Real = 2.0;
calc scale { in x : Real; return : Real = x * k; }
}
part holder : Scaler;
attribute scaler = holder.scale;

action applyTwice {
in calc f { in v : Real; return : Real; }
in a : Real;
out y : Real;
first start;
action inner { assign y := f(f(a)); }
then done;
succession first start then inner;
}
}`

// A calc read as a value arrives as a Function naming it over every transport,
// one read off an object naming that object too, and a Function sent back binds
// the calc-typed parameter the calc invokes.
func TestFunctionsCrossEveryTransport(t *testing.T) {
address := startService(t)
for name, client := range map[string]opensysml.Client{
"in-process": newClient(t),
"connect-proto": dialClient(t, address),
"connect-json": dialClient(t, address, opensysml.WithJSONBody()),
} {
t.Run(name, func(t *testing.T) {
ctx := context.Background()
info, err := client.ServerInfo(ctx)
if err != nil {
t.Fatalf("ServerInfo: %v", err)
}
if !info.Has(opensysml.CapabilityFunctionValues) {
t.Errorf("capabilities %v do not name %s", info.Capabilities, opensysml.CapabilityFunctionValues)
}
model := parse(t, client, functionSource)

pick, err := client.Evaluate(ctx, model, "F::pick")
if err != nil {
t.Fatalf("Evaluate(F::pick): %v", err)
}
if pick != (opensysml.Function{CalcID: "F::Sq"}) {
t.Errorf("F::pick = %#v, want the function F::Sq bound to no object", pick)
}

scaler, err := client.Evaluate(ctx, model, "F::scaler")
if err != nil {
t.Fatalf("Evaluate(F::scaler): %v", err)
}
fn, ok := scaler.(opensysml.Function)
if !ok || fn.CalcID != "F::Scaler::scale" || fn.Self == 0 {
t.Errorf("F::scaler = %#v, want the function F::Scaler::scale bound to holder", scaler)
}

nine, err := client.Evaluate(ctx, model, "F::nine")
if err != nil {
t.Fatalf("Evaluate(F::nine): %v", err)
}
if nine != opensysml.Real(9) {
t.Errorf("F::nine = %#v, want 9.0", nine)
}

calc, err := client.EvaluateCalc(ctx, model, "F::apply", opensysml.Function{CalcID: "F::Cube"}, opensysml.Real(2))
if err != nil {
t.Fatalf("EvaluateCalc(apply): %v", err)
}
if calc.Result != opensysml.Real(8) {
t.Errorf("apply(Cube, 2.0) = %#v, want 8.0", calc.Result)
}

run, err := client.ExecuteAction(ctx, model, "F::applyTwice", map[string]opensysml.Value{
"f": opensysml.Function{CalcID: "F::Sq"}, "a": opensysml.Real(3),
})
if err != nil {
t.Fatalf("ExecuteAction: %v", err)
}
if run.Outputs["y"] != opensysml.Real(81) {
t.Errorf("y = %#v, want 81.0", run.Outputs["y"])
}

// A function naming no calc, or an object this call did not
// create, is refused in band rather than invoked.
for label, bad := range map[string]opensysml.Function{
"an unknown calc": {CalcID: "F::Nothing"},
"a non-calc": {CalcID: "F::holder"},
"an unknown object": {CalcID: "F::Scaler::scale", Self: 99},
} {
if _, err := client.EvaluateCalc(ctx, model, "F::apply", bad, opensysml.Real(2)); err == nil {
t.Errorf("EvaluateCalc with a function naming %s succeeded", label)
}
}
var status *opensysml.StatusError
_, err = client.EvaluateCalc(ctx, model, "F::apply", opensysml.Function{}, opensysml.Real(2))
if !errors.As(err, &status) || status.Code != opensysml.CodeInvalidArgument {
t.Errorf("EvaluateCalc with an empty function: err = %v, want CodeInvalidArgument", err)
}
})
}
}

// A service without function_values would read a function input as null, so
// the client refuses to send one, however deeply nested; and what such a
// service reports for a calc read as a value is an unsupported null naming it.
func TestFunctionInputNeedsFunctionValues(t *testing.T) {
svc, err := sysmlgrpc.NewServiceWithUnavailableCapabilitiesForTesting(16, "test", []string{opensysml.CapabilityFunctionValues})
if err != nil {
t.Fatalf("NewServiceWithUnavailableCapabilitiesForTesting: %v", err)
}
t.Cleanup(svc.Close)
mux := http.NewServeMux()
mux.Handle(protoconnect.NewSysMLServiceHandler(sysmlgrpc.NewConnectAdapter(svc)))
server := httptest.NewServer(mux)
t.Cleanup(server.Close)

sq := opensysml.Function{CalcID: "F::Sq"}
for name, client := range map[string]opensysml.Client{
"connect-proto": dialClient(t, server.URL),
"connect-json": dialClient(t, server.URL, opensysml.WithJSONBody()),
} {
t.Run(name, func(t *testing.T) {
ctx := context.Background()
model := parse(t, client, functionSource)
for label, input := range map[string]opensysml.Value{
"function": sq,
"nested": opensysml.Sequence{opensysml.Int(1), opensysml.Sequence{sq}},
"in an array": opensysml.Array{Dimensions: []int64{1}, Elements: []opensysml.Value{sq}},
} {
_, err := client.ExecuteAction(ctx, model, "F::applyTwice", map[string]opensysml.Value{"f": input, "a": opensysml.Real(3)})
wantFunctionValuesRefusal(t, "ExecuteAction "+label, err)
_, err = client.EvaluateCalc(ctx, model, "F::apply", input, opensysml.Real(2))
wantFunctionValuesRefusal(t, "EvaluateCalc "+label, err)
}

// Invocation inside the model needs no capability: only the
// value crossing the boundary does.
nine, err := client.Evaluate(ctx, model, "F::nine")
if err != nil {
t.Fatalf("Evaluate(F::nine): %v", err)
}
if nine != opensysml.Real(9) {
t.Errorf("F::nine without function_values = %#v, want 9.0", nine)
}
pick, err := client.Evaluate(ctx, model, "F::pick")
if err != nil {
t.Fatalf("Evaluate(F::pick): %v", err)
}
if pick != opensysml.Null("unsupported: function F::Sq") {
t.Errorf("F::pick without function_values = %#v, want an unsupported null naming F::Sq", pick)
}
})
}
}

func wantFunctionValuesRefusal(t *testing.T, op string, err error) {
t.Helper()
var status *opensysml.StatusError
if !errors.As(err, &status) || status.Code != opensysml.CodeUnimplemented || !strings.Contains(status.Message, opensysml.CapabilityFunctionValues) {
t.Errorf("%s: err = %v, want CodeUnimplemented naming %s", op, err, opensysml.CapabilityFunctionValues)
}
}

func TestFunctionRendersAsItsCalc(t *testing.T) {
for _, testcase := range []struct {
value opensysml.Value
want string
}{
{opensysml.Function{CalcID: "F::Sq"}, "F::Sq"},
{opensysml.Function{CalcID: "F::Scaler::scale", Self: 1}, "F::Scaler::scale"},
{opensysml.Sequence{opensysml.Function{CalcID: "F::Sq"}, opensysml.Function{CalcID: "F::Cube"}}, "[F::Sq F::Cube]"},
} {
if got := fmt.Sprintf("%v", testcase.value); got != testcase.want {
t.Errorf("%#v renders as %q, want %q", testcase.value, got, testcase.want)
}
}
}
Loading
Loading