Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
13 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/unreleased/tool-execution-calc.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **`ToolExecution` on a `calc def` or calc usage.** A `calc def` or calc usage annotated `metadata ToolExecution { toolName = "…"; uri = "…"; }` is computed by the named tool wherever a calc is invoked — `sysml -calc`, `%calc`, `EvaluateCalc`, derived attributes and document formulas — its `in` parameters sent by their `ToolVariable` names, its `out` parameters and result parameter (under its `ToolVariable` name, else its declared name, else `result`) bound from the reply. The body never evaluates: a tool unregistered, refusing or failing fails the calculation with the same typed errors a performance gets, and equal inputs answered differently are noted as a divergence.
139 changes: 139 additions & 0 deletions cmd/sysml/tool_calc_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package main

import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"sync"
"testing"

"github.com/Open-MBEE/OpenSysML/internal/exec/analysis"
"github.com/Open-MBEE/OpenSysML/tests/testutil/gobuild"
)

var (
toolCalcOnce sync.Once
toolCalcPath string
toolCalcErr error
)

// toolCalcStandin builds the tool stand-in of the analysis package's tests once per
// test binary.
func toolCalcStandin(t *testing.T) string {
t.Helper()
toolCalcOnce.Do(func() {
dir, err := os.MkdirTemp("", "toolcalc")
if err != nil {
toolCalcErr = err
return
}
toolCalcPath = filepath.Join(dir, "toolcalc")
build := exec.Command("go", gobuild.Args(toolCalcPath)...)
build.Dir = filepath.Join("..", "..", "internal", "exec", "analysis", "testdata", "toolcalc")
if out, err := build.CombinedOutput(); err != nil {
toolCalcErr = fmt.Errorf("go build: %v\n%s", err, out)
}
})
if toolCalcErr != nil {
t.Fatalf("building the tool stand-in: %v", toolCalcErr)
}
return toolCalcPath
}

// toolCalcManifest writes a manifest naming Thermo and points OPENSYSML_TOOLS at it.
func toolCalcManifest(t *testing.T) {
t.Helper()
dir := t.TempDir()
entry := `{"kind":"tool","toolName":"Thermo","version":"1.0.0",` +
`"executable":` + strconv.Quote(toolCalcStandin(t)) + `,` +
`"variables":["mass","power","warn","Tmax"]}`
if err := os.WriteFile(filepath.Join(dir, "thermo.json"), []byte(entry), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv(analysis.ToolsEnv, dir)
}

// toolCalcModel is a calc the tool computes; its bodyless result is answered from the reply.
const toolCalcModel = `package thermo {
private import ScalarValues::*;
private import AnalysisTooling::*;
private import ISQ::*;

calc def Thermal {
metadata ToolExecution { toolName = "Thermo"; uri = "thermo://local"; }
in m : MassValue { @ToolVariable { name = "mass"; } }
in p : PowerValue { @ToolVariable { name = "power"; } }
out warn : Boolean { @ToolVariable { name = "warn"; } }
return : TemperatureValue { @ToolVariable { name = "Tmax"; } }
}
}
`

// toolCalcDocument is a document whose table column reads a derived attribute that
// calls the tool calc — the formula the render run evaluates.
const toolCalcDocument = toolCalcModel + `package boards {
private import DocumentQueries::*;
private import KerML::Root::Element;
private import ScalarValues::*;
private import ISQ::*;
private import thermo::Thermal;

part def Board {
attribute mass : MassValue = 2 [SI::kg];
attribute power : PowerValue = 10 [SI::W];
attribute Tmax : TemperatureValue = Thermal(mass, power);
}
part rack {
part b1 : Board;
}

calc def Boards :> Query {
in root : Element = rack;
Project(
source = WhereType(source = Descendants(source = root, maxDepth = 1), type = "PartUsage"),
properties = ("name"),
columns = (Column(name = "Tmax", expression = Board::Tmax))
)
}

part def BoardReport :> Document {
attribute redefines title = "Board Thermals";

part temps : Table {
attribute redefines caption = "Peak per board";
calc rows : Boards;
}
}
}
`

// sysml -calc answers what the tool answered when the manifest registers it.
func TestCLIToolCalcAnswersFromTheTool(t *testing.T) {
binary := buildCLI(t)
toolCalcManifest(t)

wantReport(t, check(t, binary, toolCalcModel,
"-calc", "thermo::Thermal(2 [SI::kg], 10 [SI::W])"), 0, "= 20 [SI::K]")
}

// Without a manifest entry the same invocation fails as not registered.
func TestCLIToolCalcRefusesAnUnregisteredTool(t *testing.T) {
binary := buildCLI(t)
t.Setenv(analysis.ToolsEnv, t.TempDir())

wantReport(t, check(t, binary, toolCalcModel,
"-calc", "thermo::Thermal(2 [SI::kg], 10 [SI::W])"), 2,
"tool 'Thermo' is not registered; set OPENSYSML_TOOLS")
}

// A document whose table column reads a derived attribute calling the tool calc
// renders the value the tool answered.
func TestCLIToolCalcInADocumentFormula(t *testing.T) {
binary := buildCLI(t)
toolCalcManifest(t)

wantReport(t, check(t, binary, toolCalcDocument,
"-render-document", "boards::BoardReport"), 0, "Tmax", "20")
}
2 changes: 1 addition & 1 deletion docs/guide/10-troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ in [reference/environment.md](../reference/environment.md).
- Otherwise the solver could not decide the arithmetic, and the reason it reports says so

**A run fails with "tool 'ModelCenter' is not registered; set OPENSYSML_TOOLS":**
- The action performed carries `AnalysisTooling::ToolExecution`, and an annotated action is only ever performed by the tool it names — never by evaluating its body. Point `OPENSYSML_TOOLS` at a directory holding one JSON file per tool (`toolName`, `version`, `executable`, `variables`), as [reference/environment.md](../reference/environment.md#external-tools) describes; `sysml -engines` then lists the tool as `tool:ModelCenter` with whether its executable was found
- The action performed — or the `calc def` or calc usage invoked — carries `AnalysisTooling::ToolExecution`, and an annotated action or calc is only ever run by the tool it names — never by evaluating its body. Point `OPENSYSML_TOOLS` at a directory holding one JSON file per tool (`toolName`, `version`, `executable`, `variables`), as [reference/environment.md](../reference/environment.md#external-tools) describes; `sysml -engines` then lists the tool as `tool:ModelCenter` with whether its executable was found
- A tool that exits non-zero, answers something other than one JSON object of `outputs`, omits an output, names one no parameter receives, writes more than `OPENSYSML_TOOL_MAX_OUTPUT` (default 64 MiB), or takes longer than `OPENSYSML_TOOL_TIMEOUT` (default `10s`) fails the performance with that reason; no value is invented in its place

**`sysml -engines` does not list the engine in `OPENSYSML_ENGINES`, or lists it `unavailable`:**
Expand Down
2 changes: 1 addition & 1 deletion docs/project/spec-compliance.md

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -1551,8 +1551,9 @@ sweep built-in - observed sweep ready
Every tool the manifest directory `OPENSYSML_TOOLS` names adds a `tool:<name>` engine, listed
the same way with its executable's status (`tool:ModelCenter tool object observed compute
ready (ModelCenter 14.1 at /opt/modelcenter/bin/mc-batch)`); it answers the `compute` a
performance of an action annotated `ToolExecution` asks, and nothing else does, so a tool that
is unregistered or fails stops that performance rather than falling back to the action's body
performance of an action — or an invocation of a `calc def` or calc usage — annotated
`ToolExecution` asks, and nothing else does, so a tool that
is unregistered or fails stops that performance or calculation rather than falling back to the body
([External tools](environment.md#external-tools)). Every engine the manifest directory
`OPENSYSML_ENGINES` names is listed under its own name, with the manifest's authority and
answers and the status its file can tell; a `policy`, `sampler` or `module` entry is listed as
Expand Down
15 changes: 14 additions & 1 deletion docs/reference/environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,11 @@ Nothing else in the toolchain reads these variables, and the concrete evaluator

An action of an analysis case carrying the `AnalysisTooling::ToolExecution` metadata (its
`toolName` and `uri`), with `ToolVariable` on the parameters the tool knows by other names, is
performed by that tool rather than by its body. The tools a `sysml` or `sysml-grpc` process may
performed by that tool rather than by its body. A `calc def` or calc usage carrying the same
metadata is computed the same way — its `in` parameters go to the tool, its result parameter
and `out` parameters are bound from the reply, and its body is never evaluated — wherever a
calc is invoked: `sysml -calc`, `%calc`, `EvaluateCalc`, a derived attribute (`attribute x =
toolCalc(a, b)`) and the formulas a rendered document evaluates. The tools a `sysml` or `sysml-grpc` process may
run are the entries of the directory `OPENSYSML_TOOLS` names, read once at startup; each
becomes an engine `tool:<toolName>` that `-engines`, `%engines` and `ListEngines` list with its
status, and a manifest that cannot be read is reported at startup, as a bad run bound is.
Expand Down Expand Up @@ -274,6 +278,15 @@ The body is never run when the metadata is present: with `OPENSYSML_TOOLS` unset
absent from it, the performance fails with `tool 'ModelCenter' is not registered; set
OPENSYSML_TOOLS`.

A calc computed by a tool follows the same exchange: `inputs` are the `in` and `inout`
parameters carrying `ToolVariable`, `outputs` the `out` and `inout` parameters carrying it,
together with the result parameter — keyed by its `ToolVariable` name when it carries one, else
its declared name, else `result`. The result a `calc def` invocation returns is the value bound
under that key; a calc usage invoked without arguments reports every output it names. The same
typed errors, the same refusal to run the body and the same divergence reporting apply — the
calculation fails as the tool failed, and no value is invented for an output the tool did not
answer.

A tool's answer stands as the value of that performance at strength *observed*: nothing in
OpenSysML knows what the tool should have computed. Two invocations with equal inputs answering
different outputs are reported as a divergence in the run's notes (`%trace` summarizes them), so
Expand Down
8 changes: 7 additions & 1 deletion internal/doc/queryexec/derived.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,13 @@ type verificationKey struct {

func (d *derivedValues) get(context Context) *runtime.DeclaredReader {
if d.reader == nil {
d.reader = runtime.NewDeclaredReader(context.Model, context.Resolver)
// Over a held runtime, derived features calling a tool-computed calc
// answer through the same runner the query's context drives.
if context.Runtime != nil {
d.reader = runtime.NewDeclaredReaderIn(context.Runtime)
} else {
d.reader = runtime.NewDeclaredReader(context.Model, context.Resolver)
}
}
return d.reader
}
Expand Down
111 changes: 111 additions & 0 deletions internal/exec/analysis/testdata/toolcalc/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// Command toolcalc stands in for a tool-computed calc's external tool in the tests of
// the tool protocol: it reads the one JSON request on standard input and answers
// Tmax = mass*power in the unit TOOL_CALC_UNIT names (K by default), plus warn =
// mass*power > 100. TOOL_CALC_MODE picks a failure instead: error, exit or hang.
// It is built by the tests that run it and lives under testdata, out of every build.
package main

import (
"encoding/json"
"fmt"
"io"
"os"
"time"
)

// ModeEnv selects how the stand-in answers; the empty mode answers the request.
const ModeEnv = "TOOL_CALC_MODE"

// UnitEnv names the unit the Tmax answer is measured in.
const UnitEnv = "TOOL_CALC_UNIT"

// value is one protocol value: a JSON value and, for a quantity, its unit.
type value struct {
Value json.RawMessage `json:"value"`
Unit string `json:"unit,omitempty"`
}

// request is the protocol's request object.
type request struct {
ToolName string `json:"toolName"`
URI string `json:"uri"`
Inputs map[string]value `json:"inputs"`
}

func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "toolcalc:", err)
os.Exit(3)
}
}

func run() error {
data, err := io.ReadAll(os.Stdin)
if err != nil {
return err
}
var req request
if err := json.Unmarshal(data, &req); err != nil {
return fmt.Errorf("request is not the protocol's object: %w", err)
}
switch mode := os.Getenv(ModeEnv); mode {
case "", "answer":
return reply(answer(req))
case "error":
return json.NewEncoder(os.Stdout).Encode(map[string]string{"error": "thermal model did not converge"})
case "exit":
fmt.Fprintln(os.Stderr, "license server unreachable")
os.Exit(2)
case "hang":
time.Sleep(time.Minute)
return reply(answer(req))
default:
return fmt.Errorf("%s=%q is not a mode", ModeEnv, mode)
}
return nil
}

// number reads one input's magnitude as the protocol carries it.
func number(in value) (float64, error) {
var n float64
if err := json.Unmarshal(in.Value, &n); err != nil {
return 0, fmt.Errorf("input %s is not a number: %w", in.Value, err)
}
return n, nil
}

// answer is the stand-in's computation: the product of the mass and power sent.
func answer(req request) map[string]value {
mass, err := number(req.Inputs["mass"])
if err != nil {
mass = 0
}
power, err := number(req.Inputs["power"])
if err != nil {
power = 0
}
product := mass * power
unit := os.Getenv(UnitEnv)
if unit == "" {
unit = "K"
}
return map[string]value{
"Tmax": {Value: raw(product), Unit: unit},
"warn": {Value: raw(product > 100)},
}
}

func raw(x any) json.RawMessage {
data, err := json.Marshal(x)
if err != nil {
return json.RawMessage("null")
}
return data
}

// reply writes the protocol's reply object.
func reply(outputs map[string]value) error {
return json.NewEncoder(os.Stdout).Encode(struct {
Outputs map[string]value `json:"outputs"`
}{Outputs: outputs})
}
Loading
Loading