From 751a15d6e6e6e2efe4ff7564aff194a7a7dd1795 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 18 Sep 2026 06:30:21 +0000 Subject: [PATCH] feat(clients/java): wrap execution, verification, calculation, analysis and query in the public API Model gains executeAction/executeState with exploreAction/exploreState, verifyConstraint/verifyRequirement/verifySatisfaction/validateInstance, evaluateCalc, runAnalysis/exploreAnalysis, query/queryOslc and withEngine; Connection gains listEngines. Each answers an immutable record with no generated protobuf type in the public API. A false verdict is a decided answer, not an exception; ModelException carries a FailureReason and AnalysisException.partial() keeps what a failed analysis computed. The Java conformance runner covers the new RPCs through the public API, rendering results back to protobuf, so 94 of 134 scenarios run per protocol. Unit tests cover the result types and both conversion directions; ApiIntegrationTest runs each operation against the real service binary. Co-Authored-By: jason.han --- README.md | 7 +- .../java-client-runtime-api.added.md | 1 + clients/java/README.md | 65 +- .../org/openmbee/opensysml/ActionRun.java | 31 + .../java/org/openmbee/opensysml/Analysis.java | 104 +++ .../openmbee/opensysml/AnalysisException.java | 43 ++ .../openmbee/opensysml/AnalysisOptions.java | 99 +++ .../org/openmbee/opensysml/Calculation.java | 54 ++ .../openmbee/opensysml/CaseEvaluation.java | 45 ++ .../org/openmbee/opensysml/Condition.java | 153 +++++ .../org/openmbee/opensysml/Connection.java | 20 + .../org/openmbee/opensysml/EngineInfo.java | 77 +++ .../openmbee/opensysml/ExecutionOptions.java | 73 +++ .../org/openmbee/opensysml/Exploration.java | 58 ++ .../org/openmbee/opensysml/FailureReason.java | 18 + .../org/openmbee/opensysml/Instances.java | 19 + .../java/org/openmbee/opensysml/Model.java | 606 +++++++++++++++++- .../openmbee/opensysml/ModelException.java | 22 + .../java/org/openmbee/opensysml/Outcome.java | 59 ++ .../java/org/openmbee/opensysml/Query.java | 70 ++ .../org/openmbee/opensysml/QueryElement.java | 27 + .../org/openmbee/opensysml/Satisfaction.java | 87 +++ .../java/org/openmbee/opensysml/Standing.java | 74 +++ .../java/org/openmbee/opensysml/StateRun.java | 50 ++ .../org/openmbee/opensysml/Validation.java | 76 +++ .../java/org/openmbee/opensysml/Verdict.java | 111 ++++ .../org/openmbee/opensysml/Verification.java | 66 ++ .../opensysml/VerificationVerdict.java | 60 ++ .../openmbee/opensysml/internal/Protos.java | 565 +++++++++++++++- .../opensysml/ApiIntegrationTest.java | 389 +++++++++++ .../openmbee/opensysml/ResultTypesTest.java | 337 ++++++++++ .../opensysml/internal/ResultProtosTest.java | 469 ++++++++++++++ .../openmbee/opensysml/conformance/Api.java | 422 +++++++++++- .../opensysml/conformance/Rendering.java | 360 +++++++---- .../opensysml/conformance/Runner.java | 4 +- .../opensysml/conformance/Scenario.java | 2 +- .../opensysml/conformance/SuiteTest.java | 28 +- docs/guide/09-clients.md | 32 +- docs/reference/clients.md | 24 +- docs/reference/java-api.md | 148 ++++- 40 files changed, 4764 insertions(+), 191 deletions(-) create mode 100644 changes/unreleased/java-client-runtime-api.added.md create mode 100644 clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/ActionRun.java create mode 100644 clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Analysis.java create mode 100644 clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/AnalysisException.java create mode 100644 clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/AnalysisOptions.java create mode 100644 clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Calculation.java create mode 100644 clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/CaseEvaluation.java create mode 100644 clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Condition.java create mode 100644 clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/EngineInfo.java create mode 100644 clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/ExecutionOptions.java create mode 100644 clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Exploration.java create mode 100644 clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/FailureReason.java create mode 100644 clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Instances.java create mode 100644 clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Outcome.java create mode 100644 clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Query.java create mode 100644 clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/QueryElement.java create mode 100644 clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Satisfaction.java create mode 100644 clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Standing.java create mode 100644 clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/StateRun.java create mode 100644 clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Validation.java create mode 100644 clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Verdict.java create mode 100644 clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Verification.java create mode 100644 clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/VerificationVerdict.java create mode 100644 clients/java/opensysml-client/src/test/java/org/openmbee/opensysml/ResultTypesTest.java create mode 100644 clients/java/opensysml-client/src/test/java/org/openmbee/opensysml/internal/ResultProtosTest.java diff --git a/README.md b/README.md index b7b0ce0a9e..7b304b82f4 100644 --- a/README.md +++ b/README.md @@ -307,7 +307,7 @@ The project is under active development, with the core infrastructure operationa | Public Go API (`client/opensysml`) | ✅ Complete for its v1 scope: parse, diagnostics, symbols, evaluation, instantiation and capability negotiation, answered in process or over Connect, with the edit API, conversion, verification, behaviour execution and Query out of scope ([client/opensysml/README.md](client/opensysml/README.md)) | | Python client library | ✅ Complete for the RPCs that exist (connection lifecycle, parse/symbols/eval/instantiate/execute, constraint/requirement/satisfaction/calc verification, conversion, edits, Query, IPython hooks, DataFrame) | | Rust client library | 🚧 Blocking v1 client for parse, diagnostics, symbols, evaluation and instantiation; see the [Rust client README](clients/rust/README.md) | -| Java client library | ✅ Complete for its v1 scope, with the remaining scope stated explicitly: connection lifecycle, parse/symbols/eval/instantiate and capability negotiation, with the edit API, conversion, verification, behaviour execution and Query out of scope. Connect protocol over the JDK's own HTTP client, so no gRPC or Netty reaches a host application ([clients/java/README.md](clients/java/README.md)) | +| Java client library | ✅ Connection lifecycle, parse/symbols/eval/instantiate and capability negotiation, plus typed immutable results for behaviour execution and exploration, verification and validation, calculation, analysis with engine selection, and structured and OSLC query; the edit API, multi-document parsing, conversion, sweeps and the document RPCs are stated as out of scope. Connect protocol over the JDK's own HTTP client, so no gRPC or Netty reaches a host application ([clients/java/README.md](clients/java/README.md)) | | Node/TypeScript client library | ✅ Complete for the same v1 scope, in Node and the browser, over the Connect protocol with protobuf bodies and no native addon; values arrive as discriminated unions ([clients/node/README.md](clients/node/README.md)) | @@ -548,9 +548,10 @@ how to choose; [guide chapter 9](docs/guide/09-clients.md) works through each on | Java, `org.openmbee:opensysml-client` | Connect, over the JDK's own HTTP client | not yet | [Java API](docs/reference/java-api.md) | | Rust, `opensysml` | Connect, blocking, no async runtime | not yet | [Rust API](docs/reference/rust-api.md) | -The Go and Python clients cover every RPC the service serves; Node, Java and Rust cover a v1 +The Go and Python clients cover every RPC the service serves; Node and Rust cover a v1 subset — connection lifecycle, capability negotiation, parsing, diagnostics, symbol lookup, -evaluation and instantiation — enumerated in the client libraries page. +evaluation and instantiation — and Java adds execution, verification, calculation, analysis and +query to it, as enumerated in the client libraries page. ### Python diff --git a/changes/unreleased/java-client-runtime-api.added.md b/changes/unreleased/java-client-runtime-api.added.md new file mode 100644 index 0000000000..259637cae2 --- /dev/null +++ b/changes/unreleased/java-client-runtime-api.added.md @@ -0,0 +1 @@ +- **The Java client wraps the execution, verification, calculation, analysis and query RPCs.** `Model` gains `executeAction`/`executeState` and `exploreAction`/`exploreState`, `verifyConstraint`/`verifyRequirement`/`verifySatisfaction`/`validateInstance`, `evaluateCalc`, `runAnalysis`/`exploreAnalysis`, `query`/`queryOslc` and `withEngine`, and `Connection` gains `listEngines`, each answering an immutable record (`ActionRun`, `StateRun`, `Exploration` of `Outcome`s, `Verification`, `Satisfaction`, `Validation`, `Verdict`, `VerificationVerdict`, `Calculation`, `Analysis`, `CaseEvaluation`, `Standing`, `QueryElement`, `EngineInfo`) with no generated protobuf type in the public API. A false verdict is returned as a decided answer rather than thrown; `ModelException.failureReason()` classifies an in-band failure, and `AnalysisException.partial()` keeps what a failed analysis computed before it stopped. The Java conformance runner now covers these RPCs through the public API, so 94 of the 134 scenarios run per protocol, and the remaining skips name the uncovered RPCs and the requests the public API cannot express. diff --git a/clients/java/README.md b/clients/java/README.md index e26dfb3f46..7086c52a44 100644 --- a/clients/java/README.md +++ b/clients/java/README.md @@ -30,6 +30,13 @@ try (Connection connection = Connection.open()) { // starts a private sysml Symbol vehicle = model.symbol("Demo::Vehicle"); // findSymbol returns Optional Instantiation built = model.instantiate("Demo::Vehicle"); + ActionRun run = model.executeAction("Test::addFive"); // outputs, final time, diagnostics + Verification v = model.verifyConstraint("Demo::Vehicle::massLight"); + boolean holds = v.holds(); // false is an answer, not a failure + Analysis study = model.runAnalysis("Trade::lightest"); // outputs, verdicts, case evaluations + List parts = model.query( + Query.all().where(Condition.equal("@type", List.of("PartUsage")))); + connection.capabilities().require(Capabilities.FEATURE_VALUES); } ``` @@ -40,7 +47,12 @@ records (`IntegerValue`, `RealValue`, `ComplexValue`, `QuantityValue`, `ArrayVal `FunctionValue`, `MetaobjectValue`, `EnumerationValue` (whose `EnumLiteral` carries the scalar a `high = 3` literal was given as `value()`), `InstanceReference`, `Sequence`, `NullValue`, `UnsetValue`, `UndeterminedValue`, `InfinityValue`), and `Symbol`, -`Diagnostic`, `Instance` and `Instantiation` are records with copied collections. +`Diagnostic`, `Instance` and `Instantiation` are records with copied collections, as are +the answers of execution (`ActionRun`, `StateRun`, `Exploration` of `Outcome`s), +verification (`Verification`, `Satisfaction`, `Validation`, each over `Verdict`s and +`VerificationVerdict`s), calculation and analysis (`Calculation`, `Analysis` of +`CaseEvaluation`s, with the engine `Standing`), query (`Query`, `Condition`, +`QueryElement`) and `EngineInfo`. No generated protobuf message or builder appears in the public API. A `Diagnostic` is `(severity, message, code, span)`; `code()` is the identifier to branch on (`"syntax"`, a validation code such as `"unresolved"`, `"choice-point"`, @@ -57,7 +69,8 @@ call, and `AutoCloseable`'s `close()` here throws nothing. | exception | what happened | | ---------------------- | ----------------------------------------------------------------- | | `ServiceException` | the call was refused, with a `StatusCode` (`NOT_FOUND`, …) | -| `ModelException` | the call succeeded and the answer reports a model failure | +| `ModelException` | the call succeeded and the answer reports a model failure; `failureReason()` classifies it | +| `AnalysisException` | a `ModelException` from `runAnalysis` whose `partial()` holds what the run computed before it stopped | | `TransportException` | HTTP or IO failure; the service was not reached or answered. `UNAVAILABLE`, except `DEADLINE_EXCEEDED` for a call that outlived its `requestTimeout` | | `CapabilityException` | the service does not advertise a capability the call needs | | `ServiceStartException`| no binary, a digest mismatch, or a child that would not start | @@ -65,7 +78,10 @@ call, and `AutoCloseable`'s `close()` here throws nothing. The `ServiceException`/`ModelException` split is the one the conformance suite draws too: an expression that will not evaluate is a successful call carrying an -error, not a service problem. +error, not a service problem. A verdict that is false is neither: the model has +answered, and `verifyConstraint` returns it with `Verdict.decided()` true. Only a +verdict carrying an `error` — the condition could not be evaluated, the symbol is +of another kind, the subject is ambiguous — is undecided. ## Maven, and a JDK 17 baseline @@ -305,20 +321,20 @@ them. That is why `Model.evalWithSubject` checks before it calls: a `CapabilityException` names the missing capability and the service that lacks it, before a round trip. -## What v1 does not do +## What the client does not do Deliberately out of scope, rather than half-implemented: - **the edit API** (`ApplyEdits`) — authoring notation from Java; +- **models of several documents** (`ParseSources`) — one document is parsed at a time; - **RDF conversion** (`Convert`) — Turtle/RDF export; -- **verification helpers** (`VerifyConstraint`, `VerifyRequirement`, - `VerifySatisfaction`), **behaviour execution** (`ExecuteAction`, - `ExecuteState`), **`EvaluateCalc`**, **`RunAnalysis`** and **`Query`**/OSLC; +- **parameter sweeps** (`RunSweep`); +- **native document queries and rendering** (`RunDocumentQuery`, `RenderDocument`); - **generated model-ergonomics types** — no code generation from a model into Java classes. The service still serves all of them; reach them from another client, or from the -generated stubs in `org.openmbee.opensysml.proto` with `curl`, until a v2 wraps them. +generated stubs in `org.openmbee.opensysml.proto` with `curl`, until this client wraps them. ## Generated messages @@ -352,35 +368,38 @@ unnoticed. Or as a test, which is what CI runs: `mvn -f clients/java/pom.xml test`. -Per protocol, of 59 scenarios: +Per protocol, of 134 scenarios: | protocol | ran | passed | failed | skipped | | -------------- | --: | -----: | -----: | ------: | -| `connect` | 25 | 25 | 0 | 34 | -| `connect-json` | 25 | 25 | 0 | 34 | - -**34 skipped**, and they are the scenarios of the RPCs v1 does not cover: -`ExecuteAction` (3), `ExecuteState` (2), `Convert` (5), `ApplyEdits` (5), -`VerifyConstraint` (4), `VerifyRequirement` (2), `VerifySatisfaction` (2), -`EvaluateCalc` (2), `Query` (8) — 33 — plus -`parse/naming_no_source_is_invalid`, which asserts that a request naming no -source at all is refused: the public API always names one, so the client cannot -send that request. gRPC is not run at all: this client does not speak it. +| `connect` | 94 | 94 | 0 | 40 | +| `connect-json` | 94 | 94 | 0 | 40 | + +**40 skipped**: the scenarios of the RPCs the client does not cover — +`ApplyEdits` (10), `RunSweep` (7), `RunDocumentQuery` (6), `Convert` (5), +`ParseSources` (4), `RenderDocument` (3) — 35 — plus five requests the public API +cannot express: `parse/naming_no_source_is_invalid` (the API always names a +source), a `Query` carrying both a structured and an OSLC query and one whose +comparison has no operator (`Query` and `Condition` are values that cannot be built +that way), and two `EvaluateCalc` arguments malformed on the wire, which the +client's own `Value` reader refuses before any request could carry them. The suite +test asserts that no scenario of a covered RPC is skipped as uncovered, so a +shrinking surface cannot pass quietly. gRPC is not run at all: this client does not speak it. The runner is not vacuous. `-mutate` corrupts every answer before it is compared, and `SuiteTest.aCorruptedAnswerIsCaught` asserts each corruption is caught: | `-mutate` | what it does to every answer | scenarios that fail | | ----------------- | -------------------------------- | ------------------: | -| `perturb-reals` | moves each real by a millionth | 4 | -| `truncate-lists` | drops the last repeated element | 7 | -| `rewrite-strings` | replaces each string | 13 | +| `perturb-reals` | moves each real by a millionth | 20 | +| `truncate-lists` | drops the last repeated element | 36 | +| `rewrite-strings` | replaces each string | 53 | ## Running the tests ```bash make build # bin/sysml-grpc; tests skip without it -mvn -f clients/java/pom.xml test # 119 client tests, 27 conformance tests +mvn -f clients/java/pom.xml test # 200 client tests, 36 conformance tests mvn -f clients/java/pom.xml test -Dopensysml.requireService=true # CI: absence fails ``` diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/ActionRun.java b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/ActionRun.java new file mode 100644 index 0000000000..a921988669 --- /dev/null +++ b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/ActionRun.java @@ -0,0 +1,31 @@ +package org.openmbee.opensysml; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.OptionalDouble; + +/** + * What one execution of an action produced: what {@link Model#executeAction(String)} answers. + * + * @param outputs the action's output parameters by name, empty for an action producing none + * @param finalTime the run's simulation clock when it ended, in seconds from the 0 it started at; + * absent from a service without the {@code final_time} capability + * @param diagnostics what the service reported while executing + */ +public record ActionRun( + Map outputs, OptionalDouble finalTime, List diagnostics) { + + /** + * Creates an action run, copying its collections. + * + * @param outputs the outputs by name + * @param finalTime the final time, when reported + * @param diagnostics the diagnostics + */ + public ActionRun { + outputs = Map.copyOf(outputs); + Objects.requireNonNull(finalTime, "finalTime"); + diagnostics = List.copyOf(diagnostics); + } +} diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Analysis.java b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Analysis.java new file mode 100644 index 0000000000..8018cf0429 --- /dev/null +++ b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Analysis.java @@ -0,0 +1,104 @@ +package org.openmbee.opensysml; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** + * What one run of an analysis case produced: what {@link Model#runAnalysis(String)} answers. + * + * @param outputs the case's out and return parameters by name, in declaration order; a body + * returning into an unnamed result is the output {@code "result"} + * @param verdicts the case's objectives, in order, then the assertions in its body, of kinds {@link + * Verdict#KIND_OBJECTIVE} and {@link Verdict#KIND_ASSERTION} + * @param verifications the body verdicts of the case and of the verification cases it performs, + * empty for an analysis case + * @param evaluations the applications the run made of the case's own calcs, in the order made: for + * a trade study, its evaluation function applied to each alternative in subject order + * @param instances the objects the run reported: those reachable from the subject, including it, + * and those the outputs and evaluations refer to + * @param diagnostics what the service reported while running + * @param standing how strongly the run's answer stands + */ +public record Analysis( + Map outputs, + List verdicts, + List verifications, + List evaluations, + List instances, + List diagnostics, + Standing standing) { + + /** + * Creates an analysis, copying its collections and keeping the outputs' order. + * + * @param outputs the outputs by name + * @param verdicts the verdicts + * @param verifications the body verdicts + * @param evaluations the evaluations + * @param instances the objects + * @param diagnostics the diagnostics + * @param standing the standing, never {@code null} + */ + public Analysis { + outputs = Collections.unmodifiableMap(new LinkedHashMap<>(outputs)); + verdicts = List.copyOf(verdicts); + verifications = List.copyOf(verifications); + evaluations = List.copyOf(evaluations); + instances = List.copyOf(instances); + diagnostics = List.copyOf(diagnostics); + Objects.requireNonNull(standing, "standing"); + } + + /** + * Whether every objective and assertion was decided and held. A case stating none holds + * trivially. + * + * @return {@code true} when no verdict is undecided or violated + */ + public boolean holds() { + return verdicts.stream().allMatch(verdict -> verdict.decided() && verdict.holds()); + } + + /** + * The case's objective. + * + * @return the first objective verdict, absent for a case stating none + */ + public Optional objective() { + return verdicts.stream().filter(verdict -> Verdict.KIND_OBJECTIVE.equals(verdict.kind())).findFirst(); + } + + /** + * The evaluation a trade study selected. + * + * @return the selected evaluation, absent when none was + */ + public Optional selected() { + return evaluations.stream().filter(CaseEvaluation::selected).findFirst(); + } + + /** + * The object a value refers to. + * + * @param reference a reference the run reported + * @return the object, absent when it is not among those reported + */ + public Optional resolve(Value.InstanceReference reference) { + Objects.requireNonNull(reference, "reference"); + return Instances.find(instances, reference.instanceId()); + } + + /** + * The object of an id. + * + * @param instanceId the id the service gave the object + * @return the object, absent when it is not among those reported + */ + public Optional instance(long instanceId) { + return Instances.find(instances, instanceId); + } +} diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/AnalysisException.java b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/AnalysisException.java new file mode 100644 index 0000000000..d60abee53a --- /dev/null +++ b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/AnalysisException.java @@ -0,0 +1,43 @@ +package org.openmbee.opensysml; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * An analysis case that could not run to its end but left something to inspect: the outputs and + * evaluations made before an alternative failed, an objective the failure left undecided. + * + *

A request refused before the run, or a failure leaving nothing to report, is a plain {@link + * ModelException} whose {@link #failureReason()} says why. + */ +public class AnalysisException extends ModelException { + + private static final long serialVersionUID = 1L; + + private final transient Analysis partial; + + /** + * Creates an analysis exception. + * + * @param message the failure, as the service worded it + * @param failureReason what kind of failure it is + * @param diagnostics diagnostics the answer carried + * @param partial what the run left + */ + public AnalysisException( + String message, FailureReason failureReason, List diagnostics, Analysis partial) { + super(message, failureReason, diagnostics); + this.partial = Objects.requireNonNull(partial, "partial"); + } + + /** + * What the run left: the outputs and evaluations made, the objects they name, each verdict + * undecided. + * + * @return the partial result; absent after Java serialization, which does not carry it + */ + public Optional partial() { + return Optional.ofNullable(partial); + } +} diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/AnalysisOptions.java b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/AnalysisOptions.java new file mode 100644 index 0000000000..4d1ff62b25 --- /dev/null +++ b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/AnalysisOptions.java @@ -0,0 +1,99 @@ +package org.openmbee.opensysml; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** + * How an analysis case is run. + * + * @param subject FQN of the part definition or usage to instantiate and bind as the case's subject; + * absent for a usage binding its own + * @param arguments values binding the case's {@code in} parameters in declaration order, the subject + * excluded + * @param namedArguments values binding its {@code in} parameters by name + * @param schedule the policy the actions the case performs resolve their choice points under, as + * for {@link ExecutionOptions#schedule()} + */ +public record AnalysisOptions( + Optional subject, + List arguments, + Map namedArguments, + Optional schedule) { + + /** + * Validates the options, copying their collections. + * + * @param subject the subject, when named + * @param arguments the positional arguments + * @param namedArguments the named arguments + * @param schedule the schedule, when named + */ + public AnalysisOptions { + Objects.requireNonNull(subject, "subject"); + arguments = List.copyOf(arguments); + namedArguments = Collections.unmodifiableMap(new LinkedHashMap<>(namedArguments)); + Objects.requireNonNull(schedule, "schedule"); + } + + /** + * No subject, no arguments, the service's default schedule. + * + * @return the default options + */ + public static AnalysisOptions defaults() { + return new AnalysisOptions(Optional.empty(), List.of(), Map.of(), Optional.empty()); + } + + /** + * The same options run on a subject. + * + * @param subject FQN of the part definition or usage + * @return options naming it + */ + public AnalysisOptions withSubject(String subject) { + return new AnalysisOptions(Optional.of(subject), arguments, namedArguments, schedule); + } + + /** + * The same options with these positional arguments. + * + * @param arguments the arguments, in parameter order + * @return options carrying them + */ + public AnalysisOptions withArguments(List arguments) { + return new AnalysisOptions(subject, arguments, namedArguments, schedule); + } + + /** + * The same options with these named arguments. + * + * @param namedArguments the arguments by parameter name + * @return options carrying them + */ + public AnalysisOptions withNamedArguments(Map namedArguments) { + return new AnalysisOptions(subject, arguments, namedArguments, schedule); + } + + /** + * The same options under another schedule. + * + * @param schedule the policy + * @return options naming it + */ + public AnalysisOptions withSchedule(String schedule) { + return new AnalysisOptions(subject, arguments, namedArguments, Optional.of(schedule)); + } + + /** + * Whether the schedule explores every order rather than running one. + * + * @return {@code true} for {@code "explore"} and {@code "explore:"} + */ + public boolean explores() { + return schedule.filter(ExecutionOptions::explores).isPresent(); + } +} diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Calculation.java b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Calculation.java new file mode 100644 index 0000000000..2e38956f6c --- /dev/null +++ b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Calculation.java @@ -0,0 +1,54 @@ +package org.openmbee.opensysml; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** + * What {@link Model#evaluateCalc(String, List)} computed. + * + *

A calc definition, or a usage invoked with arguments, computes one {@link #result()}. A calc + * usage evaluated from its own members computes its {@link #outputs()} by name instead, in + * declaration order; a body returning into an unnamed result is the output {@code "result"}. + * + * @param result the value computed, absent when the calc answers through its outputs + * @param outputs the calc's out and return parameters by name, empty when it answers with a result + * @param diagnostics what the service reported while computing + * @param standing how strongly the answer stands + */ +public record Calculation( + Optional result, + Map outputs, + List diagnostics, + Standing standing) { + + /** + * Creates a calculation, copying its collections and keeping the outputs' order. + * + * @param result the result, when there is one + * @param outputs the outputs by name + * @param diagnostics the diagnostics + * @param standing the standing, never {@code null} + */ + public Calculation { + Objects.requireNonNull(result, "result"); + outputs = Collections.unmodifiableMap(new LinkedHashMap<>(outputs)); + diagnostics = List.copyOf(diagnostics); + Objects.requireNonNull(standing, "standing"); + } + + /** + * The one value the calc computed: its result, or its single output. + * + * @return the value, absent when the calc computed none or several outputs + */ + public Optional value() { + if (result.isPresent()) { + return result; + } + return outputs.size() == 1 ? Optional.of(outputs.values().iterator().next()) : Optional.empty(); + } +} diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/CaseEvaluation.java b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/CaseEvaluation.java new file mode 100644 index 0000000000..fb9fad3c2d --- /dev/null +++ b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/CaseEvaluation.java @@ -0,0 +1,45 @@ +package org.openmbee.opensysml; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * One application of a calc an analysis case declares, made while the case ran. For a trade study + * it is the evaluation function scoring one alternative. Reported by a service advertising the + * {@code case_evaluations} capability. + * + * @param functionId FQN of the calc applied + * @param arguments what it was applied to, in parameter order; an alternative is an {@link + * Value.InstanceReference} the {@link Analysis} resolves + * @param result what it computed, absent when {@code error} says why it computed nothing + * @param error why the application failed, absent when it computed a value + * @param selected whether this is the evaluation whose argument {@code selectOne} picked and the + * case returned: the alternative a trade study selected + * @param tied whether this evaluation computed what the selected one did without being it + */ +public record CaseEvaluation( + String functionId, + List arguments, + Optional result, + Optional error, + boolean selected, + boolean tied) { + + /** + * Creates an evaluation, copying its arguments. + * + * @param functionId the calc's FQN, never {@code null} + * @param arguments the arguments + * @param result the result, when there is one + * @param error the failure, when there is one + * @param selected whether it was selected + * @param tied whether it tied the selected one + */ + public CaseEvaluation { + Objects.requireNonNull(functionId, "functionId"); + arguments = List.copyOf(arguments); + Objects.requireNonNull(result, "result"); + Objects.requireNonNull(error, "error"); + } +} diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Condition.java b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Condition.java new file mode 100644 index 0000000000..46564a2df9 --- /dev/null +++ b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Condition.java @@ -0,0 +1,153 @@ +package org.openmbee.opensysml; + +import java.util.List; +import java.util.Objects; + +/** + * A {@link Query} filter: one comparison of a property, or several conditions combined. Build one + * with {@link #equal}, {@link #greater}, {@link #less}, {@link #all} or {@link #any}, and negate it + * with {@link #negated()}. + */ +public sealed interface Condition { + + /** + * A condition matching an element whose property is any of the values given. The property names + * one of the query properties the API reference documents ({@code "@type"}, {@code "name"}, + * {@code "qualifiedName"}, ...); an unknown one fails the call. + * + * @param property the property + * @param values the values it may equal + * @return the comparison + */ + static Comparison equal(String property, List values) { + return new Comparison(property, Comparison.Operator.EQUAL, values, false); + } + + /** + * A condition matching an element whose property is greater than the value. + * + * @param property the property + * @param value the value + * @return the comparison + */ + static Comparison greater(String property, String value) { + return new Comparison(property, Comparison.Operator.GREATER, List.of(value), false); + } + + /** + * A condition matching an element whose property is less than the value. + * + * @param property the property + * @param value the value + * @return the comparison + */ + static Comparison less(String property, String value) { + return new Comparison(property, Comparison.Operator.LESS, List.of(value), false); + } + + /** + * A condition matching an element every condition matches. An empty list fails the call: it has + * no defensible verdict. + * + * @param conditions the conditions + * @return the combination + */ + static Combination all(List conditions) { + return new Combination(Combination.Operator.AND, conditions); + } + + /** + * A condition matching an element at least one condition matches. An empty list fails the call. + * + * @param conditions the conditions + * @return the combination + */ + static Combination any(List conditions) { + return new Combination(Combination.Operator.OR, conditions); + } + + /** + * The condition matching what this one does not. A comparison negates its verdict; a + * combination, which the wire has no negation for, negates each operand and swaps {@code all} + * for {@code any}, which says the same thing. + * + * @return the negation + */ + Condition negated(); + + /** + * One comparison of a property against values. + * + * @param property the property compared + * @param operator how it is compared + * @param values what it is compared against: any of them for {@link Operator#EQUAL}, the one + * value for the others + * @param inverse whether the comparison's verdict is negated + */ + record Comparison(String property, Operator operator, List values, boolean inverse) + implements Condition { + + /** How a property is compared. */ + public enum Operator { + /** The property equals one of the values. */ + EQUAL, + /** The property is greater than the value. */ + GREATER, + /** The property is less than the value. */ + LESS + } + + /** + * Creates a comparison, copying its values. + * + * @param property the property, never {@code null} + * @param operator the operator, never {@code null} + * @param values the values + * @param inverse whether it is negated + */ + public Comparison { + Objects.requireNonNull(property, "property"); + Objects.requireNonNull(operator, "operator"); + values = List.copyOf(values); + } + + @Override + public Comparison negated() { + return new Comparison(property, operator, values, !inverse); + } + } + + /** + * Several conditions combined. + * + * @param operator how they combine + * @param conditions the conditions combined + */ + record Combination(Operator operator, List conditions) implements Condition { + + /** How conditions combine. */ + public enum Operator { + /** Every condition must match. */ + AND, + /** At least one condition must match. */ + OR + } + + /** + * Creates a combination, copying its conditions. + * + * @param operator the operator, never {@code null} + * @param conditions the conditions + */ + public Combination { + Objects.requireNonNull(operator, "operator"); + conditions = List.copyOf(conditions); + } + + @Override + public Combination negated() { + Operator swapped = operator == Operator.AND ? Operator.OR : Operator.AND; + return new Combination(swapped, conditions.stream().map(Condition::negated).toList()); + } + } +} diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Connection.java b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Connection.java index 9a75891273..49554a764c 100644 --- a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Connection.java +++ b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Connection.java @@ -5,6 +5,8 @@ import org.openmbee.opensysml.internal.PrivateService; import org.openmbee.opensysml.internal.Protos; import org.openmbee.opensysml.internal.ServiceRegistry; +import org.openmbee.opensysml.proto.ListEnginesRequest; +import org.openmbee.opensysml.proto.ListEnginesResponse; import org.openmbee.opensysml.proto.ParseFileRequest; import org.openmbee.opensysml.proto.ParseFileResponse; import org.openmbee.opensysml.proto.ServerInfoRequest; @@ -131,6 +133,24 @@ public Capabilities capabilities() { return capabilities; } + /** + * The analysis engines the service answers with, in name order: what each answers, how strongly + * it can, and whether it can run here. Name one with {@link Model#withEngine(String)}. + * + * @return the engines + * @throws CapabilityException if the service does not advertise {@code engines} + */ + public List listEngines() { + checkOpen(); + capabilities.require(Capabilities.ENGINES); + ListEnginesResponse response = + call( + "ListEngines", + ListEnginesRequest.getDefaultInstance(), + ListEnginesResponse.getDefaultInstance()); + return Protos.engines(response.getEnginesList()); + } + /** * The {@code host:port} this connection talks to. * diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/EngineInfo.java b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/EngineInfo.java new file mode 100644 index 0000000000..fc9ad33bc8 --- /dev/null +++ b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/EngineInfo.java @@ -0,0 +1,77 @@ +package org.openmbee.opensysml; + +import java.util.List; +import java.util.Objects; + +/** + * One analysis engine the service answers with: what it answers, how strongly it can, and whether + * it can run here. + * + * @param name the engine's name, as {@link Model#withEngine(String)} selects it + * @param authority the strongest evidence the engine can ever produce, spelled as {@link + * Standing#strength()} is + * @param answers the kinds of question the engine answers ({@code "evaluate"}, {@code "holds"}, + * {@code "outcomes"}, ...) + * @param bounds the bounds the engine takes, in the order it reports them + * @param process the external process the engine needs, empty for one running in-process + * @param processFound where the process was found, empty when it was not or none is needed + * @param ready whether the engine can run: it is served, and needs no process or its process was + * found + * @param unavailable why the engine cannot run, empty when it can + * @param kind where the engine comes from: {@code "built-in"}, or the kind of the manifest entry + * registering it ({@code "tool"}, {@code "engine"}, {@code "policy"}, {@code "sampler"}) + * @param protocol how the engine is spoken to, empty for a built-in one + * @param source the manifest entry the engine was registered from, empty for a built-in one + * @param command the command the entry resolved to, empty for a built-in one + * @param version the version the entry declares, empty for a built-in one + * @param served whether this service runs the engine for a request that names it + */ +public record EngineInfo( + String name, + String authority, + List answers, + List bounds, + String process, + String processFound, + boolean ready, + String unavailable, + String kind, + String protocol, + String source, + String command, + String version, + boolean served) { + + /** + * Creates an engine description, copying its lists. + * + * @param name the name, never {@code null} + * @param authority the authority, never {@code null} + * @param answers the questions answered + * @param bounds the bounds taken + * @param process the process, never {@code null} + * @param processFound where it was found, never {@code null} + * @param ready whether it can run + * @param unavailable why it cannot, never {@code null} + * @param kind its origin, never {@code null} + * @param protocol its protocol, never {@code null} + * @param source its manifest entry, never {@code null} + * @param command its command, never {@code null} + * @param version its version, never {@code null} + * @param served whether it is served + */ + public EngineInfo { + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(authority, "authority"); + answers = List.copyOf(answers); + bounds = List.copyOf(bounds); + Objects.requireNonNull(process, "process"); + Objects.requireNonNull(processFound, "processFound"); + Objects.requireNonNull(unavailable, "unavailable"); + Objects.requireNonNull(kind, "kind"); + Objects.requireNonNull(protocol, "protocol"); + Objects.requireNonNull(source, "source"); + Objects.requireNonNull(command, "command"); + Objects.requireNonNull(version, "version"); + } +} diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/ExecutionOptions.java b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/ExecutionOptions.java new file mode 100644 index 0000000000..072358fae9 --- /dev/null +++ b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/ExecutionOptions.java @@ -0,0 +1,73 @@ +package org.openmbee.opensysml; + +import java.util.Objects; +import java.util.Optional; + +/** + * How a behavior is run. + * + * @param schedule the policy the run resolves its choice points under, as {@code sysml -schedule} + * spells it: {@code "declared"}, {@code "reverse"} (the service's default) or {@code + * "seed:"} for one run; {@code "explore"} or {@code "explore:runs=,depth="} for an + * exploration. Absent leaves the choice to the call. + * @param performer the object the behavior runs on, as {@code sysml -action " "} + * names it: a part definition or usage to make an object of, or a path from one into its parts + * ({@code "Mission::mission.vehicle"}), made for the run. Absent runs outside any object. + */ +public record ExecutionOptions(Optional schedule, Optional performer) { + + private static final String EXPLORE = "explore"; + + /** + * Validates the options. + * + * @param schedule the schedule, when named + * @param performer the performer, when named + */ + public ExecutionOptions { + Objects.requireNonNull(schedule, "schedule"); + Objects.requireNonNull(performer, "performer"); + } + + /** + * The service's default schedule, outside any object. + * + * @return the default options + */ + public static ExecutionOptions defaults() { + return new ExecutionOptions(Optional.empty(), Optional.empty()); + } + + /** + * The same options under another schedule. + * + * @param schedule the policy + * @return options naming it + */ + public ExecutionOptions withSchedule(String schedule) { + return new ExecutionOptions(Optional.of(schedule), performer); + } + + /** + * The same options, performed by an object. + * + * @param performer the object, as a declaration or a path into one + * @return options naming it + */ + public ExecutionOptions withPerformer(String performer) { + return new ExecutionOptions(schedule, Optional.of(performer)); + } + + /** + * Whether the schedule explores every order rather than running one. + * + * @return {@code true} for {@code "explore"} and {@code "explore:"} + */ + public boolean explores() { + return schedule.filter(ExecutionOptions::explores).isPresent(); + } + + static boolean explores(String schedule) { + return schedule.equals(EXPLORE) || schedule.startsWith(EXPLORE + ":"); + } +} diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Exploration.java b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Exploration.java new file mode 100644 index 0000000000..dd3ae990eb --- /dev/null +++ b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Exploration.java @@ -0,0 +1,58 @@ +package org.openmbee.opensysml; + +import java.util.ArrayList; +import java.util.List; + +/** + * Every distinct outcome a behavior reaches under some order of its choice points, and how the + * search for them ended: what {@link Model#exploreAction(String)} and its siblings answer. + * + * @param outcomes the outcomes reached, in the order first reached + * @param complete whether every order within the budget ran + * @param runs how many runs were made + * @param budgetsHit the budgets that ended the search ({@code "runs"}, {@code "depth"}), empty when + * it completed + * @param runsBudget the most runs the search would make + * @param depthBudget the most choice points one run would resolve + */ +public record Exploration( + List outcomes, + boolean complete, + int runs, + List budgetsHit, + int runsBudget, + int depthBudget) { + + /** + * Creates an exploration, copying its collections. + * + * @param outcomes the outcomes + * @param complete whether the search completed + * @param runs the runs made + * @param budgetsHit the budgets that ended it + * @param runsBudget the run budget + * @param depthBudget the depth budget + */ + public Exploration { + outcomes = List.copyOf(outcomes); + budgetsHit = List.copyOf(budgetsHit); + } + + /** + * How the search ended, as the {@code sysml} command renders it: {@code "complete (6 runs)"} or + * {@code "incomplete: runs budget 100 hit after 100 runs"}. + * + * @return the status line + */ + public String status() { + if (complete) { + return "complete (" + runs + " runs)"; + } + List named = new ArrayList<>(budgetsHit.size()); + for (String budget : budgetsHit) { + int limit = budget.equals("depth") ? depthBudget : runsBudget; + named.add(budget + " budget " + limit); + } + return "incomplete: " + String.join(" and ", named) + " hit after " + runs + " runs"; + } +} diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/FailureReason.java b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/FailureReason.java new file mode 100644 index 0000000000..d62c296090 --- /dev/null +++ b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/FailureReason.java @@ -0,0 +1,18 @@ +package org.openmbee.opensysml; + +/** + * What kind of failure an undecided {@link Verdict} or a failed calculation, analysis or validation + * reports, so a caller acts on the kind rather than on the message text. + */ +public enum FailureReason { + /** No failure, or one the service did not classify. */ + UNSPECIFIED, + /** A condition or calculation that could not be evaluated. */ + EVALUATION, + /** A symbol that declares something else than was asked about. */ + WRONG_KIND, + /** Several objects carry the element: name one as the subject. */ + AMBIGUOUS_SUBJECT, + /** A reason this release of the client does not know. */ + UNKNOWN +} diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Instances.java b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Instances.java new file mode 100644 index 0000000000..bc2fff9271 --- /dev/null +++ b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Instances.java @@ -0,0 +1,19 @@ +package org.openmbee.opensysml; + +import java.util.List; +import java.util.Optional; + +/** Lookup shared by the results that carry the objects their verdicts and values refer to. */ +final class Instances { + + private Instances() {} + + static Optional find(List instances, long instanceId) { + for (Instance instance : instances) { + if (instance.id() == instanceId) { + return Optional.of(instance); + } + } + return Optional.empty(); + } +} diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Model.java b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Model.java index 1b9ddbce3f..8dd356aa56 100644 --- a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Model.java +++ b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Model.java @@ -3,13 +3,32 @@ import org.openmbee.opensysml.internal.Protos; import org.openmbee.opensysml.proto.DiagnosticsRequest; import org.openmbee.opensysml.proto.DiagnosticsResponse; +import org.openmbee.opensysml.proto.EvaluateCalcRequest; +import org.openmbee.opensysml.proto.EvaluateCalcResponse; import org.openmbee.opensysml.proto.EvaluateRequest; import org.openmbee.opensysml.proto.EvaluateResponse; +import org.openmbee.opensysml.proto.ExecuteActionRequest; +import org.openmbee.opensysml.proto.ExecuteActionResponse; +import org.openmbee.opensysml.proto.ExecuteStateRequest; +import org.openmbee.opensysml.proto.ExecuteStateResponse; import org.openmbee.opensysml.proto.GetSymbolRequest; import org.openmbee.opensysml.proto.InstantiateRequest; import org.openmbee.opensysml.proto.InstantiateResponse; +import org.openmbee.opensysml.proto.QueryRequest; +import org.openmbee.opensysml.proto.QueryResponse; +import org.openmbee.opensysml.proto.RunAnalysisRequest; +import org.openmbee.opensysml.proto.RunAnalysisResponse; import org.openmbee.opensysml.proto.SymbolResponse; +import org.openmbee.opensysml.proto.ValidateInstanceRequest; +import org.openmbee.opensysml.proto.ValidateInstanceResponse; +import org.openmbee.opensysml.proto.VerifyConstraintRequest; +import org.openmbee.opensysml.proto.VerifyConstraintResponse; +import org.openmbee.opensysml.proto.VerifyRequirementRequest; +import org.openmbee.opensysml.proto.VerifyRequirementResponse; +import org.openmbee.opensysml.proto.VerifySatisfactionRequest; +import org.openmbee.opensysml.proto.VerifySatisfactionResponse; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Optional; @@ -18,7 +37,20 @@ * *

Obtained from {@link Connection#load(java.nio.file.Path)}, {@link Connection#parse(String)} or * {@link Connection#model(String)}. Immutable and thread-safe; it holds no state of its own beyond - * the hash and what the parse reported. + * the hash, what the parse reported and the engine {@link #withEngine(String)} named. + * + *

Reading a model is {@link #eval}, {@link #symbol} and {@link #instantiate}. Running it is + * {@link #executeAction} and {@link #executeState} for one run under a schedule, {@link + * #exploreAction} and {@link #exploreState} for every run. Checking it is {@link + * #verifyConstraint}, {@link #verifyRequirement}, {@link #verifySatisfaction} and {@link + * #validateInstance}, which answer verdicts: a condition the model answers false about is a + * {@link Verdict} that does not hold, not an exception. {@link #evaluateCalc} and {@link + * #runAnalysis} compute, and {@link #query} selects elements. + * + *

Every call answers what the service reported or throws: {@link ModelException} when the + * service answered but reported a failure about the model, {@link ServiceException} when it refused + * the call, {@link CapabilityException} before anything is sent when the service does not advertise + * what the call needs. */ public final class Model { @@ -26,16 +58,27 @@ public final class Model { private final String hash; private final Optional root; private final List parseDiagnostics; + private final Optional engine; Model( Connection connection, String hash, Optional root, List parseDiagnostics) { + this(connection, hash, root, parseDiagnostics, Optional.empty()); + } + + private Model( + Connection connection, + String hash, + Optional root, + List parseDiagnostics, + Optional engine) { this.connection = connection; this.hash = hash; this.root = root; this.parseDiagnostics = List.copyOf(parseDiagnostics); + this.engine = engine; } /** @@ -75,6 +118,33 @@ public List parseDiagnostics() { return parseDiagnostics; } + /** + * The engine this model's verifications, calculations and analyses are put to. + * + * @return the engine, absent when the service chooses + */ + public Optional engine() { + return engine; + } + + /** + * The same model, its verifications, calculations and analyses put to one engine: an engine's + * name as {@link Connection#listEngines()} reports it, {@link Standing#ENGINE_ALL} for every + * engine that covers the question, or {@link Standing#ENGINE_AUTO} to leave the choice to the + * service. A name the service does not register fails the call with {@link + * StatusCode#INVALID_ARGUMENT}. + * + * @param engine the engine + * @return a model bound to it + * @throws CapabilityException if the service does not advertise {@code engines}, which it would + * otherwise ignore rather than refuse + */ + public Model withEngine(String engine) { + Objects.requireNonNull(engine, "engine"); + connection.capabilities().require(Capabilities.ENGINES); + return new Model(connection, hash, root, parseDiagnostics, Optional.of(engine)); + } + /** * Asks the service for this model's diagnostics. * @@ -188,6 +258,540 @@ public Instantiation instantiate(String symbolId) { return Protos.instantiation(response); } + /** + * Executes an action once, under the service's default schedule and outside any object. + * + * @param actionSymbolId qualified name of the action definition or usage + * @return the outputs it produced + * @throws ModelException if the action could not be executed + * @throws ServiceException if the service does not hold this model + */ + public ActionRun executeAction(String actionSymbolId) { + return executeAction(actionSymbolId, Map.of(), ExecutionOptions.defaults()); + } + + /** + * Executes an action once, with its input parameters bound. + * + * @param actionSymbolId qualified name of the action definition or usage + * @param inputs values for its input parameters, by name + * @return the outputs it produced + * @throws ModelException if the action could not be executed + * @throws ServiceException if the service does not hold this model + */ + public ActionRun executeAction(String actionSymbolId, Map inputs) { + return executeAction(actionSymbolId, inputs, ExecutionOptions.defaults()); + } + + /** + * Executes an action once, under a schedule and on an object. + * + * @param actionSymbolId qualified name of the action definition or usage + * @param inputs values for its input parameters, by name + * @param options the schedule the run resolves its choice points under and the object performing + * it; an exploring schedule belongs to {@link #exploreAction} + * @return the outputs it produced + * @throws IllegalArgumentException if the schedule explores + * @throws ModelException if the action could not be executed + * @throws ServiceException if the service does not hold this model, or the schedule names no + * policy + * @throws CapabilityException if a schedule is named and the service does not advertise {@code + * schedule}, or a performer and it does not advertise {@code performer} + */ + public ActionRun executeAction( + String actionSymbolId, Map inputs, ExecutionOptions options) { + ExecuteActionResponse response = executeAction(actionSymbolId, inputs, options, false); + return Protos.actionRun(response, connection.capabilities().has(Capabilities.FINAL_TIME)); + } + + /** + * Runs an action once per valid order of its choice points, within the {@code explore} budget. + * + * @param actionSymbolId qualified name of the action definition or usage + * @return every distinct outcome reached, and how the search ended + * @throws ModelException if the action could not be explored at all + * @throws ServiceException if the service does not hold this model + * @throws CapabilityException if the service does not advertise {@code schedule_explore} + */ + public Exploration exploreAction(String actionSymbolId) { + return exploreAction(actionSymbolId, Map.of(), ExecutionOptions.defaults()); + } + + /** + * Runs an action once per valid order of its choice points, within a budget. + * + *

Runs agreeing on their outputs are one {@link Outcome}; a run failing under some order is an + * outcome whose error is set, not a failure of the call. + * + * @param actionSymbolId qualified name of the action definition or usage + * @param inputs values for its input parameters, by name + * @param options the exploring schedule ({@code "explore"}, the default when none is named, or + * {@code "explore:runs=,depth="}) and the object performing each run, made anew for it + * @return every distinct outcome reached, and how the search ended + * @throws IllegalArgumentException if the schedule does not explore + * @throws ModelException if the action could not be explored at all + * @throws ServiceException if the service does not hold this model, or the schedule's budget is + * malformed + * @throws CapabilityException if the service does not advertise {@code schedule_explore}, or a + * performer is named and it does not advertise {@code performer} + */ + public Exploration exploreAction( + String actionSymbolId, Map inputs, ExecutionOptions options) { + ExecuteActionResponse response = executeAction(actionSymbolId, inputs, options, true); + return Protos.exploration(response.getOutcomesList(), response.getExploration()); + } + + private ExecuteActionResponse executeAction( + String actionSymbolId, Map inputs, ExecutionOptions options, boolean explore) { + Objects.requireNonNull(actionSymbolId, "actionSymbolId"); + Objects.requireNonNull(inputs, "inputs"); + ExecuteActionRequest.Builder request = + ExecuteActionRequest.newBuilder() + .setModelHash(hash) + .setActionSymbolId(actionSymbolId) + .putAllInputs(Protos.protos(inputs)) + .setSchedule(schedule(options, explore)); + options.performer().ifPresent(request::setPerformerSymbolId); + ExecuteActionResponse response = + connection.call("ExecuteAction", request.build(), ExecuteActionResponse.getDefaultInstance()); + failed(response.getError(), FailureReason.UNSPECIFIED, response.getDiagnosticsList()); + return response; + } + + /** + * Executes a state machine once, under the service's default schedule and outside any object. + * + * @param stateMachineSymbolId qualified name of the state definition or usage + * @param events the events to send it, in order + * @return the states it visited and its final context + * @throws ModelException if the machine could not be executed + * @throws ServiceException if the service does not hold this model + */ + public StateRun executeState(String stateMachineSymbolId, List events) { + return executeState(stateMachineSymbolId, events, ExecutionOptions.defaults()); + } + + /** + * Executes a state machine once, under a schedule and on an object. + * + * @param stateMachineSymbolId qualified name of the state definition or usage + * @param events the events to send it, in order + * @param options the schedule and performer, as for {@link #executeAction(String, Map, + * ExecutionOptions)} + * @return the states it visited and its final context + * @throws IllegalArgumentException if the schedule explores + * @throws ModelException if the machine could not be executed + * @throws ServiceException if the service does not hold this model, or the schedule names no + * policy + * @throws CapabilityException if a schedule is named and the service does not advertise {@code + * schedule}, or a performer and it does not advertise {@code performer} + */ + public StateRun executeState( + String stateMachineSymbolId, List events, ExecutionOptions options) { + ExecuteStateResponse response = executeState(stateMachineSymbolId, events, options, false); + return Protos.stateRun(response, connection.capabilities().has(Capabilities.FINAL_TIME)); + } + + /** + * Runs a state machine once per valid order of its choice points, within the {@code explore} + * budget. + * + * @param stateMachineSymbolId qualified name of the state definition or usage + * @param events the events to send it, in order + * @return every distinct outcome reached, and how the search ended + * @throws ModelException if the machine could not be explored at all + * @throws ServiceException if the service does not hold this model + * @throws CapabilityException if the service does not advertise {@code schedule_explore} + */ + public Exploration exploreState(String stateMachineSymbolId, List events) { + return exploreState(stateMachineSymbolId, events, ExecutionOptions.defaults()); + } + + /** + * Runs a state machine once per valid order of its choice points, within a budget. + * + * @param stateMachineSymbolId qualified name of the state definition or usage + * @param events the events to send it, in order + * @param options the exploring schedule and performer, as for {@link #exploreAction(String, Map, + * ExecutionOptions)} + * @return every distinct outcome reached, and how the search ended + * @throws IllegalArgumentException if the schedule does not explore + * @throws ModelException if the machine could not be explored at all + * @throws ServiceException if the service does not hold this model, or the schedule's budget is + * malformed + * @throws CapabilityException if the service does not advertise {@code schedule_explore}, or a + * performer is named and it does not advertise {@code performer} + */ + public Exploration exploreState( + String stateMachineSymbolId, List events, ExecutionOptions options) { + ExecuteStateResponse response = executeState(stateMachineSymbolId, events, options, true); + return Protos.exploration(response.getOutcomesList(), response.getExploration()); + } + + private ExecuteStateResponse executeState( + String stateMachineSymbolId, List events, ExecutionOptions options, boolean explore) { + Objects.requireNonNull(stateMachineSymbolId, "stateMachineSymbolId"); + Objects.requireNonNull(events, "events"); + ExecuteStateRequest.Builder request = + ExecuteStateRequest.newBuilder() + .setModelHash(hash) + .setStateMachineSymbolId(stateMachineSymbolId) + .addAllEvents(events) + .setSchedule(schedule(options, explore)); + options.performer().ifPresent(request::setPerformerSymbolId); + ExecuteStateResponse response = + connection.call("ExecuteState", request.build(), ExecuteStateResponse.getDefaultInstance()); + failed(response.getError(), FailureReason.UNSPECIFIED, response.getDiagnosticsList()); + return response; + } + + /** + * Verifies a constraint against the model's declared values. + * + * @param symbolId qualified name of the constraint definition or usage + * @return its verdict, which does not hold when the model answers false and is undecided when + * the constraint could not be evaluated + * @throws ModelException if the service could not answer at all + * @throws ServiceException if the service does not hold this model + * @throws CapabilityException if the service does not advertise {@code verification} + */ + public Verification verifyConstraint(String symbolId) { + return verifyConstraint(symbolId, Optional.empty()); + } + + /** + * Verifies a constraint against an object's values. + * + * @param symbolId qualified name of the constraint definition or usage + * @param subjectSymbolId qualified name of the part definition or usage to instantiate and verify + * the constraint on + * @return its verdict, about that object + * @throws ModelException if the service could not answer at all + * @throws ServiceException if the service does not hold this model + * @throws CapabilityException if the service does not advertise {@code verification} + */ + public Verification verifyConstraint(String symbolId, String subjectSymbolId) { + Objects.requireNonNull(subjectSymbolId, "subjectSymbolId"); + return verifyConstraint(symbolId, Optional.of(subjectSymbolId)); + } + + private Verification verifyConstraint(String symbolId, Optional subjectSymbolId) { + Objects.requireNonNull(symbolId, "symbolId"); + connection.capabilities().require(Capabilities.VERIFICATION); + VerifyConstraintRequest.Builder request = + VerifyConstraintRequest.newBuilder().setModelHash(hash).setSymbolId(symbolId); + subjectSymbolId.ifPresent(request::setSubjectSymbolId); + engine.ifPresent(request::setEngine); + VerifyConstraintResponse response = + connection.call( + "VerifyConstraint", request.build(), VerifyConstraintResponse.getDefaultInstance()); + failed(response.getError(), FailureReason.UNSPECIFIED, response.getDiagnosticsList()); + return Protos.verification(response); + } + + /** + * Verifies a requirement's constraints against the model's declared values. + * + * @param symbolId qualified name of the requirement definition or usage + * @return its verdict, with what the verification cases verifying it answered + * @throws ModelException if the service could not answer at all + * @throws ServiceException if the service does not hold this model + * @throws CapabilityException if the service does not advertise {@code verification} + */ + public Verification verifyRequirement(String symbolId) { + return verifyRequirement(symbolId, Optional.empty()); + } + + /** + * Verifies a requirement's constraints against an object's values. + * + * @param symbolId qualified name of the requirement definition or usage + * @param subjectSymbolId qualified name of the part definition or usage to instantiate and verify + * the requirement on + * @return its verdict, about that object + * @throws ModelException if the service could not answer at all + * @throws ServiceException if the service does not hold this model + * @throws CapabilityException if the service does not advertise {@code verification} + */ + public Verification verifyRequirement(String symbolId, String subjectSymbolId) { + Objects.requireNonNull(subjectSymbolId, "subjectSymbolId"); + return verifyRequirement(symbolId, Optional.of(subjectSymbolId)); + } + + private Verification verifyRequirement(String symbolId, Optional subjectSymbolId) { + Objects.requireNonNull(symbolId, "symbolId"); + connection.capabilities().require(Capabilities.VERIFICATION); + VerifyRequirementRequest.Builder request = + VerifyRequirementRequest.newBuilder().setModelHash(hash).setSymbolId(symbolId); + subjectSymbolId.ifPresent(request::setSubjectSymbolId); + engine.ifPresent(request::setEngine); + VerifyRequirementResponse response = + connection.call( + "VerifyRequirement", request.build(), VerifyRequirementResponse.getDefaultInstance()); + failed(response.getError(), FailureReason.UNSPECIFIED, response.getDiagnosticsList()); + return Protos.verification(response); + } + + /** + * Evaluates every {@code satisfy} assertion in the model. + * + * @return one verdict per assertion, in declaration order + * @throws ModelException if the service could not answer at all + * @throws ServiceException if the service does not hold this model + * @throws CapabilityException if the service does not advertise {@code verification} + */ + public Satisfaction verifySatisfaction() { + return verifySatisfaction(Optional.empty()); + } + + /** + * Evaluates the {@code satisfy} assertions within one element. + * + * @param scopeSymbolId qualified name of the package, definition or usage whose assertions are + * evaluated + * @return one verdict per assertion, in declaration order + * @throws ModelException if the service could not answer at all + * @throws ServiceException if the service does not hold this model + * @throws CapabilityException if the service does not advertise {@code verification} + */ + public Satisfaction verifySatisfaction(String scopeSymbolId) { + Objects.requireNonNull(scopeSymbolId, "scopeSymbolId"); + return verifySatisfaction(Optional.of(scopeSymbolId)); + } + + private Satisfaction verifySatisfaction(Optional scopeSymbolId) { + connection.capabilities().require(Capabilities.VERIFICATION); + VerifySatisfactionRequest.Builder request = + VerifySatisfactionRequest.newBuilder().setModelHash(hash); + scopeSymbolId.ifPresent(request::setSymbolId); + engine.ifPresent(request::setEngine); + VerifySatisfactionResponse response = + connection.call( + "VerifySatisfaction", request.build(), VerifySatisfactionResponse.getDefaultInstance()); + failed(response.getError(), response.getFailureReason(), response.getDiagnosticsList()); + return Protos.satisfaction(response); + } + + /** + * Checks every assertion about an object of a part definition or usage, and about the objects it + * holds, against their values. + * + * @param symbolId qualified name of the part definition or usage to instantiate and validate + * @return one verdict per assertion, and one for the object as a whole + * @throws ModelException if the service could not answer at all: an unknown symbol, or one that + * declares no object + * @throws ServiceException if the service does not hold this model + * @throws CapabilityException if the service does not advertise {@code verification} + */ + public Validation validateInstance(String symbolId) { + Objects.requireNonNull(symbolId, "symbolId"); + connection.capabilities().require(Capabilities.VERIFICATION); + ValidateInstanceRequest.Builder request = + ValidateInstanceRequest.newBuilder().setModelHash(hash).setSymbolId(symbolId); + engine.ifPresent(request::setEngine); + ValidateInstanceResponse response = + connection.call( + "ValidateInstance", request.build(), ValidateInstanceResponse.getDefaultInstance()); + failed(response.getError(), response.getFailureReason(), response.getDiagnosticsList()); + return Protos.validation(response); + } + + /** + * Evaluates a calc. + * + * @param symbolId qualified name of the calc definition or usage + * @param arguments values for its {@code in} parameters, in declaration order; empty evaluates a + * usage from its own members + * @return what it computed + * @throws ModelException if the calc could not be evaluated, its {@link + * ModelException#failureReason()} saying why + * @throws ServiceException if the service does not hold this model + */ + public Calculation evaluateCalc(String symbolId, List arguments) { + Objects.requireNonNull(symbolId, "symbolId"); + Objects.requireNonNull(arguments, "arguments"); + EvaluateCalcRequest.Builder request = + EvaluateCalcRequest.newBuilder() + .setModelHash(hash) + .setSymbolId(symbolId) + .addAllArguments(Protos.protos(arguments)); + engine.ifPresent(request::setEngine); + EvaluateCalcResponse response = + connection.call("EvaluateCalc", request.build(), EvaluateCalcResponse.getDefaultInstance()); + failed(response.getError(), response.getFailureReason(), response.getDiagnosticsList()); + return Protos.calculation(response); + } + + /** + * Runs an analysis case usage that binds its own subject. + * + * @param symbolId qualified name of the analysis case usage + * @return what it computed and the verdicts of its objective and assertions + * @throws AnalysisException if the case could not run to its end but left something to inspect + * @throws ModelException if the request was refused before the run, or the failure left nothing to + * report; its {@link ModelException#failureReason()} says why + * @throws ServiceException if the service does not hold this model + * @throws CapabilityException if the service does not advertise {@code verification} + */ + public Analysis runAnalysis(String symbolId) { + return runAnalysis(symbolId, AnalysisOptions.defaults()); + } + + /** + * Runs an analysis case once, on a subject and with its parameters bound. + * + *

The subject named is instantiated and bound as the case's subject; positional arguments + * bind its {@code in} parameters in declaration order, the subject excluded, and named arguments + * bind them by name. Its objective and each {@code assert constraint} in its body are then checked + * against what it computed. + * + * @param symbolId qualified name of the analysis case definition or usage + * @param options the subject, arguments and schedule; an exploring schedule belongs to {@link + * #exploreAnalysis} + * @return what it computed and the verdicts of its objective and assertions + * @throws IllegalArgumentException if the schedule explores + * @throws AnalysisException if the case could not run to its end but left something to inspect + * @throws ModelException if the request was refused before the run, or the failure left nothing to + * report; its {@link ModelException#failureReason()} says why + * @throws ServiceException if the service does not hold this model, or the schedule names no + * policy + * @throws CapabilityException if the service does not advertise {@code verification}, or a + * schedule is named and it does not advertise {@code schedule} + */ + public Analysis runAnalysis(String symbolId, AnalysisOptions options) { + RunAnalysisResponse response = runAnalysis(symbolId, options, false); + Analysis analysis = Protos.analysis(response); + if (response.getError().isEmpty()) { + return analysis; + } + FailureReason reason = Protos.failureReason(response.getFailureReason()); + if (analysis.outputs().isEmpty() + && analysis.verdicts().isEmpty() + && analysis.evaluations().isEmpty() + && analysis.instances().isEmpty()) { + throw new ModelException(response.getError(), reason, analysis.diagnostics()); + } + throw new AnalysisException(response.getError(), reason, analysis.diagnostics(), analysis); + } + + /** + * Runs an analysis case once per valid order of the choice points its actions meet. + * + *

Runs agreeing on the case's outputs and verdicts are one {@link Outcome}; a verdict is + * reported among the outcome's outputs as {@code "objective "} or {@code "assertion + * "}. + * + * @param symbolId qualified name of the analysis case definition or usage + * @param options the subject, arguments and exploring schedule ({@code "explore"}, the default + * when none is named, or {@code "explore:runs=,depth="}) + * @return every distinct outcome reached, and how the search ended + * @throws IllegalArgumentException if the schedule does not explore + * @throws ModelException if the case could not be explored at all + * @throws ServiceException if the service does not hold this model, or the schedule's budget is + * malformed + * @throws CapabilityException if the service does not advertise {@code verification} or {@code + * schedule_explore} + */ + public Exploration exploreAnalysis(String symbolId, AnalysisOptions options) { + RunAnalysisResponse response = runAnalysis(symbolId, options, true); + failed(response.getError(), response.getFailureReason(), response.getDiagnosticsList()); + return Protos.exploration(response.getOutcomesList(), response.getExploration()); + } + + private RunAnalysisResponse runAnalysis( + String symbolId, AnalysisOptions options, boolean explore) { + Objects.requireNonNull(symbolId, "symbolId"); + Objects.requireNonNull(options, "options"); + connection.capabilities().require(Capabilities.VERIFICATION); + RunAnalysisRequest.Builder request = + RunAnalysisRequest.newBuilder() + .setModelHash(hash) + .setSymbolId(symbolId) + .addAllArguments(Protos.protos(options.arguments())) + .putAllNamedArguments(Protos.protos(options.namedArguments())) + .setSchedule(schedule(options.schedule(), options.explores(), explore)); + options.subject().ifPresent(request::setSubjectSymbolId); + engine.ifPresent(request::setEngine); + return connection.call("RunAnalysis", request.build(), RunAnalysisResponse.getDefaultInstance()); + } + + /** + * Selects elements of the model. + * + * @param query the elements considered, the properties reported and the filter applied + * @return the elements selected, each with the properties asked for + * @throws ServiceException if the service does not hold this model, or the query names an unknown + * scope or property, or leaves a comparison's operator unset + * @throws CapabilityException if the service does not advertise {@code query} + */ + public List query(Query query) { + Objects.requireNonNull(query, "query"); + connection.capabilities().require(Capabilities.QUERY); + return selected(QueryRequest.newBuilder().setModelHash(hash).setQuery(Protos.proto(query))); + } + + /** + * Selects elements of the model with an OSLC query string, such as {@code + * "oslc.where=rdf:type=\"PartUsage\"&oslc.select=sysml:name"}. + * + * @param oslcQuery the query, as OSLC Query Syntax spells it + * @return the elements selected, each with the properties asked for + * @throws ServiceException if the service does not hold this model, or the query does not parse + * @throws CapabilityException if the service does not advertise {@code oslc_query} + */ + public List queryOslc(String oslcQuery) { + Objects.requireNonNull(oslcQuery, "oslcQuery"); + connection.capabilities().require(Capabilities.OSLC_QUERY); + return selected(QueryRequest.newBuilder().setModelHash(hash).setOslcQuery(oslcQuery)); + } + + private List selected(QueryRequest.Builder request) { + QueryResponse response = + connection.call("Query", request.build(), QueryResponse.getDefaultInstance()); + return Protos.queryElements(response.getElementsList()); + } + + private String schedule(ExecutionOptions options, boolean explore) { + Objects.requireNonNull(options, "options"); + if (options.performer().isPresent()) { + connection.capabilities().require(Capabilities.PERFORMER); + } + return schedule(options.schedule(), options.explores(), explore); + } + + private String schedule(Optional schedule, boolean explores, boolean explore) { + if (explore) { + if (schedule.isPresent() && !explores) { + throw new IllegalArgumentException( + "schedule " + schedule.orElseThrow() + " runs once; an exploration takes explore"); + } + connection.capabilities().require(Capabilities.SCHEDULE_EXPLORE); + return schedule.orElse("explore"); + } + if (explores) { + throw new IllegalArgumentException( + "schedule " + schedule.orElseThrow() + " answers every outcome; use the explore call"); + } + if (schedule.isPresent()) { + connection.capabilities().require(Capabilities.SCHEDULE); + } + return schedule.orElse(""); + } + + private static void failed( + String error, + org.openmbee.opensysml.proto.FailureReason reason, + List diagnostics) { + failed(error, Protos.failureReason(reason), diagnostics); + } + + private static void failed( + String error, FailureReason reason, List diagnostics) { + if (!error.isEmpty()) { + throw new ModelException(error, reason, Protos.diagnostics(diagnostics)); + } + } + private SymbolResponse symbolResponse(String symbolId) { Objects.requireNonNull(symbolId, "symbolId"); return connection.call( diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/ModelException.java b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/ModelException.java index 77f714038b..d93449a893 100644 --- a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/ModelException.java +++ b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/ModelException.java @@ -23,6 +23,7 @@ public class ModelException extends OpenSysMLException { private static final long serialVersionUID = 2L; private static final int MAX_SERIALIZED_DIAGNOSTICS = 100_000; + private final FailureReason failureReason; private transient List diagnostics; /** @@ -32,10 +33,31 @@ public class ModelException extends OpenSysMLException { * @param diagnostics diagnostics the answer carried */ public ModelException(String message, List diagnostics) { + this(message, FailureReason.UNSPECIFIED, diagnostics); + } + + /** + * Creates a model exception the service classified. + * + * @param message the failure, as the service worded it + * @param failureReason what kind of failure it is + * @param diagnostics diagnostics the answer carried + */ + public ModelException(String message, FailureReason failureReason, List diagnostics) { super(message); + this.failureReason = Objects.requireNonNull(failureReason, "failureReason"); this.diagnostics = List.copyOf(Objects.requireNonNull(diagnostics, "diagnostics")); } + /** + * What kind of failure this is, so a caller acts on the kind rather than on the message text. + * + * @return the reason; {@link FailureReason#UNSPECIFIED} when the service did not classify it + */ + public FailureReason failureReason() { + return failureReason; + } + /** * The diagnostics the answer carried. * diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Outcome.java b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Outcome.java new file mode 100644 index 0000000000..1ace4fadc3 --- /dev/null +++ b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Outcome.java @@ -0,0 +1,59 @@ +package org.openmbee.opensysml; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** + * One distinct result an exploration reached: the runs agreeing on it are counted as its + * linearizations, and one of them is its witness. + * + * @param outputs an action's output parameters, or a state machine's final context, by name; for + * an analysis case, its outputs and, as {@code "objective "} and {@code "assertion + * "} entries, what its verdicts answered + * @param finalState the state a machine ended in, absent for an action + * @param statesVisited the trace of states a machine entered, empty for an action + * @param error why the runs reaching this outcome failed, absent when they completed + * @param linearizations how many of the orders explored reached this outcome + * @param witness the choices of one run that reached it, each as {@code ": "} + * @param diagnostics what the service reported for the witness run + */ +public record Outcome( + Map outputs, + Optional finalState, + List statesVisited, + Optional error, + int linearizations, + List witness, + List diagnostics) { + + /** + * Creates an outcome, copying its collections. + * + * @param outputs the outputs by name + * @param finalState the final state, when there is one + * @param statesVisited the trace + * @param error the failure, when the runs failed + * @param linearizations the number of orders reaching it + * @param witness one run's choices + * @param diagnostics the diagnostics + */ + public Outcome { + outputs = Map.copyOf(outputs); + Objects.requireNonNull(finalState, "finalState"); + statesVisited = List.copyOf(statesVisited); + Objects.requireNonNull(error, "error"); + witness = List.copyOf(witness); + diagnostics = List.copyOf(diagnostics); + } + + /** + * Whether the runs reaching this outcome completed rather than failed. + * + * @return {@code true} when no error was reported + */ + public boolean completed() { + return error.isEmpty(); + } +} diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Query.java b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Query.java new file mode 100644 index 0000000000..0f5b597b75 --- /dev/null +++ b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Query.java @@ -0,0 +1,70 @@ +package org.openmbee.opensysml; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * A selection of a model's elements, as the SysML v2 API and Services Query resource makes one: the + * elements considered, the properties reported for each and the filter every one must satisfy. + * + * @param scope FQNs of the elements considered, each together with everything nested inside it; + * empty considers the whole model + * @param select the properties to report ({@code "name"}, {@code "owner"}, {@code + * "qualifiedName"}, ...); empty reports all of them + * @param where the filter every considered element must satisfy, absent for none + */ +public record Query(List scope, List select, Optional where) { + + /** + * Creates a query, copying its lists. + * + * @param scope the scope + * @param select the selection + * @param where the filter, when there is one + */ + public Query { + scope = List.copyOf(scope); + select = List.copyOf(select); + Objects.requireNonNull(where, "where"); + } + + /** + * Every element of the model, with every property. + * + * @return the unconstrained query + */ + public static Query all() { + return new Query(List.of(), List.of(), Optional.empty()); + } + + /** + * The same query over these elements and what they nest. + * + * @param scope FQNs of the elements + * @return a query scoped to them + */ + public Query withScope(List scope) { + return new Query(scope, select, where); + } + + /** + * The same query reporting these properties. + * + * @param select the property names + * @return a query selecting them + */ + public Query withSelect(List select) { + return new Query(scope, select, where); + } + + /** + * The same query filtered by a condition. + * + * @param where the filter + * @return a query every element of which satisfies it + */ + public Query where(Condition where) { + return new Query(scope, select, Optional.of(where)); + } +} diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/QueryElement.java b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/QueryElement.java new file mode 100644 index 0000000000..b9c45f28b2 --- /dev/null +++ b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/QueryElement.java @@ -0,0 +1,27 @@ +package org.openmbee.opensysml; + +import java.util.Map; +import java.util.Objects; + +/** + * One element a {@link Query} selected. + * + * @param id the element's qualified name + * @param type its metamodel type name ({@code "PartUsage"}, {@code "PartDefinition"}, ...) + * @param properties what the query selected, omitting a property the element does not have + */ +public record QueryElement(String id, String type, Map properties) { + + /** + * Creates a query element, copying its properties. + * + * @param id the qualified name, never {@code null} + * @param type the type name, never {@code null} + * @param properties the properties + */ + public QueryElement { + Objects.requireNonNull(id, "id"); + Objects.requireNonNull(type, "type"); + properties = Map.copyOf(properties); + } +} diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Satisfaction.java b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Satisfaction.java new file mode 100644 index 0000000000..30a6e49120 --- /dev/null +++ b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Satisfaction.java @@ -0,0 +1,87 @@ +package org.openmbee.opensysml; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * The verdict of each {@code satisfy} assertion evaluated, in declaration order: what {@link + * Model#verifySatisfaction()} answers. A model stating none answers with no verdicts. + * + * @param verdicts one verdict per assertion + * @param verifications the body verdicts of every requirement the verdicts are about, each naming + * its requirement, which the verdicts carry as their {@link Verdict#requirementId()} + * @param instances the objects the verdicts are about, each reported once + * @param diagnostics what the service reported while verifying + */ +public record Satisfaction( + List verdicts, + List verifications, + List instances, + List diagnostics) { + + /** + * Creates a satisfaction result, copying its collections. + * + * @param verdicts the verdicts + * @param verifications the body verdicts + * @param instances the objects + * @param diagnostics the diagnostics + */ + public Satisfaction { + verdicts = List.copyOf(verdicts); + verifications = List.copyOf(verifications); + instances = List.copyOf(instances); + diagnostics = List.copyOf(diagnostics); + } + + /** + * Whether every assertion was decided and held. A model stating none holds trivially. + * + * @return {@code true} when no verdict is undecided or violated + */ + public boolean holds() { + return verdicts.stream().allMatch(verdict -> verdict.decided() && verdict.holds()); + } + + /** + * The assertions the model answered false about. + * + * @return the violated verdicts, in declaration order + */ + public List violated() { + return verdicts.stream().filter(Verdict::violated).toList(); + } + + /** + * The assertions that could not be evaluated. + * + * @return the undecided verdicts, in declaration order + */ + public List undecided() { + return verdicts.stream().filter(verdict -> !verdict.decided()).toList(); + } + + /** + * The body verdicts reported for one requirement. + * + * @param requirementId FQN of the requirement + * @return its verification cases' verdicts, in the order reported + */ + public List verificationsOf(String requirementId) { + Objects.requireNonNull(requirementId, "requirementId"); + return verifications.stream() + .filter(verdict -> verdict.requirementId().filter(requirementId::equals).isPresent()) + .toList(); + } + + /** + * The object of an id. + * + * @param instanceId the id the service gave the object + * @return the object, absent when it is not among those reported + */ + public Optional instance(long instanceId) { + return Instances.find(instances, instanceId); + } +} diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Standing.java b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Standing.java new file mode 100644 index 0000000000..629294d233 --- /dev/null +++ b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Standing.java @@ -0,0 +1,74 @@ +package org.openmbee.opensysml; + +import java.util.List; +import java.util.Objects; + +/** + * How strongly an answer stands: the engine that answered, the strength of its evidence and the + * bounds it ran under. A service without the {@code engines} capability reports none of it. + * + * @param engine the engine that answered, as {@link Connection#listEngines()} names it; empty when + * the service reported none + * @param strength {@code "not covered"}, {@code "observed"}, {@code "witnessed"}, {@code "bounded"} + * or {@code "proved"}; empty when the service reported none + * @param bounds the bounds the answer ran under + */ +public record Standing(String engine, String strength, List bounds) { + + /** The engine selection that leaves the choice of engine to the service. */ + public static final String ENGINE_AUTO = "auto"; + + /** The engine selection that puts the question to every engine covering it. */ + public static final String ENGINE_ALL = "all"; + + /** + * Creates a standing, copying its bounds. + * + * @param engine the engine, empty rather than {@code null} when none was reported + * @param strength the strength, empty rather than {@code null} when none was reported + * @param bounds the bounds + */ + public Standing { + Objects.requireNonNull(engine, "engine"); + Objects.requireNonNull(strength, "strength"); + bounds = List.copyOf(bounds); + } + + /** + * The standing a service that reports none leaves. + * + * @return a standing naming no engine + */ + public static Standing none() { + return new Standing("", "", List.of()); + } + + /** + * Whether the service said which engine answered. + * + * @return {@code true} when an engine is named + */ + public boolean reported() { + return !engine.isEmpty(); + } + + /** + * One bound an engine ran under. + * + * @param name the bound, as the engine names it ({@code "runs"}, {@code "depth"}) + * @param limit the value it ran under + * @param reached whether the run met it, which is what keeps the answer from being stronger + */ + public record Bound(String name, long limit, boolean reached) { + /** + * Creates a bound. + * + * @param name the bound's name, never {@code null} + * @param limit the limit + * @param reached whether it was met + */ + public Bound { + Objects.requireNonNull(name, "name"); + } + } +} diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/StateRun.java b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/StateRun.java new file mode 100644 index 0000000000..38c9c50994 --- /dev/null +++ b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/StateRun.java @@ -0,0 +1,50 @@ +package org.openmbee.opensysml; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.OptionalDouble; + +/** + * What one execution of a state machine produced: what {@link Model#executeState(String, List)} + * answers. + * + * @param statesVisited the trace of states entered, in order + * @param finalContext the machine's context when execution stopped, by feature name + * @param finalTime the run's simulation clock when it ended, in seconds from the 0 it started at; + * absent from a service without the {@code final_time} capability + * @param diagnostics what the service reported while executing + */ +public record StateRun( + List statesVisited, + Map finalContext, + OptionalDouble finalTime, + List diagnostics) { + + /** + * Creates a state run, copying its collections. + * + * @param statesVisited the trace + * @param finalContext the context by feature name + * @param finalTime the final time, when reported + * @param diagnostics the diagnostics + */ + public StateRun { + statesVisited = List.copyOf(statesVisited); + finalContext = Map.copyOf(finalContext); + Objects.requireNonNull(finalTime, "finalTime"); + diagnostics = List.copyOf(diagnostics); + } + + /** + * The state the machine ended in. + * + * @return the last state visited, absent when none was entered + */ + public Optional finalState() { + return statesVisited.isEmpty() + ? Optional.empty() + : Optional.of(statesVisited.get(statesVisited.size() - 1)); + } +} diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Validation.java b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Validation.java new file mode 100644 index 0000000000..4835fee431 --- /dev/null +++ b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Validation.java @@ -0,0 +1,76 @@ +package org.openmbee.opensysml; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * Every assertion about one object and the objects it holds, checked against their values: what + * {@link Model#validateInstance(String)} answers. + * + * @param summary the object as a whole, of kind {@link Verdict#KIND_OBJECT}: holds when every + * assertion holds and every held object was reached; undecided when some assertion was, or + * nesting was left unreached + * @param verdicts one per assertion, each about the object its {@link Verdict#instanceId()} names, + * reached along its {@link Verdict#instancePath()} + * @param verifications the body verdicts of the verification cases verifying each requirement a + * verdict is about, each naming its requirement + * @param instances every object reached, the validated one first + * @param diagnostics what the service reported while validating + * @param bounded whether nesting deeper than the validation descends, or past its budget, was left + * unvalidated, in which case the summary decides nothing + */ +public record Validation( + Verdict summary, + List verdicts, + List verifications, + List instances, + List diagnostics, + boolean bounded) { + + /** + * Creates a validation, copying its collections. + * + * @param summary the object's verdict, never {@code null} + * @param verdicts the assertion verdicts + * @param verifications the body verdicts + * @param instances the objects + * @param diagnostics the diagnostics + * @param bounded whether nesting was left unvalidated + */ + public Validation { + Objects.requireNonNull(summary, "summary"); + verdicts = List.copyOf(verdicts); + verifications = List.copyOf(verifications); + instances = List.copyOf(instances); + diagnostics = List.copyOf(diagnostics); + } + + /** + * Whether the object as a whole was decided and holds. + * + * @return {@code true} for a decided summary that holds + */ + public boolean holds() { + return summary.decided() && summary.holds(); + } + + /** + * The validated object. + * + * @return the object, absent when the service reported none + */ + public Optional root() { + return instances.isEmpty() ? Optional.empty() : Optional.of(instances.get(0)); + } + + /** + * The object of an id. + * + * @param instanceId the id the service gave the object + * @return the object, absent when it is not among those reported + */ + public Optional instance(long instanceId) { + return Instances.find(instances, instanceId); + } +} diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Verdict.java b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Verdict.java new file mode 100644 index 0000000000..f6e7e8610c --- /dev/null +++ b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Verdict.java @@ -0,0 +1,111 @@ +package org.openmbee.opensysml; + +import java.util.Objects; +import java.util.Optional; + +/** + * One verification's answer: whether a condition held and, when it did not, what the model answered + * false about. + * + *

{@code holds} false with {@code error} absent is the model's answer of false; false with an + * error present is no answer at all, see {@link #decided()}. Neither is an exception: a verdict is + * a result to read, and only a call the service could not answer throws {@link ModelException}. + * + * @param kind what was verified: {@link #KIND_CONSTRAINT}, {@link #KIND_REQUIREMENT}, {@link + * #KIND_SATISFY}, {@link #KIND_OBJECTIVE}, {@link #KIND_ASSERTION} or {@link #KIND_OBJECT} + * @param elementId FQN of the element verified, absent for an anonymous satisfy assertion + * @param element the element as a reader names it, or the assertion as written + * @param holds the model's answer, meaningful only when {@link #decided()} + * @param condition the condition that evaluated to false, as written, when the runtime names one + * @param instanceId id of the object the verdict is about, absent when it is about declared values + * alone; the result carrying the verdict resolves it to an {@link Instance} + * @param instanceTypeId FQN of that object's type, absent with {@code instanceId} + * @param error why evaluation failed, present only when nothing was decided + * @param failureReason what kind of failure {@code error} reports + * @param requirementId FQN of the requirement a satisfaction verdict asserts satisfied, absent when + * the assertion names none + * @param instancePath the features held from a validated object to the one this verdict is about + * ({@code "engine.pump"}, {@code "wheels[2]"}), absent for the validated object itself and + * outside a {@link Validation} + * @param standing how strongly the verdict stands + */ +public record Verdict( + String kind, + Optional elementId, + String element, + boolean holds, + Optional condition, + Optional instanceId, + Optional instanceTypeId, + Optional error, + FailureReason failureReason, + Optional requirementId, + Optional instancePath, + Standing standing) { + + /** A constraint verified against declared or an object's values. */ + public static final String KIND_CONSTRAINT = "constraint"; + + /** A requirement's require constraints verified. */ + public static final String KIND_REQUIREMENT = "requirement"; + + /** A {@code satisfy} assertion. */ + public static final String KIND_SATISFY = "satisfy"; + + /** An analysis case's objective. */ + public static final String KIND_OBJECTIVE = "objective"; + + /** An {@code assert constraint} in a case's body. */ + public static final String KIND_ASSERTION = "assertion"; + + /** A validated object as a whole. */ + public static final String KIND_OBJECT = "object"; + + /** + * Creates a verdict. + * + * @param kind what was verified, never {@code null} + * @param elementId the element's FQN, when it has one + * @param element the element as named, never {@code null} + * @param holds the answer + * @param condition the failing condition, when named + * @param instanceId the object, when there is one + * @param instanceTypeId that object's type, when there is one + * @param error the failure, when evaluation failed + * @param failureReason the kind of failure, never {@code null} + * @param requirementId the requirement asserted satisfied, when named + * @param instancePath the path to the object, when validating + * @param standing the standing, never {@code null} + */ + public Verdict { + Objects.requireNonNull(kind, "kind"); + Objects.requireNonNull(elementId, "elementId"); + Objects.requireNonNull(element, "element"); + Objects.requireNonNull(condition, "condition"); + Objects.requireNonNull(instanceId, "instanceId"); + Objects.requireNonNull(instanceTypeId, "instanceTypeId"); + Objects.requireNonNull(error, "error"); + Objects.requireNonNull(failureReason, "failureReason"); + Objects.requireNonNull(requirementId, "requirementId"); + Objects.requireNonNull(instancePath, "instancePath"); + Objects.requireNonNull(standing, "standing"); + } + + /** + * Whether the model answered at all: evaluation succeeded and {@link #holds()} is its answer. + * + * @return {@code true} when no error was reported + */ + public boolean decided() { + return error.isEmpty(); + } + + /** + * Whether the model answered false: the condition was evaluated and did not hold. + * + * @return {@code true} for a decided verdict that does not hold + */ + public boolean violated() { + return decided() && !holds; + } +} diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Verification.java b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Verification.java new file mode 100644 index 0000000000..787897eb57 --- /dev/null +++ b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Verification.java @@ -0,0 +1,66 @@ +package org.openmbee.opensysml; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * One constraint's or requirement's verdict, with the objects it is about: what {@link + * Model#verifyConstraint(String)} and {@link Model#verifyRequirement(String)} answer. + * + * @param verdict the answer + * @param verifications the body verdicts of the verification cases verifying this requirement, + * empty for a constraint and for a service reporting none + * @param instances the objects reachable from the verdict's subject, including it, so its feature + * values need no further call + * @param diagnostics what the service reported while verifying + */ +public record Verification( + Verdict verdict, + List verifications, + List instances, + List diagnostics) { + + /** + * Creates a verification, copying its collections. + * + * @param verdict the answer, never {@code null} + * @param verifications the body verdicts + * @param instances the objects + * @param diagnostics the diagnostics + */ + public Verification { + Objects.requireNonNull(verdict, "verdict"); + verifications = List.copyOf(verifications); + instances = List.copyOf(instances); + diagnostics = List.copyOf(diagnostics); + } + + /** + * Whether the model answered that the condition holds. + * + * @return {@code true} for a decided verdict that holds + */ + public boolean holds() { + return verdict.decided() && verdict.holds(); + } + + /** + * The object the verdict is about. + * + * @return the object, absent when the verdict is about declared values alone + */ + public Optional subject() { + return verdict.instanceId().flatMap(id -> Instances.find(instances, id)); + } + + /** + * The object of an id. + * + * @param instanceId the id the service gave the object + * @return the object, absent when it is not among those reported + */ + public Optional instance(long instanceId) { + return Instances.find(instances, instanceId); + } +} diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/VerificationVerdict.java b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/VerificationVerdict.java new file mode 100644 index 0000000000..a8761d4328 --- /dev/null +++ b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/VerificationVerdict.java @@ -0,0 +1,60 @@ +package org.openmbee.opensysml; + +import java.util.Objects; +import java.util.Optional; + +/** + * What the body of a verification case answered when it ran, which is a separate answer from + * whether the requirement it verifies is satisfied. Reported by a service advertising the {@code + * verification_verdicts} capability. + * + * @param caseId FQN of the verification case that ran + * @param kind the verdict its body produced: {@link #PASS}, {@link #FAIL}, {@link #INCONCLUSIVE} or + * {@link #ERROR} + * @param detail the text of an error verdict, or why an inconclusive one decided nothing; absent + * for a pass and a fail + * @param subcase whether this is the verdict of a case another performed, reported on its own + * because the library states no roll-up for it + * @param requirementId FQN of the requirement the verdict was reported for, absent when the case + * ran for itself + */ +public record VerificationVerdict( + String caseId, String kind, Optional detail, boolean subcase, Optional requirementId) { + + /** A body whose verdict value is {@code VerdictKind::pass}. */ + public static final String PASS = "pass"; + + /** A body whose verdict value is {@code VerdictKind::fail}. */ + public static final String FAIL = "fail"; + + /** A body that ran and produced no verdict value. */ + public static final String INCONCLUSIVE = "inconclusive"; + + /** A body whose run could not be carried out. */ + public static final String ERROR = "error"; + + /** + * Creates a verification verdict. + * + * @param caseId the case's FQN, never {@code null} + * @param kind the verdict kind, never {@code null} + * @param detail the detail, when there is one + * @param subcase whether it is a subcase's + * @param requirementId the requirement, when reported for one + */ + public VerificationVerdict { + Objects.requireNonNull(caseId, "caseId"); + Objects.requireNonNull(kind, "kind"); + Objects.requireNonNull(detail, "detail"); + Objects.requireNonNull(requirementId, "requirementId"); + } + + /** + * Whether the body passed. + * + * @return {@code true} for a {@link #PASS} verdict + */ + public boolean passed() { + return PASS.equals(kind); + } +} diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/Protos.java b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/Protos.java index f52a89600e..146bcb72e8 100644 --- a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/Protos.java +++ b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/Protos.java @@ -1,33 +1,72 @@ package org.openmbee.opensysml.internal; +import org.openmbee.opensysml.ActionRun; +import org.openmbee.opensysml.Analysis; +import org.openmbee.opensysml.Calculation; +import org.openmbee.opensysml.CaseEvaluation; +import org.openmbee.opensysml.Condition; import org.openmbee.opensysml.Diagnostic; +import org.openmbee.opensysml.EngineInfo; import org.openmbee.opensysml.EnumLiteral; +import org.openmbee.opensysml.Exploration; +import org.openmbee.opensysml.FailureReason; import org.openmbee.opensysml.Instance; import org.openmbee.opensysml.Instantiation; +import org.openmbee.opensysml.Outcome; import org.openmbee.opensysml.Quantity; +import org.openmbee.opensysml.Query; +import org.openmbee.opensysml.QueryElement; +import org.openmbee.opensysml.Satisfaction; +import org.openmbee.opensysml.Standing; +import org.openmbee.opensysml.StateRun; import org.openmbee.opensysml.Symbol; import org.openmbee.opensysml.TransportException; +import org.openmbee.opensysml.Validation; import org.openmbee.opensysml.Value; +import org.openmbee.opensysml.Verdict; +import org.openmbee.opensysml.Verification; +import org.openmbee.opensysml.VerificationVerdict; import org.openmbee.opensysml.proto.AttributeInfo; +import org.openmbee.opensysml.proto.Bound; +import org.openmbee.opensysml.proto.CalcOutput; +import org.openmbee.opensysml.proto.CompositeConstraint; +import org.openmbee.opensysml.proto.CompositeOperator; +import org.openmbee.opensysml.proto.Constraint; +import org.openmbee.opensysml.proto.ExecuteActionResponse; +import org.openmbee.opensysml.proto.ExecuteStateResponse; +import org.openmbee.opensysml.proto.ExplorationStatus; import org.openmbee.opensysml.proto.FeatureValue; import org.openmbee.opensysml.proto.InstantiateResponse; import org.openmbee.opensysml.proto.MultiplicityInfo; +import org.openmbee.opensysml.proto.PrimitiveConstraint; +import org.openmbee.opensysml.proto.PrimitiveOperator; +import org.openmbee.opensysml.proto.QueryResultElement; +import org.openmbee.opensysml.proto.RunAnalysisResponse; import org.openmbee.opensysml.proto.Span; import org.openmbee.opensysml.proto.Specialization; import org.openmbee.opensysml.proto.SymbolInfo; +import org.openmbee.opensysml.proto.TensorQuantity; import org.openmbee.opensysml.proto.TypeInfo; +import org.openmbee.opensysml.proto.Undetermined; import org.openmbee.opensysml.proto.UnitFactor; import org.openmbee.opensysml.proto.UnitTerm; +import org.openmbee.opensysml.proto.ValidateInstanceResponse; +import org.openmbee.opensysml.proto.ValueSequence; +import org.openmbee.opensysml.proto.ValueSet; +import org.openmbee.opensysml.proto.VerifyConstraintResponse; +import org.openmbee.opensysml.proto.VerifyRequirementResponse; +import org.openmbee.opensysml.proto.VerifySatisfactionResponse; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Optional; +import java.util.OptionalDouble; /** * Reads the generated messages into the client's own immutable types, so no generated class and no - * builder reaches a caller. + * builder reaches a caller, and writes the client's values into the messages a request carries. */ public final class Protos { @@ -393,4 +432,528 @@ public static Instantiation instantiation(InstantiateResponse response) { public static Optional present(String field) { return field.isEmpty() ? Optional.empty() : Optional.of(field); } + + /** + * A value as a request carries it. + * + * @param value the immutable value + * @return the generated value + */ + public static org.openmbee.opensysml.proto.Value proto(Value value) { + org.openmbee.opensysml.proto.Value.Builder builder = + org.openmbee.opensysml.proto.Value.newBuilder(); + if (value instanceof Value.IntegerValue integral) { + builder.setIntValue(integral.value()); + } else if (value instanceof Value.RealValue real) { + builder.setRealValue(real.value()); + } else if (value instanceof Value.ComplexValue complex) { + builder.setComplex( + org.openmbee.opensysml.proto.Complex.newBuilder() + .setReal(complex.real()) + .setImaginary(complex.imaginary())); + } else if (value instanceof Value.BooleanValue flag) { + builder.setBoolValue(flag.value()); + } else if (value instanceof Value.StringValue text) { + builder.setStringValue(text.value()); + } else if (value instanceof Value.InstanceReference reference) { + builder.setInstanceId(reference.instanceId()); + } else if (value instanceof Value.Sequence sequence) { + ValueSequence.Builder elements = ValueSequence.newBuilder(); + sequence.elements().forEach(element -> elements.addElements(proto(element))); + builder.setSequence(elements); + } else if (value instanceof Value.NullValue) { + builder.setNull(""); + } else if (value instanceof Value.UnsetValue) { + builder.setUnset(true); + } else if (value instanceof Value.UndeterminedValue undetermined) { + builder.setUndetermined( + Undetermined.newBuilder() + .setReason(undetermined.reason()) + .setCount( + MultiplicityInfo.newBuilder() + .setLower(undetermined.countLower()) + .setUpper(undetermined.countUpper()))); + } else if (value instanceof Value.InfinityValue) { + builder.setInfinity(true); + } else if (value instanceof Value.QuantityValue quantity) { + builder.setQuantity(proto(quantity.quantity())); + } else if (value instanceof Value.EnumerationValue literal) { + builder.setEnumLiteral(proto(literal.literal())); + } else if (value instanceof Value.ArrayValue array) { + org.openmbee.opensysml.proto.Array.Builder elements = + org.openmbee.opensysml.proto.Array.newBuilder().addAllDimensions(array.dimensions()); + array.elements().forEach(element -> elements.addElements(proto(element))); + builder.setArray(elements); + } else if (value instanceof Value.VectorValue vector) { + org.openmbee.opensysml.proto.Vector.Builder components = + org.openmbee.opensysml.proto.Vector.newBuilder(); + vector.components().forEach(component -> components.addComponents(proto(component))); + builder.setVector(components); + } else if (value instanceof Value.VectorQuantityValue vector) { + org.openmbee.opensysml.proto.VectorQuantity.Builder components = + org.openmbee.opensysml.proto.VectorQuantity.newBuilder(); + vector.components().forEach(component -> components.addComponents(proto(component))); + builder.setVectorQuantity(components); + } else if (value instanceof Value.MeasurementRefValue ref) { + org.openmbee.opensysml.proto.MeasurementRef.Builder reference = + org.openmbee.opensysml.proto.MeasurementRef.newBuilder() + .setUnit(ref.unit()) + .setUnitTerm(proto(ref.reduction())); + ref.unitId().ifPresent(reference::setUnitId); + builder.setMeasurementRef(reference); + } else if (value instanceof Value.FunctionValue function) { + builder.setFunction( + org.openmbee.opensysml.proto.Function.newBuilder() + .setCalcId(function.calcId()) + .setSelfId(function.selfId().orElse(0L))); + } else if (value instanceof Value.SetValue set) { + ValueSet.Builder elements = ValueSet.newBuilder(); + set.elements().forEach(element -> elements.addElements(proto(element))); + builder.setSet(elements); + } else if (value instanceof Value.TensorQuantityValue tensor) { + TensorQuantity.Builder components = + TensorQuantity.newBuilder().addAllDimensions(tensor.dimensions()); + tensor.components().forEach(component -> components.addComponents(proto(component))); + builder.setTensorQuantity(components); + } else if (value instanceof Value.MetaobjectValue metaobject) { + builder.setMetaobject( + org.openmbee.opensysml.proto.Metaobject.newBuilder() + .setElementId(metaobject.elementId()) + .setMetaclassId(metaobject.metaclassId())); + } else { + throw new IllegalArgumentException("no wire form for " + value.getClass().getName()); + } + return builder.build(); + } + + /** + * Values by name, as a request's map carries them. + * + * @param values the immutable values by name + * @return the generated values by name + */ + public static Map protos(Map values) { + Map out = new LinkedHashMap<>(); + values.forEach((name, value) -> out.put(name, proto(value))); + return out; + } + + /** + * Values in order, as a request's list carries them. + * + * @param values the immutable values + * @return the generated values, in order + */ + public static List protos(List values) { + return values.stream().map(Protos::proto).toList(); + } + + private static org.openmbee.opensysml.proto.Quantity proto(Quantity quantity) { + org.openmbee.opensysml.proto.Quantity.Builder builder = + org.openmbee.opensysml.proto.Quantity.newBuilder(); + if (quantity.magnitude() instanceof Long integral) { + builder.setIntMagnitude(integral); + } else { + builder.setRealMagnitude(quantity.magnitude().doubleValue()); + } + quantity.unit().ifPresent(builder::setUnit); + quantity.reduction().ifPresent(reduction -> builder.setUnitTerm(proto(reduction))); + return builder.build(); + } + + private static UnitTerm proto(Quantity.UnitTerm reduction) { + UnitTerm.Builder term = + UnitTerm.newBuilder() + .setScaleNum(reduction.scaleNumerator()) + .setScaleDen(reduction.scaleDenominator()); + for (Quantity.UnitFactor factor : reduction.factors()) { + term.addFactors( + UnitFactor.newBuilder().setUnitId(factor.unitId()).setExponent(factor.exponent())); + } + return term.build(); + } + + private static org.openmbee.opensysml.proto.EnumLiteral proto(EnumLiteral literal) { + org.openmbee.opensysml.proto.EnumLiteral.Builder builder = + org.openmbee.opensysml.proto.EnumLiteral.newBuilder() + .setLiteralId(literal.literalId()) + .setEnumerationId(literal.enumerationId()) + .setName(literal.name()); + literal.value().ifPresent(value -> builder.setValue(proto(value))); + return builder.build(); + } + + /** + * A query as a request carries it. + * + * @param query the immutable query + * @return the generated query + */ + public static org.openmbee.opensysml.proto.Query proto(Query query) { + org.openmbee.opensysml.proto.Query.Builder builder = + org.openmbee.opensysml.proto.Query.newBuilder() + .addAllScope(query.scope()) + .addAllSelect(query.select()); + query.where().ifPresent(where -> builder.setWhere(proto(where))); + return builder.build(); + } + + private static Constraint proto(Condition condition) { + Constraint.Builder builder = Constraint.newBuilder(); + if (condition instanceof Condition.Comparison comparison) { + builder.setPrimitive( + PrimitiveConstraint.newBuilder() + .setInverse(comparison.inverse()) + .setProperty(comparison.property()) + .setOperator(proto(comparison.operator())) + .addAllValue(comparison.values())); + } else if (condition instanceof Condition.Combination combination) { + CompositeConstraint.Builder composite = + CompositeConstraint.newBuilder().setOperator(proto(combination.operator())); + combination.conditions().forEach(each -> composite.addConstraint(proto(each))); + builder.setComposite(composite); + } + return builder.build(); + } + + private static PrimitiveOperator proto(Condition.Comparison.Operator operator) { + return switch (operator) { + case EQUAL -> PrimitiveOperator.PRIMITIVE_OPERATOR_EQUAL; + case GREATER -> PrimitiveOperator.PRIMITIVE_OPERATOR_GREATER; + case LESS -> PrimitiveOperator.PRIMITIVE_OPERATOR_LESS; + }; + } + + private static CompositeOperator proto(Condition.Combination.Operator operator) { + return switch (operator) { + case AND -> CompositeOperator.COMPOSITE_OPERATOR_AND; + case OR -> CompositeOperator.COMPOSITE_OPERATOR_OR; + }; + } + + /** + * The elements a query selected. + * + * @param elements the generated elements + * @return immutable elements, in order + */ + public static List queryElements(List elements) { + return elements.stream() + .map( + element -> + new QueryElement(element.getId(), element.getType(), element.getPropertiesMap())) + .toList(); + } + + /** + * A failure reason. + * + * @param reason the generated reason + * @return the matching reason, {@link FailureReason#UNKNOWN} for one this release does not know + */ + public static FailureReason failureReason(org.openmbee.opensysml.proto.FailureReason reason) { + return switch (reason) { + case FAILURE_REASON_UNSPECIFIED -> FailureReason.UNSPECIFIED; + case FAILURE_REASON_EVALUATION -> FailureReason.EVALUATION; + case FAILURE_REASON_WRONG_KIND -> FailureReason.WRONG_KIND; + case FAILURE_REASON_AMBIGUOUS_SUBJECT -> FailureReason.AMBIGUOUS_SUBJECT; + case UNRECOGNIZED -> FailureReason.UNKNOWN; + }; + } + + /** + * The standing of an answer. + * + * @param engine the engine field + * @param strength the strength field + * @param bounds the bounds + * @return the immutable standing + */ + public static Standing standing(String engine, String strength, List bounds) { + List out = new ArrayList<>(bounds.size()); + for (Bound bound : bounds) { + out.add(new Standing.Bound(bound.getName(), bound.getLimit(), bound.getReached())); + } + return new Standing(engine, strength, out); + } + + /** + * A verdict. + * + * @param verdict the generated verdict + * @return the immutable verdict + */ + public static Verdict verdict(org.openmbee.opensysml.proto.Verdict verdict) { + return new Verdict( + verdict.getKind(), + present(verdict.getElementId()), + verdict.getElement(), + verdict.getHolds(), + present(verdict.getCondition()), + verdict.getInstanceId() == 0 ? Optional.empty() : Optional.of(verdict.getInstanceId()), + present(verdict.getInstanceTypeId()), + present(verdict.getError()), + failureReason(verdict.getFailureReason()), + present(verdict.getRequirementId()), + present(verdict.getInstancePath()), + standing(verdict.getEngine(), verdict.getStrength(), verdict.getBoundsList())); + } + + /** + * Verdicts. + * + * @param verdicts the generated verdicts + * @return immutable verdicts, in order + */ + public static List verdicts(List verdicts) { + return verdicts.stream().map(Protos::verdict).toList(); + } + + /** + * The body verdicts of verification cases. + * + * @param verdicts the generated verdicts + * @return immutable verdicts, in order + */ + public static List verificationVerdicts( + List verdicts) { + return verdicts.stream() + .map( + verdict -> + new VerificationVerdict( + verdict.getCaseId(), + verdict.getKind(), + present(verdict.getDetail()), + verdict.getSubcase(), + present(verdict.getRequirementId()))) + .toList(); + } + + /** + * Instances. + * + * @param instances the generated instances + * @return immutable instances, in order + */ + public static List instances(List instances) { + return instances.stream().map(Protos::instance).toList(); + } + + /** + * A constraint's verification. + * + * @param response the generated answer + * @return the immutable verification + */ + public static Verification verification(VerifyConstraintResponse response) { + return new Verification( + verdict(response.getVerdict()), + List.of(), + instances(response.getInstancesList()), + diagnostics(response.getDiagnosticsList())); + } + + /** + * A requirement's verification. + * + * @param response the generated answer + * @return the immutable verification + */ + public static Verification verification(VerifyRequirementResponse response) { + return new Verification( + verdict(response.getVerdict()), + verificationVerdicts(response.getVerificationVerdictsList()), + instances(response.getInstancesList()), + diagnostics(response.getDiagnosticsList())); + } + + /** + * The satisfaction assertions' verdicts. + * + * @param response the generated answer + * @return the immutable satisfaction + */ + public static Satisfaction satisfaction(VerifySatisfactionResponse response) { + return new Satisfaction( + verdicts(response.getVerdictsList()), + verificationVerdicts(response.getVerificationVerdictsList()), + instances(response.getInstancesList()), + diagnostics(response.getDiagnosticsList())); + } + + /** + * An object's validation. + * + * @param response the generated answer + * @return the immutable validation + */ + public static Validation validation(ValidateInstanceResponse response) { + return new Validation( + verdict(response.getSummary()), + verdicts(response.getVerdictsList()), + verificationVerdicts(response.getVerificationVerdictsList()), + instances(response.getInstancesList()), + diagnostics(response.getDiagnosticsList()), + response.getBounded()); + } + + /** + * Values by name, read from a request's or an answer's map. + * + * @param values the generated values by name + * @return immutable values by name + */ + public static Map values(Map values) { + Map out = new LinkedHashMap<>(); + values.forEach((name, value) -> out.put(name, readable(value))); + return out; + } + + /** + * Named outputs, in the order reported. + * + * @param outputs the generated outputs + * @return immutable values by name + */ + public static Map outputs(List outputs) { + Map out = new LinkedHashMap<>(); + for (CalcOutput output : outputs) { + out.put(output.getName(), readable(output.getValue())); + } + return out; + } + + /** + * What a calc computed. + * + * @param response the generated answer + * @return the immutable calculation + */ + public static Calculation calculation( + org.openmbee.opensysml.proto.EvaluateCalcResponse response) { + return new Calculation( + response.hasResult() ? Optional.of(readable(response.getResult())) : Optional.empty(), + outputs(response.getOutputsList()), + diagnostics(response.getDiagnosticsList()), + standing(response.getEngine(), response.getStrength(), response.getBoundsList())); + } + + /** + * What an analysis case's run produced. + * + * @param response the generated answer + * @return the immutable analysis + */ + public static Analysis analysis(RunAnalysisResponse response) { + List evaluations = new ArrayList<>(response.getEvaluationsCount()); + for (org.openmbee.opensysml.proto.CaseEvaluation evaluation : response.getEvaluationsList()) { + List arguments = new ArrayList<>(evaluation.getArgumentsCount()); + for (org.openmbee.opensysml.proto.Value argument : evaluation.getArgumentsList()) { + arguments.add(readable(argument)); + } + evaluations.add( + new CaseEvaluation( + evaluation.getFunctionId(), + arguments, + evaluation.hasResult() ? Optional.of(readable(evaluation.getResult())) : Optional.empty(), + present(evaluation.getError()), + evaluation.getSelected(), + evaluation.getTied())); + } + return new Analysis( + outputs(response.getOutputsList()), + verdicts(response.getVerdictsList()), + verificationVerdicts(response.getVerificationVerdictsList()), + evaluations, + instances(response.getInstancesList()), + diagnostics(response.getDiagnosticsList()), + standing(response.getEngine(), response.getStrength(), response.getBoundsList())); + } + + /** + * What an action's run produced. + * + * @param response the generated answer + * @param finalTimeReported whether the service reports a run's final time + * @return the immutable run + */ + public static ActionRun actionRun(ExecuteActionResponse response, boolean finalTimeReported) { + return new ActionRun( + values(response.getOutputsMap()), + finalTimeReported ? OptionalDouble.of(response.getFinalTime()) : OptionalDouble.empty(), + diagnostics(response.getDiagnosticsList())); + } + + /** + * What a state machine's run produced. + * + * @param response the generated answer + * @param finalTimeReported whether the service reports a run's final time + * @return the immutable run + */ + public static StateRun stateRun(ExecuteStateResponse response, boolean finalTimeReported) { + return new StateRun( + response.getStatesVisitedList(), + values(response.getFinalContextMap()), + finalTimeReported ? OptionalDouble.of(response.getFinalTime()) : OptionalDouble.empty(), + diagnostics(response.getDiagnosticsList())); + } + + /** + * What an exploration reached. + * + * @param outcomes the generated outcomes + * @param status how the search ended + * @return the immutable exploration + */ + public static Exploration exploration( + List outcomes, ExplorationStatus status) { + List out = new ArrayList<>(outcomes.size()); + for (org.openmbee.opensysml.proto.Outcome outcome : outcomes) { + out.add( + new Outcome( + values(outcome.getOutputsMap()), + present(outcome.getFinalState()), + outcome.getStatesVisitedList(), + present(outcome.getError()), + outcome.getLinearizations(), + outcome.getWitnessList(), + diagnostics(outcome.getDiagnosticsList()))); + } + return new Exploration( + out, + status.getComplete(), + status.getRuns(), + status.getBudgetsHitList(), + status.getRunsBudget(), + status.getDepthBudget()); + } + + /** + * The engines a service answers with. + * + * @param engines the generated descriptions + * @return immutable descriptions, in order + */ + public static List engines(List engines) { + return engines.stream() + .map( + engine -> + new EngineInfo( + engine.getName(), + engine.getAuthority(), + engine.getAnswersList(), + engine.getBoundsList(), + engine.getProcess(), + engine.getProcessFound(), + engine.getReady(), + engine.getUnavailable(), + engine.getKind(), + engine.getProtocol(), + engine.getSource(), + engine.getCommand(), + engine.getVersion(), + engine.getServed())) + .toList(); + } } diff --git a/clients/java/opensysml-client/src/test/java/org/openmbee/opensysml/ApiIntegrationTest.java b/clients/java/opensysml-client/src/test/java/org/openmbee/opensysml/ApiIntegrationTest.java index c40c985a53..0e2d433ef0 100644 --- a/clients/java/opensysml-client/src/test/java/org/openmbee/opensysml/ApiIntegrationTest.java +++ b/clients/java/opensysml-client/src/test/java/org/openmbee/opensysml/ApiIntegrationTest.java @@ -10,6 +10,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import java.util.Map; import java.util.Optional; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; @@ -596,4 +597,392 @@ void aClosedConnectionRefusesCalls() { closed.close(); // idempotent assertThrows(IllegalStateException.class, () -> closed.parse(VEHICLE)); } + + private static final String BEHAVIOR = + """ + package Test { + private import ScalarValues::*; + action addFive { + attribute result : Integer = 0; + first start; + action inner { assign result := result + 5; } + done; + succession first start then inner; + succession first inner then done; + } + action noStart { + attribute result : Integer = 0; + } + action race { + attribute x : Integer = 0; + first start; + fork split; + action a { assign x := 1; } + action b { assign x := 2; } + action c { assign x := 3; } + join sync; + done; + succession first start then split; + succession first split then a; + succession first split then b; + succession first split then c; + succession first a then sync; + succession first b then sync; + succession first c then sync; + succession first sync then done; + } + state Machine { + entry; then init; + state init; + state Running; + succession first init then Running; + succession first Running then done; + } + } + """; + + @Test + void anActionRunsWithItsInputsAndReportsItsAttributes() { + Model model = connection.parse(BEHAVIOR); + ActionRun run = model.executeAction("Test::addFive"); + assertEquals(new Value.IntegerValue(5), run.outputs().get("result")); + assertEquals( + connection.capabilities().has(Capabilities.FINAL_TIME), run.finalTime().isPresent()); + + ActionRun seeded = + model.executeAction("Test::addFive", Map.of("result", new Value.IntegerValue(10))); + assertEquals(new Value.IntegerValue(15), seeded.outputs().get("result")); + + ActionRun declared = + model.executeAction( + "Test::race", Map.of(), ExecutionOptions.defaults().withSchedule("declared")); + assertEquals(new Value.IntegerValue(3), declared.outputs().get("x")); + } + + @Test + void anActionThatCannotStartIsAModelFailureAndABadScheduleIsRefused() { + Model model = connection.parse(BEHAVIOR); + ModelException failed = + assertThrows(ModelException.class, () -> model.executeAction("Test::noStart")); + assertFalse(failed.getMessage().isBlank()); + ServiceException refused = + assertThrows( + ServiceException.class, + () -> + model.executeAction( + "Test::race", Map.of(), ExecutionOptions.defaults().withSchedule("seed:abc"))); + assertEquals(StatusCode.INVALID_ARGUMENT, refused.status()); + assertTrue(refused.getMessage().contains("seed:abc")); + assertThrows( + IllegalArgumentException.class, + () -> + model.exploreAction( + "Test::race", Map.of(), ExecutionOptions.defaults().withSchedule("declared"))); + } + + @Test + void exploringAnActionReachesEveryOutcomeWithItsWitness() { + Model model = connection.parse(BEHAVIOR); + Exploration exploration = model.exploreAction("Test::race"); + assertTrue(exploration.complete()); + assertEquals(6, exploration.runs()); + assertEquals(List.of(), exploration.budgetsHit()); + assertEquals("complete (6 runs)", exploration.status()); + assertEquals( + List.of(new Value.IntegerValue(1), new Value.IntegerValue(2), new Value.IntegerValue(3)), + exploration.outcomes().stream().map(o -> o.outputs().get("x")).toList()); + for (Outcome outcome : exploration.outcomes()) { + assertTrue(outcome.completed()); + assertEquals(2, outcome.linearizations()); + assertFalse(outcome.witness().isEmpty()); + } + + Exploration bounded = + model.exploreAction( + "Test::race", Map.of(), ExecutionOptions.defaults().withSchedule("explore:runs=2")); + assertFalse(bounded.complete()); + assertEquals(List.of("runs"), bounded.budgetsHit()); + assertEquals(2, bounded.runsBudget()); + assertTrue(bounded.status().startsWith("incomplete: runs budget 2")); + } + + @Test + void aStateMachineReportsTheStatesItVisitedAndExploresToItsFinalState() { + Model model = connection.parse(BEHAVIOR); + StateRun run = model.executeState("Test::Machine", List.of()); + assertEquals(List.of("init", "Running", "done"), run.statesVisited()); + assertEquals(Optional.of("done"), run.finalState()); + + Exploration exploration = model.exploreState("Test::Machine", List.of()); + assertTrue(exploration.complete()); + assertEquals(1, exploration.outcomes().size()); + assertEquals(Optional.of("done"), exploration.outcomes().get(0).finalState()); + assertEquals( + List.of("init", "Running", "done"), exploration.outcomes().get(0).statesVisited()); + + assertThrows(ModelException.class, () -> model.executeState("Test::NoMachine", List.of())); + } + + private static final String VERIFICATION = + """ + package Demo { + part def Vehicle { + attribute mass default = 1500.0; + constraint massPositive { mass > 0.0 } + constraint massLight { mass < 100.0 } + requirement lightEnough { require constraint { mass < 2000.0 } } + requirement tiny { require constraint { mass < 10.0 } } + } + requirement def MassLimit { + subject vehicle : Vehicle; + attribute maxMass; + require constraint { vehicle.mass <= maxMass } + } + requirement massLimit : MassLimit { attribute :>> maxMass = 2000.0; } + requirement massTiny : MassLimit { attribute :>> maxMass = 10.0; } + part sedan : Vehicle { attribute :>> mass = 1200.0; } + part analysis { + assert satisfy massLimit by sedan; + assert satisfy massTiny by sedan; + } + calc add { in x; in y; x + y } + } + """; + + @Test + void aConstraintIsVerifiedAgainstDeclaredValuesOrAnObjectAndAFalseAnswerIsNotAFailure() { + Model model = connection.parse(VERIFICATION); + Verification holding = model.verifyConstraint("Demo::Vehicle::massPositive"); + assertTrue(holding.holds()); + assertTrue(holding.verdict().decided()); + assertEquals(Optional.empty(), holding.subject()); + + Verification violated = model.verifyConstraint("Demo::Vehicle::massLight"); + assertFalse(violated.holds()); + assertTrue(violated.verdict().violated()); + assertTrue(violated.verdict().condition().isPresent()); + assertEquals(Optional.empty(), violated.verdict().error()); + + Verification about = model.verifyConstraint("Demo::Vehicle::massPositive", "Demo::sedan"); + assertTrue(about.holds()); + assertEquals("Demo::sedan", about.subject().orElseThrow().typeSymbolId()); + + Verification wrongKind = model.verifyConstraint("Demo::sedan"); + assertFalse(wrongKind.verdict().decided()); + assertFalse(wrongKind.holds()); + assertEquals(FailureReason.WRONG_KIND, wrongKind.verdict().failureReason()); + assertTrue(wrongKind.verdict().error().orElseThrow().length() > 0); + } + + @Test + void requirementsAndSatisfactionsReportEachVerdict() { + Model model = connection.parse(VERIFICATION); + assertTrue(model.verifyRequirement("Demo::Vehicle::lightEnough").holds()); + Verification tiny = model.verifyRequirement("Demo::Vehicle::tiny"); + assertTrue(tiny.verdict().violated()); + + Satisfaction all = model.verifySatisfaction(); + assertEquals(2, all.verdicts().size()); + assertFalse(all.holds()); + assertEquals(1, all.violated().size()); + assertEquals(List.of(), all.undecided()); + assertEquals(Optional.of("Demo::massTiny"), all.violated().get(0).requirementId()); + + Satisfaction scoped = model.verifySatisfaction("Demo::analysis"); + assertEquals(2, scoped.verdicts().size()); + + Validation validation = model.validateInstance("Demo::sedan"); + assertFalse(validation.holds()); + assertEquals("Demo::sedan", validation.root().orElseThrow().typeSymbolId()); + assertTrue(validation.verdicts().size() >= 2); + assertThrows(ModelException.class, () -> model.validateInstance("Demo::nosuch")); + ModelException wrongKind = + assertThrows(ModelException.class, () -> model.validateInstance("Demo")); + assertEquals(FailureReason.WRONG_KIND, wrongKind.failureReason()); + } + + private static final String VERIFICATION_CASES = + """ + package Demo { + private import ScalarValues::*; + part def Widget { attribute m : Integer default = 0; } + part good : Widget; + requirement def Zeroed { + subject w : Widget; + require constraint { w.m == 0 } + } + requirement zeroed : Zeroed { subject w = good; } + requirement bounded : Zeroed { subject w = good; } + verification def ZeroCheck { + subject w : Widget; + objective { verify zeroed; } + VerificationCases::PassIf(w.m == 0) + } + verification def BoundCheck { + subject w : Widget; + objective { verify bounded; } + VerificationCases::PassIf(w.m == 1) + } + verification checkZero : ZeroCheck { subject w = good; } + verification checkBound : BoundCheck { subject w = good; } + part checks { + assert satisfy zeroed by good; + assert satisfy bounded by good; + } + } + """; + + @Test + void theVerificationCasesOfARequirementReportBesideItsVerdict() { + Model model = connection.parse(VERIFICATION_CASES); + Verification bounded = model.verifyRequirement("Demo::bounded"); + assertTrue(bounded.holds()); + assertEquals(1, bounded.verifications().size()); + VerificationVerdict check = bounded.verifications().get(0); + assertEquals("Demo::checkBound", check.caseId()); + assertEquals(VerificationVerdict.FAIL, check.kind()); + assertFalse(check.passed()); + assertEquals(Optional.of("Demo::bounded"), check.requirementId()); + + Satisfaction checks = model.verifySatisfaction("Demo::checks"); + assertTrue(checks.holds()); + assertEquals(VerificationVerdict.PASS, checks.verificationsOf("Demo::zeroed").get(0).kind()); + assertEquals(VerificationVerdict.FAIL, checks.verificationsOf("Demo::bounded").get(0).kind()); + } + + @Test + void aCalcIsEvaluatedWithPositionalArgumentsAndTheWrongKindIsAModelFailure() { + Model model = connection.parse(VERIFICATION); + Calculation sum = + model.evaluateCalc( + "Demo::add", List.of(new Value.IntegerValue(2), new Value.IntegerValue(3))); + assertEquals(Optional.of(new Value.IntegerValue(5)), sum.value()); + assertEquals(Optional.of(new Value.IntegerValue(5)), sum.result()); + ModelException wrongKind = + assertThrows(ModelException.class, () -> model.evaluateCalc("Demo::sedan", List.of())); + assertEquals(FailureReason.WRONG_KIND, wrongKind.failureReason()); + } + + @Test + void aTradeStudyArrivesThroughThePublicApiWithItsSelectedAlternative() { + Model model = connection.parse(TRADE_STUDY); + Analysis analysis = model.runAnalysis("Trade::lightest"); + assertTrue(analysis.holds()); + assertEquals("tradeStudyObjective", analysis.objective().orElseThrow().element()); + Value selected = analysis.outputs().get("selectedAlternative"); + assertInstanceOf(Value.InstanceReference.class, selected); + assertEquals( + "Trade::b", + analysis.resolve((Value.InstanceReference) selected).orElseThrow().typeSymbolId()); + assertEquals( + List.of("Trade::a", "Trade::b", "Trade::c"), + analysis.evaluations().stream() + .map(e -> analysis.resolve((Value.InstanceReference) e.arguments().get(0))) + .map(i -> i.orElseThrow().typeSymbolId()) + .toList()); + assertEquals( + List.of(false, true, false), + analysis.evaluations().stream().map(org.openmbee.opensysml.CaseEvaluation::selected).toList()); + assertEquals( + List.of(false, false, true), + analysis.evaluations().stream().map(org.openmbee.opensysml.CaseEvaluation::tied).toList()); + assertEquals(analysis.evaluations().get(1), analysis.selected().orElseThrow()); + } + + @Test + void anAnalysisBindsItsArgumentsAndAFailedRunKeepsWhatItLeft() { + Model model = connection.parse(TRADE_STUDY); + Analysis three = + model.runAnalysis( + "Trade::perOffset", + AnalysisOptions.defaults().withNamedArguments(Map.of("offset", new Value.IntegerValue(3)))); + assertEquals( + List.of(Optional.of(new Value.RealValue(10.0)), Optional.of(new Value.RealValue(10.0))), + three.evaluations().stream().map(org.openmbee.opensysml.CaseEvaluation::result).toList()); + + AnalysisException failed = + assertThrows( + AnalysisException.class, + () -> + model.runAnalysis( + "Trade::perOffset", + AnalysisOptions.defaults().withArguments(List.of(new Value.IntegerValue(4))))); + assertTrue(failed.getMessage().contains("division by zero")); + assertEquals(FailureReason.EVALUATION, failed.failureReason()); + Analysis partial = failed.partial().orElseThrow(); + assertEquals(2, partial.evaluations().size()); + assertEquals(Optional.of(new Value.RealValue(15.0)), partial.evaluations().get(0).result()); + assertTrue(partial.evaluations().get(1).error().orElseThrow().contains("division by zero")); + assertFalse(partial.holds()); + assertEquals(Optional.empty(), partial.selected()); + + ModelException wrongKind = + assertThrows(ModelException.class, () -> model.runAnalysis("Trade::a")); + assertEquals(FailureReason.WRONG_KIND, wrongKind.failureReason()); + assertFalse(wrongKind instanceof AnalysisException); + } + + private static final String QUERY = + """ + package Demo { + abstract part def Vehicle { attribute mass; } + part def Wheel; + part vehicle : Vehicle { + part wheels : Wheel[4]; + attribute vin; + } + part spare : Wheel; + } + """; + + @Test + void aQuerySelectsElementsByTypeScopeAndProperty() { + Model model = connection.parse(QUERY); + List all = model.query(Query.all()).stream().map(QueryElement::id).toList(); + assertTrue(all.containsAll(List.of("Demo", "Demo::Vehicle", "Demo::vehicle::wheels", "Demo::spare"))); + + List parts = + model.query(Query.all().where(Condition.equal("@type", List.of("PartUsage")))); + assertEquals( + List.of("Demo::spare", "Demo::vehicle", "Demo::vehicle::wheels"), + parts.stream().map(QueryElement::id).sorted().toList()); + parts.forEach(e -> assertEquals("PartUsage", e.type())); + + List scoped = model.query(Query.all().withScope(List.of("Demo::vehicle"))); + assertEquals(3, scoped.size()); + + List selected = + model.query( + Query.all() + .withSelect(List.of("name", "owner")) + .where(Condition.equal("qualifiedName", List.of("Demo::vehicle::wheels")))); + assertEquals( + List.of( + new QueryElement( + "Demo::vehicle::wheels", + "PartUsage", + Map.of("name", "wheels", "owner", "Demo::vehicle"))), + selected); + + assertEquals( + 3, model.queryOslc("oslc.where=rdf:type=\"PartUsage\"&oslc.select=sysml:name").size()); + ServiceException refused = + assertThrows( + ServiceException.class, + () -> model.query(Query.all().withScope(List.of("Demo::Missing")))); + assertEquals(StatusCode.INVALID_ARGUMENT, refused.status()); + } + + @Test + void theServiceListsItsEnginesAndAModelCanBeBoundToOne() { + List engines = connection.listEngines(); + assertFalse(engines.isEmpty()); + engines.forEach(e -> assertFalse(e.name().isBlank())); + Model model = connection.parse(VERIFICATION); + Model bound = model.withEngine(Standing.ENGINE_AUTO); + assertEquals(Optional.of(Standing.ENGINE_AUTO), bound.engine()); + assertEquals(model.hash(), bound.hash()); + Verification holding = bound.verifyConstraint("Demo::Vehicle::massPositive"); + assertTrue(holding.holds()); + } } diff --git a/clients/java/opensysml-client/src/test/java/org/openmbee/opensysml/ResultTypesTest.java b/clients/java/opensysml-client/src/test/java/org/openmbee/opensysml/ResultTypesTest.java new file mode 100644 index 0000000000..7818f7a5ed --- /dev/null +++ b/clients/java/opensysml-client/src/test/java/org/openmbee/opensysml/ResultTypesTest.java @@ -0,0 +1,337 @@ +package org.openmbee.opensysml; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalDouble; +import org.junit.jupiter.api.Test; + +/** The public result types of execution, verification, analysis and query: immutable and decided. */ +class ResultTypesTest { + + private static Verdict verdict(boolean holds, String error) { + return new Verdict( + Verdict.KIND_CONSTRAINT, + Optional.of("Demo::Vehicle::massLight"), + "massLight", + holds, + holds ? Optional.empty() : Optional.of("mass < 100.0"), + Optional.empty(), + Optional.empty(), + error.isEmpty() ? Optional.empty() : Optional.of(error), + error.isEmpty() ? FailureReason.UNSPECIFIED : FailureReason.EVALUATION, + Optional.empty(), + Optional.empty(), + Standing.none()); + } + + @Test + void aFalseVerdictIsDecidedAndAnErroredOneIsNot() { + Verdict violated = verdict(false, ""); + assertTrue(violated.decided()); + assertTrue(violated.violated()); + assertFalse(violated.holds()); + + Verdict undecided = verdict(false, "mass is unbound"); + assertFalse(undecided.decided()); + assertFalse(undecided.violated()); + assertEquals(FailureReason.EVALUATION, undecided.failureReason()); + + Verdict holding = verdict(true, ""); + assertTrue(holding.decided()); + assertFalse(holding.violated()); + } + + @Test + void aSatisfactionSortsItsVerdictsByWhatTheySay() { + Verdict holding = verdict(true, ""); + Verdict violated = verdict(false, ""); + Verdict undecided = verdict(false, "unbound"); + Satisfaction satisfaction = + new Satisfaction(List.of(holding, violated, undecided), List.of(), List.of(), List.of()); + assertFalse(satisfaction.holds()); + assertEquals(List.of(violated), satisfaction.violated()); + assertEquals(List.of(undecided), satisfaction.undecided()); + assertTrue(new Satisfaction(List.of(holding), List.of(), List.of(), List.of()).holds()); + assertTrue(new Satisfaction(List.of(), List.of(), List.of(), List.of()).holds()); + } + + @Test + void aSatisfactionFindsTheVerificationCasesOfARequirement() { + VerificationVerdict pass = + new VerificationVerdict( + "Demo::checkZero", VerificationVerdict.PASS, Optional.empty(), false, Optional.of("Demo::zeroed")); + VerificationVerdict fail = + new VerificationVerdict( + "Demo::checkBound", + VerificationVerdict.FAIL, + Optional.of("bound exceeded"), + false, + Optional.of("Demo::bounded")); + Satisfaction satisfaction = + new Satisfaction(List.of(), List.of(pass, fail), List.of(), List.of()); + assertEquals(List.of(fail), satisfaction.verificationsOf("Demo::bounded")); + assertEquals(List.of(), satisfaction.verificationsOf("Demo::other")); + assertTrue(pass.passed()); + assertFalse(fail.passed()); + } + + @Test + void resultsCopyTheCollectionsTheyAreGivenAndRefuseChanges() { + List verdicts = new ArrayList<>(List.of(verdict(true, ""))); + Map outputs = new LinkedHashMap<>(); + outputs.put("total", new Value.RealValue(12.0)); + Analysis analysis = + new Analysis(outputs, verdicts, List.of(), List.of(), List.of(), List.of(), Standing.none()); + verdicts.clear(); + outputs.put("later", new Value.RealValue(1.0)); + assertEquals(1, analysis.verdicts().size()); + assertEquals(List.of("total"), List.copyOf(analysis.outputs().keySet())); + Value added = new Value.RealValue(2.0); + assertThrows(UnsupportedOperationException.class, () -> analysis.outputs().put("x", added)); + Verdict extra = verdict(false, ""); + assertThrows(UnsupportedOperationException.class, () -> analysis.verdicts().add(extra)); + + Map context = new LinkedHashMap<>(Map.of("n", new Value.IntegerValue(1))); + StateRun run = + new StateRun(new ArrayList<>(List.of("init", "done")), context, OptionalDouble.empty(), List.of()); + context.clear(); + assertEquals(Optional.of("done"), run.finalState()); + assertEquals(1, run.finalContext().size()); + assertEquals( + Optional.empty(), + new StateRun(List.of(), Map.of(), OptionalDouble.empty(), List.of()).finalState()); + } + + @Test + void anOutputOrderIsTheOneTheServiceReported() { + Map outputs = new LinkedHashMap<>(); + outputs.put("z", new Value.IntegerValue(1)); + outputs.put("a", new Value.IntegerValue(2)); + Calculation calculation = + new Calculation(Optional.empty(), outputs, List.of(), Standing.none()); + assertEquals(List.of("z", "a"), List.copyOf(calculation.outputs().keySet())); + } + + @Test + void aCalculationsValueIsItsResultOrItsOnlyOutput() { + Value five = new Value.IntegerValue(5); + assertEquals( + Optional.of(five), + new Calculation(Optional.of(five), Map.of(), List.of(), Standing.none()).value()); + assertEquals( + Optional.of(five), + new Calculation(Optional.empty(), Map.of("out", five), List.of(), Standing.none()).value()); + Map two = new LinkedHashMap<>(); + two.put("a", five); + two.put("b", five); + assertEquals( + Optional.empty(), new Calculation(Optional.empty(), two, List.of(), Standing.none()).value()); + } + + @Test + void anAnalysisNamesItsObjectiveItsSelectedAlternativeAndTheObjectsItRefersTo() { + Instance engine = new Instance(7, "Trade::b", Map.of()); + Verdict objective = + new Verdict( + Verdict.KIND_OBJECTIVE, + Optional.of("Trade::lightest::objective"), + "tradeStudyObjective", + true, + Optional.empty(), + Optional.of(7L), + Optional.of("Trade::b"), + Optional.empty(), + FailureReason.UNSPECIFIED, + Optional.empty(), + Optional.empty(), + Standing.none()); + CaseEvaluation loser = + new CaseEvaluation( + "Trade::lightest::evaluationFunction", + List.of(new Value.InstanceReference(3)), + Optional.of(new Value.RealValue(30.0)), + Optional.empty(), + false, + false); + CaseEvaluation winner = + new CaseEvaluation( + "Trade::lightest::evaluationFunction", + List.of(new Value.InstanceReference(7)), + Optional.of(new Value.RealValue(10.0)), + Optional.empty(), + true, + false); + Analysis analysis = + new Analysis( + Map.of("selectedAlternative", new Value.InstanceReference(7)), + List.of(objective), + List.of(), + List.of(loser, winner), + List.of(engine), + List.of(), + new Standing("run", "observed", List.of())); + assertTrue(analysis.holds()); + assertEquals(Optional.of(objective), analysis.objective()); + assertEquals(Optional.of(winner), analysis.selected()); + assertEquals(Optional.of(engine), analysis.resolve(new Value.InstanceReference(7))); + assertEquals(Optional.empty(), analysis.resolve(new Value.InstanceReference(3))); + assertEquals(Optional.of(engine), analysis.instance(7)); + assertTrue(analysis.standing().reported()); + } + + @Test + void anAnalysisExceptionKeepsWhatTheRunLeft() { + Analysis partial = + new Analysis( + Map.of(), List.of(verdict(false, "division by zero")), List.of(), List.of(), List.of(), List.of(), Standing.none()); + AnalysisException e = + new AnalysisException("division by zero", FailureReason.EVALUATION, List.of(), partial); + assertEquals(Optional.of(partial), e.partial()); + assertEquals(FailureReason.EVALUATION, e.failureReason()); + assertEquals("division by zero", e.getMessage()); + } + + @Test + void anExplorationReportsHowItEnded() { + Outcome one = + new Outcome( + Map.of("x", new Value.IntegerValue(1)), + Optional.empty(), + List.of(), + Optional.empty(), + 2, + List.of("first of a, b, c: a"), + List.of()); + Outcome failed = + new Outcome( + Map.of(), Optional.empty(), List.of(), Optional.of("deadlock"), 1, List.of(), List.of()); + assertTrue(one.completed()); + assertFalse(failed.completed()); + assertEquals( + "complete (6 runs)", + new Exploration(List.of(one), true, 6, List.of(), 1024, 64).status()); + assertEquals( + "incomplete: runs budget 100 hit after 100 runs", + new Exploration(List.of(one, failed), false, 100, List.of("runs"), 100, 64).status()); + assertEquals( + "incomplete: runs budget 4 and depth budget 2 hit after 4 runs", + new Exploration(List.of(), false, 4, List.of("runs", "depth"), 4, 2).status()); + } + + @Test + void executionOptionsKnowWhetherTheyExplore() { + assertFalse(ExecutionOptions.defaults().explores()); + assertFalse(ExecutionOptions.defaults().withSchedule("declared").explores()); + assertTrue(ExecutionOptions.defaults().withSchedule("explore").explores()); + assertTrue(ExecutionOptions.defaults().withSchedule("explore:runs=10,depth=4").explores()); + ExecutionOptions options = + ExecutionOptions.defaults().withSchedule("seed:7").withPerformer("Demo::sedan"); + assertEquals(Optional.of("seed:7"), options.schedule()); + assertEquals(Optional.of("Demo::sedan"), options.performer()); + assertEquals(Optional.empty(), ExecutionOptions.defaults().performer()); + } + + @Test + void analysisOptionsAccumulateAndCopyTheirArguments() { + List positional = new ArrayList<>(List.of(new Value.RealValue(10.0))); + Map named = new LinkedHashMap<>(Map.of("limit", new Value.RealValue(50.0))); + AnalysisOptions options = + AnalysisOptions.defaults() + .withSubject("An::barge") + .withArguments(positional) + .withNamedArguments(named) + .withSchedule("explore"); + positional.clear(); + named.clear(); + assertEquals(Optional.of("An::barge"), options.subject()); + assertEquals(List.of(new Value.RealValue(10.0)), options.arguments()); + assertEquals(Map.of("limit", new Value.RealValue(50.0)), options.namedArguments()); + assertTrue(options.explores()); + assertFalse(AnalysisOptions.defaults().explores()); + Value extra = new Value.RealValue(1.0); + assertThrows(UnsupportedOperationException.class, () -> options.arguments().add(extra)); + } + + @Test + void aQueryIsBuiltUpAndItsConditionsNegate() { + Condition.Comparison parts = Condition.equal("@type", List.of("PartUsage", "PartDefinition")); + Condition.Comparison heavy = Condition.greater("mass", "1000"); + Query query = + Query.all() + .withScope(List.of("Demo")) + .withSelect(List.of("name", "qualifiedName")) + .where(Condition.all(List.of(parts, heavy))); + assertEquals(List.of("Demo"), query.scope()); + assertEquals(List.of("name", "qualifiedName"), query.select()); + assertEquals(Optional.of(Condition.all(List.of(parts, heavy))), query.where()); + assertEquals(Optional.empty(), Query.all().where()); + + assertEquals( + new Condition.Comparison( + "@type", Condition.Comparison.Operator.EQUAL, List.of("PartUsage", "PartDefinition"), true), + parts.negated()); + assertEquals(parts, parts.negated().negated()); + assertEquals( + Condition.any(List.of(parts.negated(), heavy.negated())), + Condition.all(List.of(parts, heavy)).negated()); + assertEquals( + Condition.all(List.of(parts.negated(), heavy.negated())), + Condition.any(List.of(parts, heavy)).negated()); + assertEquals(Condition.Comparison.Operator.LESS, Condition.less("mass", "10").operator()); + } + + @Test + void aQueryElementCopiesItsProperties() { + Map properties = new LinkedHashMap<>(Map.of("name", "sedan")); + QueryElement element = new QueryElement("Demo::sedan", "PartUsage", properties); + properties.clear(); + assertEquals(Map.of("name", "sedan"), element.properties()); + assertThrows(UnsupportedOperationException.class, () -> element.properties().put("a", "b")); + } + + @Test + void aStandingIsReportedOnlyWhenAnEngineAnswered() { + assertFalse(Standing.none().reported()); + Standing standing = + new Standing("explore", "observed", List.of(new Standing.Bound("runs", 1024, false))); + assertTrue(standing.reported()); + assertEquals("explore", standing.engine()); + assertEquals(1024, standing.bounds().get(0).limit()); + assertEquals("auto", Standing.ENGINE_AUTO); + assertEquals("all", Standing.ENGINE_ALL); + } + + @Test + void aVerificationFindsItsSubjectAmongItsInstances() { + Instance sedan = new Instance(1, "Demo::sedan", Map.of()); + Verdict about = + new Verdict( + Verdict.KIND_CONSTRAINT, + Optional.of("Demo::Vehicle::massPositive"), + "massPositive", + true, + Optional.empty(), + Optional.of(1L), + Optional.of("Demo::sedan"), + Optional.empty(), + FailureReason.UNSPECIFIED, + Optional.empty(), + Optional.empty(), + Standing.none()); + Verification verification = new Verification(about, List.of(), List.of(sedan), List.of()); + assertTrue(verification.holds()); + assertEquals(Optional.of(sedan), verification.subject()); + assertEquals(Optional.empty(), verification.instance(2)); + Verification declared = + new Verification(verdict(true, ""), List.of(), List.of(sedan), List.of()); + assertEquals(Optional.empty(), declared.subject()); + } +} diff --git a/clients/java/opensysml-client/src/test/java/org/openmbee/opensysml/internal/ResultProtosTest.java b/clients/java/opensysml-client/src/test/java/org/openmbee/opensysml/internal/ResultProtosTest.java new file mode 100644 index 0000000000..bff7ae40d8 --- /dev/null +++ b/clients/java/opensysml-client/src/test/java/org/openmbee/opensysml/internal/ResultProtosTest.java @@ -0,0 +1,469 @@ +package org.openmbee.opensysml.internal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalDouble; +import org.junit.jupiter.api.Test; +import org.openmbee.opensysml.ActionRun; +import org.openmbee.opensysml.Analysis; +import org.openmbee.opensysml.Calculation; +import org.openmbee.opensysml.Condition; +import org.openmbee.opensysml.EngineInfo; +import org.openmbee.opensysml.Exploration; +import org.openmbee.opensysml.FailureReason; +import org.openmbee.opensysml.Query; +import org.openmbee.opensysml.QueryElement; +import org.openmbee.opensysml.Quantity; +import org.openmbee.opensysml.Standing; +import org.openmbee.opensysml.StateRun; +import org.openmbee.opensysml.Validation; +import org.openmbee.opensysml.Value; +import org.openmbee.opensysml.Verdict; +import org.openmbee.opensysml.Verification; +import org.openmbee.opensysml.VerificationVerdict; +import org.openmbee.opensysml.proto.Bound; +import org.openmbee.opensysml.proto.CalcOutput; +import org.openmbee.opensysml.proto.CaseEvaluation; +import org.openmbee.opensysml.proto.CompositeOperator; +import org.openmbee.opensysml.proto.Constraint; +import org.openmbee.opensysml.proto.Diagnostic; +import org.openmbee.opensysml.proto.EvaluateCalcResponse; +import org.openmbee.opensysml.proto.ExecuteActionResponse; +import org.openmbee.opensysml.proto.ExecuteStateResponse; +import org.openmbee.opensysml.proto.ExplorationStatus; +import org.openmbee.opensysml.proto.Instance; +import org.openmbee.opensysml.proto.Outcome; +import org.openmbee.opensysml.proto.PrimitiveOperator; +import org.openmbee.opensysml.proto.QueryResultElement; +import org.openmbee.opensysml.proto.RunAnalysisResponse; +import org.openmbee.opensysml.proto.UnitFactor; +import org.openmbee.opensysml.proto.UnitTerm; +import org.openmbee.opensysml.proto.ValidateInstanceResponse; +import org.openmbee.opensysml.proto.VerifyConstraintResponse; + +/** Reading execution, verification, analysis and query answers off the wire, and writing requests. */ +class ResultProtosTest { + + private static org.openmbee.opensysml.proto.Value integer(long value) { + return org.openmbee.opensysml.proto.Value.newBuilder().setIntValue(value).build(); + } + + private static org.openmbee.opensysml.proto.Value real(double value) { + return org.openmbee.opensysml.proto.Value.newBuilder().setRealValue(value).build(); + } + + @Test + void aVerdictKeepsEveryFieldAndReadsAnAbsentObjectAsDeclaredValues() { + var wire = + org.openmbee.opensysml.proto.Verdict.newBuilder() + .setKind("constraint") + .setElementId("Demo::Vehicle::massLight") + .setElement("massLight") + .setHolds(false) + .setCondition("mass < 100.0") + .setRequirementId("Demo::Vehicle::lightEnough") + .setInstancePath("sedan") + .setEngine("interval") + .setStrength("sound") + .addBounds(Bound.newBuilder().setName("depth").setLimit(8).setReached(true)) + .build(); + Verdict verdict = Protos.verdict(wire); + assertEquals(Verdict.KIND_CONSTRAINT, verdict.kind()); + assertEquals(Optional.of("Demo::Vehicle::massLight"), verdict.elementId()); + assertEquals("massLight", verdict.element()); + assertFalse(verdict.holds()); + assertTrue(verdict.decided()); + assertTrue(verdict.violated()); + assertEquals(Optional.of("mass < 100.0"), verdict.condition()); + assertEquals(Optional.empty(), verdict.instanceId()); + assertEquals(Optional.empty(), verdict.instanceTypeId()); + assertEquals(Optional.empty(), verdict.error()); + assertEquals(FailureReason.UNSPECIFIED, verdict.failureReason()); + assertEquals(Optional.of("Demo::Vehicle::lightEnough"), verdict.requirementId()); + assertEquals(Optional.of("sedan"), verdict.instancePath()); + assertEquals( + new Standing("interval", "sound", List.of(new Standing.Bound("depth", 8, true))), + verdict.standing()); + + Verdict about = + Protos.verdict( + org.openmbee.opensysml.proto.Verdict.newBuilder() + .setKind("object") + .setInstanceId(3) + .setInstanceTypeId("Demo::sedan") + .setError("mass is unbound") + .setFailureReason( + org.openmbee.opensysml.proto.FailureReason.FAILURE_REASON_EVALUATION) + .build()); + assertEquals(Optional.of(3L), about.instanceId()); + assertEquals(Optional.of("Demo::sedan"), about.instanceTypeId()); + assertEquals(Optional.of("mass is unbound"), about.error()); + assertEquals(FailureReason.EVALUATION, about.failureReason()); + assertFalse(about.decided()); + assertFalse(about.standing().reported()); + } + + @Test + void aFailureReasonThisReleaseDoesNotKnowIsUnknownRatherThanRefused() { + assertEquals( + FailureReason.UNKNOWN, + Protos.failureReason(org.openmbee.opensysml.proto.FailureReason.UNRECOGNIZED)); + assertEquals( + FailureReason.WRONG_KIND, + Protos.failureReason(org.openmbee.opensysml.proto.FailureReason.FAILURE_REASON_WRONG_KIND)); + assertEquals( + FailureReason.AMBIGUOUS_SUBJECT, + Protos.failureReason( + org.openmbee.opensysml.proto.FailureReason.FAILURE_REASON_AMBIGUOUS_SUBJECT)); + } + + @Test + void aVerificationCarriesItsObjectsBodyVerdictsAndDiagnostics() { + VerifyConstraintResponse response = + VerifyConstraintResponse.newBuilder() + .setVerdict( + org.openmbee.opensysml.proto.Verdict.newBuilder() + .setKind("constraint") + .setHolds(true) + .setInstanceId(1)) + .addInstances(Instance.newBuilder().setId(1).setTypeSymbolId("Demo::sedan")) + .addDiagnostics(Diagnostic.newBuilder().setMessage("note").setSeverity("info")) + .build(); + Verification verification = Protos.verification(response); + assertTrue(verification.holds()); + assertEquals("Demo::sedan", verification.subject().orElseThrow().typeSymbolId()); + assertEquals(1, verification.diagnostics().size()); + assertEquals("note", verification.diagnostics().get(0).message()); + assertEquals(List.of(), verification.verifications()); + } + + @Test + void aValidationKeepsItsSummaryItsVerdictsAndWhetherItWasBounded() { + ValidateInstanceResponse response = + ValidateInstanceResponse.newBuilder() + .setSummary( + org.openmbee.opensysml.proto.Verdict.newBuilder() + .setKind("object") + .setHolds(false) + .setInstanceId(1)) + .addVerdicts( + org.openmbee.opensysml.proto.Verdict.newBuilder() + .setKind("assertion") + .setHolds(false) + .setRequirementId("Demo::massTiny")) + .addVerificationVerdicts( + org.openmbee.opensysml.proto.VerificationVerdict.newBuilder() + .setCaseId("Demo::checkTiny") + .setKind("fail") + .setDetail("10 < 1200") + .setSubcase(true) + .setRequirementId("Demo::massTiny")) + .addInstances(Instance.newBuilder().setId(1).setTypeSymbolId("Demo::sedan")) + .setBounded(true) + .build(); + Validation validation = Protos.validation(response); + assertFalse(validation.holds()); + assertTrue(validation.bounded()); + assertEquals("Demo::sedan", validation.root().orElseThrow().typeSymbolId()); + assertEquals(1, validation.verdicts().size()); + VerificationVerdict body = validation.verifications().get(0); + assertEquals( + new VerificationVerdict( + "Demo::checkTiny", + VerificationVerdict.FAIL, + Optional.of("10 < 1200"), + true, + Optional.of("Demo::massTiny")), + body); + assertFalse(body.passed()); + } + + @Test + void aCalculationReadsADirectResultOrNamedOutputsInOrderWithItsStanding() { + Calculation direct = + Protos.calculation(EvaluateCalcResponse.newBuilder().setResult(integer(5)).build()); + assertEquals(Optional.of(new Value.IntegerValue(5)), direct.result()); + assertEquals(Optional.of(new Value.IntegerValue(5)), direct.value()); + assertFalse(direct.standing().reported()); + + Calculation named = + Protos.calculation( + EvaluateCalcResponse.newBuilder() + .addOutputs(CalcOutput.newBuilder().setName("z").setValue(real(1.5))) + .addOutputs(CalcOutput.newBuilder().setName("a").setValue(real(2.5))) + .setEngine("interval") + .setStrength("sound") + .addBounds(Bound.newBuilder().setName("runs").setLimit(100)) + .build()); + assertEquals(Optional.empty(), named.result()); + assertEquals(List.of("z", "a"), List.copyOf(named.outputs().keySet())); + assertEquals(Optional.empty(), named.value()); + assertEquals("interval", named.standing().engine()); + assertEquals("sound", named.standing().strength()); + assertEquals(List.of(new Standing.Bound("runs", 100, false)), named.standing().bounds()); + } + + @Test + void anAnalysisKeepsEachEvaluationItsErrorAndTheObjectsTheOutputsReferTo() { + RunAnalysisResponse response = + RunAnalysisResponse.newBuilder() + .addOutputs( + CalcOutput.newBuilder() + .setName("selectedAlternative") + .setValue( + org.openmbee.opensysml.proto.Value.newBuilder().setInstanceId(7))) + .addVerdicts( + org.openmbee.opensysml.proto.Verdict.newBuilder() + .setKind("objective") + .setElement("tradeStudyObjective") + .setHolds(true)) + .addEvaluations( + CaseEvaluation.newBuilder() + .setFunctionId("Trade::lightest::evaluationFunction") + .addArguments( + org.openmbee.opensysml.proto.Value.newBuilder().setInstanceId(7)) + .setResult(real(10.0)) + .setSelected(true)) + .addEvaluations( + CaseEvaluation.newBuilder() + .setFunctionId("Trade::lightest::evaluationFunction") + .addArguments( + org.openmbee.opensysml.proto.Value.newBuilder().setInstanceId(8)) + .setError("division by zero") + .setTied(true)) + .addInstances(Instance.newBuilder().setId(7).setTypeSymbolId("Trade::b")) + .addInstances(Instance.newBuilder().setId(8).setTypeSymbolId("Trade::c")) + .setEngine("run") + .setStrength("observed") + .build(); + Analysis analysis = Protos.analysis(response); + assertTrue(analysis.holds()); + assertEquals("tradeStudyObjective", analysis.objective().orElseThrow().element()); + assertEquals(2, analysis.evaluations().size()); + org.openmbee.opensysml.CaseEvaluation selected = analysis.selected().orElseThrow(); + assertEquals(List.of(new Value.InstanceReference(7)), selected.arguments()); + assertEquals(Optional.of(new Value.RealValue(10.0)), selected.result()); + assertEquals(Optional.empty(), selected.error()); + org.openmbee.opensysml.CaseEvaluation failed = analysis.evaluations().get(1); + assertEquals(Optional.empty(), failed.result()); + assertEquals(Optional.of("division by zero"), failed.error()); + assertTrue(failed.tied()); + assertFalse(failed.selected()); + assertEquals( + "Trade::b", + analysis + .resolve((Value.InstanceReference) analysis.outputs().get("selectedAlternative")) + .orElseThrow() + .typeSymbolId()); + assertEquals(new Standing("run", "observed", List.of()), analysis.standing()); + } + + @Test + void aRunReportsItsFinalTimeOnlyWhenTheServiceDoes() { + ExecuteActionResponse action = + ExecuteActionResponse.newBuilder() + .putOutputs("result", integer(5)) + .setFinalTime(2.5) + .addDiagnostics(Diagnostic.newBuilder().setMessage("ran").setSeverity("info")) + .build(); + ActionRun reported = Protos.actionRun(action, true); + assertEquals(Map.of("result", new Value.IntegerValue(5)), reported.outputs()); + assertEquals(OptionalDouble.of(2.5), reported.finalTime()); + assertEquals(1, reported.diagnostics().size()); + assertEquals(OptionalDouble.empty(), Protos.actionRun(action, false).finalTime()); + + ExecuteStateResponse state = + ExecuteStateResponse.newBuilder() + .addStatesVisited("init") + .addStatesVisited("Running") + .putFinalContext("n", integer(1)) + .setFinalTime(0.0) + .build(); + StateRun run = Protos.stateRun(state, true); + assertEquals(List.of("init", "Running"), run.statesVisited()); + assertEquals(Optional.of("Running"), run.finalState()); + assertEquals(Map.of("n", new Value.IntegerValue(1)), run.finalContext()); + assertEquals(OptionalDouble.of(0.0), run.finalTime()); + } + + @Test + void anExplorationKeepsEveryOutcomeItsWitnessAndHowTheSearchEnded() { + List outcomes = + List.of( + Outcome.newBuilder() + .putOutputs("x", integer(1)) + .setLinearizations(2) + .addWitness("first of a, b, c: a") + .addDiagnostics(Diagnostic.newBuilder().setMessage("choice").setSeverity("info")) + .build(), + Outcome.newBuilder() + .setFinalState("done") + .addStatesVisited("init") + .addStatesVisited("done") + .setLinearizations(1) + .build(), + Outcome.newBuilder().setError("deadlock").setLinearizations(1).build()); + ExplorationStatus status = + ExplorationStatus.newBuilder() + .setComplete(false) + .setRuns(100) + .addBudgetsHit("runs") + .setRunsBudget(100) + .setDepthBudget(64) + .build(); + Exploration exploration = Protos.exploration(outcomes, status); + assertEquals(3, exploration.outcomes().size()); + org.openmbee.opensysml.Outcome first = exploration.outcomes().get(0); + assertEquals(Map.of("x", new Value.IntegerValue(1)), first.outputs()); + assertEquals(2, first.linearizations()); + assertEquals(List.of("first of a, b, c: a"), first.witness()); + assertEquals(1, first.diagnostics().size()); + assertTrue(first.completed()); + org.openmbee.opensysml.Outcome machine = exploration.outcomes().get(1); + assertEquals(Optional.of("done"), machine.finalState()); + assertEquals(List.of("init", "done"), machine.statesVisited()); + org.openmbee.opensysml.Outcome failed = exploration.outcomes().get(2); + assertEquals(Optional.of("deadlock"), failed.error()); + assertFalse(failed.completed()); + assertFalse(exploration.complete()); + assertEquals(100, exploration.runs()); + assertEquals(List.of("runs"), exploration.budgetsHit()); + assertEquals("incomplete: runs budget 100 hit after 100 runs", exploration.status()); + } + + @Test + void aQueryWritesItsScopeSelectionAndNestedConditions() { + Query query = + Query.all() + .withScope(List.of("Demo")) + .withSelect(List.of("name")) + .where( + Condition.all( + List.of( + Condition.equal("@type", List.of("PartUsage", "PartDefinition")).negated(), + Condition.any( + List.of( + Condition.greater("mass", "1000"), + Condition.less("mass", "10")))))); + org.openmbee.opensysml.proto.Query wire = Protos.proto(query); + assertEquals(List.of("Demo"), wire.getScopeList()); + assertEquals(List.of("name"), wire.getSelectList()); + Constraint where = wire.getWhere(); + assertEquals(Constraint.ConstraintCase.COMPOSITE, where.getConstraintCase()); + assertEquals(CompositeOperator.COMPOSITE_OPERATOR_AND, where.getComposite().getOperator()); + var type = where.getComposite().getConstraint(0).getPrimitive(); + assertTrue(type.getInverse()); + assertEquals("@type", type.getProperty()); + assertEquals(PrimitiveOperator.PRIMITIVE_OPERATOR_EQUAL, type.getOperator()); + assertEquals(List.of("PartUsage", "PartDefinition"), type.getValueList()); + var mass = where.getComposite().getConstraint(1).getComposite(); + assertEquals(CompositeOperator.COMPOSITE_OPERATOR_OR, mass.getOperator()); + assertEquals( + PrimitiveOperator.PRIMITIVE_OPERATOR_GREATER, mass.getConstraint(0).getPrimitive().getOperator()); + assertEquals(List.of("1000"), mass.getConstraint(0).getPrimitive().getValueList()); + assertEquals( + PrimitiveOperator.PRIMITIVE_OPERATOR_LESS, mass.getConstraint(1).getPrimitive().getOperator()); + assertFalse(Protos.proto(Query.all()).hasWhere()); + } + + @Test + void queryElementsKeepTheirIdsTypesAndSelectedProperties() { + List elements = + Protos.queryElements( + List.of( + QueryResultElement.newBuilder() + .setId("Demo::sedan") + .setType("PartUsage") + .putProperties("name", "sedan") + .build(), + QueryResultElement.newBuilder().setId("Demo::Wheel").setType("PartDefinition").build())); + assertEquals( + List.of( + new QueryElement("Demo::sedan", "PartUsage", Map.of("name", "sedan")), + new QueryElement("Demo::Wheel", "PartDefinition", Map.of())), + elements); + } + + @Test + void engineDescriptionsKeepEveryField() { + List engines = + Protos.engines( + List.of( + org.openmbee.opensysml.proto.EngineInfo.newBuilder() + .setName("interval") + .setAuthority("sound") + .addAnswers("constraint") + .addBounds("depth") + .setProcess("sysml-interval") + .setProcessFound("/usr/bin/sysml-interval") + .setReady(true) + .setKind("external") + .setProtocol("stdio") + .setSource("Engines::interval") + .setCommand("sysml-interval --serve") + .setVersion("1.2.0") + .setServed(true) + .build())); + EngineInfo engine = engines.get(0); + assertEquals("interval", engine.name()); + assertEquals("sound", engine.authority()); + assertEquals(List.of("constraint"), engine.answers()); + assertEquals(List.of("depth"), engine.bounds()); + assertEquals("sysml-interval", engine.process()); + assertEquals("/usr/bin/sysml-interval", engine.processFound()); + assertTrue(engine.ready()); + assertEquals("", engine.unavailable()); + assertEquals("external", engine.kind()); + assertEquals("stdio", engine.protocol()); + assertEquals("Engines::interval", engine.source()); + assertEquals("sysml-interval --serve", engine.command()); + assertEquals("1.2.0", engine.version()); + assertTrue(engine.served()); + } + + @Test + void aRequestValueSurvivesTheRoundTripThroughTheWire() { + UnitTerm metre = + UnitTerm.newBuilder() + .addFactors(UnitFactor.newBuilder().setUnitId("SI::metre").setExponent(1)) + .setScaleNum(1000) + .setScaleDen(1) + .build(); + List values = + List.of( + new Value.IntegerValue(3), + new Value.RealValue(2.5), + new Value.BooleanValue(true), + new Value.StringValue("four"), + new Value.ComplexValue(1.0, -2.0), + new Value.InstanceReference(7), + new Value.NullValue(), + new Value.Sequence(List.of(new Value.IntegerValue(1), new Value.StringValue("a"))), + new Value.VectorValue(List.of(new Value.RealValue(3.0), new Value.RealValue(4.0))), + new Value.QuantityValue( + Protos.quantity( + org.openmbee.opensysml.proto.Quantity.newBuilder() + .setIntMagnitude(3) + .setUnit("km") + .setUnitTerm(metre) + .build()))); + for (Value value : values) { + org.openmbee.opensysml.proto.Value wire = Protos.proto(value); + assertEquals(Optional.of(value), Protos.value(wire), value.toString()); + } + List wires = Protos.protos(values); + assertEquals(values.size(), wires.size()); + Map named = + Protos.protos(Map.of("limit", new Value.RealValue(50.0))); + assertEquals(50.0, named.get("limit").getRealValue()); + Quantity quantity = ((Value.QuantityValue) values.get(9)).quantity(); + assertEquals(Optional.of("km"), quantity.unit()); + assertThrows(NullPointerException.class, () -> Protos.proto((Value) null)); + } +} diff --git a/clients/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Api.java b/clients/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Api.java index 5dff4b8b32..6aff008abd 100644 --- a/clients/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Api.java +++ b/clients/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Api.java @@ -1,58 +1,121 @@ package org.openmbee.opensysml.conformance; import com.google.protobuf.Message; +import org.openmbee.opensysml.ActionRun; +import org.openmbee.opensysml.Analysis; +import org.openmbee.opensysml.AnalysisException; +import org.openmbee.opensysml.AnalysisOptions; +import org.openmbee.opensysml.Calculation; import org.openmbee.opensysml.Capabilities; import org.openmbee.opensysml.CapabilityException; +import org.openmbee.opensysml.Condition; import org.openmbee.opensysml.Connection; +import org.openmbee.opensysml.ExecutionOptions; +import org.openmbee.opensysml.Exploration; import org.openmbee.opensysml.Instantiation; import org.openmbee.opensysml.Language; import org.openmbee.opensysml.Model; import org.openmbee.opensysml.ModelException; import org.openmbee.opensysml.ParseOptions; +import org.openmbee.opensysml.Query; +import org.openmbee.opensysml.QueryElement; +import org.openmbee.opensysml.Satisfaction; import org.openmbee.opensysml.ServiceException; +import org.openmbee.opensysml.StateRun; import org.openmbee.opensysml.Symbol; +import org.openmbee.opensysml.TransportException; +import org.openmbee.opensysml.Validation; import org.openmbee.opensysml.Value; +import org.openmbee.opensysml.Verification; +import org.openmbee.opensysml.internal.Protos; +import org.openmbee.opensysml.proto.CompositeConstraint; +import org.openmbee.opensysml.proto.Constraint; import org.openmbee.opensysml.proto.DiagnosticsRequest; import org.openmbee.opensysml.proto.DiagnosticsResponse; +import org.openmbee.opensysml.proto.EvaluateCalcRequest; +import org.openmbee.opensysml.proto.EvaluateCalcResponse; import org.openmbee.opensysml.proto.EvaluateRequest; import org.openmbee.opensysml.proto.EvaluateResponse; +import org.openmbee.opensysml.proto.ExecuteActionRequest; +import org.openmbee.opensysml.proto.ExecuteActionResponse; +import org.openmbee.opensysml.proto.ExecuteStateRequest; +import org.openmbee.opensysml.proto.ExecuteStateResponse; import org.openmbee.opensysml.proto.GetSymbolRequest; import org.openmbee.opensysml.proto.InstantiateRequest; import org.openmbee.opensysml.proto.InstantiateResponse; +import org.openmbee.opensysml.proto.ListEnginesRequest; +import org.openmbee.opensysml.proto.ListEnginesResponse; import org.openmbee.opensysml.proto.ParseFileRequest; import org.openmbee.opensysml.proto.ParseFileResponse; +import org.openmbee.opensysml.proto.PrimitiveConstraint; +import org.openmbee.opensysml.proto.QueryRequest; +import org.openmbee.opensysml.proto.QueryResponse; +import org.openmbee.opensysml.proto.RunAnalysisRequest; +import org.openmbee.opensysml.proto.RunAnalysisResponse; import org.openmbee.opensysml.proto.ServerInfoRequest; import org.openmbee.opensysml.proto.ServerInfoResponse; import org.openmbee.opensysml.proto.SymbolResponse; import org.openmbee.opensysml.proto.Sysml; +import org.openmbee.opensysml.proto.ValidateInstanceRequest; +import org.openmbee.opensysml.proto.ValidateInstanceResponse; +import org.openmbee.opensysml.proto.VerifyConstraintRequest; +import org.openmbee.opensysml.proto.VerifyConstraintResponse; +import org.openmbee.opensysml.proto.VerifyRequirementRequest; +import org.openmbee.opensysml.proto.VerifyRequirementResponse; +import org.openmbee.opensysml.proto.VerifySatisfactionRequest; +import org.openmbee.opensysml.proto.VerifySatisfactionResponse; import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.TreeSet; /** * Makes a scenario's call through the client's public API and writes the answer back as the - * response message the scenario is stated against. An RPC v1 does not cover is reported as such + * response message the scenario is stated against. An RPC the API does not cover is reported as such * rather than called over the transport, which is what the skipped count in a report counts. */ final class Api { private static final String RPC_EVALUATE = "Evaluate"; + private static final String RPC_EVALUATE_CALC = "EvaluateCalc"; + private static final String RPC_EXECUTE_ACTION = "ExecuteAction"; + private static final String RPC_EXECUTE_STATE = "ExecuteState"; private static final String RPC_GET_DIAGNOSTICS = "GetDiagnostics"; private static final String RPC_GET_SERVER_INFO = "GetServerInfo"; private static final String RPC_GET_SYMBOL = "GetSymbol"; private static final String RPC_INSTANTIATE = "Instantiate"; + private static final String RPC_LIST_ENGINES = "ListEngines"; private static final String RPC_PARSE_FILE = "ParseFile"; + private static final String RPC_QUERY = "Query"; + private static final String RPC_RUN_ANALYSIS = "RunAnalysis"; + private static final String RPC_VALIDATE_INSTANCE = "ValidateInstance"; + private static final String RPC_VERIFY_CONSTRAINT = "VerifyConstraint"; + private static final String RPC_VERIFY_REQUIREMENT = "VerifyRequirement"; + private static final String RPC_VERIFY_SATISFACTION = "VerifySatisfaction"; - /** The RPCs the v1 API covers, and so the ones a scenario can be run through it. */ + /** The RPCs the public API covers, and so the ones a scenario can be run through it. */ static final Set COVERED = Set.of( RPC_GET_SERVER_INFO, + RPC_LIST_ENGINES, RPC_PARSE_FILE, RPC_GET_SYMBOL, RPC_GET_DIAGNOSTICS, RPC_EVALUATE, - RPC_INSTANTIATE); + RPC_INSTANTIATE, + RPC_EXECUTE_ACTION, + RPC_EXECUTE_STATE, + RPC_VERIFY_CONSTRAINT, + RPC_VERIFY_REQUIREMENT, + RPC_VERIFY_SATISFACTION, + RPC_VALIDATE_INSTANCE, + RPC_EVALUATE_CALC, + RPC_RUN_ANALYSIS, + RPC_QUERY); private final Connection connection; @@ -68,7 +131,7 @@ record Answered(Message response) implements Answer {} /** The call was refused with a status. */ record Refused(String status, String message) implements Answer {} - /** The v1 API cannot make this call, so the scenario is skipped. */ + /** The public API cannot make this call, so the scenario is skipped. */ record Unsupported(String reason) implements Answer {} } @@ -81,11 +144,21 @@ record Unsupported(String reason) implements Answer {} static Message.Builder request(String method) { return switch (method) { case RPC_GET_SERVER_INFO -> ServerInfoRequest.newBuilder(); + case RPC_LIST_ENGINES -> ListEnginesRequest.newBuilder(); case RPC_PARSE_FILE -> ParseFileRequest.newBuilder(); case RPC_GET_SYMBOL -> GetSymbolRequest.newBuilder(); case RPC_GET_DIAGNOSTICS -> DiagnosticsRequest.newBuilder(); case RPC_EVALUATE -> EvaluateRequest.newBuilder(); case RPC_INSTANTIATE -> InstantiateRequest.newBuilder(); + case RPC_EXECUTE_ACTION -> ExecuteActionRequest.newBuilder(); + case RPC_EXECUTE_STATE -> ExecuteStateRequest.newBuilder(); + case RPC_VERIFY_CONSTRAINT -> VerifyConstraintRequest.newBuilder(); + case RPC_VERIFY_REQUIREMENT -> VerifyRequirementRequest.newBuilder(); + case RPC_VERIFY_SATISFACTION -> VerifySatisfactionRequest.newBuilder(); + case RPC_VALIDATE_INSTANCE -> ValidateInstanceRequest.newBuilder(); + case RPC_EVALUATE_CALC -> EvaluateCalcRequest.newBuilder(); + case RPC_RUN_ANALYSIS -> RunAnalysisRequest.newBuilder(); + case RPC_QUERY -> QueryRequest.newBuilder(); default -> throw new IllegalArgumentException("no request type for " + method); }; } @@ -112,17 +185,28 @@ static boolean declared(String method) { */ Answer call(String method, Message request) { if (!COVERED.contains(method)) { - return new Answer.Unsupported("the v1 API does not cover " + method); + return new Answer.Unsupported("the public API does not cover " + method); } try { return new Answer.Answered( switch (method) { case RPC_GET_SERVER_INFO -> serverInfo(); + case RPC_LIST_ENGINES -> listEngines(); case RPC_PARSE_FILE -> parse((ParseFileRequest) request); case RPC_GET_SYMBOL -> symbol((GetSymbolRequest) request); case RPC_GET_DIAGNOSTICS -> diagnostics((DiagnosticsRequest) request); case RPC_EVALUATE -> evaluate((EvaluateRequest) request); case RPC_INSTANTIATE -> instantiate((InstantiateRequest) request); + case RPC_EXECUTE_ACTION -> executeAction((ExecuteActionRequest) request); + case RPC_EXECUTE_STATE -> executeState((ExecuteStateRequest) request); + case RPC_VERIFY_CONSTRAINT -> verifyConstraint((VerifyConstraintRequest) request); + case RPC_VERIFY_REQUIREMENT -> verifyRequirement((VerifyRequirementRequest) request); + case RPC_VERIFY_SATISFACTION -> + verifySatisfaction((VerifySatisfactionRequest) request); + case RPC_VALIDATE_INSTANCE -> validateInstance((ValidateInstanceRequest) request); + case RPC_EVALUATE_CALC -> evaluateCalc((EvaluateCalcRequest) request); + case RPC_RUN_ANALYSIS -> runAnalysis((RunAnalysisRequest) request); + case RPC_QUERY -> query((QueryRequest) request); default -> throw new IllegalStateException(method); }); } catch (Unsupported e) { @@ -136,7 +220,7 @@ Answer call(String method, Message request) { } } - /** A call the v1 API has no way to make, thrown where the request is read. */ + /** A call the public API has no way to make, thrown where the request is read. */ private static final class Unsupported extends RuntimeException { private static final long serialVersionUID = 1L; @@ -153,6 +237,12 @@ private ServerInfoResponse serverInfo() { .build(); } + private ListEnginesResponse listEngines() { + return ListEnginesResponse.newBuilder() + .addAllEngines(Rendering.engines(connection.listEngines())) + .build(); + } + private ParseFileResponse parse(ParseFileRequest request) { ParseOptions options = new ParseOptions(Language.fromWireName(request.getLanguage()), request.getStrictConformance()); @@ -162,7 +252,7 @@ private ParseFileResponse parse(ParseFileRequest request) { case CONTENT -> connection.parse(request.getContent(), options); case SOURCE_NOT_SET -> throw new Unsupported( - "the v1 API always names a source, so it cannot send a request naming none"); + "the public API always names a source, so it cannot send a request naming none"); }; ParseFileResponse.Builder response = ParseFileResponse.newBuilder() @@ -201,7 +291,7 @@ private EvaluateResponse evaluate(EvaluateRequest request) { boolean hasContext = !request.getContextSymbolId().isEmpty(); boolean hasSubject = !request.getSubjectSymbolId().isEmpty(); if (hasContext && hasSubject) { - throw new Unsupported("the v1 API evaluates in a context or against a subject, not both"); + throw new Unsupported("the public API evaluates in a context or against a subject, not both"); } try { Value value; @@ -239,4 +329,320 @@ private InstantiateResponse instantiate(InstantiateRequest request) { .build(); } } + + private ExecuteActionResponse executeAction(ExecuteActionRequest request) { + Model model = connection.model(request.getModelHash()); + ExecutionOptions options = execution(request.getSchedule(), request.getPerformerSymbolId()); + Map inputs = values(request.getInputsMap()); + try { + if (options.explores()) { + Exploration exploration = model.exploreAction(request.getActionSymbolId(), inputs, options); + return ExecuteActionResponse.newBuilder() + .addAllOutcomes(Rendering.outcomes(exploration)) + .setExploration(Rendering.exploration(exploration)) + .build(); + } + ActionRun run = model.executeAction(request.getActionSymbolId(), inputs, options); + ExecuteActionResponse.Builder response = + ExecuteActionResponse.newBuilder() + .putAllOutputs(Rendering.values(run.outputs())) + .addAllDiagnostics(Rendering.diagnostics(run.diagnostics())); + run.finalTime().ifPresent(response::setFinalTime); + return response.build(); + } catch (ModelException e) { + return ExecuteActionResponse.newBuilder() + .setError(e.getMessage()) + .addAllDiagnostics(Rendering.diagnostics(e.diagnostics())) + .build(); + } + } + + private ExecuteStateResponse executeState(ExecuteStateRequest request) { + Model model = connection.model(request.getModelHash()); + ExecutionOptions options = execution(request.getSchedule(), request.getPerformerSymbolId()); + try { + if (options.explores()) { + Exploration exploration = + model.exploreState( + request.getStateMachineSymbolId(), request.getEventsList(), options); + return ExecuteStateResponse.newBuilder() + .addAllOutcomes(Rendering.outcomes(exploration)) + .setExploration(Rendering.exploration(exploration)) + .build(); + } + StateRun run = + model.executeState(request.getStateMachineSymbolId(), request.getEventsList(), options); + ExecuteStateResponse.Builder response = + ExecuteStateResponse.newBuilder() + .addAllStatesVisited(run.statesVisited()) + .putAllFinalContext(Rendering.values(run.finalContext())) + .addAllDiagnostics(Rendering.diagnostics(run.diagnostics())); + run.finalTime().ifPresent(response::setFinalTime); + return response.build(); + } catch (ModelException e) { + return ExecuteStateResponse.newBuilder() + .setError(e.getMessage()) + .addAllDiagnostics(Rendering.diagnostics(e.diagnostics())) + .build(); + } + } + + private VerifyConstraintResponse verifyConstraint(VerifyConstraintRequest request) { + Model model = engine(connection.model(request.getModelHash()), request.getEngine()); + try { + Verification verification = + request.getSubjectSymbolId().isEmpty() + ? model.verifyConstraint(request.getSymbolId()) + : model.verifyConstraint(request.getSymbolId(), request.getSubjectSymbolId()); + return VerifyConstraintResponse.newBuilder() + .setVerdict(Rendering.verdict(verification.verdict())) + .addAllInstances(Rendering.instances(verification.instances())) + .addAllDiagnostics(Rendering.diagnostics(verification.diagnostics())) + .build(); + } catch (ModelException e) { + return VerifyConstraintResponse.newBuilder() + .setError(e.getMessage()) + .addAllDiagnostics(Rendering.diagnostics(e.diagnostics())) + .build(); + } + } + + private VerifyRequirementResponse verifyRequirement(VerifyRequirementRequest request) { + Model model = engine(connection.model(request.getModelHash()), request.getEngine()); + try { + Verification verification = + request.getSubjectSymbolId().isEmpty() + ? model.verifyRequirement(request.getSymbolId()) + : model.verifyRequirement(request.getSymbolId(), request.getSubjectSymbolId()); + return VerifyRequirementResponse.newBuilder() + .setVerdict(Rendering.verdict(verification.verdict())) + .addAllInstances(Rendering.instances(verification.instances())) + .addAllDiagnostics(Rendering.diagnostics(verification.diagnostics())) + .addAllVerificationVerdicts( + Rendering.verificationVerdicts(verification.verifications())) + .build(); + } catch (ModelException e) { + return VerifyRequirementResponse.newBuilder() + .setError(e.getMessage()) + .addAllDiagnostics(Rendering.diagnostics(e.diagnostics())) + .build(); + } + } + + private VerifySatisfactionResponse verifySatisfaction(VerifySatisfactionRequest request) { + Model model = engine(connection.model(request.getModelHash()), request.getEngine()); + try { + Satisfaction satisfaction = + request.getSymbolId().isEmpty() + ? model.verifySatisfaction() + : model.verifySatisfaction(request.getSymbolId()); + return VerifySatisfactionResponse.newBuilder() + .addAllVerdicts(Rendering.verdicts(satisfaction.verdicts())) + .addAllInstances(Rendering.instances(satisfaction.instances())) + .addAllDiagnostics(Rendering.diagnostics(satisfaction.diagnostics())) + .addAllVerificationVerdicts( + Rendering.verificationVerdicts(satisfaction.verifications())) + .build(); + } catch (ModelException e) { + return VerifySatisfactionResponse.newBuilder() + .setError(e.getMessage()) + .setFailureReason(Rendering.failureReason(e.failureReason())) + .addAllDiagnostics(Rendering.diagnostics(e.diagnostics())) + .build(); + } + } + + private ValidateInstanceResponse validateInstance(ValidateInstanceRequest request) { + Model model = engine(connection.model(request.getModelHash()), request.getEngine()); + try { + Validation validation = model.validateInstance(request.getSymbolId()); + return ValidateInstanceResponse.newBuilder() + .setSummary(Rendering.verdict(validation.summary())) + .addAllVerdicts(Rendering.verdicts(validation.verdicts())) + .addAllInstances(Rendering.instances(validation.instances())) + .addAllDiagnostics(Rendering.diagnostics(validation.diagnostics())) + .addAllVerificationVerdicts(Rendering.verificationVerdicts(validation.verifications())) + .setBounded(validation.bounded()) + .build(); + } catch (ModelException e) { + return ValidateInstanceResponse.newBuilder() + .setError(e.getMessage()) + .setFailureReason(Rendering.failureReason(e.failureReason())) + .addAllDiagnostics(Rendering.diagnostics(e.diagnostics())) + .build(); + } + } + + private EvaluateCalcResponse evaluateCalc(EvaluateCalcRequest request) { + Model model = engine(connection.model(request.getModelHash()), request.getEngine()); + try { + Calculation calculation = + model.evaluateCalc(request.getSymbolId(), values(request.getArgumentsList())); + EvaluateCalcResponse.Builder response = + EvaluateCalcResponse.newBuilder() + .addAllOutputs(Rendering.outputs(calculation.outputs())) + .addAllDiagnostics(Rendering.diagnostics(calculation.diagnostics())) + .setEngine(calculation.standing().engine()) + .setStrength(calculation.standing().strength()) + .addAllBounds(Rendering.bounds(calculation.standing())); + calculation.result().ifPresent(result -> response.setResult(Rendering.value(result))); + return response.build(); + } catch (ModelException e) { + return EvaluateCalcResponse.newBuilder() + .setError(e.getMessage()) + .setFailureReason(Rendering.failureReason(e.failureReason())) + .addAllDiagnostics(Rendering.diagnostics(e.diagnostics())) + .build(); + } + } + + private RunAnalysisResponse runAnalysis(RunAnalysisRequest request) { + Model model = engine(connection.model(request.getModelHash()), request.getEngine()); + AnalysisOptions options = + AnalysisOptions.defaults() + .withArguments(values(request.getArgumentsList())) + .withNamedArguments(values(request.getNamedArgumentsMap())); + if (!request.getSubjectSymbolId().isEmpty()) { + options = options.withSubject(request.getSubjectSymbolId()); + } + if (!request.getSchedule().isEmpty()) { + options = options.withSchedule(request.getSchedule()); + } + try { + if (options.explores()) { + Exploration exploration = model.exploreAnalysis(request.getSymbolId(), options); + return RunAnalysisResponse.newBuilder() + .addAllOutcomes(Rendering.outcomes(exploration)) + .setExploration(Rendering.exploration(exploration)) + .build(); + } + return analysis(model.runAnalysis(request.getSymbolId(), options)).build(); + } catch (AnalysisException e) { + return e.partial() + .map(Api::analysis) + .orElseGet( + () -> + RunAnalysisResponse.newBuilder() + .addAllDiagnostics(Rendering.diagnostics(e.diagnostics()))) + .setError(e.getMessage()) + .setFailureReason(Rendering.failureReason(e.failureReason())) + .build(); + } catch (ModelException e) { + return RunAnalysisResponse.newBuilder() + .setError(e.getMessage()) + .setFailureReason(Rendering.failureReason(e.failureReason())) + .addAllDiagnostics(Rendering.diagnostics(e.diagnostics())) + .build(); + } + } + + private static RunAnalysisResponse.Builder analysis(Analysis analysis) { + return RunAnalysisResponse.newBuilder() + .addAllOutputs(Rendering.outputs(analysis.outputs())) + .addAllVerdicts(Rendering.verdicts(analysis.verdicts())) + .addAllInstances(Rendering.instances(analysis.instances())) + .addAllDiagnostics(Rendering.diagnostics(analysis.diagnostics())) + .addAllVerificationVerdicts(Rendering.verificationVerdicts(analysis.verifications())) + .addAllEvaluations(Rendering.evaluations(analysis.evaluations())) + .setEngine(analysis.standing().engine()) + .setStrength(analysis.standing().strength()) + .addAllBounds(Rendering.bounds(analysis.standing())); + } + + private QueryResponse query(QueryRequest request) { + Model model = connection.model(request.getModelHash()); + if (request.hasQuery() && !request.getOslcQuery().isEmpty()) { + throw new Unsupported("the public API sends a structured query or an OSLC one, not both"); + } + List elements = + request.getOslcQuery().isEmpty() + ? model.query(query(request.getQuery())) + : model.queryOslc(request.getOslcQuery()); + return QueryResponse.newBuilder().addAllElements(Rendering.elements(elements)).build(); + } + + private static Query query(org.openmbee.opensysml.proto.Query query) { + Query built = Query.all().withScope(query.getScopeList()).withSelect(query.getSelectList()); + return query.hasWhere() ? built.where(condition(query.getWhere())) : built; + } + + private static Condition condition(Constraint constraint) { + return switch (constraint.getConstraintCase()) { + case PRIMITIVE -> { + PrimitiveConstraint primitive = constraint.getPrimitive(); + Condition.Comparison comparison = + switch (primitive.getOperator()) { + case PRIMITIVE_OPERATOR_EQUAL -> + Condition.equal(primitive.getProperty(), primitive.getValueList()); + case PRIMITIVE_OPERATOR_GREATER -> + Condition.greater(primitive.getProperty(), soleValue(primitive)); + case PRIMITIVE_OPERATOR_LESS -> + Condition.less(primitive.getProperty(), soleValue(primitive)); + case PRIMITIVE_OPERATOR_UNSPECIFIED, UNRECOGNIZED -> + throw new Unsupported( + "the public API cannot send a comparison with no operator"); + }; + yield primitive.getInverse() ? comparison.negated() : comparison; + } + case COMPOSITE -> { + CompositeConstraint composite = constraint.getComposite(); + List conditions = composite.getConstraintList().stream().map(Api::condition).toList(); + yield switch (composite.getOperator()) { + case COMPOSITE_OPERATOR_AND -> Condition.all(conditions); + case COMPOSITE_OPERATOR_OR -> Condition.any(conditions); + case COMPOSITE_OPERATOR_UNSPECIFIED, UNRECOGNIZED -> + throw new Unsupported("the public API cannot send a combination with no operator"); + }; + } + case CONSTRAINT_NOT_SET -> + throw new Unsupported("the public API cannot send an empty condition"); + }; + } + + private static String soleValue(PrimitiveConstraint primitive) { + if (primitive.getValueCount() != 1) { + throw new Unsupported("the public API compares an ordering against one value"); + } + return primitive.getValue(0); + } + + private static ExecutionOptions execution(String schedule, String performer) { + ExecutionOptions options = ExecutionOptions.defaults(); + if (!schedule.isEmpty()) { + options = options.withSchedule(schedule); + } + if (!performer.isEmpty()) { + options = options.withPerformer(performer); + } + return options; + } + + private static Model engine(Model model, String engine) { + return engine.isEmpty() ? model : model.withEngine(engine); + } + + private static List values(List values) { + List read = new ArrayList<>(values.size()); + for (org.openmbee.opensysml.proto.Value value : values) { + read.add(value(value)); + } + return read; + } + + private static Map values(Map values) { + Map read = new LinkedHashMap<>(); + values.forEach((name, value) -> read.put(name, value(value))); + return read; + } + + private static Value value(org.openmbee.opensysml.proto.Value value) { + Optional read; + try { + read = Protos.value(value); + } catch (TransportException malformed) { + throw new Unsupported("the public API cannot send a value the client would refuse to read"); + } + return read.orElseThrow( + () -> new Unsupported("the public API cannot send a value of a kind it does not know")); + } } diff --git a/clients/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Rendering.java b/clients/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Rendering.java index 96cd63056b..b06a7cf28a 100644 --- a/clients/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Rendering.java +++ b/clients/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Rendering.java @@ -1,24 +1,31 @@ package org.openmbee.opensysml.conformance; +import org.openmbee.opensysml.CaseEvaluation; import org.openmbee.opensysml.Diagnostic; -import org.openmbee.opensysml.EnumLiteral; +import org.openmbee.opensysml.EngineInfo; +import org.openmbee.opensysml.Exploration; +import org.openmbee.opensysml.FailureReason; import org.openmbee.opensysml.Instance; -import org.openmbee.opensysml.Quantity; +import org.openmbee.opensysml.Outcome; +import org.openmbee.opensysml.QueryElement; +import org.openmbee.opensysml.Standing; import org.openmbee.opensysml.Symbol; import org.openmbee.opensysml.Value; +import org.openmbee.opensysml.Verdict; +import org.openmbee.opensysml.VerificationVerdict; +import org.openmbee.opensysml.internal.Protos; import org.openmbee.opensysml.proto.AttributeInfo; +import org.openmbee.opensysml.proto.Bound; +import org.openmbee.opensysml.proto.CalcOutput; +import org.openmbee.opensysml.proto.ExplorationStatus; import org.openmbee.opensysml.proto.FeatureValue; import org.openmbee.opensysml.proto.MultiplicityInfo; +import org.openmbee.opensysml.proto.QueryResultElement; import org.openmbee.opensysml.proto.Span; import org.openmbee.opensysml.proto.Specialization; import org.openmbee.opensysml.proto.SymbolInfo; -import org.openmbee.opensysml.proto.TensorQuantity; import org.openmbee.opensysml.proto.TypeInfo; -import org.openmbee.opensysml.proto.Undetermined; -import org.openmbee.opensysml.proto.UnitFactor; -import org.openmbee.opensysml.proto.UnitTerm; -import org.openmbee.opensysml.proto.ValueSequence; -import org.openmbee.opensysml.proto.ValueSet; +import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -38,123 +45,250 @@ private Rendering() {} * @return the generated value */ static org.openmbee.opensysml.proto.Value value(Value value) { - org.openmbee.opensysml.proto.Value.Builder builder = org.openmbee.opensysml.proto.Value.newBuilder(); - if (value instanceof Value.IntegerValue integral) { - builder.setIntValue(integral.value()); - } else if (value instanceof Value.RealValue real) { - builder.setRealValue(real.value()); - } else if (value instanceof Value.ComplexValue complex) { - builder.setComplex( - org.openmbee.opensysml.proto.Complex.newBuilder() - .setReal(complex.real()) - .setImaginary(complex.imaginary())); - } else if (value instanceof Value.BooleanValue flag) { - builder.setBoolValue(flag.value()); - } else if (value instanceof Value.StringValue text) { - builder.setStringValue(text.value()); - } else if (value instanceof Value.InstanceReference reference) { - builder.setInstanceId(reference.instanceId()); - } else if (value instanceof Value.Sequence sequence) { - ValueSequence.Builder elements = ValueSequence.newBuilder(); - sequence.elements().forEach(element -> elements.addElements(value(element))); - builder.setSequence(elements); - } else if (value instanceof Value.NullValue) { - builder.setNull(""); - } else if (value instanceof Value.UnsetValue) { - builder.setUnset(true); - } else if (value instanceof Value.UndeterminedValue undetermined) { - builder.setUndetermined( - Undetermined.newBuilder() - .setReason(undetermined.reason()) - .setCount( - MultiplicityInfo.newBuilder() - .setLower(undetermined.countLower()) - .setUpper(undetermined.countUpper()))); - } else if (value instanceof Value.InfinityValue) { - builder.setInfinity(true); - } else if (value instanceof Value.QuantityValue quantity) { - builder.setQuantity(quantity(quantity.quantity())); - } else if (value instanceof Value.EnumerationValue literal) { - builder.setEnumLiteral(literal(literal.literal())); - } else if (value instanceof Value.ArrayValue array) { - org.openmbee.opensysml.proto.Array.Builder elements = - org.openmbee.opensysml.proto.Array.newBuilder().addAllDimensions(array.dimensions()); - array.elements().forEach(element -> elements.addElements(value(element))); - builder.setArray(elements); - } else if (value instanceof Value.VectorValue vector) { - org.openmbee.opensysml.proto.Vector.Builder components = - org.openmbee.opensysml.proto.Vector.newBuilder(); - vector.components().forEach(component -> components.addComponents(value(component))); - builder.setVector(components); - } else if (value instanceof Value.VectorQuantityValue vector) { - org.openmbee.opensysml.proto.VectorQuantity.Builder components = - org.openmbee.opensysml.proto.VectorQuantity.newBuilder(); - vector.components().forEach(component -> components.addComponents(quantity(component))); - builder.setVectorQuantity(components); - } else if (value instanceof Value.MeasurementRefValue ref) { - org.openmbee.opensysml.proto.MeasurementRef.Builder reference = - org.openmbee.opensysml.proto.MeasurementRef.newBuilder() - .setUnit(ref.unit()) - .setUnitTerm(unitTerm(ref.reduction())); - ref.unitId().ifPresent(reference::setUnitId); - builder.setMeasurementRef(reference); - } else if (value instanceof Value.FunctionValue function) { - builder.setFunction( - org.openmbee.opensysml.proto.Function.newBuilder() - .setCalcId(function.calcId()) - .setSelfId(function.selfId().orElse(0L))); - } else if (value instanceof Value.SetValue set) { - ValueSet.Builder elements = ValueSet.newBuilder(); - set.elements().forEach(element -> elements.addElements(value(element))); - builder.setSet(elements); - } else if (value instanceof Value.TensorQuantityValue tensor) { - TensorQuantity.Builder components = - TensorQuantity.newBuilder().addAllDimensions(tensor.dimensions()); - tensor.components().forEach(component -> components.addComponents(quantity(component))); - builder.setTensorQuantity(components); - } else if (value instanceof Value.MetaobjectValue metaobject) { - builder.setMetaobject( - org.openmbee.opensysml.proto.Metaobject.newBuilder() - .setElementId(metaobject.elementId()) - .setMetaclassId(metaobject.metaclassId())); - } else { - throw new IllegalStateException("no rendering for " + value.getClass()); - } + return Protos.proto(value); + } + + /** + * Values by name. + * + * @param values the immutable values by name + * @return the generated values by name + */ + static Map values(Map values) { + return Protos.protos(values); + } + + /** + * Named outputs, in order. + * + * @param outputs the immutable outputs by name + * @return the generated outputs + */ + static List outputs(Map outputs) { + List rendered = new ArrayList<>(outputs.size()); + outputs.forEach( + (name, value) -> + rendered.add(CalcOutput.newBuilder().setName(name).setValue(value(value)).build())); + return rendered; + } + + /** + * Instances. + * + * @param instances the immutable instances + * @return the generated instances, in order + */ + static List instances(List instances) { + return instances.stream().map(Rendering::instance).toList(); + } + + /** + * The bounds of a standing. + * + * @param standing the immutable standing + * @return the generated bounds, in order + */ + static List bounds(Standing standing) { + return standing.bounds().stream() + .map( + bound -> + Bound.newBuilder() + .setName(bound.name()) + .setLimit(bound.limit()) + .setReached(bound.reached()) + .build()) + .toList(); + } + + /** + * A verdict. + * + * @param verdict the immutable verdict + * @return the generated verdict + */ + static org.openmbee.opensysml.proto.Verdict verdict(Verdict verdict) { + org.openmbee.opensysml.proto.Verdict.Builder builder = + org.openmbee.opensysml.proto.Verdict.newBuilder() + .setKind(verdict.kind()) + .setElement(verdict.element()) + .setHolds(verdict.holds()) + .setFailureReason(failureReason(verdict.failureReason())) + .setEngine(verdict.standing().engine()) + .setStrength(verdict.standing().strength()) + .addAllBounds(bounds(verdict.standing())); + verdict.elementId().ifPresent(builder::setElementId); + verdict.condition().ifPresent(builder::setCondition); + verdict.instanceId().ifPresent(builder::setInstanceId); + verdict.instanceTypeId().ifPresent(builder::setInstanceTypeId); + verdict.error().ifPresent(builder::setError); + verdict.requirementId().ifPresent(builder::setRequirementId); + verdict.instancePath().ifPresent(builder::setInstancePath); return builder.build(); } - private static org.openmbee.opensysml.proto.Quantity quantity(Quantity quantity) { - org.openmbee.opensysml.proto.Quantity.Builder builder = org.openmbee.opensysml.proto.Quantity.newBuilder(); - if (quantity.magnitude() instanceof Long integral) { - builder.setIntMagnitude(integral); - } else { - builder.setRealMagnitude(quantity.magnitude().doubleValue()); + /** + * A failure reason. + * + * @param reason the immutable reason + * @return the generated reason + * @throws IllegalStateException for a reason the client read off the wire without knowing it, + * which has no rendering the scenario could be compared against + */ + static org.openmbee.opensysml.proto.FailureReason failureReason(FailureReason reason) { + return switch (reason) { + case UNSPECIFIED -> org.openmbee.opensysml.proto.FailureReason.FAILURE_REASON_UNSPECIFIED; + case EVALUATION -> org.openmbee.opensysml.proto.FailureReason.FAILURE_REASON_EVALUATION; + case WRONG_KIND -> org.openmbee.opensysml.proto.FailureReason.FAILURE_REASON_WRONG_KIND; + case AMBIGUOUS_SUBJECT -> + org.openmbee.opensysml.proto.FailureReason.FAILURE_REASON_AMBIGUOUS_SUBJECT; + case UNKNOWN -> throw new IllegalStateException("no rendering for an unknown failure reason"); + }; + } + + /** + * Verdicts. + * + * @param verdicts the immutable verdicts + * @return the generated verdicts, in order + */ + static List verdicts(List verdicts) { + return verdicts.stream().map(Rendering::verdict).toList(); + } + + /** + * The body verdicts of verification cases. + * + * @param verdicts the immutable verdicts + * @return the generated verdicts, in order + */ + static List verificationVerdicts( + List verdicts) { + List rendered = + new ArrayList<>(verdicts.size()); + for (VerificationVerdict verdict : verdicts) { + org.openmbee.opensysml.proto.VerificationVerdict.Builder builder = + org.openmbee.opensysml.proto.VerificationVerdict.newBuilder() + .setCaseId(verdict.caseId()) + .setKind(verdict.kind()) + .setSubcase(verdict.subcase()); + verdict.detail().ifPresent(builder::setDetail); + verdict.requirementId().ifPresent(builder::setRequirementId); + rendered.add(builder.build()); } - quantity.unit().ifPresent(builder::setUnit); - quantity.reduction().ifPresent(reduction -> builder.setUnitTerm(unitTerm(reduction))); - return builder.build(); + return rendered; } - private static UnitTerm unitTerm(Quantity.UnitTerm reduction) { - UnitTerm.Builder term = - UnitTerm.newBuilder() - .setScaleNum(reduction.scaleNumerator()) - .setScaleDen(reduction.scaleDenominator()); - for (Quantity.UnitFactor factor : reduction.factors()) { - term.addFactors( - UnitFactor.newBuilder().setUnitId(factor.unitId()).setExponent(factor.exponent())); + /** + * Case evaluations. + * + * @param evaluations the immutable evaluations + * @return the generated evaluations, in order + */ + static List evaluations( + List evaluations) { + List rendered = + new ArrayList<>(evaluations.size()); + for (CaseEvaluation evaluation : evaluations) { + org.openmbee.opensysml.proto.CaseEvaluation.Builder builder = + org.openmbee.opensysml.proto.CaseEvaluation.newBuilder() + .setFunctionId(evaluation.functionId()) + .setSelected(evaluation.selected()) + .setTied(evaluation.tied()); + evaluation.arguments().forEach(argument -> builder.addArguments(value(argument))); + evaluation.result().ifPresent(result -> builder.setResult(value(result))); + evaluation.error().ifPresent(builder::setError); + rendered.add(builder.build()); } - return term.build(); + return rendered; } - private static org.openmbee.opensysml.proto.EnumLiteral literal(EnumLiteral literal) { - return org.openmbee.opensysml.proto.EnumLiteral.newBuilder() - .setLiteralId(literal.literalId()) - .setEnumerationId(literal.enumerationId()) - .setName(literal.name()) + /** + * The outcomes of an exploration. + * + * @param exploration the immutable exploration + * @return the generated outcomes, in order + */ + static List outcomes(Exploration exploration) { + List rendered = + new ArrayList<>(exploration.outcomes().size()); + for (Outcome outcome : exploration.outcomes()) { + org.openmbee.opensysml.proto.Outcome.Builder builder = + org.openmbee.opensysml.proto.Outcome.newBuilder() + .putAllOutputs(values(outcome.outputs())) + .addAllStatesVisited(outcome.statesVisited()) + .setLinearizations(outcome.linearizations()) + .addAllWitness(outcome.witness()) + .addAllDiagnostics(diagnostics(outcome.diagnostics())); + outcome.finalState().ifPresent(builder::setFinalState); + outcome.error().ifPresent(builder::setError); + rendered.add(builder.build()); + } + return rendered; + } + + /** + * How an exploration ended. + * + * @param exploration the immutable exploration + * @return the generated status + */ + static ExplorationStatus exploration(Exploration exploration) { + return ExplorationStatus.newBuilder() + .setComplete(exploration.complete()) + .setRuns(exploration.runs()) + .addAllBudgetsHit(exploration.budgetsHit()) + .setRunsBudget(exploration.runsBudget()) + .setDepthBudget(exploration.depthBudget()) .build(); } + /** + * The elements a query selected. + * + * @param elements the immutable elements + * @return the generated elements, in order + */ + static List elements(List elements) { + return elements.stream() + .map( + element -> + QueryResultElement.newBuilder() + .setId(element.id()) + .setType(element.type()) + .putAllProperties(element.properties()) + .build()) + .toList(); + } + + /** + * Engines. + * + * @param engines the immutable descriptions + * @return the generated descriptions, in order + */ + static List engines(List engines) { + return engines.stream() + .map( + engine -> + org.openmbee.opensysml.proto.EngineInfo.newBuilder() + .setName(engine.name()) + .setAuthority(engine.authority()) + .addAllAnswers(engine.answers()) + .addAllBounds(engine.bounds()) + .setProcess(engine.process()) + .setProcessFound(engine.processFound()) + .setReady(engine.ready()) + .setUnavailable(engine.unavailable()) + .setKind(engine.kind()) + .setProtocol(engine.protocol()) + .setSource(engine.source()) + .setCommand(engine.command()) + .setVersion(engine.version()) + .setServed(engine.served()) + .build()) + .toList(); + } + /** * Diagnostics. * diff --git a/clients/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Runner.java b/clients/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Runner.java index 552ad87111..5912b02f43 100644 --- a/clients/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Runner.java +++ b/clients/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Runner.java @@ -131,7 +131,7 @@ private void execute(Scenario scenario, Report.Result result) { if (!Api.COVERED.contains(scenario.method())) { result.outcome = "skip"; result.status = "-"; - result.reason = "the v1 API does not cover " + scenario.method(); + result.reason = "the public API does not cover " + scenario.method(); return; } @@ -157,7 +157,7 @@ private void execute(Scenario scenario, Report.Result result) { if (model.isPresent() && !model.get().fixtures().isEmpty()) { result.outcome = "skip"; result.status = "-"; - result.reason = "the v1 API parses one document at a time, not a model of several"; + result.reason = "the public API parses one document at a time, not a model of several"; return; } if (model.isPresent()) { diff --git a/clients/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Scenario.java b/clients/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Scenario.java index 98bcd58929..29722d4b64 100644 --- a/clients/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Scenario.java +++ b/clients/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Scenario.java @@ -31,7 +31,7 @@ record Scenario( /** * The source a scenario needs parsed before its call: one fixture, or several parsed together - * as one model, which the v1 API's single-document {@code parse} cannot make. + * as one model, which the public API's single-document {@code parse} cannot make. */ record Fixture( String fixture, List fixtures, String language, boolean strictConformance) {} diff --git a/clients/java/opensysml-conformance/src/test/java/org/openmbee/opensysml/conformance/SuiteTest.java b/clients/java/opensysml-conformance/src/test/java/org/openmbee/opensysml/conformance/SuiteTest.java index bcce31d9b8..d8726b1ec9 100644 --- a/clients/java/opensysml-conformance/src/test/java/org/openmbee/opensysml/conformance/SuiteTest.java +++ b/clients/java/opensysml-conformance/src/test/java/org/openmbee/opensysml/conformance/SuiteTest.java @@ -54,18 +54,24 @@ void theSameScenariosPassOverJsonBodies() { } @Test - void theSkippedScenariosAreTheOnesV1DoesNotCover() { + void theSkippedScenariosAreTheOnesThePublicApiDoesNotCover() { Report.Summary summary = run(Encoding.PROTOBUF, Mutations.NONE); - List skipped = - summary.results.stream() - .filter(result -> result.outcome.equals("skip")) - .map(result -> result.rpc) - .distinct() - .toList(); - skipped.forEach(rpc -> assertTrue(Api.declared(rpc), rpc + " is not an RPC of the service")); - assertTrue( - skipped.stream().noneMatch(rpc -> Api.COVERED.contains(rpc) && rpc.equals("Evaluate")), - "Evaluate is covered and must not be skipped wholesale: " + skipped); + List skipped = + summary.results.stream().filter(result -> result.outcome.equals("skip")).toList(); + for (Report.Result result : skipped) { + assertTrue(Api.declared(result.rpc), result.rpc + " is not an RPC of the service"); + if (Api.COVERED.contains(result.rpc)) { + assertFalse( + result.reason.startsWith("the public API does not cover"), + result.id + " is skipped as uncovered, yet " + result.rpc + " is covered"); + } + } + for (String rpc : Api.COVERED) { + assertTrue( + summary.results.stream() + .anyMatch(result -> result.rpc.equals(rpc) && result.outcome.equals("pass")), + rpc + " is covered and must run at least one scenario"); + } assertEquals(summary.skipped, summary.results.stream().filter(r -> r.outcome.equals("skip")).count()); } diff --git a/docs/guide/09-clients.md b/docs/guide/09-clients.md index 3b33eb631c..344f0e640a 100644 --- a/docs/guide/09-clients.md +++ b/docs/guide/09-clients.md @@ -14,9 +14,10 @@ starts and stops on its own. | Java | `org.openmbee:opensysml-client` | Connect, over the JDK's own HTTP client | [Java API](../reference/java-api.md) | | Rust | `opensysml` | Connect, blocking, with no async runtime | [Rust API](../reference/rust-api.md) | -They do not all cover the same ground. Go and Python expose every RPC the service offers. Node, -Java and Rust cover a smaller v1 surface (parse, look up a symbol, evaluate, instantiate), and of -those three only Node has an escape hatch to the rest, through the generated Connect client it +They do not all cover the same ground. Go and Python expose every RPC the service offers. Java adds +execution, verification, calculation, analysis and query to the v1 surface; Node and Rust cover +that smaller v1 surface (parse, look up a symbol, evaluate, instantiate), and of +those two only Node has an escape hatch to the rest, through the generated Connect client it exposes. Only Python and Go are published so far. [Client libraries](../reference/clients.md) lays out what each covers and how to choose; [the troubleshooting chapter](10-troubleshooting.md) covers runs that stop short. @@ -1290,6 +1291,13 @@ try (Connection connection = Connection.open()) { // starts a private sysml Symbol vehicle = model.symbol("Demo::Vehicle"); // findSymbol returns Optional Instantiation built = model.instantiate("Demo::Vehicle"); + + ActionRun run = model.executeAction("Test::addFive"); // outputs, final time, diagnostics + Exploration every = model.exploreAction("Test::race"); // one Outcome per distinct end state + Verification light = model.verifyConstraint("Demo::Vehicle::massLight", "Demo::sedan"); + Analysis study = model.runAnalysis("Trade::lightest"); // selected alternative, evaluations + List parts = model.query( + Query.all().where(Condition.equal("@type", List.of("PartUsage")))); } ``` @@ -1301,19 +1309,23 @@ Netty out of a host that has its own. Nothing is published yet; `make build` fol Everything returned is immutable, and no protobuf message appears in the public API: `Value` is a sealed interface over records, so its variants are closed and enumerable, and `Symbol`, `Diagnostic`, -`Instance` and `Instantiation` are records with copied collections. Everything thrown is unchecked -and descends from `OpenSysMLException`, with `ServiceException` (the call was refused) kept separate -from `ModelException` (the call succeeded and the answer reports a model failure). +`Instance`, `Instantiation` and the execution, verification, analysis and query results are records +with copied collections. Everything thrown is unchecked and descends from `OpenSysMLException`, with +`ServiceException` (the call was refused) kept separate from `ModelException` (the call succeeded and +the answer reports a model failure). A verdict that is false is neither: `light.holds()` answers +`false` with `light.verdict().decided()` true, and only a verdict the service could not evaluate +carries an `error` and a `failureReason`. One private service is started per classloader, so an Eclipse plugin and a web application in one JVM each own one and share nothing, while every connection made through one copy of the client shares a child and therefore its parse cache. Call `Connection.stopSharedServices()` from a plugin's `stop()` or a `ServletContextListener`, since unloading a classloader does not by itself stop the child. -The typed surface stops short of execution (`ExecuteAction`, `ExecuteState` and `RunAnalysis` are -among what v1 does not do), so nothing here takes a `schedule` or reads `outcomes`; -`Capabilities.SCHEDULE` and `Capabilities.SCHEDULE_EXPLORE` only name the two capabilities a -service advertising scheduling policies reports. +`ExecutionOptions.defaults().withSchedule("seed:7")` fixes the order a run takes and +`exploreAction` takes an `explore` schedule and answers every `Outcome` with the `witness` order +that reaches it; `Capabilities.SCHEDULE` and `Capabilities.SCHEDULE_EXPLORE` are checked before +the call. `runAnalysis` throws an `AnalysisException` whose `partial()` keeps what a failed run +computed, so a trade study that stops on one alternative still shows the others. [The Java API reference](../reference/java-api.md) documents the surface, the exceptions and the options. diff --git a/docs/reference/clients.md b/docs/reference/clients.md index cc30357f41..f94b0a558c 100644 --- a/docs/reference/clients.md +++ b/docs/reference/clients.md @@ -33,14 +33,15 @@ The protocols and what the service serves on a single port are described in - **In a Rust program: `opensysml`.** Blocking, with no asynchronous runtime in its default dependency tree, and safe to call from inside one. -The Go and Python clients each cover every RPC the service has; the Node, Java and Rust clients -cover the v1 subset described below. +The Go and Python clients each cover every RPC the service has; the Node and Rust clients cover +the v1 subset described below, and the Java client covers that subset and the execution, +verification, calculation, analysis and query RPCs beside it. ## What the newer surfaces cover -The Node, Java and Rust clients are v1 surfaces with the same scope: connection lifecycle, +The Node and Rust clients are v1 surfaces with the same scope: connection lifecycle, capability negotiation, parsing (a file or inline source), diagnostics, symbol lookup, expression -evaluation and instantiation. The following are deliberately **not** in v1, in all three clients +evaluation and instantiation. The following are deliberately **not** in v1, in both clients rather than half-implemented in some: - the edit API (`ApplyEdits`) and generated model-ergonomics types; @@ -51,6 +52,19 @@ rather than half-implemented in some: - `Query` and OSLC query; - native document queries and rendering (`RunDocumentQuery`, `RenderDocument`). +The Java client starts from the same v1 scope and adds, as typed immutable results: + +- verification (`VerifyConstraint`, `VerifyRequirement`, `VerifySatisfaction`, `ValidateInstance`), + keeping a false verdict as an answer rather than a failure; +- `EvaluateCalc` and `RunAnalysis`, with `ListEngines` and the `engine` selection they take, and + the partial result a failed analysis leaves behind; +- behaviour execution (`ExecuteAction`, `ExecuteState`), single runs and exploration of every + schedule; +- `Query` and OSLC query. + +It leaves out the edit API, `ParseSources`, `Convert`, `RunSweep` and the document RPCs, as the +other two do; [the Java API](java-api.md#what-the-client-does-not-do) lists them. + Those RPCs exist and are served. `ApplyEdits` also edits a model of several documents, parsed together by `ParseSources`, as one atomic batch — every document the edits reach is answered in `ApplyEditsResponse.documents` under the name the parse gave it, and the sole-document `content` @@ -66,7 +80,7 @@ conformance runners skip the multi-document scenarios naming that reason. Only the Node client offers an escape hatch to them: `connection.rpc` is the generated Connect client. The Java and Rust clients ship the protobuf messages but no public call that sends one, so from those languages, reach these RPCs through the -Go or Python client until a v2 wraps them. Each client's conformance report names, per scenario, +Go or Python client until they are wrapped. Each client's conformance report names, per scenario, which of these gaps a skip belongs to, so a shrinking surface cannot pass quietly. The Go API covers all of them except the generated model-ergonomics types: it reads models through diff --git a/docs/reference/java-api.md b/docs/reference/java-api.md index a8df828110..1584f462fb 100644 --- a/docs/reference/java-api.md +++ b/docs/reference/java-api.md @@ -1,7 +1,7 @@ # The Java client API This page covers what `org.openmbee:opensysml-client` exposes, what it deliberately keeps -out of its public surface, and where its v1 stops. To choose between the clients, see +out of its public surface, and where it stops. To choose between the clients, see [client libraries](clients.md); for a task-oriented walkthrough, see [guide chapter 9](../guide/09-clients.md#from-java). The client's own notes on its dependency footprint, service ownership and release verification are in @@ -39,6 +39,7 @@ try (Connection connection = Connection.open()) { // private child serv | `parse(String)`, `parse(String, ParseOptions)` | parses inline content | | `model(String modelHash)` | adopts a model the service already holds | | `capabilities()` | what `GetServerInfo` reported, asked once at open | +| `listEngines()` | the analysis engines the service can put a question to, as `EngineInfo` | | `address()`, `ownsService()` | where this connection talks, and whether it started that service | | `close()` | idempotent; releases a private child, only disconnects from an external one | | `Connection.stopSharedServices()` | stops what this classloader still owns; returns how many | @@ -85,6 +86,123 @@ Instantiation built = model.instantiate("Demo::Vehicle"); `ParseOptions` is a record of `Language` (`SYSML` or `KERML`) and `strictConformance`, with `defaults()` and `withLanguage`/`withStrictConformance`. +### Execution + +```java +ActionRun run = model.executeAction("Test::addFive"); // declared values as inputs +run.outputs().get("result"); // every attribute, when it stopped +ActionRun seeded = model.executeAction("Test::addFive", + Map.of("result", new Value.IntegerValue(10))); +ActionRun ordered = model.executeAction("Test::race", Map.of(), + ExecutionOptions.defaults().withSchedule("declared")); // or "seed:7", "random" +StateRun machine = model.executeState("Test::Machine", List.of("go")); +machine.statesVisited(); // ["init", "Running", "done"] +machine.finalState(); // Optional: the last of them + +Exploration all = model.exploreAction("Test::race"); // every schedule +all.complete(); // false when a budget stopped it +for (Outcome outcome : all.outcomes()) { + outcome.outputs(); outcome.witness(); outcome.linearizations(); // one order that reaches it +} +``` + +`executeAction`/`executeState` run once and answer an `ActionRun` (`outputs`, +`finalTime`, `diagnostics`) or a `StateRun` (`statesVisited`, `finalContext`, +`finalTime`, `diagnostics`). `finalTime` is filled only by a service advertising +`final_time`. `ExecutionOptions` carries a `schedule` and a `performer`; the +capabilities they need (`schedule`, `performer`) are checked before the call, and a +schedule the service does not know is refused as `INVALID_ARGUMENT`. +`exploreAction`/`exploreState` take an exploration schedule (`explore`, +`explore:runs=N,depth=M`; a non-exploring schedule is an `IllegalArgumentException`) +and answer an `Exploration` of `Outcome`s, each a distinct end state with the +`witness` order that reached it and how many `linearizations` do, plus whether the +search was `complete`, how many `runs` it made and which `budgetsHit`. + +### Verification + +```java +Verification v = model.verifyConstraint("Demo::Vehicle::massLight"); // over declared values +Verification about = model.verifyConstraint("Demo::Vehicle::massLight", "Demo::sedan"); +Verification req = model.verifyRequirement("Demo::Vehicle::lightEnough"); +Satisfaction all = model.verifySatisfaction(); // every `assert satisfy` +Satisfaction scoped = model.verifySatisfaction("Demo::analysis"); +Validation checked = model.validateInstance("Demo::sedan"); // every constraint of one object + +v.holds(); // false here: mass is 1500, the constraint asks < 100 +v.verdict().decided(); // true — a false answer is an answer +v.verdict().condition(); // Optional: the condition that was false +v.verdict().error(); // Optional: why no answer could be reached, when decided() is false +v.verdict().failureReason(); // EVALUATION, WRONG_KIND, AMBIGUOUS_SUBJECT, … +``` + +A `Verdict` records `kind` (`constraint`, `requirement`, `satisfy`, …), +`elementId`/`element`, `holds`, the `condition` it evaluated, the instance it was +checked on (`instanceId`, `instanceTypeId`, `instancePath`), the `requirementId` a +satisfaction stands for, and the engine `standing` that produced it. **A false +verdict with no error is a decided answer, and is returned, not thrown**: the model +says the constraint does not hold. `decided()` is false only when `error` is filled +— the condition could not be evaluated, the symbol is of another kind, the subject +is ambiguous — and `failureReason` classifies which. `Verification` carries one +verdict; `Satisfaction` carries one per assertion, with `violated()` and +`undecided()` selecting among them; `Validation` carries a `summary` over one +object's constraints beside every `verdict`, and `bounded` says whether the check +ran out of budget. Each also carries the `instances` it materialised (to follow +`instanceId`), the `verifications` — a `VerificationVerdict` per verification-case +body that verifies the requirement, `PASS`/`FAIL`/`INCONCLUSIVE`/`ERROR` — and +`diagnostics`. A request that cannot be verified at all (no such symbol, a symbol of +another kind asked of `validateInstance`) is a `ModelException` whose +`failureReason()` says why. + +### Calculation and analysis + +```java +Calculation sum = model.evaluateCalc("Demo::add", + List.of(new Value.IntegerValue(2), new Value.IntegerValue(3))); +sum.value(); // Optional: the direct result, or the sole `out` +sum.outputs(); // every named `out`, when there are several + +Analysis study = model.runAnalysis("Trade::lightest"); +study.holds(); // every verdict the objective reached +study.outputs().get("selectedAlternative"); +study.selected(); // Optional: the alternative the objective chose +study.evaluations(); // one CaseEvaluation per alternative: arguments, result, error, selected, tied +Analysis bound = model.runAnalysis("Trade::perOffset", + AnalysisOptions.defaults() + .withNamedArguments(Map.of("offset", new Value.IntegerValue(3))) + .withSubject("Trade::sedan")); +``` + +`Calculation`, `Analysis` and every `Verdict` carry the `Standing` of the answer: +which `engine` answered (`run`, `explore`, `solve`, `sweep`), the `strength` of +its answer (`observed`, `witnessed`, `bounded`, `proved`, or `not covered`) and +the `bounds` it ran under, each `reached` or not. `model.withEngine("solve")` (or +`Standing.ENGINE_AUTO`/`ENGINE_ALL`) binds every question the model is asked to an +engine, and `connection.listEngines()` names what is available; both need the +`engines` capability. A run whose objective stops on an error throws an `AnalysisException` +carrying, in `partial()`, the outputs, verdicts, evaluations and instances the run +left behind — including the `CaseEvaluation` whose `error` stopped it — so a +caller can still show what was computed; a run that produced nothing throws the +plain `ModelException`. + +### Query + +```java +List parts = model.query( + Query.all() + .withScope(List.of("Demo::vehicle")) // beneath these elements + .withSelect(List.of("name", "owner")) // properties to read + .where(Condition.all(List.of( + Condition.equal("@type", List.of("PartUsage")), + Condition.greater("mass", "1000").negated())))); +parts.get(0).id(); parts.get(0).type(); parts.get(0).properties(); // qualified name, metaclass, selected values +List same = model.queryOslc("oslc.where=rdf:type=\"PartUsage\"&oslc.select=sysml:name"); +``` + +`Condition` is sealed over `Comparison` (`equal`, `greater`, `less`, each +`negated()`) and `Combination` (`all`, `any`), so the query is a value a caller can +build and inspect. `queryOslc` needs the `oslc_query` capability; a scope naming +no element is refused as `INVALID_ARGUMENT`. + ## Values and the rest of the domain Every answer is immutable, and no generated protobuf message or builder appears in @@ -138,7 +256,8 @@ throws nothing. | exception | what happened | | --- | --- | | `ServiceException` | the call was refused, carrying a `StatusCode` (`NOT_FOUND`, …) | -| `ModelException` | the call succeeded and the answer reports a model failure | +| `ModelException` | the call succeeded and the answer reports a model failure; `failureReason()` classifies it and `diagnostics()` carry what the service said | +| `AnalysisException` | a `ModelException` from `runAnalysis` whose `partial()` holds what the run computed before it stopped | | `TransportException` | HTTP or IO failure; the service was not reached or answered | | `CapabilityException` | the service does not advertise a capability the call needs | | `ServiceStartException` | no binary, a digest mismatch, or a child that would not start | @@ -147,7 +266,8 @@ throws nothing. The `ServiceException`/`ModelException` split is the one the conformance suite draws too: an expression that will not evaluate is a successful call carrying an -error, not a service problem. +error, not a service problem. A verdict that is *false* is neither: it is an answer, +and `verifyConstraint` returns it. ## Capability negotiation @@ -180,27 +300,29 @@ rather than trusted from the checksum served beside it. [clients/java/README.md](../../clients/java/README.md) states the trust model, its opt-out and its limitations in full. -## What v1 does not do +## What the client does not do Deliberately out of scope, rather than half-implemented: the edit API -(`ApplyEdits`), RDF conversion (`Convert`), the verification helpers -(`VerifyConstraint`, `VerifyRequirement`, `VerifySatisfaction`), behaviour -execution (`ExecuteAction`, `ExecuteState`), `EvaluateCalc`, `RunAnalysis`, `Query`/OSLC, and -generated model-ergonomics types. The service still serves all of them, but the +(`ApplyEdits`), models of several documents (`ParseSources`), RDF conversion +(`Convert`), parameter sweeps (`RunSweep`), native document queries and rendering +(`RunDocumentQuery`, `RenderDocument`), and generated model-ergonomics types. The +service still serves all of them, but the public API offers no generic call: `org.openmbee.opensysml.proto` carries the request and response messages — `ApplyEditsResponse.getDocumentsList()` carries the edited notation of every document an edit rewrote, by parse name, beside the sole-document `content` — and the transport that would send one is `org.openmbee.opensysml.internal`, which is internal and not a compatibility promise. Reach -those RPCs from the Go or Python client until a v2 wraps them here. +those RPCs from the Go or Python client until this one wraps them. ## Conformance `opensysml-conformance` runs the language-neutral scenarios **through the public API** and writes the report shape `cmd/conformance` writes; `mvn -f -clients/java/pom.xml test` is what CI runs. Of 59 scenarios, 25 run and pass over -both `connect` and `connect-json`, and 34 are skipped — the scenarios of the RPCs -v1 does not cover, plus one the public API cannot express (a `ParseFile` naming no -source). gRPC is not run at all: this client does not speak it. `-mutate` corrupts +clients/java/pom.xml test` is what CI runs. Of 134 scenarios, 94 run and pass over +both `connect` and `connect-json`, and 40 are skipped — the scenarios of the RPCs +the client does not cover, plus the requests the public API cannot express: a +`ParseFile` naming no source, a `Query` with both a structured and an OSLC query or +a comparison with no operator, and an `EvaluateCalc` argument the client's own +`Value` reader would refuse. gRPC is not run at all: this client does not speak it. `-mutate` corrupts every answer before it is compared, and a test asserts each corruption is caught, which is what keeps the run from being vacuous.