Skip to content

feat(runtime): calc definitions, usages and calc parameters as function values - #122

Merged
HuiJun merged 14 commits into
mainfrom
feature/function-values
Sep 8, 2026
Merged

feat(runtime): calc definitions, usages and calc parameters as function values#122
HuiJun merged 14 commits into
mainfrom
feature/function-values

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

What and why

On main, naming a calc where a value is expected fails (Fn(Sq, 3.0)cannot evaluate definition Sq), so a calc cannot be passed to another calc, bound to an in calc parameter, or handed to SampledFunctions::Sample. This PR makes a calc definition, a calc usage with an unsupplied input, and an in calc parameter/feature read as a function value, and invokes it through the same path a calc usage invocation takes.

calc def Sq { in v : Real; return : Real = v * v; }
calc def Fn { in calc f { in v : Real; return : Real; } in a : Real; return : Real = f(a); }
calc def UseFn { return : Real = Fn(Sq, 3.0); }   // 9.0

Value kind: ValFunction beside ValExpr, not generalizing it

ValExpr (BodyExpr) is a closure over one expression body that a collection operation evaluates per element — it has no parameters, no calcShape, and is never invoked by argument binding. A function value is the opposite: a reference to a lowered calc (calcShape, the invocation interface invokeCalcShapeIn already consumes) plus the environment it was read in. Folding one into the other would have meant either giving ValExpr a parameter list it never binds or turning function values into statement closures, which is exactly what the design must not do. So ValFunction is a distinct kind and ValExpr keeps its one job; nothing was bridged and no ValExpr caller changed.

type functionValue struct {
	shape     *calcShape       // the lowered calc; a natively implemented library function carries its implementation
	library   *libraryFunction
	scope     *symbols.Scope   // scope the value was read in
	self      *Instance        // object it was read off (twice.scale vs thrice.scale)
	enclosing []frame          // bindings of the behavior body it is declared in, when it is
}
// invocation, every path:
ec.ctx.invokeCalcShapeIn(fn.shape, args, fn.scope, fn.self, fn.enclosing)

Paths covered: read of a calc def / calc usage / in calc parameter; binding to a calc-typed parameter (type-checked: ErrNotAFunction), positional and named arguments; f(a), p.f(a), f(f(a)); a calc bound as an action input; SampledFunctions::Sample applying a user calc; a calc read off an object; a calc declared in a behavior body closing over that body's bindings and a function returned from a calc; equality/set keying; adoption between runtimes; typed errors ErrNotAFunction, ErrCalcArity, ErrUnboundParameter, ErrFunctionUnbound, ErrFunctionNeedsRuntime.

A nested calc closes over its enclosing body only where the body it runs, or a default it declares, is written inside that behavior: a body inherited from a calc declared elsewhere (calc again : RecursiveStep;) reads none of the enclosing frames (calcShape.bodyEnclosing). Without that rule a case performing itself as a step nested the caller's frames at every level of the recursion. The frames it does close over are those of the innermost active run of the behavior that lexically owns it (EvalContext.enclosingRun, frame.runs) — a calc frame owned by that calc def or a usage specializing it, or an action performance typed by it — never a caller's; when no such run is active it closes over nothing, so a caller's same-named parameter cannot satisfy a nested calc's reference.

Wire

Value.function = 17 (field 16 is infinity on main) with Function { string calc_id = 1; int64 self_id = 2; } — the calc's qualified name and the response-local id of the object it was read off (0 for none). Because every call instantiates the model afresh, a request function carrying a non-zero self_id is refused in band rather than matched to whichever object the call numbered the same; a calc is applied over an object within the call that holds it (Evaluate of F::apply(F::holder.scale, 3.0)). Advertised as the function_values capability. A function closing over a behavior body's bindings cannot be named by calc and object, so it crosses as unsupported: function <calc>. buf breaking against main is clean (a new arm only). Go, Python, Node, Rust and Java clients expose it as a typed value and refuse to send one to a service without the capability; the public Go package and the conformance runner round-trip it.

Codegen

Native compilation refuses a calc that binds or applies a function value with a typed error rather than miscompiling it; recorded as such in spec-compliance.md.

Known limitation, documented

SampledFunctions::SamplePair still fails, on main and here alike, with type mismatch: operator '-' is not defined for a Real and a sequence: reading an inherited [0..*] member yields a singleton sequence where the library body expects a scalar. That is a pre-existing multiplicity issue independent of function dispatch, marked ❌ with the reproducer in spec-compliance.md rather than worked around.

Specification basis

KerML 1.1 §7.4.4 (a Function is a Behavior with a result; an Expression is a Step typed by a Function; a feature reference to either denotes it), §8.3.4.8 Function::result / FeatureReferenceExpression; SysML v2 §7.17 and §8.3.16 (a calc def is a Function, a calc usage an Expression, CalculationUsage as a parameter). Adds rows to the calculation and API sections of docs/project/spec-compliance.md (✅ for the paths above, ❌ for SamplePair, the codegen refusal stated).

How it was verified

New coverage (the four-layer contract):

  • parser golden action_calc_parameter.sysml (in calc on an action);
  • conformance function_value_probe (+ trace golden), _read, _calc_usage, _named_args, _chain_call, _action_parameter, _feature_closure, _body_closure, _library, _sampled, _sampled_closure;
  • robustness: call of a non-function, binding a non-function, arity mismatch, unbound calc parameter, a built-in that binds arguments unevaluated, an inherited body outside the closure;
  • value_kinds_test.go (identity/keying, every-kind dispatch), adopt_test.go, eval_no_value_test.go, repl/evalin_test.go;
  • grpc/convert_function_test.go, client/opensysml/function_test.go, Python/Node/Java/Rust client tests, conformance scenarios 01-server-info, 04-evaluate, 10-evaluate-calc (function.sysml) over gRPC, Connect protobuf and Connect JSON, Rust and Java runners.

Gates, all clean on the final commit:

gofmt -l .                       (empty)
go build ./...                   ok
go vet ./...                     ok
go test ./...                    ok
go test -race ./...              ok
make lint                        ✓ Lint passed
make proto-lint proto-breaking   ✓ No breaking schema changes
python3 scripts/changelog.py check
python3 scripts/check-doc-ids.py
make docs-counts                 ✓ current
OPENSYSML_REQUIRE_TRAINING_CORPUS=1 OPENSYSML_REQUIRE_PILOT_CORPORA=1 \
  go test -count=1 ./internal/core/model -run 'TestTrainingExamples|TestPilotCorpora'   ok

internal/core/model/testdata/training_examples_expected.txt is unchanged. Java verification was run against a local Maven mirror because Maven Central rate-limited the build; nothing of that is in the tree.

Checklist

  • make test and make lint pass locally
  • Tests added or updated for the change
  • Documentation extended where it already covers the surface (see CONTRIBUTING.md)
  • Changelog entry added as changes/unreleased/<slug>.<section>.md, not as an edit to CHANGELOG.md
  • baselines regenerated and make docs-counts run if a gate count moved (compliance rows need nothing: the census is counted at docs build)
  • No internal work-item labels (waves, slices, F4, K5) in the body, docs, or changelog

devin-ai-integration Bot and others added 3 commits September 8, 2026 02:33
…on values

A calc definition, a calc usage awaiting an input, or an `in calc` parameter read as a value is a function value: the lowered calc together with the scope, object and enclosing body frames it was read in, invoked through the calc invocation path. Calc-typed parameters accept one by position or name, `f(a)` invokes it, `SampledFunctions::Sample` samples a user calc, and a natively implemented library function is a value too.

Function values cross the wire as `Value.function` (`calc_id`, optional `self_id`) under the `function_values` capability, exposed by the Go, Python, Node, Rust and Java clients. Native compilation refuses a calc that binds or applies a function value with a typed error.

Co-Authored-By: jason.han <[email protected]>
…that body is written

A calc usage nested in a behavior body keeps the enclosing frames only when the body it runs is declared inside that behavior; a body inherited from a calc declared elsewhere reads none of them. A case performing itself as a step no longer nests the caller's frames at every level of the recursion, which multiplied the frames cloned per step.

Co-Authored-By: jason.han <[email protected]>
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@devin-ai-integration
devin-ai-integration Bot marked this pull request as ready for review September 8, 2026 03:35
devin-ai-integration[bot]

This comment was marked as resolved.

…object-bound wire functions

A function value read off a nested calc now captures the enclosing body's
bindings only when the calc reads them — through a body written in that
behavior or a default it declares there — so a usage inheriting its body from
a calc declared elsewhere no longer carries frames it cannot read, and is no
longer refused on the wire as closing over a body.

A calc-typed parameter invoked by a qualified name (`Apply::f(a)`, or through
the calc a run specializes and a redeclaration) applies what the run bound it
to, found through the active frames' owner qualification, as the bare name does.

A request function carrying a non-zero self_id is refused: every call
instantiates the model afresh, so the object it names lived only within the
response that sent it and another call's object may carry the same number.
The wire contract, proto comment, clients and conformance scenario say so.

Co-Authored-By: jason.han <[email protected]>
devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration Bot and others added 2 commits September 8, 2026 04:25
…ng behavior

A calc declared in a behavior body used to close over every frame on the evaluation stack, so a caller's same-named parameter could satisfy a name in the nested body. Frames are now selected by ownership: a nested calc closes over the frames through the innermost active run of the behavior it is declared in (a calc invocation or usage of that behavior or one specializing it, or an action performance typed by it), and over nothing when no such run is active. Direct invocation, function-value snapshots and nested calc usages all apply the rule.

Co-Authored-By: jason.han <[email protected]>
Regenerates the protobuf artifacts from the merged schema, which carries both the function value arm and the parameter sweep RPC, and binds sweep arguments against the run's runtime so a function bound by every row of a sweep applies as it does in an analysis run.

Co-Authored-By: jason.han <[email protected]>
devin-ai-integration[bot]

This comment was marked as resolved.

A function value returned from a specialized run snapshots the frames it
closes over; the snapshot now carries the frames' redefinition aliases, so
a nested calc reading an inherited parameter name a subclass renamed
resolves it instead of failing.

A call through a feature chain, holder.scale(3.0), is checked statically
as a direct call is: the chain's terminal calc feature is resolved, its
positional and named arguments are held to that feature's effective inputs,
a chain to a non-behavior is refused, and the call is typed by the calc's
result so binding it to an incompatible declared type is reported.

Co-Authored-By: jason.han <[email protected]>
devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration Bot and others added 2 commits September 8, 2026 05:05
…ders

A frame snapshot copied an action performance's bindings but not which action it was a run of, so a calc captured from an action body could no longer find the performance when it applied a second body-local calc. The snapshot now records the action it was copied from (frame.performed), which frame.runs answers for as it does for the live performance.

The holder analysis prepended every invocation operand to the positional arguments, so a call through a feature chain (picker.pick(lead, trail)) was read with the chain as its first argument and its callee unresolved. It now resolves the chain to the calc applied and maps the written arguments against that calc's returned parameters, sharing ChainCallee/InvocationArgs with the checker.

Co-Authored-By: jason.han <[email protected]>
devin-ai-integration[bot]

This comment was marked as resolved.

…puts

A chain call such as holder.scaled() now denotes the calc feature applied over the receiver's object directly, instead of first reading the chain as a value: a calc every input of which a default supplies, or one with no input, reads bare as its result and so could not be called.

Co-Authored-By: jason.han <[email protected]>
devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration Bot and others added 2 commits September 8, 2026 06:17
Two reads of a calc declared in a behavior body within one run of that body are one
function: valueEqual and valueKeyFunc compare the run each read closed over (a
Context-numbered identity every invocation, usage evaluation and performance frame
carries into its snapshots) rather than the pointer of the read, so `inner == inner`
holds and a set of both reads holds one value, while functions returned by two runs
stay distinct.

Co-Authored-By: jason.han <[email protected]>
Resolve the Value oneof collision by keeping infinity at field 16 and moving
function to field 17; regenerate protobuf stubs and keep both the
function_values and infinity_value capabilities across service, docs and clients.

Co-Authored-By: jason.han <[email protected]>
@HuiJun
HuiJun merged commit fd7d0ab into main Sep 8, 2026
12 checks passed
@HuiJun
HuiJun deleted the feature/function-values branch September 8, 2026 13:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant