Skip to content

feat(runtime): evaluate cast expressions - #115

Merged
HuiJun merged 16 commits into
mainfrom
feature/cast-expression-evaluation
Sep 8, 2026
Merged

feat(runtime): evaluate cast expressions#115
HuiJun merged 16 commits into
mainfrom
feature/cast-expression-evaluation

Conversation

@devin-ai-integration

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

Copy link
Copy Markdown
Contributor

What and why

x as T had no runtime evaluation: eval.go listed ast.OpAs among the unimplemented operators
and reported that a cast would need a value to carry the type it was cast to. It does not — a cast
selects, so all it needs is what a value already is.

runtime/cast.go evaluates it. semantics/cast.go Model.ClassifiesTypes answers, for the types a
value is of, whether the target classifies all of them, none of them, or is narrower than them; the
runtime only dispatches on Value kinds and asks that question:

switch ec.ctx.model.ClassifiesTypes(ec.castTypes(value), target) {
case semantics.ClassifiesAll:  keep
case semantics.ClassifiesNone: drop
default:                       ec.castNarrowerKeeps(value, target) // the value's own content
}

Where the target is narrower than every type the value is of, the value itself settles it: a scalar
by its magnitude against the ScalarValues lattice (semantics.PrimTypeOfValue /
PrimConforms, reached through the new Model.ScalarLatticeElement), a quantity by whether its
unit is commensurable with the dimension the target fixes, and an object or an enumeration literal
by the types it carries — which have already answered. A collection is filtered element-wise in
order; a kept value is answered unchanged; nothing kept is the empty sequence. No conversion is
performed and no library function is touched: ToInteger and its siblings remain the functions
that convert.

7 as Real            → 7            (1, 2.5, 3) as Integer → (1, 3)
4.0 as Integer       → 4.0          (-3) as Natural        → ()
2.5 as Integer       → ()           car as Vehicle         → the same part
5 m as LengthValue   → 5 m          5 m as DurationValue   → ()

The type a value's own feature is declared with is a type it is of as well (declaredCastTypes),
which is what settles a target the value's content cannot: attribute e : Even = 4 keeps e as Even
and attribute room : RoomLength = 4 [m] keeps room as RoomLength, and a scalar-valued
enumeration keeps its literals (GradePoints::a as GradePoints). Base::Anything, which every
declaration implicitly specializes, states nothing about the values held and is left out
(semantics.IsAnything). An expression written as a value is of the evaluation type the model reads
it as (Model.ExprResultType), so { in v; v > 1 } as Performances::BooleanEvaluation is a body
still applicable afterwards while the same body as Integer is empty. A quantity type stating a
measurement reference of its own measures every value of its dimension
(Model.FixesMeasurementReference); one narrowing lengths by something else does not, so a bare
5 [m] as RoomLength is undecided while the value read from a RoomLength feature is kept.

Every type an operand's result is declared with counts, not only the first
(Model.ExprResultTypes), so a feature typed by two types is classified by both and an expression the
resolver names nothing for — a selection, an index, a chain — is classified by its operand's types. A
union classifies the values of every type it unions, however deeply nested: Model.Classifies is the
one predicate for that, applied alike by the cast, by the static cast check (which otherwise warned
that the operand's type was unrelated) and by an object's write conformance (which otherwise refused
the kept object for the feature the result is written to). A complex value arithmetic left on the real
axis is judged by the real number it holds where an ordering bound applies, so a positive sum on the
axis is a Positive and one off the axis is not.

A target that neither the value's types nor its content settles — a bare 5 against
Even :> Integer, a bare 5 [m] against RoomLength :> LengthValue — is the typed
ErrUndecidedClassification, reported rather than answered as the empty sequence, so a value that
may well be one of the target's is never dropped silently. An unresolved target is
ErrUnresolvedType.

Classifying a value is model-level evaluable: evaluableOperator reads the type as, istype and
hastype name instead of folding the operand, so a metadata body may bind x = 1 as Integer, while
an unresolved target or an operand that is not evaluable there is still refused.

A cast reads the operand's own scalar type from the library rather than through the reading scope
(castTypes, scalarLibraryType), so it decides the same way in a scope that writes
ScalarValues::Integer out in full and imports nothing — where deriving the name through the scope
alone reported that the value's type could not be determined. The classification operators keep
their scope-based derivation: moving them onto the library type changed how feature writes report a
type mismatch, which is not this change's subject.

An empty result is a result, so a feature a cast may drop everything from needs multiplicity
[0..1]: return : Integer = r as Integer reports a multiplicity violation for r = 2.5, because
a lower bound of 1 is unsatisfiable by ().

The unsupported-operator row and its test keep their subject (all, bitwise complement, OpMeta
and OpIndex reached as operators); the test's as probe is now bitwise complement.

Specification basis

KerML 1.1 §8.3.4.9 CastExpression: the result is the values of the argument that the target type
classifies, so a cast selects values and converts none. The pinned grammar
(build/pilot-grammars/KerMLExpressions.xtext, CastOperator) already gave as its parse, so no
parser change and no golden AST fixture were needed.

docs/project/spec-compliance.md gains a Cast expressions row (✅ Faithful) and the
no-runtime-evaluation row above it loses the cast. The Structured values note that said the
runtime does not evaluate as now says which part of the (that.that as SpatialItem) chain still
reports: that.that, before the cast sees a value.

One divergence is recorded rather than papered over. Judging a scalar by its magnitude is the rule
the runtime already applies to a binding (a constant is an instance of the narrowest scalar type
that holds it, so an Integer feature holds 4 / 2), and it is narrower than the direct type the
classification operators read, which is the literal's own type: n : Integer = 7 answers
n istype Natural false while n as Natural keeps 7, and r : Real = 4.0 answers
r istype Integer false while r as Integer keeps 4.0. The pinned reference settles the
classification reading (nat3 : Natural = 3 answers hastype Integer true) and draws no output
at all for either cast, so it settles nothing about the cast; aligning one reading to the other on
no evidence would move an externally refereed row, so both are documented as they stand.

How it was verified

gofmt -l .        (empty)
go build ./...    ok
go vet ./...      ok
go test ./...     ok (all packages)

Corpus gates, corpora present and required:

./scripts/download-training-examples.sh && ./scripts/download-pilot-corpora.sh
OPENSYSML_REQUIRE_TRAINING_CORPUS=1 OPENSYSML_REQUIRE_PILOT_CORPORA=1 \
  go test -count=1 ./internal/core/model -run 'TestTrainingExamples|TestPilotCorpora'
ok  github.com/Open-MBEE/OpenSysML/internal/core/model  17.193s

python3 scripts/changelog.py check, make docs-check and make docs-counts pass; no measured
figure moved.

New conformance cases under internal/core/runtime/testdata/conformance/:
calc_cast_qualified_target (fully qualified targets in a scope importing nothing),
calc_cast_scalar_values (the ScalarValues hierarchy, integral and non-integral Real,
Boolean, String), calc_cast_enumeration (a literal against its enumeration, a supertype,
Base::DataValue, and a sibling enumeration that keeps nothing), calc_cast_quantity
(ISQBase::LengthValue, Quantities::ScalarQuantityValue, an incommensurable
ISQBase::DurationValue, and a mixed length/duration sequence filtered),
calc_cast_instances (classifier, superclass, a sibling part definition that keeps nothing),
calc_cast_structured (a vector quantity kept as a vector and tensor quantity, refused by shape as
a scalar quantity, refused as a plain NumericalValue) and
calc_cast_sequence_elementwise (element-wise order, nothing kept, empty input). Robustness:
cast_to_an_unresolved_type, cast_undecided_by_the_value and
cast_of_a_quantity_to_a_constrained_subtype, all typed errors.

calc_cast_declared_types (a custom scalar subtype, its scalar supertype, a scalar-valued
enumeration and its supertype, a target of another kind that keeps nothing) and
calc_cast_expression_value (a boolean body kept as a BooleanEvaluation and applied by select,
a body kept as an Evaluation, a body dropped by a scalar target) cover the declared-type and
expression-value readings; calc_cast_quantity gains the declared quantity subtype; and
passes/w8d_metadata_usage_test.go:TestW8DMetadataClassificationValuesAreModelLevelEvaluable
covers the metadata tier both ways.

Two further points the review raised are fixed in the branch. ScalarValues::Positive shares
Natural's lattice element but not its zero, so the cast applies the strict bound on top of lattice
conformance (0 as Positive and -1 as Positive are empty, covered in calc_cast_scalar_values),
and an empty filtered result keeps the source elements' unit so a following sum still answers a
quantity (covered in calc_cast_quantity). A structured value narrower than every type it is of is
now decided by write_conformance.go's valueConforms — the same shape, unit and frame reading a
write to a feature of the target type applies — rather than reported undecidable.

Pilot execution referee

./scripts/download-pilot-evaluator.sh then go run ./cmd/pilot-exec-diff, with the seven
cast_expressions.cases added — 154 cases:

agree: 71 · kind-only: 1 · order-only: 0 · disagree: 4
pilot-unevaluated: 59 · pilot-silent: 7 · pilot-error: 2 · ours-error: 2 · both-error: 8
nondeterministic: 0

No new disagreement: relative to the previous 147 the seven cases are agree +2,
pilot-silent +3, pilot-unevaluated +2, and the four pre-existing disagree are unchanged.
Case by case:

case pilot ours bucket
integer-as-real (n as Real) LiteralInteger 7 7 agree
sequence-as-integer ((1, 2.5, 3) as Integer) LiteralInteger 1, LiteralInteger 3 [1, 3] agree
integer-as-natural no output 7 pilot-silent
fraction-as-integer (2.5 as Integer) no output [] pilot-silent
whole-as-integer (4.0 as Integer) no output 4.0 pilot-silent
car-as-vehicle PartUsage car the part pilot-unevaluated
car-as-car PartUsage car the part pilot-unevaluated

The two agreeing cases are the substance of the feature: the selection is unchanged for a value the
target classifies, and element-wise for a sequence. The three pilot-silent cases are the harness
limit documented on docs/project/pilot-execution-referee.md: the pilot prints nothing both for a
legitimately empty result and for an expression it declines, so it neither confirms nor contradicts
2.5 as Integer being empty or 4.0 as Integer keeping 4.0 — this is where the divergence noted
above is unrefereeable, and it is stated in the record rather than read as agreement. The two part
cases get the unevaluated PartUsage car back: it names the same value we select, but it is not an
evaluation of the cast, so it is not counted as agreement either.

docs/project/pilot-execution-referee.md and the referee skill record the new counts and this
adjudication.

Checklist

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

A CastExpression selects the values of its operand that the target type
classifies and converts none of them, element-wise and in order for a
collection, the empty sequence when none is classified.

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

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

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

✅ I will automatically:

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

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

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

A cast derived the operand's own type name through the reading scope, so a
cast whose target was written as a fully qualified ScalarValues name in a
scope importing none of them failed instead of deciding.

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

Copy link
Copy Markdown
Contributor Author

End-to-end verification through the sysml REPL and CLI, after the cast-only scope fix: 36 CLI checks, all matching their expected results.

Cast selection and qualified-scope verification
  • Scalars keep their representation; sequences keep order and duplicates; nothing kept is [].
  • Parts keep identity (=== holds on the selected value); enumeration and quantity casts select by conformance and by dimension.
  • Fully qualified casts in a scope importing no ScalarValues names work on both surfaces.
  • Both failure paths give a clear diagnostic and CLI exit 2, with no panic and no hang, and the REPL keeps evaluating afterwards.
  • ToInteger / ToReal still convert, independently of selection.
Scalar and sequence selection Qualified cast, no imports
Scalar selection Qualified cast

The mandatory-return probe calc def Cast { in r : Real; return : Integer = r as Integer; } keeps 4.0; for 2.5 the cast's empty result correctly violates the return's implicit lower bound of 1. Declared [0..1], it answers [].

Coverage boundary

Observable CLI and REPL behaviour, not Go error identities (those are covered by the robustness cases). -json -e does not accept this usage, so JSON rendering of a cast result is untested.

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

This comment was marked as resolved.

Positive shares Natural's lattice element, so a cast to it also checks the value is above zero; an empty cast result is built with sequenceFrom so it keeps the unit its source's elements measure in.

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

This comment was marked as resolved.

An array, vector, vector or tensor quantity, measurement reference, frame or transformation is judged against a narrower target by the shape, units and frame reading a write to a feature of that type applies, rather than being reported undecidable.

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

This comment was marked as resolved.

A declaration named Integer, Real, Boolean, String or Complex in the scope reading a value was taken as that value's type, so a cast to the ScalarValues type of the same name kept nothing. The library symbol now answers first.

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

This comment was marked as resolved.

A cast now reads the type a value's feature is declared with alongside the types
the value itself states, so a custom scalar subtype, a scalar-valued enumeration
and a constrained quantity subtype keep the values declared with them, and an
expression written as a value is kept by the evaluation type it is read as.

Classification operators are model-level evaluable, reading the type they name
rather than folding their operand, so a metadata body may bind 1 as Integer.

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

This comment was marked as resolved.

A cast reads all of an operand's declared result types, a union classifies the values of the types it unions, and a complex value on the real axis is judged by the real number it holds.

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

This comment was marked as resolved.

A composed type classifies as the types composing it do: any of a union, every
one of an intersection, the first of a difference and none of the rest. istype
asks the same relation, and the static cast check asks whether the two types may
share a value at all, so a union-typed operand cast to a member is not warned
about.

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

This comment was marked as resolved.

devin-ai-integration Bot and others added 2 commits September 8, 2026 06:21
…ialization

A value directly conforming to a difference target no longer bypasses the types the difference subtracts, reached through the target, its supertypes or an intersection.

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

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

@HuiJun
HuiJun merged commit 5f641be into main Sep 8, 2026
12 checks passed
@HuiJun
HuiJun deleted the feature/cast-expression-evaluation branch September 8, 2026 12:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant