Skip to content

fix(runtime): evaluate binary + and - with an empty second operand as the unary sign - #1538

Merged
HuiJun merged 3 commits into
developfrom
fix/operator-empty-second-operand
Oct 26, 2026
Merged

HuiJun merged 3 commits into
developfrom
fix/operator-empty-second-operand

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

What and why

A binary + or - written with an empty second operand was refused (1 + null → type mismatch: operator '+' is not defined for an Integer and null) while the function form of the same invocation evaluated ('+'(1, null) → 1, '-'(3, null) → -3). KerML 1.1 Beta 2 §8.4.4.9.6 makes the operator notation the invocation of the library function, so the two forms must agree.

  • Context.arithmeticValues (the one evaluation both the operator notation and the library's '+'/'-' forms reach) now applies the unary sign (Context.unaryValue of signOperator) when y is omitted — null, (), or a feature holding no value — for every operand but a String (StringFunctions::'+' declares both parameters [1], so "a" + null stays refused).
  • arithmeticForm (the function form) no longer carries its own unary branch; it binds an omitted y as null and calls the same arithmeticValues.
  • The vector sign (-v, v - null) moved from evalUnary into unaryValue, so the operator, the function form and the omitted-y path share it.
  • Generated code agrees: internal/translate/codegen/compile.go signOrArithmetic compiles a +/- whose right operand is a collection — a literal null or a sequence of nulls is retyped to the left operand's collection type first — to a run-time choice between the sign of x (signOf, the unary case's code) and the operation over the one value the collection holds; both operands are evaluated once (held in temporaries when impure) and spend the interpreter's steps, the elements of a null sequence included (retype carries the collection type into the elements of a SeqLit). The arithmetic case's body became arithmetic, and binaryOperands splits into hoistedOperands + the scalar checks so the +/- case can see the collection before it is held to one value.
  • compile_test.go:TestCompiledCalcErrorParity: Add(1) against in b : Integer (a reference usage, [0..*], so b is empty — the adjudication of the unbound-parameter row) now answers 1 on both tiers; the too-few refusal is asserted on Mul(1) instead, whose parameters are [1].

Behaviour before → after (Integer, Natural, Rational, Real, quantities and vectors alike):

expression before after
1 + null, '+'(1, null) OperandTypeError / 1 1 / 1
3 - null, 3 - (), '-'(3, null) OperandTypeError / — / -3 -3 / -3 / -3
1.5 - null, 2 [m] - null, v - null OperandTypeError -1.5, -2 [m], ⟨-1.0, -2.0⟩
x - y with in y : Integer[0..1] empty OperandTypeError -x
null + 1 (x is [1]) OperandTypeError unchanged (OperandTypeError; the function form '+'(null, 1) is ErrMultiplicityViolation naming x)
2 * null, 2 / (), 7 % null, 2 ** null, 1 < null, "a" + null OperandTypeError unchanged — every parameter is [1]

Specification basis

KerML 1.1 Beta 2 §8.4.4.9.6 Operator Expressions: a binary OperatorExpression expr_1 + expr_2 is the InvocationExpression DataFunctions::'+'(expr_1, expr_2); + and - are the only operators with a unary and a binary usage, and the library declares their second parameter y with multiplicity 0..1 in DataFunctions, ScalarFunctions, NumericalFunctions, NaturalFunctions, IntegerFunctions, RationalFunctions, RealFunctions, ComplexFunctions and VectorFunctions (internal/workspace/libs/stdlib); x is [1] everywhere, as are both operands of '*', '/', '%', '**', '^', the comparisons and StringFunctions::'+'. The library body of '-' with y omitted is the negation, which is what '-'(3, null) already answered; the operator form now reads the same body.

docs/project/spec-compliance.md: the binary-operators row moves ⚠️ Approximate → ✅ Faithful and states the agreed behaviour; the "every operator the library declares is callable as a function" row ('-' with y omitted) and the unbound-parameter row (F(1) against in x; in y;, now 1) are updated to match. make docs-counts reports the committed counts current.

Pilot: the pinned pilot leaves both the operator form (OperatorExpression +) and the function form unevaluated, so it referees neither side; the clause and the library's multiplicities decide.

Pilot-exec referee movement

New tools/referee/exec/testdata/cases/empty_second_operand.cases (model empty_second_operand.sysml), ten cases, every one pilot-unevaluated with our answer recorded beside the unevaluated expression — no existing case moved:

case ours pilot
op-plus-null 1 + null 1 unevaluated
fn-plus-null '+'(1, null) 1 unevaluated
op-minus-null 3 - null -3 unevaluated
fn-minus-null '-'(3, null) -3 unevaluated
op-minus-empty 3 - () -3 unevaluated
op-real-minus-null 1.5 - null -1.5 unevaluated
op-null-plus null + 1 OperandTypeError unevaluated
fn-null-plus '+'(null, 1) ErrMultiplicityViolation (parameter x) unevaluated
op-times-null 2 * null OperandTypeError unevaluated
op-less-null 1 < null OperandTypeError unevaluated

Bucket counts (go run -C tools ./cmd/pilot-exec-diff, pilot artifact 0.62.0): 500 → 510 cases, pilot-unevaluated 157 → 167, every other bucket unchanged (agree 228 · kind-only 1 · order-only 0 · disagree 37 · differs-by-design 5 · pilot-silent 24 · pilot-error 4 · ours-error 10 · ours-undetermined 17 · both-error 17 · nondeterministic 0). docs/project/pilot-execution-referee.md and the referee skill's reference line record the new denominator and bucket.

How it was verified

  • Conformance calc_operator_empty_second_operand (.sysml + .expected.json): both forms side by side over Integer, Natural, Rational and Real, a [0..1] input parameter holding no value, and the refusals (null + 1, '+'(null, 1), 2 * null, 2 / null, 7 % null, 2 ** null, 1 < null).
  • Robustness robustness_operator_empty_operand_test.go (TestRuntimeRobustnessOperatorEmptyOperand): empty_second_operand_is_the_unary_sign (operator form equals function form for null, (), an empty feature, an emptied sequence, a Real, a quantity), empty_required_operand_is_refused (typed OperandTypeError for every [1] operand, ErrMultiplicityViolation for the function form).
  • tests/codegen TestCompiledCalcsAgreeWithInterpreter and TestCompiledStepBudgetMatchesInterpreter (Go and C): new fixture calcs Seq::SubS2 (3 - x), SubR2 (1 - x over Real[0..*]), AddNull (x + null), AddNulls (x + (null, null)), SubNull (x - null), MulS2 (2 * x, refused when empty) beside the existing AddS2 (1 + x), each over null, a one-element and a two-element collection — same value or same error text, and the same step budget, as the interpreter.
  • No golden execution trace: nothing here is ordering-sensitive. No parser golden: the grammar is untouched (1 + null already parsed).
  • By hand on the rebuilt bin/sysml: the reproducer answers 1, 1, -3, -3; v - null over VectorFunctions::VectorOf((1.0, 2.0)) is ⟨-1.0, -2.0⟩, 2 [m] - null is -2 [m], open + null over an unbound attribute open : Integer; stays <undetermined>, "a" + null stays refused.
  • Dry-merges before each push, in detached worktrees: against origin/develop (ab1a9cf9a at the first two pushes, e5f9c3fca at the third) and against chore/pilot-pin-2026-09 (PR chore(pilot): pin the OMG pilot implementation to release 2026-09 (0.63.0) #1529; 8a100b31, then 9ca600a59): every merge clean, no conflicts; on both merged trees TestDocumentedCaseCount, TestShippedCaseFilesResolve, TestExecutionConformance, TestRuntimeRobustnessOperatorEmptyOperand, TestCompiledCalcErrorParity, TestOperatorFunction*, the loop-allocation footprint tests, internal/translate/codegen, tools/referee/diff and tools/census/doccounts pass.

Gate (corpora fetched: training examples, pilot corpora, spec examples, pilot library XMI, PSSM suite; OPENSYSML_REQUIRE_*=1 for each):

go build ./...                      ok
go vet ./...                        ok
gofmt -l .                          (empty)
go test ./...                       ok (122 packages, 0 FAIL; -timeout 40m) at the second commit; the third commit re-ran
                                    ./tests/codegen ./internal/translate/codegen ./internal/exec/runtime (the packages it touches): ok
python3 scripts/changelog.py check  ok
mkdocs build --strict               ok (Documentation built in 29.79 seconds)
make docs-counts                    ✓ Documentation counts and refereed figures are current

The third commit answers the review: a null-typed right operand (x + (null, null)) no longer drops the sequence's element steps — it takes the same run-time path as a collection operand (TestCompiledStepBudgetMatchesInterpreter now covers Seq::AddNulls), and the new code comments are within two lines.

The first push failed the runtime CI shard on the loop-allocation footprint tests (arithmeticValues tested emptiness through elementsOf, which allocates for a scalar); the second commit tests through isEmptyValue, which does not, and compiles the generated-code path.

Not verified: the Reference-FMUs gate (tests/fmi, needs fmpy) skipped; the referee was run with the pinned 0.62.0 evaluator, not the 0.63.0 artifact PR #1529 moves to.

Checklist

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

Link to Devin session: https://nasa-jpl-demo.devinenterprise.com/sessions/693818cb0e46445da1c4d5d3211107ec
Open in Devin Desktop: https://nasa-jpl-demo.devinenterprise.com/desktop/session/693818cb0e46445da1c4d5d3211107ec?variant=devin
Requested by: @HuiJun

… the unary sign

KerML 1.1 §8.4.4.9.6 makes a binary OperatorExpression the invocation of the
library's '+'/'-', whose second parameter y is [0..1]: 1 + null is '+'(1, null)
and answers 1, 3 - null is '-'(3, null) and answers -3. Both forms now reach
the same arithmeticValues, which applies the unary sign when y is omitted; the
first parameter and every operand of *, /, %, **, ^ and the comparisons stay
[1] and refused when empty.

Co-Authored-By: jason.han <hanhuijun@gmail.com>
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".

  • Disable automatic comment, CI, and merge conflict monitoring

…ry sign

The interpreter now evaluates a binary + or - whose second operand is empty as
the unary sign (KerML 1.1 §8.4.4.9.6, y is [0..1]); generated Go and C code
decides the same at run time over a collection operand, evaluating it once and
spending the interpreter's steps, and compiles a literal null operand to the
sign directly. The arithmeticValues emptiness test no longer allocates per
evaluation.

Co-Authored-By: jason.han <hanhuijun@gmail.com>
@devin-ai-integration
devin-ai-integration Bot marked this pull request as ready for review October 26, 2026 16:58
devin-ai-integration[bot]

This comment was marked as resolved.

…taking the sign

A literal null or a sequence of nulls on the right of + or - is now retyped to
the left operand's collection type and takes the same run-time path as any
collection operand, so its elements' steps are spent as the interpreter spends
them; retype carries the collection type into the elements of a null
sequence, and the left operand is held in a temporary as the shared operand
path requires. Code comments shortened to the repository's two-line limit.

Co-Authored-By: jason.han <hanhuijun@gmail.com>
@HuiJun
HuiJun merged commit 3e8c733 into develop Oct 26, 2026
30 checks passed
@HuiJun
HuiJun deleted the fix/operator-empty-second-operand branch October 26, 2026 20:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant