From 31b38ac4471fcccd62b033cdebe1222773a728d4 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 00:33:13 +0000 Subject: [PATCH 01/22] fix(semantics): specialize collection body result types A collection operation's static type follows what its declaration hands through rather than the element type of the collection: collect and the `xs.{...}` notation are typed by the body's result, select/reject/selectOne keep the elements, reduce follows its reducer and forAll/exists stay Boolean, each with the multiplicity the Kernel Function Library declares. A body whose result cannot be typed keeps the library's Anything. The checker selects an invocation's result parameter under the chains being typed, so a body reading the feature it values terminates as a self-referential argument does. Co-Authored-By: jason.han --- .../collection-body-result-type.fixed.md | 1 + docs/project/spec-compliance.md | 11 +- .../core/passes/typecheck_dimension_test.go | 13 ++ .../core/passes/typecheck_trigger_test.go | 13 +- internal/core/passes/typecheck_value.go | 5 +- internal/core/passes/w7g_one_type_test.go | 12 +- internal/core/semantics/collection.go | 146 ++++++++++++ internal/core/semantics/collection_test.go | 219 ++++++++++++++++++ .../core/semantics/operator_conformance.go | 10 +- internal/core/semantics/valuetype.go | 41 +++- 10 files changed, 449 insertions(+), 22 deletions(-) create mode 100644 changes/unreleased/collection-body-result-type.fixed.md create mode 100644 internal/core/semantics/collection.go create mode 100644 internal/core/semantics/collection_test.go diff --git a/changes/unreleased/collection-body-result-type.fixed.md b/changes/unreleased/collection-body-result-type.fixed.md new file mode 100644 index 000000000..531c4c574 --- /dev/null +++ b/changes/unreleased/collection-body-result-type.fixed.md @@ -0,0 +1 @@ +- **A collection operation's static type follows what its declaration hands through, not the element type of the collection.** `xs->collect { in x : C; x.mass }` and `xs.{ in x : C; x.mass }` are typed by the body's result (`MassValue`), a nested collect by its innermost body, a body answering a sequence by every element type and `xs->collect f` by the named function's result; `select`, `reject` and `selectOne` keep the elements of `xs`, `reduce` follows its reducer's result and `forAll`/`exists` stay `Boolean`, each with the multiplicity the Kernel Function Library declares. A body whose result cannot be typed keeps the library's `Anything`. Value conformance, trigger arguments and enumerated values are judged by the specialized type, so `accept when counts.{in n : Integer; n}` is refused where it was silent, and `when counts.{in n; n > 3}` is accepted where it was refused. A body reading the feature it values terminates as a self-referential argument does. diff --git a/docs/project/spec-compliance.md b/docs/project/spec-compliance.md index 6eb920c68..dd7f9e104 100644 --- a/docs/project/spec-compliance.md +++ b/docs/project/spec-compliance.md @@ -1376,10 +1376,12 @@ they cannot drift apart. | A failing expression of literals alone is answered at the prompt with the failure itself, so `sysml -e "(1,2,3)#(0)"` reports the index rather than "no declarations loaded" | `repl/meta.go` `tryEvalLiteral`, `isLiteralAnswerError` | `repl/runtime_commands_test.go` `TestEvalReportsTheAnswerOfALiteralExpressionThatFails` | ✅ Faithful | | A name the session declares is answered by that declaration, so the prompt's literal pass declines an expression using one rather than letting a library operation of the same unqualified name stand in for it | `repl/meta.go` `tryEvalLiteral`, `declaresANameIn` | `repl/runtime_commands_test.go` `TestEvalPrefersASessionDeclarationOverALibraryOperation` | ✅ Faithful | -⚠️ A body parameter takes its type from the element type of whatever the operand -turns out to hold, which the expression checker does not track: an expression -over the parameter (`xs.?{in e; e + 1}`) therefore has no static type, and its -selector is checked at evaluation rather than where it is written. Statically the +⚠️ An untyped body parameter takes its type from the element type of whatever the +operand turns out to hold, which the expression checker does not track: an +expression over the parameter (`xs.?{in e; e + 1}`) therefore has no static type, +and its selector is checked at evaluation rather than where it is written; a body +over a typed parameter (`xs.{in e : Real; e + 1}`) types the collect by its result +(the collection-operation row under *Static Expression Type Checking*). Statically the checker reports what it can know — a selector whose result type *is* known and is not Boolean, an index that is no whole number, a literal index of 0 or past a sequence written out (`passes/typecheck_expr.go` `inferIndex`, `inferSelect`) — and the @@ -1460,6 +1462,7 @@ known, so unmodelled types never produce a false positive. | Each argument of an invocation corresponds to one `in` parameter of the invoked type (KerML 1.1 §8.3.4.8 `validateInvocationExpressionParameterRedefinition`, pilot `Must correspond to one input parameter of the invoked type`): a named argument naming an `out` or `return` parameter, a feature that is no parameter, or a general's parameter the invoked function's own redefines by position; and a positional argument past the last `in` parameter, in a function, a `calc def`, a step or a feature typed by a behavior, with inherited and redefined parameters counted through `effectiveInParameters` | `passes/typecheck_expr.go` `checkArguments`, `checkNamedArguments` (`msgInvocationParameterRedefinition`) | `passes/duplicate_binding_test.go` `TestInvocationParameterRedefinitionKerML`; census probe `validateInvocationExpressionParameterRedefinition.kerml`; corpus `semantic/k32-too-many-arguments.kerml` | ✅ Faithful; the pilot's wording heads ours, followed by the cause (`…: F has no parameter named "y"`, `…: F takes 1 argument(s), found 2`) at the offending argument. A required parameter left unbound (`F()`) draws OpenSysML's own `unbound-parameter` advisory (warning), a check the pilot does not make | | A constructor expression binds each feature of the instantiated type at most once (KerML 1.1 §8.3.4.8 `validateConstructorExpressionNoDuplicateFeatureRedefinition`, pilot `Feature already bound`): `new C(x = 1, x = 2)`, an alias or a qualified name of the same feature, and an inherited feature bound under its own and its redefining name all count; reported at the second binding, `x of C is already bound by an earlier argument`, and the same rule reaches a constructor nested in a send payload (under a conditional, a sequence, a feature chain, a call argument or a body expression) or in a feature chain's operand | `passes/typecheck_expr.go` `inferConstructor` (the constructor row below), `inferFeatureChain` | `passes/duplicate_binding_test.go` (`TestConstructorDuplicateFeatureBindingKerML`, `TestConstructorDuplicateFeatureBindingSysML`); corpus `semantic/k34-constructor-feature-bound-twice.kerml`; census probe `validateConstructorExpressionNoDuplicateFeatureRedefinition.kerml` | ✅ Faithful (a call's named argument is not indexed as a member reference of the called type, so editor rename and find-references do not reach `f(F::x = …)` — an editor follow-up; constructor labels are) | | Invocation overload selection: a name visible as several function/calc declarations (owned, inherited, imported or re-exported — a library function only where the model imports its package) selects the one whose effective input parameters take the arguments — each argument, by position or name, to a distinct parameter it conforms to; a candidate the arguments bind every default-less parameter of is preferred to one they leave a parameter of, which stays applicable and draws the `unbound-parameter` advisory, so `pick("a")` against `pick(in x : Integer)` and `pick(in s : String; in y : Real)` binds to the second rather than reporting a mismatch against the first, `pick(2)` against `pick(in x : Real; in y : Real)` and `pick(in x : Integer)` runs the complete one, and `pick()` against two candidates it fits alike is `invocation-ambiguous` — and by type conformance (the `ScalarValues` lattice, strings, booleans, collections, quantities and declared types through `Model.Conforms` — a non-literal argument's type only bounds its values, so a broader declared type fits a narrower parameter, while declared types neither of which conforms to the other, `MassValue` to a `VolumeValue` parameter, never bind, as the pinned validator judges a binding; a `Collections` parameter takes any sequence, as the runtime reads it, and a `KerML::Root::Element` parameter the element any argument names, as `DocumentQueries` bind it, ranked between a declared type and `Anything`); among several fits the most specific wins, a tie or incomparable pair is reported as `invocation-ambiguous` naming the tied candidates (`InvocationSelection.Tied`, the applicable ones none is more specific than, so a broader overload the arguments also fit is not among them), and no fit keeps the argument diagnostic against the first candidate, naming the others considered. The selection is memoized in a side table keyed by the invocation node and scope, the runtime dispatches the declaration selected there, and a named argument written twice is reported (`binds parameter twice` for a calc call, `ErrDuplicateArgument` for a nested action call) and dispatched by none. A feature typed by a behavior is a candidate performing that behavior, so `ref pick : Twice;` is selected beside a same-named calc when only its signature fits. An expression evaluates to a calc's result, so a call in expression context whose name denotes a calc selects among the calcs alone, whatever the import order and however closely a same-named action's inputs fit — the arguments are then checked and dispatched against the calc, and the action is never evaluated — while `action call = tag(3);` and `perform tag(3);` select among actions only. A bodiless calc specializing a library function (`calc def Renamed :> sqrt { in y :>> x; }`, or a feature typed by one) is computed by that function through its own effective signature: the runtime binds the written arguments to the specialization's parameters as any calc's are bound — renamed, defaulted, optional; each default evaluated where it is declared with the earlier parameters in scope (`in y :>> y = x + 1.0;`), each value checked against the effective type and multiplicity the specialization states (`in n : Integer :>> x;`, `[1]`) before the library computes it — and lays them out in the library's order (`runtime/invoke_calc.go` `libraryCalcPerformed`, `effectiveParameter`, `invokeLibraryPerformance`), for an expression, a `send` and the `InvokeCalc`/`InvokeCalcNamed` API alike; a calc with a body of its own is never redirected. A nested action call with an explicit empty argument list (`action call = tag();`) binds nothing — a required input is unbound, a defaulted one takes its default — where only the bare `perform tag;` and `action call : Tag;` forms read the caller's same-named values | `resolve/invocation.go` `Resolver.InvocationCandidates` (all matches of the winning visibility category), `semantics/invocation.go` `Model.SelectInvocation`, `passes/typecheck_expr.go` `inferInvocation`, `passes/invocation.go` `argument`, `runtime/eval.go` `invocationTarget` and `runtime/invoke_action.go` `resolveActionSymbol` for a nested `action call = A(...)` or `perform A(...)`, whose receiver `x->A()` is the first argument (`ErrAmbiguousInvocation`, `ErrReceiverWithNamedArgs`), `model/workspace.go` `calledDeclaration` and `Workspace.AmbiguousInvocationInDoc` for editor navigation (a call selecting one overload navigates to it; a tied call lists every tied overload under go-to-definition and hover, and is left out of find-references and rename, which refuses to start from it), `semantics/invocation.go` `Model.SelectCall` (the checker's argument typing installed by `passes.NewArgumentTyper`) for `queryplan/compiler.go` `compileInvocation` and `columns.go` `compileColumn` (a tie is `ErrorAmbiguousInvocation`) and `runtime/signal.go` `invokesCalc` (a send names a signal unless the call selects a calc) | `passes/invocation_test.go` (`TestInvocationOverloadRedeclaredLibraryInputs`, `TestInvocationOverloadOmittedInputSelectsTheFittingCandidate`, `TestInvocationOverloadCompleteCandidateBeatsOmission`, `TestInvocationOverloadOmittedInputsAreAmbiguous`), `runtime/invocation_selection_test.go` (`calc_call_binds_a_library_function_through_redeclared_inputs`, `calc_call_selects_a_calc_over_a_more_specific_action`, `calc_call_omitting_an_input_runs_the_fitting_candidate`, `calc_call_omitting_every_input_is_ambiguous`), `queryplan/overload_test.go`, `runtime/signal_test.go:TestActionSendCallsACalcSharingASignalsName`, `lsp/invocation_navigation_test.go`, `lsp/ambiguous_invocation_navigation_test.go`, conformance `calc_library_overload_by_argument_type`, `calc_library_overload_partial_import`, `calc_library_overload_ambiguous`, `calc_library_function_shadowed_by_model`, `action_node_invocation_overload_inherited_input` (the winning action's matching input is inherited, and the runtime frame binds it), `lsp/library_invocation_test.go:TestPublishDiagnosticsSelectsLibraryOverloadByArgumentType`, pilot-exec-diff `w6d:complex-abs`, `:complex-is-zero` | ⚠️ Approximate: a model's own `calc` of the name still shadows every library declaration, and an argument whose type is statically unknown (an unresolved parameter type, an untyped feature) keeps today's first-visible selection with no new diagnostic — the corpus gates hold at zero movement. The pinned pilot evaluates none of the probes (`ToInteger`, `abs`, `isZero` over their overload sets come back as the unevaluated `InvocationExpression`), so the most-specific rule is self-assessed against KerML 1.1 §8.3.4.8 (a decimal literal is a `LiteralRational`, so `ToInteger(7.9)` selects `RationalFunctions::ToInteger` over `RealFunctions`'). A parameter's `[m..n]` multiplicity decides only whether a call may omit it: one admitting none, or one a default reaches along its redefinitions (`Model.OptionalParameter`, `Model.ParameterDefault`), is optional to the checker and to `runtime/action_frame.go` `checkInputsBound` alike, and the omitted input is read as empty or as the inherited default (`passes/invocation_test.go:TestInvocationPerformedActionOptionalInputs`, `runtime/invocation_selection_test.go:action_call_omits_optional_inputs`). Not done: a value's element count is not checked against the multiplicity | +| A collection operation's static result is typed by the argument the library's declaration hands through, not by the `Anything[0..*]` it declares (KerML 1.1 §8.3.4.8 `checkSelectExpressionResultSpecialization`: a select's result subsets the collection's; §9.2 Kernel Function Library `ControlFunctions.kerml`, `collect`'s result "the collection of results" of `mapper`, `select`/`reject`/`selectOne` elements of `collection`, `reduce` the `reducer`'s result, `forAll`/`exists` `Boolean[1]`): `xs->collect {in x : C; x.mass}` and `xs.{in x : C; x.mass}` are typed `MassValue`, `xs->collect {in x : C; x.name}` `String`, a nested collect by the innermost body, a body answering a sequence `(x.mass, x.name)` by every element type, `xs->collect Mass` by the named function's result, `xs->select {…}`/`->reject {…}`/`->selectOne {…}` as `xs` is, `->reduce '+'` by the reducer's result and `->forAll`/`->exists` `Boolean`; the operation is the resolved library declaration, in the receiver, plain and named-argument notations alike, and a body whose result cannot be typed — its parameter declares no type — leaves the library's `Anything`, never a guess. The multiplicity stays the declaration's (`collect`/`select`/`reject`/`reduce` `[0..*]`, `selectOne` `[0..1]`, `forAll`/`exists` `[1]`). A body reading the feature it values (`total = cs->collect {in x : C; total}->reduce '+'`) terminates under the same guard as an argument that does | `semantics/collection.go` `Model.collectionResultTypes`, `collectResultTypes`, `argumentTo`, `appliedResultTypes`, `bodyResultTypes`, `elementResultTypes`, reached from `semantics/valuetype.go` `Model.ExprResultType`, `exprConformance`, `invocationConformance` and `semantics/operator_conformance.go` `resultTypes`; `semantics/invocation.go` `SelectCall`/`callArguments` (`typingArgs`); `passes/typecheck_value.go` `exprChecker.invocationResultParameter` (selects under the chains being typed) | `semantics/collection_test.go` `TestCollectionResultTypes`, `TestCollectionResultMultiplicity`, `TestCollectionNestedAndSequenceBodies`, `TestCollectionArgumentNotations`, `TestCollectionUntypedBodyFallsBackToLibraryResult`, `TestCollectionSelfReferentialBodyTerminates`, `TestCollectionResultConformance`; `passes/typecheck_dimension_test.go:TestRecursiveRollupThroughACall`, `:TestRecursiveRollupThroughACollectBody`; `passes/typecheck_trigger_test.go:TestCollectAndSelectTriggerArguments`; `passes/w7g_one_type_test.go:TestW7GASelectedEnumeratedValueKeepsItsOperandType`; grammar `PrimaryExpression` (`'->' … BodyExpression`, `'.' BodyExpression`, `'.?' BodyExpression`) | ✅ Faithful for `select`/`reject`/`selectOne`/`forAll`/`exists` (the pinned pilot subsets a select's result from its collection and types the rest by the declared result); ⚠️ Approximate for `collect`/`reduce` (self-assessed: the specification declares their result `Anything[0..*]` and the pilot types it so, giving `xs.{in x : C; x.mass}` no static type; typing it by the body is stricter than the reference, so a diagnostic it raises — a `when` trigger over a collect of Integers, an enumerated value collecting Reals — the pilot stays silent on) | | An unqualified call to a Kernel Function Library function the model does not import is `unresolved` for the checker and the runtime alike: the library packages are members of the root namespace but not implicitly imported, so a bare `sqrt(4.0)` with no `import RealFunctions::*;` draws the `unresolved` diagnostic offering the imports that would resolve it and fails to evaluate with the same text, `RealFunctions::sqrt(4.0)` resolves anywhere, and under the import the call checks clean, is selected among the imported overloads and evaluates; the OpenSysML extension functions (`exp`, `ln`, `log`, `atan2`) are gated by the same rule | `resolve/invocation.go` `ResolveInvocationName` (the name resolves as any other), `runtime/eval.go` `invocationTarget` (dispatch by the declaration resolved, `passes.SelectInvocation` choosing among several) | `passes/invocation_test.go:TestInvocationUnimportedLibraryFunction`, `:TestInvocationQualifiedLibraryFunctionArgumentsChecked`, `:TestInvocationOverloadOnlyImportedPackagesContribute`, `runtime/library_functions_test.go:TestLibraryFunctionUnqualifiedNames`, `:TestLibraryFunctionQualifiedCallNeedsNoImport`, conformance `calc_library_function_unimported`, `calc_library_function_imported`, `lsp/library_invocation_test.go:TestPublishDiagnosticsAgreesWithRuntimeOnLibraryCall`, `:TestPublishDiagnosticsKeepsExtensionFunctionImportGated`, `lsp/invocation_navigation_test.go:TestDefinitionReachesImportedLibraryFunction` | ✅ Faithful. Observed against the pinned pilot: it reports `Couldn't resolve reference to Element 'sqrt'` for the unimported call and resolves the qualified one | | No false positives on the shipped library and examples | corpus guard | `model/typecheck_expr_corpus_test.go` | ✅ Faithful | | `[` is not an index in KerML (pilot `validateOperatorExpressionBracketOperator`, `Use #(...) for indexing`; the specification gives `[` no KerML function — `BaseFunctions::'['` is abstract): every bracket in a `.kerml` document warns at the operator expression, a `.sysml` document is judged by the quantity rule instead | `passes/typecheck_expr.go` `exprChecker.checkBracket`; code `bracket-operator` | `typecheck_operator_test.go:TestBracketOperatorInKerML`, `TestBracketOperatorIsKerMLOnly`; census probe `validateOperatorExpressionBracketOperator.kerml` | ✅ Faithful (warning, as the pilot) | diff --git a/internal/core/passes/typecheck_dimension_test.go b/internal/core/passes/typecheck_dimension_test.go index a317f8685..e0c41b5d1 100644 --- a/internal/core/passes/typecheck_dimension_test.go +++ b/internal/core/passes/typecheck_dimension_test.go @@ -128,6 +128,19 @@ func TestRecursiveRollupThroughACall(t *testing.T) { }`) } +// TestRecursiveRollupThroughACollectBody: the rollup written with a collect whose +// body names the feature being typed — its result type is the body's — terminates too. +func TestRecursiveRollupThroughACollectBody(t *testing.T) { + wantNoDimensionDiags(t, `private import NumericalFunctions::*; + private import ControlFunctions::*; + part def MassedComponent { + part subcomponents : MassedComponent [*] default null; + attribute mass :> ISQ::mass; + attribute totalMass :> ISQ::mass = mass + sum(subcomponents->collect { in c : MassedComponent; c.totalMass }); + attribute heaviest :> ISQ::mass = subcomponents->collect { in c : MassedComponent; c.heaviest }->reduce '+'; + }`) +} + // TestBoundMeasurementUnit: a unit binds to the unit definition typing it, to any // measurement-reference supertype, and to no quantity value type; the checker // judges each as the runtime's write conformance does. A quantity bound to a diff --git a/internal/core/passes/typecheck_trigger_test.go b/internal/core/passes/typecheck_trigger_test.go index 5f9277d9b..fd6ee459a 100644 --- a/internal/core/passes/typecheck_trigger_test.go +++ b/internal/core/passes/typecheck_trigger_test.go @@ -499,17 +499,19 @@ func TestIndexedTriggerArgument(t *testing.T) { } } -// `xs.{…}` is the Anything-typed result of collect; `xs.?{…}` keeps elements of -// xs and is typed as xs is. The pilot rejects each shape below and accepts the -// select shapes kept silent. +// `xs.{…}` is the result of collect, typed as its body's result is — Anything where +// the body returns an untyped parameter; `xs.?{…}` keeps elements of xs and is +// typed as xs is. The pilot leaves every collect Anything, so it rejects the +// collect shapes kept silent too, and accepts the select shapes kept silent. func TestCollectAndSelectTriggerArguments(t *testing.T) { const collected = "found a collection `.{…}` maps to, typed Anything" for _, tc := range []struct{ trigger, code, found string }{ - {"when counts.{in n; n > 3}", "trigger-when-boolean", collected}, + {"when counts.{in n : Integer; n}", "trigger-when-boolean", "found Integer"}, {"when flags.{in f; f}", "trigger-when-boolean", collected}, {"when counts.?{in n; n > 3}", "trigger-when-boolean", "found Integer"}, {"when holder.ok.{in f; f}", "trigger-when-boolean", collected}, {"after waits.{in w; w}", "trigger-after-duration", collected}, + {"after counts.{in n : Integer; n}", "trigger-after-duration", "found Integer"}, {"after counts.?{in n; n > 3}", "trigger-after-duration", "found Integer"}, {"after times.?{in i; true}", "trigger-after-duration", "found TimeInstantValue"}, {"at counts.?{in n; true}", "trigger-at-time-instant", "found Integer"}, @@ -518,6 +520,9 @@ func TestCollectAndSelectTriggerArguments(t *testing.T) { wantTriggerDiag(t, "transition first a accept "+tc.trigger+" then b;", tc.code, tc.found) } for _, trigger := range []string{ + "when counts.{in n; n > 3}", + "when flags.{in f : Boolean; f}", + "after waits.{in w : DurationValue; w}", "when flags.?{in f; f}", "when flags.?{in f; f}#(1)", "after waits.?{in w; w > 1 [s]}", diff --git a/internal/core/passes/typecheck_value.go b/internal/core/passes/typecheck_value.go index 5bd2852be..1f9396c24 100644 --- a/internal/core/passes/typecheck_value.go +++ b/internal/core/passes/typecheck_value.go @@ -334,7 +334,10 @@ func (ec *exprChecker) invocationResultParameter(scope *symbols.Scope, value ast if chain := ChainCallee(inv); chain != nil { sym, _ = ec.resolver.ResolveTarget(scope, chain) } else if inv.Type != nil { - sym = SelectInvocation(ec.resolver, ec.model, scope, inv, ec.performs(inv)).Selected + // The arguments type silently, but under the chains being typed: one whose + // body reads the feature being valued would otherwise type it again. + silent := exprChecker{resolver: ec.resolver, model: ec.model, lang: ec.lang, chaining: ec.chaining, performed: ec.performed} + sym = silent.selectInvocation(scope, inv, silent.argumentTypes(scope, inv), ec.performs(inv)).Selected } if sym == nil || !ec.isInvocationBehavior(sym, map[*symbols.Symbol]bool{}) { return nil diff --git a/internal/core/passes/w7g_one_type_test.go b/internal/core/passes/w7g_one_type_test.go index 4d30621d6..03fee9513 100644 --- a/internal/core/passes/w7g_one_type_test.go +++ b/internal/core/passes/w7g_one_type_test.go @@ -239,7 +239,9 @@ func TestW7GACastAndBodyEnumeratedValuesAreTyped(t *testing.T) { // TestW7GASelectedEnumeratedValueKeepsItsOperandType: `xs.?{…}` keeps the // elements of xs, so it types the value as xs is typed — through a chain, an // alias, a feature typed only by its own value or subsetting/redefining one, a -// nested selection or an indexed element — while `.{…}` and `,` yield Anything (pilot 2026-07 agrees on every case). +// nested selection or an indexed element — and `xs.{…}` is typed as its body's +// result is, Anything where that is untyped, while `,` yields Anything (pilot +// 2026-07 agrees on every case but the typed collect bodies, which it leaves Anything). func TestW7GASelectedEnumeratedValueKeepsItsOperandType(t *testing.T) { const src = `package T { private import ScalarValues::*; @@ -270,13 +272,16 @@ func TestW7GASelectedEnumeratedValueKeepsItsOperandType(t *testing.T) { l = zz.?{in r : Real; r > 1.0}; m = zz; n = w2.qq.?{in r : Real; r > 1.0}; + q = xs.{in r : Real; r}; + r = others.{in o : Other; o}; } enum def WrongNum :> Real { o = ns.?{in n : Natural; n > 1}; p = others.?{in o : Other; true}; + s = ns.{in n : Natural; n}; } enum def Right { - ok1 = xs.{in r : Real; r}; + ok1 = xs.{in r; r}; ok2 = Right::ok1.?{in l : Right; true}; } enum def RightNum :> Real { @@ -294,6 +299,7 @@ func TestW7GASelectedEnumeratedValueKeepsItsOperandType(t *testing.T) { ok14 = zz.?{in r : Real; r > 1.0}; ok15 = zz; ok16 = w2.qq.?{in r : Real; r > 1.0}; + ok17 = xs.{in r : Real; r}; } }` var got []string @@ -303,7 +309,7 @@ func TestW7GASelectedEnumeratedValueKeepsItsOperandType(t *testing.T) { } got = append(got, strings.Fields(src[d.Span.Offset:d.Span.End()])[0]) } - want := []string{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p"} + want := []string{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "q", "r", "o", "p", "s"} if strings.Join(got, " ") != strings.Join(want, " ") { t.Fatalf("selected enumerated values typed outside their enumeration: got %v, want %v", got, want) } diff --git a/internal/core/semantics/collection.go b/internal/core/semantics/collection.go new file mode 100644 index 000000000..af77adfa8 --- /dev/null +++ b/internal/core/semantics/collection.go @@ -0,0 +1,146 @@ +package semantics + +import ( + "strings" + + "github.com/Open-MBEE/OpenSysML/internal/core/ast" + "github.com/Open-MBEE/OpenSysML/internal/core/symbols" +) + +// Kernel Function Library collection functions whose result the arguments specialize. +const ( + fqnCollect = "ControlFunctions::collect" + fqnSelect = "ControlFunctions::select" + fqnReject = "ControlFunctions::reject" + fqnSelectOne = "ControlFunctions::selectOne" + fqnReduce = "ControlFunctions::reduce" +) + +// collectionResultTypes is the types a call of a ControlFunctions collection function results in: +// collect and reduce those of the body applied, select/reject/selectOne those of the collection. +func (m *Model) collectionResultTypes(scope *symbols.Scope, e *ast.InvocationExpr, fn *symbols.Symbol) []*symbols.Symbol { + if fn == nil || e == nil { + return nil + } + var types []*symbols.Symbol + switch fn { + case m.libSymbol(fqnCollect), m.libSymbol(fqnReduce): + types = m.appliedResultTypes(scope, m.argumentTo(scope, e, fn, 1)) + case m.libSymbol(fqnSelect), m.libSymbol(fqnReject), m.libSymbol(fqnSelectOne): + if collection := m.argumentTo(scope, e, fn, 0); collection != nil { + types = m.resultTypes(scope, collection) + } + } + return informativeTypes(types) +} + +// collectResultTypes is the types `xs.{ in x; … }` results in: its body's; empty when unknown. +func (m *Model) collectResultTypes(scope *symbols.Scope, e *ast.CollectExpr) []*symbols.Symbol { + body, ok := e.Body.(*ast.BodyExpr) + if !ok { + return nil + } + return informativeTypes(m.bodyResultTypes(scope, body)) +} + +// argumentTo is the expression e passes to the i-th input parameter of fn: the receiver of +// `xs->fn(…)` binds the first, then positional arguments in order, then named ones by name. +func (m *Model) argumentTo(scope *symbols.Scope, e *ast.InvocationExpr, fn *symbols.Symbol, i int) ast.Node { + positional := make([]ast.Node, 0, len(e.Args)+1) + if e.Operand != nil { + positional = append(positional, e.Operand) + } + positional = append(positional, e.Args...) + if i < len(positional) { + return positional[i] + } + if len(e.NamedArgs) == 0 { + return nil + } + sig := m.signatureOf(fn) + for _, arg := range e.NamedArgs { + if arg.Name != nil && m.parameterIndex(scope, sig, arg.Name) == i { + return arg.Value + } + } + return nil +} + +// appliedResultTypes is the types the expression applied to each element results in: a body +// `{ in x; … }` by its result, a function or expression named by its result parameter. +func (m *Model) appliedResultTypes(scope *symbols.Scope, applied ast.Node) []*symbols.Symbol { + switch n := applied.(type) { + case nil: + return nil + case *ast.BodyExpr: + return m.bodyResultTypes(scope, n) + case *ast.FeatureReference, *ast.QualifiedName, *ast.FeatureChainExpr: + sym, ok := m.resolver.ResolveTarget(scope, n) + if !ok || sym == nil { + return nil + } + if alias, ok := m.resolver.ResolveAliasTarget(sym); ok { + sym = alias + } + if result := m.ResultParameterOf(sym); result != nil { + return m.featureResultTypes(result) + } + } + return nil +} + +// bodyResultTypes is the types the result of a body `{ in x; … }` is declared with, resolved +// among its parameters; a sequence result contributes each element, so every element type. +func (m *Model) bodyResultTypes(scope *symbols.Scope, body *ast.BodyExpr) []*symbols.Symbol { + if body == nil || body.Result == nil || m.resolver == nil { + return nil + } + return m.elementResultTypes(symbols.BodyExprScope(scope, body), body.Result) +} + +// elementResultTypes is resultTypes over the elements a value contributes: those of a sequence +// `(a, b)`, nested ones flattened, else the expression itself; empty when any is unknown. +func (m *Model) elementResultTypes(scope *symbols.Scope, node ast.Node) []*symbols.Symbol { + seq, ok := node.(*ast.SequenceExpr) + if !ok { + return m.resultTypes(scope, node) + } + var out []*symbols.Symbol + for _, element := range seq.Elements { + types := m.elementResultTypes(scope, element) + if len(types) == 0 { + return nil + } + for _, typ := range types { + if !containsElement(out, typ) { + out = append(out, typ) + } + } + } + return out +} + +// informativeTypes is types unless every one is Anything, which says nothing; then nil. +func informativeTypes(types []*symbols.Symbol) []*symbols.Symbol { + for _, typ := range types { + if !isAnything(typ) { + return types + } + } + return nil +} + +// typesConformance judges a value typed by every one of types: it conforms when one does. +func (m *Model) typesConformance(types []*symbols.Symbol, want *symbols.Symbol) Conformance { + if len(types) == 0 { + return conformanceUnknown() + } + names := make([]string, 0, len(types)) + for _, typ := range types { + if m.Conforms(typ, want) { + return Conformance{Known: true, Holds: true} + } + names = append(names, leafName(typ.Name)) + } + return Conformance{Known: true, Found: strings.Join(names, " and ")} +} diff --git a/internal/core/semantics/collection_test.go b/internal/core/semantics/collection_test.go new file mode 100644 index 000000000..18c52dfcd --- /dev/null +++ b/internal/core/semantics/collection_test.go @@ -0,0 +1,219 @@ +package semantics + +import ( + "slices" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/core/ast" + "github.com/Open-MBEE/OpenSysML/internal/core/symbols" +) + +// collectionModel builds a model of parts C with a mass and a name, held by S as +// cs [*], where the collection expressions under test are S's attribute values. +func collectionModel(t *testing.T, attributes string) (*Model, *symbols.Scope) { + t.Helper() + m, root := stdlibModelWithDoc(t, "collection.sysml", `package P { + private import ScalarValues::*; + private import ISQ::*; + private import SI::*; + private import ControlFunctions::*; + private import ScalarFunctions::'+'; + part def C { attribute mass :> ISQ::mass; attribute name : String; } + part def S { + part cs : C[*]; + `+attributes+` + } + }`) + return m, sym(t, sym(t, root, "P").Scope, "S").Scope +} + +// valueOf is the value expression of the attribute named in scope. +func valueOf(t *testing.T, scope *symbols.Scope, name string) ast.Node { + t.Helper() + u, ok := sym(t, scope, name).Decl.(*ast.Usage) + if !ok || u.Value == nil { + t.Fatalf("%s: not a valued usage", name) + } + return u.Value +} + +// typeNamesOf is the leaf names of ExprResultTypes of the attribute's value, and +// checks ExprResultType agrees with its first. +func typeNamesOf(t *testing.T, m *Model, scope *symbols.Scope, name string) []string { + t.Helper() + value := valueOf(t, scope, name) + types := m.ExprResultTypes(scope, value) + first := m.ExprResultType(scope, value) + if len(types) == 0 { + if first != nil { + t.Fatalf("%s: ExprResultType = %s, ExprResultTypes none", name, first.Name) + } + return nil + } + if first != types[0] { + t.Fatalf("%s: ExprResultType = %v, ExprResultTypes start with %v", name, first, types[0]) + } + names := make([]string, 0, len(types)) + for _, typ := range types { + names = append(names, leafName(typ.Name)) + } + return names +} + +func wantValueTypes(t *testing.T, m *Model, scope *symbols.Scope, name string, want ...string) { + t.Helper() + if got := typeNamesOf(t, m, scope, name); !slices.Equal(got, want) { + t.Errorf("%s: result types %v, want %v", name, got, want) + } +} + +// wantResultRange checks the multiplicity of the result parameter of the function +// the attribute's value calls, which the specialized type does not narrow. +func wantResultRange(t *testing.T, m *Model, scope *symbols.Scope, name string, lower, upper int64, unbounded bool) { + t.Helper() + call, ok := valueOf(t, scope, name).(*ast.InvocationExpr) + if !ok { + t.Fatalf("%s: not an invocation", name) + } + r, ok := m.MultiplicityOf(m.invocationResult(scope, call)) + if !ok { + t.Fatalf("%s: result parameter declares no multiplicity", name) + } + if !r.Lower.Known || r.Lower.Value != lower || !r.Upper.Known || r.Upper.Infinite != unbounded || (!unbounded && r.Upper.Value != upper) { + t.Errorf("%s: result multiplicity %+v, want [%d..%d] unbounded=%v", name, r, lower, upper, unbounded) + } +} + +// collect results in what its body returns, in either notation, whatever the +// element type; select, reject and selectOne keep the elements of the collection. +func TestCollectionResultTypes(t *testing.T) { + m, s := collectionModel(t, ` + attribute masses = cs->collect { in x : C; x.mass }; + attribute masses2 = cs.{ in x : C; x.mass }; + attribute names = cs->collect { in x : C; x.name }; + attribute heavy = cs->select { in x : C; x.mass > 1 [kg] }; + attribute heavy2 = cs.?{ in x : C; x.mass > 1 [kg] }; + attribute light = cs->reject { in x : C; x.mass > 1 [kg] }; + attribute one = cs->selectOne { in x : C; x.mass > 1 [kg] }; + attribute all = cs->forAll { in x : C; x.mass > 1 [kg] }; + attribute some = cs->exists { in x : C; x.mass > 1 [kg] }; + attribute total = cs->collect { in x : C; x.mass }->reduce '+'; + attribute total2 = cs->collect { in x : C; x.mass }->reduce { in a : MassValue; in b : MassValue; a }; + attribute plain = cs.mass;`) + wantValueTypes(t, m, s, "masses", "MassValue") + wantValueTypes(t, m, s, "masses2", "MassValue") + wantValueTypes(t, m, s, "names", "String") + wantValueTypes(t, m, s, "heavy", "C") + wantValueTypes(t, m, s, "heavy2", "C") + wantValueTypes(t, m, s, "light", "C") + wantValueTypes(t, m, s, "one", "C") + wantValueTypes(t, m, s, "all", "Boolean") + wantValueTypes(t, m, s, "some", "Boolean") + wantValueTypes(t, m, s, "total", "ScalarValue") + wantValueTypes(t, m, s, "total2", "MassValue") + wantValueTypes(t, m, s, "plain", "MassValue") +} + +// The result multiplicity is the one the library declares for each function: a +// body returning one value per element still collects to `[0..*]`. +func TestCollectionResultMultiplicity(t *testing.T) { + m, s := collectionModel(t, ` + attribute masses = cs->collect { in x : C; x.mass }; + attribute heavy = cs->select { in x : C; x.mass > 1 [kg] }; + attribute light = cs->reject { in x : C; x.mass > 1 [kg] }; + attribute one = cs->selectOne { in x : C; x.mass > 1 [kg] }; + attribute all = cs->forAll { in x : C; x.mass > 1 [kg] }; + attribute some = cs->exists { in x : C; x.mass > 1 [kg] }; + attribute total = cs->collect { in x : C; x.mass }->reduce '+';`) + wantResultRange(t, m, s, "masses", 0, 0, true) + wantResultRange(t, m, s, "heavy", 0, 0, true) + wantResultRange(t, m, s, "light", 0, 0, true) + wantResultRange(t, m, s, "one", 0, 1, false) + wantResultRange(t, m, s, "all", 1, 1, false) + wantResultRange(t, m, s, "some", 1, 1, false) + wantResultRange(t, m, s, "total", 0, 0, true) +} + +// A nested collect types the outer body by the inner result; a body returning a +// sequence contributes each element, so the result is typed by every element type. +func TestCollectionNestedAndSequenceBodies(t *testing.T) { + m, s := collectionModel(t, ` + attribute nested = cs->collect { in x : C; cs->collect { in y : C; y.name } }; + attribute nested2 = cs.{ in x : C; cs.{ in y : C; y.mass } }; + attribute pairs = cs->collect { in x : C; (x.mass, x.mass) }; + attribute mixed = cs->collect { in x : C; (x.mass, (x.name, x.mass)) }; + attribute chained = cs->collect { in x : C; x.name }->select { in n : String; n == "a" };`) + wantValueTypes(t, m, s, "nested", "String") + wantValueTypes(t, m, s, "nested2", "MassValue") + wantValueTypes(t, m, s, "pairs", "MassValue") + wantValueTypes(t, m, s, "mixed", "MassValue", "String") + wantValueTypes(t, m, s, "chained", "String") +} + +// The arguments bind by the library's parameters in the prefix and named notations too. +func TestCollectionArgumentNotations(t *testing.T) { + m, s := collectionModel(t, ` + attribute masses = collect(cs, { in x : C; x.mass }); + attribute named = collect(mapper = { in x : C; x.name }, collection = cs); + attribute heavy = select(collection = cs, selector = { in x : C; x.mass > 1 [kg] }); + attribute fromFunction = cs->collect Mass; + calc def Mass { in c : C; return : MassValue = c.mass; }`) + wantValueTypes(t, m, s, "masses", "MassValue") + wantValueTypes(t, m, s, "named", "String") + wantValueTypes(t, m, s, "heavy", "C") + wantValueTypes(t, m, s, "fromFunction", "MassValue") +} + +// A body whose result the model cannot type — its parameter declares no type, so +// the member is unresolved — leaves the result the library's Anything, not a guess. +func TestCollectionUntypedBodyFallsBackToLibraryResult(t *testing.T) { + m, s := collectionModel(t, ` + attribute unknown = cs->collect { in x; x.mass }; + attribute unknown2 = cs.{ in x; x.mass }; + attribute body = cs->collect { in x : C; { in y; y } };`) + wantValueTypes(t, m, s, "unknown", "Anything") + wantValueTypes(t, m, s, "unknown2", "Anything") + wantValueTypes(t, m, s, "body", "Evaluation") +} + +// A body that names the feature it values leads the typer back to itself; typing terminates. +func TestCollectionSelfReferentialBodyTerminates(t *testing.T) { + m, s := collectionModel(t, ` + attribute total :> ISQ::mass = cs->collect { in x : C; total }->reduce '+'; + attribute loop = cs->collect { in x : C; loop }; + attribute loop2 = cs.{ in x : C; loop2 }; + attribute kept = cs->select { in x : C; kept == x };`) + wantValueTypes(t, m, s, "total", "ScalarValue") + wantValueTypes(t, m, s, "loop", "Anything") + wantValueTypes(t, m, s, "loop2", "Anything") + wantValueTypes(t, m, s, "kept", "C") +} + +// Conformance judges the specialized result: a collect of masses is a MassValue and +// no String; a select keeps C, no ScalarValue; an untyped body stays untyped. +func TestCollectionResultConformance(t *testing.T) { + m, s := collectionModel(t, ` + attribute masses = cs->collect { in x : C; x.mass }; + attribute masses2 = cs.{ in x : C; x.mass }; + attribute heavy = cs->select { in x : C; x.mass > 1 [kg] }; + attribute unknown = cs.{ in x; x.mass };`) + for _, name := range []string{"masses", "masses2"} { + c := m.ExprConformsToLibrary(s, valueOf(t, s, name), "ISQ::MassValue") + if !c.Known || !c.Holds { + t.Errorf("%s as MassValue: %+v, want it to hold", name, c) + } + c = m.ExprConformsToLibrary(s, valueOf(t, s, name), fqnString) + if !c.Known || c.Holds || c.Untyped || c.Found != "MassValue" { + t.Errorf("%s as String: %+v, want known, not holding, found MassValue", name, c) + } + } + c := m.ExprConformsToLibrary(s, valueOf(t, s, "heavy"), "ScalarValues::ScalarValue") + if !c.Known || c.Holds || c.Found != "C" { + t.Errorf("heavy as ScalarValue: %+v, want known, not holding, found C", c) + } + c = m.ExprConformsToLibrary(s, valueOf(t, s, "unknown"), fqnString) + if !c.Known || c.Holds || !c.Untyped || !strings.Contains(c.Found, "Anything") { + t.Errorf("unknown as String: %+v, want untyped Anything", c) + } +} diff --git a/internal/core/semantics/operator_conformance.go b/internal/core/semantics/operator_conformance.go index 7b8fe6aa7..3256dc03b 100644 --- a/internal/core/semantics/operator_conformance.go +++ b/internal/core/semantics/operator_conformance.go @@ -56,10 +56,18 @@ func (m *Model) resultTypes(scope *symbols.Scope, node ast.Node) []*symbols.Symb } return m.featureResultTypes(sym) case *ast.InvocationExpr: - if result := m.invocationResult(scope, n); result != nil { + called := m.invocationCallee(scope, n) + if types := m.collectionResultTypes(scope, n, called); len(types) > 0 { + return types + } + if result := m.ResultParameterOf(called); result != nil { return m.featureResultTypes(result) } return nil + case *ast.CollectExpr: + if types := m.collectResultTypes(scope, n); len(types) > 0 { + return types + } case *ast.IndexExpr: if !n.Bracket { return m.indexResultTypes(scope, n) diff --git a/internal/core/semantics/valuetype.go b/internal/core/semantics/valuetype.go index f278d66d3..057ec4f92 100644 --- a/internal/core/semantics/valuetype.go +++ b/internal/core/semantics/valuetype.go @@ -142,7 +142,10 @@ func (m *Model) exprConformance(scope *symbols.Scope, node ast.Node, want *symbo } return c case *ast.CollectExpr: - // `xs.{…}` is the result of ControlFunctions::collect, typed Anything. + // `xs.{…}` is the result of ControlFunctions::collect: the body's results, else Anything. + if types := m.collectResultTypes(scope, n); len(types) > 0 { + return m.typesConformance(types, want) + } c := m.typeConformance(m.libSymbol(fqnAnything), want) if c.Known && !c.Holds { c.Found = "a collection `.{…}` maps to, typed Anything" @@ -218,14 +221,24 @@ func (m *Model) ExprResultType(scope *symbols.Scope, node ast.Node) *symbols.Sym case *ast.SelectExpr: // `xs.?{…}` keeps elements of xs (KerML checkSelectExpressionResultSpecialization). return m.ExprResultType(scope, n.Operand) - case *ast.NullExpr, *ast.SequenceExpr, *ast.CollectExpr: + case *ast.CollectExpr: + // `xs.{…}` is the result of ControlFunctions::collect: the body's results, else Anything. + if types := m.collectResultTypes(scope, n); len(types) > 0 { + return types[0] + } + return m.libSymbol(fqnAnything) + case *ast.NullExpr, *ast.SequenceExpr: return m.libSymbol(fqnAnything) case *ast.BodyExpr: return m.bodyExprType(scope, n) case *ast.ConstructorExpr: return m.namedType(scope, n.Type) case *ast.InvocationExpr: - if result := m.invocationResult(scope, n); result != nil { + called := m.invocationCallee(scope, n) + if types := m.collectionResultTypes(scope, n, called); len(types) > 0 { + return types[0] + } + if result := m.ResultParameterOf(called); result != nil { return m.featureResultType(result) } } @@ -956,22 +969,32 @@ func (m *Model) incommensurableSum(scope *symbols.Scope, node ast.Node) (*ast.Op // invocationConformance judges an invocation's value by the declared type of the // result parameter of the overload it calls. func (m *Model) invocationConformance(scope *symbols.Scope, e *ast.InvocationExpr, want *symbols.Symbol) Conformance { - result := m.invocationResult(scope, e) + called := m.invocationCallee(scope, e) + if types := m.collectionResultTypes(scope, e, called); len(types) > 0 { + return m.typesConformance(types, want) + } + result := m.ResultParameterOf(called) if result == nil { return conformanceUnknown() } return m.featureConformance(result, want) } -// invocationResult is the result parameter of the declaration e calls, as the -// checker selects it; nil when the call is unresolved, ambiguous or fits none. -func (m *Model) invocationResult(scope *symbols.Scope, e *ast.InvocationExpr) *symbols.Symbol { +// invocationCallee is the declaration e calls, as the checker selects it; nil +// when the call is unresolved, ambiguous or fits none. +func (m *Model) invocationCallee(scope *symbols.Scope, e *ast.InvocationExpr) *symbols.Symbol { if e.Type == nil || m.resolver == nil { return nil } sym, ok := m.calledFunction(scope, e) - if !ok || sym == nil { + if !ok { return nil } - return m.ResultParameterOf(sym) + return sym +} + +// invocationResult is the result parameter of the declaration e calls; nil when +// the call is unresolved, ambiguous or fits none. +func (m *Model) invocationResult(scope *symbols.Scope, e *ast.InvocationExpr) *symbols.Symbol { + return m.ResultParameterOf(m.invocationCallee(scope, e)) } From 4ee2ccf08cac1d397fc3f9c97eae625e7fc3eb17 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 01:19:21 +0000 Subject: [PATCH 02/22] fix(semantics): judge every element of a collection body's sequence result A body producing a sequence `(true, 1)` conforms only when every element does, and stays unknown while no element fails and one is untyped; casts of such bodies are sound when one element and the target are related. Declared result types keep their existential reading. Co-Authored-By: jason.han --- .../core/passes/typecheck_operator_test.go | 14 + .../core/passes/typecheck_trigger_test.go | 7 + internal/core/semantics/collection.go | 298 +++++++++++++++--- internal/core/semantics/collection_test.go | 40 ++- .../core/semantics/operator_conformance.go | 14 +- internal/core/semantics/valuetype.go | 12 +- 6 files changed, 322 insertions(+), 63 deletions(-) diff --git a/internal/core/passes/typecheck_operator_test.go b/internal/core/passes/typecheck_operator_test.go index 7caff3857..de1655cac 100644 --- a/internal/core/passes/typecheck_operator_test.go +++ b/internal/core/passes/typecheck_operator_test.go @@ -151,6 +151,20 @@ func TestCastConformanceUnrelatedTypes(t *testing.T) { "16:17 cast argument is typed by A, unrelated to the target C") } +// A collection body's result types the cast argument element by element: a body +// mapping to a String selects no C; one mapping each element to a Boolean and an +// Integer selects no String, while it does select the Integers; an element the +// body leaves untyped may be anything, so nothing is known. +func TestCastConformanceCollectionBody(t *testing.T) { + castDiags(t, "", `feature bad = xs.{in x : A; s} as C;`, "16:16 cast argument is typed by String, unrelated to the target C") + castDiags(t, "", `feature bad = xs.{in x : A; (true, 1)} as String;`, "16:16 cast argument is typed by Boolean and Integer, unrelated to the target String") + castDiags(t, "", `feature bad = xs.{in x : A; (x, 1)} as String;`, "16:16 cast argument is typed by A and Integer, unrelated to the target String") + castDiags(t, "", `feature bad = xs->ControlFunctions::collect {in x : A; (true, 1)} as String;`, "16:16 cast argument is typed by Boolean and Integer, unrelated to the target String") + castDiags(t, "", `feature some = xs.{in x : A; (true, 1)} as Integer; feature other = xs.{in x : A; (x, 1)} as B;`) + castDiags(t, "", `feature open = xs.{in x; (x, 1)} as String; feature open2 = xs.{in x; (untyped, 1)} as String;`) + castDiags(t, "", `feature kept = xs->ControlFunctions::select {in x : A; true} as B;`) +} + // A cast up, down, or sideways through one of several types conforms; so does // one whose argument's type is not statically known, or is Anything, and one // between a composed type and a type it is composed of. diff --git a/internal/core/passes/typecheck_trigger_test.go b/internal/core/passes/typecheck_trigger_test.go index fd6ee459a..55f27d09b 100644 --- a/internal/core/passes/typecheck_trigger_test.go +++ b/internal/core/passes/typecheck_trigger_test.go @@ -516,12 +516,19 @@ func TestCollectAndSelectTriggerArguments(t *testing.T) { {"after times.?{in i; true}", "trigger-after-duration", "found TimeInstantValue"}, {"at counts.?{in n; true}", "trigger-at-time-instant", "found Integer"}, {"at times.{in i; i}", "trigger-at-time-instant", collected}, + {"when flags.{in f : Boolean; (f, 1)}", "trigger-when-boolean", "found Natural"}, + {"when flags.{in f; (f, 1)}", "trigger-when-boolean", "found Natural"}, + {"when flags.{in f : Boolean; (f, (1, label))}", "trigger-when-boolean", "found Natural and String"}, + {"when flags.{in f; (f, label)}", "trigger-when-boolean", "found String"}, + {"when counts.{in n : Integer; (n > 3, untyped)}", "trigger-when-boolean", collected}, } { wantTriggerDiag(t, "transition first a accept "+tc.trigger+" then b;", tc.code, tc.found) } for _, trigger := range []string{ "when counts.{in n; n > 3}", "when flags.{in f : Boolean; f}", + "when flags.{in f : Boolean; (f, true)}", + "when flags.{in f; (f > 3, true)}", "after waits.{in w : DurationValue; w}", "when flags.?{in f; f}", "when flags.?{in f; f}#(1)", diff --git a/internal/core/semantics/collection.go b/internal/core/semantics/collection.go index af77adfa8..639822277 100644 --- a/internal/core/semantics/collection.go +++ b/internal/core/semantics/collection.go @@ -16,31 +16,77 @@ const ( fqnReduce = "ControlFunctions::reduce" ) -// collectionResultTypes is the types a call of a ControlFunctions collection function results in: -// collect and reduce those of the body applied, select/reject/selectOne those of the collection. -func (m *Model) collectionResultTypes(scope *symbols.Scope, e *ast.InvocationExpr, fn *symbols.Symbol) []*symbols.Symbol { +// collectionSource is what the elements of a collection function's result are: an +// expression in its scope — the result of the body applied, or the collection kept — +// or the result parameter of the function applied by name. +type collectionSource struct { + scope *symbols.Scope + node ast.Node + result *symbols.Symbol +} + +// invocationSource is the source of a call of a ControlFunctions collection function: collect +// and reduce the body or function applied, select/reject/selectOne the collection; not ok +// where the arguments say nothing and the declaration alone decides. +func (m *Model) invocationSource(scope *symbols.Scope, e *ast.InvocationExpr, fn *symbols.Symbol) (collectionSource, bool) { if fn == nil || e == nil { - return nil + return collectionSource{}, false } - var types []*symbols.Symbol switch fn { case m.libSymbol(fqnCollect), m.libSymbol(fqnReduce): - types = m.appliedResultTypes(scope, m.argumentTo(scope, e, fn, 1)) + return m.appliedSource(scope, m.argumentTo(scope, e, fn, 1)) case m.libSymbol(fqnSelect), m.libSymbol(fqnReject), m.libSymbol(fqnSelectOne): if collection := m.argumentTo(scope, e, fn, 0); collection != nil { - types = m.resultTypes(scope, collection) + return collectionSource{scope: scope, node: collection}, true } } - return informativeTypes(types) + return collectionSource{}, false } -// collectResultTypes is the types `xs.{ in x; … }` results in: its body's; empty when unknown. -func (m *Model) collectResultTypes(scope *symbols.Scope, e *ast.CollectExpr) []*symbols.Symbol { +// collectSource is the source of `xs.{ in x; … }`: its body's result. +func (m *Model) collectSource(scope *symbols.Scope, e *ast.CollectExpr) (collectionSource, bool) { body, ok := e.Body.(*ast.BodyExpr) if !ok { + return collectionSource{}, false + } + return m.bodySource(scope, body) +} + +// appliedSource is the source of the expression applied to each element: a body +// `{ in x; … }` by its result, a function or expression named by its result parameter. +func (m *Model) appliedSource(scope *symbols.Scope, applied ast.Node) (collectionSource, bool) { + switch n := applied.(type) { + case *ast.BodyExpr: + return m.bodySource(scope, n) + case *ast.FeatureReference, *ast.QualifiedName, *ast.FeatureChainExpr: + if result := m.appliedResult(scope, n); result != nil { + return collectionSource{result: result}, true + } + } + return collectionSource{}, false +} + +// bodySource is the result of a body `{ in x; … }`, resolved among its parameters. +func (m *Model) bodySource(scope *symbols.Scope, body *ast.BodyExpr) (collectionSource, bool) { + if body == nil || body.Result == nil || m.resolver == nil { + return collectionSource{}, false + } + return collectionSource{scope: symbols.BodyExprScope(scope, body), node: body.Result}, true +} + +// appliedResult is the result parameter of the function or expression the name applied denotes. +func (m *Model) appliedResult(scope *symbols.Scope, applied ast.Node) *symbols.Symbol { + if m.resolver == nil { return nil } - return informativeTypes(m.bodyResultTypes(scope, body)) + sym, ok := m.resolver.ResolveTarget(scope, applied) + if !ok || sym == nil { + return nil + } + if alias, ok := m.resolver.ResolveAliasTarget(sym); ok { + sym = alias + } + return m.ResultParameterOf(sym) } // argumentTo is the expression e passes to the i-th input parameter of fn: the receiver of @@ -66,53 +112,61 @@ func (m *Model) argumentTo(scope *symbols.Scope, e *ast.InvocationExpr, fn *symb return nil } -// appliedResultTypes is the types the expression applied to each element results in: a body -// `{ in x; … }` by its result, a function or expression named by its result parameter. -func (m *Model) appliedResultTypes(scope *symbols.Scope, applied ast.Node) []*symbols.Symbol { - switch n := applied.(type) { - case nil: +// collectionResultTypes is the types every element of a collection function call's result +// has; nil where they are unknown or say nothing. +func (m *Model) collectionResultTypes(scope *symbols.Scope, e *ast.InvocationExpr, fn *symbols.Symbol) []*symbols.Symbol { + src, ok := m.invocationSource(scope, e, fn) + if !ok { return nil - case *ast.BodyExpr: - return m.bodyResultTypes(scope, n) - case *ast.FeatureReference, *ast.QualifiedName, *ast.FeatureChainExpr: - sym, ok := m.resolver.ResolveTarget(scope, n) - if !ok || sym == nil { - return nil - } - if alias, ok := m.resolver.ResolveAliasTarget(sym); ok { - sym = alias - } - if result := m.ResultParameterOf(sym); result != nil { - return m.featureResultTypes(result) - } } - return nil + return m.sourceTypes(src) } -// bodyResultTypes is the types the result of a body `{ in x; … }` is declared with, resolved -// among its parameters; a sequence result contributes each element, so every element type. -func (m *Model) bodyResultTypes(scope *symbols.Scope, body *ast.BodyExpr) []*symbols.Symbol { - if body == nil || body.Result == nil || m.resolver == nil { +// collectResultTypes is the types every element of `xs.{ in x; … }` has; nil when unknown. +func (m *Model) collectResultTypes(scope *symbols.Scope, e *ast.CollectExpr) []*symbols.Symbol { + src, ok := m.collectSource(scope, e) + if !ok { return nil } - return m.elementResultTypes(symbols.BodyExprScope(scope, body), body.Result) + return m.sourceTypes(src) } -// elementResultTypes is resultTypes over the elements a value contributes: those of a sequence -// `(a, b)`, nested ones flattened, else the expression itself; empty when any is unknown. +func (m *Model) sourceTypes(src collectionSource) []*symbols.Symbol { + if src.result != nil { + return informativeTypes(m.featureResultTypes(src.result)) + } + return informativeTypes(m.elementResultTypes(src.scope, src.node)) +} + +// elementResultTypes is the types every element a value contributes has: the expression's own, +// or for a sequence `(a, b)` those each element conforms to; nil when any element is unknown. func (m *Model) elementResultTypes(scope *symbols.Scope, node ast.Node) []*symbols.Symbol { seq, ok := node.(*ast.SequenceExpr) if !ok { return m.resultTypes(scope, node) } - var out []*symbols.Symbol - for _, element := range seq.Elements { + var common []*symbols.Symbol + for i, element := range seq.Elements { types := m.elementResultTypes(scope, element) if len(types) == 0 { return nil } - for _, typ := range types { - if !containsElement(out, typ) { + if i > 0 { + types = m.sharedTypes(common, types) + } + if common = types; len(common) == 0 { + return nil + } + } + return common +} + +// sharedTypes is the types of either list a value of each list conforms to. +func (m *Model) sharedTypes(a, b []*symbols.Symbol) []*symbols.Symbol { + var out []*symbols.Symbol + for _, list := range [][]*symbols.Symbol{a, b} { + for _, typ := range list { + if !containsElement(out, typ) && m.anyConforms(a, typ) && m.anyConforms(b, typ) { out = append(out, typ) } } @@ -120,6 +174,152 @@ func (m *Model) elementResultTypes(scope *symbols.Scope, node ast.Node) []*symbo return out } +func (m *Model) anyConforms(types []*symbols.Symbol, want *symbols.Symbol) bool { + for _, typ := range types { + if m.Conforms(typ, want) { + return true + } + } + return false +} + +// collectionConformance judges the value of a collection function call by its source: every +// element must conform. Not ok where the source is unknown and the declaration decides. +func (m *Model) collectionConformance(scope *symbols.Scope, e *ast.InvocationExpr, fn *symbols.Symbol, want *symbols.Symbol, byUnit bool) (Conformance, bool) { + src, ok := m.invocationSource(scope, e, fn) + if !ok { + return conformanceUnknown(), false + } + return m.sourceConformance(src, want, byUnit) +} + +// collectConformance judges the value of `xs.{ in x; … }` by its body's result: every element must conform. +func (m *Model) collectConformance(scope *symbols.Scope, e *ast.CollectExpr, want *symbols.Symbol, byUnit bool) (Conformance, bool) { + src, ok := m.collectSource(scope, e) + if !ok { + return conformanceUnknown(), false + } + return m.sourceConformance(src, want, byUnit) +} + +func (m *Model) sourceConformance(src collectionSource, want *symbols.Symbol, byUnit bool) (Conformance, bool) { + judged := m.judgeSource(src, + func(scope *symbols.Scope, node ast.Node) Conformance { + return m.elementConformance(scope, node, want, byUnit) + }, + func(result *symbols.Symbol) Conformance { return m.featureConformance(result, want) }) + return decided(everyHolds(judged)) +} + +// collectionCastConformance judges `xs.{…} as T` or a collection function call cast by its +// source: the cast is sound when some element's types and T specialize one another. Not ok +// where the source is unknown and the declaration decides. +func (m *Model) collectionCastConformance(scope *symbols.Scope, operand ast.Node, target *symbols.Symbol) (Conformance, bool) { + var src collectionSource + var ok bool + switch n := operand.(type) { + case *ast.CollectExpr: + src, ok = m.collectSource(scope, n) + case *ast.InvocationExpr: + src, ok = m.invocationSource(scope, n, m.invocationCallee(scope, n)) + } + if !ok { + return conformanceUnknown(), false + } + judged := m.judgeSource(src, + func(scope *symbols.Scope, node ast.Node) Conformance { return m.castConformance(scope, node, target) }, + func(result *symbols.Symbol) Conformance { + return m.castTypesConformance(m.featureResultTypes(result), target) + }) + return decided(anyHolds(judged)) +} + +// judgeSource judges each element of a source: a result parameter as the feature, an +// expression by itself, a sequence `(a, b)` element by element. +func (m *Model) judgeSource(src collectionSource, byNode func(*symbols.Scope, ast.Node) Conformance, byResult func(*symbols.Symbol) Conformance) []Conformance { + if src.result != nil { + return []Conformance{byResult(src.result)} + } + return elementJudgements(src.scope, src.node, byNode) +} + +func elementJudgements(scope *symbols.Scope, node ast.Node, judge func(*symbols.Scope, ast.Node) Conformance) []Conformance { + seq, ok := node.(*ast.SequenceExpr) + if !ok { + return []Conformance{judge(scope, node)} + } + var out []Conformance + for _, element := range seq.Elements { + out = append(out, elementJudgements(scope, element, judge)...) + } + return out +} + +// elementConformance judges one element; a value typed Anything alone says nothing of it. +func (m *Model) elementConformance(scope *symbols.Scope, node ast.Node, want *symbols.Symbol, byUnit bool) Conformance { + c := m.exprConformance(scope, node, want, byUnit) + if c.Known && !c.Holds && m.typedAnything(scope, node) { + return conformanceUnknown() + } + return c +} + +// typedAnything reports a value whose only type is Anything. +func (m *Model) typedAnything(scope *symbols.Scope, node ast.Node) bool { + types := m.resultTypes(scope, node) + return len(types) > 0 && len(informativeTypes(types)) == 0 +} + +// everyHolds holds when every judgement does, fails naming each known not to, and is +// unknown while none fails and one is unknown or untyped. +func everyHolds(judged []Conformance) Conformance { + if len(judged) == 0 { + return conformanceUnknown() + } + var found []string + unknown := false + for _, c := range judged { + switch { + case !c.Known || c.Untyped: + unknown = true + case !c.Holds: + found = appendUnique(found, c.Found) + } + } + switch { + case len(found) > 0: + return Conformance{Known: true, Found: strings.Join(found, " and ")} + case unknown: + return conformanceUnknown() + } + return Conformance{Known: true, Holds: true} +} + +// anyHolds holds when any judgement does, fails naming each when every one is known not +// to, and is unknown while none holds and one is unknown or untyped. +func anyHolds(judged []Conformance) Conformance { + if len(judged) == 0 { + return conformanceUnknown() + } + var found []string + for _, c := range judged { + switch { + case c.Known && c.Holds: + return c + case !c.Known || c.Untyped: + return conformanceUnknown() + } + found = appendUnique(found, c.Found) + } + return Conformance{Known: true, Found: strings.Join(found, " and ")} +} + +// decided is c with whether it decides anything: an unknown or untyped value leaves the +// declared result to decide. +func decided(c Conformance) (Conformance, bool) { + return c, c.Known && !c.Untyped +} + // informativeTypes is types unless every one is Anything, which says nothing; then nil. func informativeTypes(types []*symbols.Symbol) []*symbols.Symbol { for _, typ := range types { @@ -130,17 +330,11 @@ func informativeTypes(types []*symbols.Symbol) []*symbols.Symbol { return nil } -// typesConformance judges a value typed by every one of types: it conforms when one does. -func (m *Model) typesConformance(types []*symbols.Symbol, want *symbols.Symbol) Conformance { - if len(types) == 0 { - return conformanceUnknown() - } - names := make([]string, 0, len(types)) - for _, typ := range types { - if m.Conforms(typ, want) { - return Conformance{Known: true, Holds: true} +func appendUnique(list []string, s string) []string { + for _, item := range list { + if item == s { + return list } - names = append(names, leafName(typ.Name)) } - return Conformance{Known: true, Found: strings.Join(names, " and ")} + return append(list, s) } diff --git a/internal/core/semantics/collection_test.go b/internal/core/semantics/collection_test.go index 18c52dfcd..d328d80fb 100644 --- a/internal/core/semantics/collection_test.go +++ b/internal/core/semantics/collection_test.go @@ -136,18 +136,23 @@ func TestCollectionResultMultiplicity(t *testing.T) { } // A nested collect types the outer body by the inner result; a body returning a -// sequence contributes each element, so the result is typed by every element type. +// sequence is typed by what every element conforms to — nothing, so Anything, where +// the elements share no type. func TestCollectionNestedAndSequenceBodies(t *testing.T) { m, s := collectionModel(t, ` attribute nested = cs->collect { in x : C; cs->collect { in y : C; y.name } }; attribute nested2 = cs.{ in x : C; cs.{ in y : C; y.mass } }; attribute pairs = cs->collect { in x : C; (x.mass, x.mass) }; + attribute widened = cs->collect { in x : C; (1, 2.5) }; attribute mixed = cs->collect { in x : C; (x.mass, (x.name, x.mass)) }; + attribute partly = cs->collect { in x; (x, 1) }; attribute chained = cs->collect { in x : C; x.name }->select { in n : String; n == "a" };`) wantValueTypes(t, m, s, "nested", "String") wantValueTypes(t, m, s, "nested2", "MassValue") wantValueTypes(t, m, s, "pairs", "MassValue") - wantValueTypes(t, m, s, "mixed", "MassValue", "String") + wantValueTypes(t, m, s, "widened", "Real") + wantValueTypes(t, m, s, "mixed", "Anything") + wantValueTypes(t, m, s, "partly", "Anything") wantValueTypes(t, m, s, "chained", "String") } @@ -191,13 +196,40 @@ func TestCollectionSelfReferentialBodyTerminates(t *testing.T) { } // Conformance judges the specialized result: a collect of masses is a MassValue and -// no String; a select keeps C, no ScalarValue; an untyped body stays untyped. +// no String; a select keeps C, no ScalarValue; an untyped body stays untyped. A body +// returning a sequence conforms when every element does, fails naming an element +// known not to, and stays the untyped Anything while an element is untyped and none fails. func TestCollectionResultConformance(t *testing.T) { m, s := collectionModel(t, ` attribute masses = cs->collect { in x : C; x.mass }; attribute masses2 = cs.{ in x : C; x.mass }; attribute heavy = cs->select { in x : C; x.mass > 1 [kg] }; - attribute unknown = cs.{ in x; x.mass };`) + attribute unknown = cs.{ in x; x.mass }; + attribute pairs = cs->collect { in x : C; (x.mass, x.mass) }; + attribute mixed = cs.{ in x : C; (true, 1) }; + attribute partly = cs.{ in x; (x, 1) }; + attribute partly2 = cs->collect { in x; (x, 1) };`) + for name, want := range map[string]string{"pairs": "ISQ::MassValue", "mixed": "Base::Anything"} { + if c := m.ExprConformsToLibrary(s, valueOf(t, s, name), want); !c.Known || !c.Holds { + t.Errorf("%s as %s: %+v, want it to hold", name, want, c) + } + } + if c := m.ExprConformsToLibrary(s, valueOf(t, s, "pairs"), fqnString); !c.Known || c.Holds || c.Untyped || c.Found != "MassValue" { + t.Errorf("pairs as String: %+v, want known, not holding, found MassValue", c) + } + for _, want := range []string{FQNBoolean, fqnInteger} { + if c := m.ExprConformsToLibrary(s, valueOf(t, s, "mixed"), want); !c.Known || c.Holds || c.Untyped || c.Found == "" { + t.Errorf("mixed as %s: %+v, want known, not holding, naming the element", want, c) + } + } + if c := m.ExprConformsToLibrary(s, valueOf(t, s, "partly"), fqnInteger); !c.Known || c.Holds || !c.Untyped || !strings.Contains(c.Found, "Anything") { + t.Errorf("partly as Integer: %+v, want untyped Anything", c) + } + for _, name := range []string{"partly", "partly2"} { + if c := m.ExprConformsToLibrary(s, valueOf(t, s, name), FQNBoolean); !c.Known || c.Holds || c.Untyped || c.Found != "Natural" { + t.Errorf("%s as Boolean: %+v, want known, not holding, found Natural", name, c) + } + } for _, name := range []string{"masses", "masses2"} { c := m.ExprConformsToLibrary(s, valueOf(t, s, name), "ISQ::MassValue") if !c.Known || !c.Holds { diff --git a/internal/core/semantics/operator_conformance.go b/internal/core/semantics/operator_conformance.go index 3256dc03b..a2adb6945 100644 --- a/internal/core/semantics/operator_conformance.go +++ b/internal/core/semantics/operator_conformance.go @@ -19,7 +19,19 @@ func (m *Model) CastConformance(scope *symbols.Scope, e *ast.OperatorExpr) Confo if target == nil { return conformanceUnknown() } - types := m.resultTypes(scope, e.Operands[0]) + if c, ok := m.collectionCastConformance(scope, e.Operands[0], target); ok { + return c + } + return m.castConformance(scope, e.Operands[0], target) +} + +// castConformance judges casting the value of node to target by the node's types. +func (m *Model) castConformance(scope *symbols.Scope, node ast.Node, target *symbols.Symbol) Conformance { + return m.castTypesConformance(m.resultTypes(scope, node), target) +} + +// castTypesConformance is sound when one of types and target specialize one another. +func (m *Model) castTypesConformance(types []*symbols.Symbol, target *symbols.Symbol) Conformance { if len(types) == 0 { return conformanceUnknown() } diff --git a/internal/core/semantics/valuetype.go b/internal/core/semantics/valuetype.go index 057ec4f92..fe694e718 100644 --- a/internal/core/semantics/valuetype.go +++ b/internal/core/semantics/valuetype.go @@ -124,7 +124,7 @@ func (m *Model) exprConformance(scope *symbols.Scope, node ast.Node, want *symbo case *ast.OperatorExpr: return m.operatorConformance(scope, n, want, byUnit) case *ast.InvocationExpr: - return m.invocationConformance(scope, n, want) + return m.invocationConformance(scope, n, want, byUnit) case *ast.NullExpr: // `null` evaluates to nothing, typed Anything (KerML nullEvaluations). c := m.typeConformance(m.libSymbol(fqnAnything), want) @@ -143,8 +143,8 @@ func (m *Model) exprConformance(scope *symbols.Scope, node ast.Node, want *symbo return c case *ast.CollectExpr: // `xs.{…}` is the result of ControlFunctions::collect: the body's results, else Anything. - if types := m.collectResultTypes(scope, n); len(types) > 0 { - return m.typesConformance(types, want) + if c, ok := m.collectConformance(scope, n, want, byUnit); ok { + return c } c := m.typeConformance(m.libSymbol(fqnAnything), want) if c.Known && !c.Holds { @@ -968,10 +968,10 @@ func (m *Model) incommensurableSum(scope *symbols.Scope, node ast.Node) (*ast.Op // invocationConformance judges an invocation's value by the declared type of the // result parameter of the overload it calls. -func (m *Model) invocationConformance(scope *symbols.Scope, e *ast.InvocationExpr, want *symbols.Symbol) Conformance { +func (m *Model) invocationConformance(scope *symbols.Scope, e *ast.InvocationExpr, want *symbols.Symbol, byUnit bool) Conformance { called := m.invocationCallee(scope, e) - if types := m.collectionResultTypes(scope, e, called); len(types) > 0 { - return m.typesConformance(types, want) + if c, ok := m.collectionConformance(scope, e, called, want, byUnit); ok { + return c } result := m.ResultParameterOf(called) if result == nil { From db46a9adafb8b127e133d11ab99d4d69d01871b6 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 01:43:36 +0000 Subject: [PATCH 03/22] fix(semantics): judge bound collection values and reduce's unreduced element A collection value bound to a typed feature or passed as an argument is judged by the types of the elements it maps to or keeps, each element of a sequence-valued body on its own, rather than by the Anything the library declares as its result. reduce may hand a one-element collection back unreduced, so its result is also the collection's element unless the collection is known to hold two or more. Co-Authored-By: jason.han --- .../collection-body-result-type.fixed.md | 2 +- docs/project/spec-compliance.md | 2 +- internal/core/passes/typecheck_expr.go | 3 +- internal/core/passes/typecheck_value.go | 32 ++- internal/core/passes/typecheck_value_test.go | 104 ++++++++- internal/core/semantics/collection.go | 217 +++++++++++++----- internal/core/semantics/collection_test.go | 40 ++++ 7 files changed, 332 insertions(+), 68 deletions(-) diff --git a/changes/unreleased/collection-body-result-type.fixed.md b/changes/unreleased/collection-body-result-type.fixed.md index 531c4c574..bf07b655a 100644 --- a/changes/unreleased/collection-body-result-type.fixed.md +++ b/changes/unreleased/collection-body-result-type.fixed.md @@ -1 +1 @@ -- **A collection operation's static type follows what its declaration hands through, not the element type of the collection.** `xs->collect { in x : C; x.mass }` and `xs.{ in x : C; x.mass }` are typed by the body's result (`MassValue`), a nested collect by its innermost body, a body answering a sequence by every element type and `xs->collect f` by the named function's result; `select`, `reject` and `selectOne` keep the elements of `xs`, `reduce` follows its reducer's result and `forAll`/`exists` stay `Boolean`, each with the multiplicity the Kernel Function Library declares. A body whose result cannot be typed keeps the library's `Anything`. Value conformance, trigger arguments and enumerated values are judged by the specialized type, so `accept when counts.{in n : Integer; n}` is refused where it was silent, and `when counts.{in n; n > 3}` is accepted where it was refused. A body reading the feature it values terminates as a self-referential argument does. +- **A collection operation's static type follows what its declaration hands through, not the element type of the collection.** `xs->collect { in x : C; x.mass }` and `xs.{ in x : C; x.mass }` are typed by the body's result (`MassValue`), a nested collect by its innermost body, a body answering a sequence by every element type and `xs->collect f` by the named function's result; `select`, `reject` and `selectOne` keep the elements of `xs`, `reduce` follows its reducer's result — and the element a one-element collection hands back unreduced, unless the collection is known to hold two or more — and `forAll`/`exists` stay `Boolean`, each with the multiplicity the Kernel Function Library declares. A body whose result cannot be typed keeps the library's `Anything`. Value conformance, a feature's bound value, invocation arguments, trigger arguments and enumerated values are judged by the specialized type, so `accept when counts.{in n : Integer; n}` is refused where it was silent, and `when counts.{in n; n > 3}` is accepted where it was refused. A body reading the feature it values terminates as a self-referential argument does. diff --git a/docs/project/spec-compliance.md b/docs/project/spec-compliance.md index dd7f9e104..09d58c50e 100644 --- a/docs/project/spec-compliance.md +++ b/docs/project/spec-compliance.md @@ -1462,7 +1462,7 @@ known, so unmodelled types never produce a false positive. | Each argument of an invocation corresponds to one `in` parameter of the invoked type (KerML 1.1 §8.3.4.8 `validateInvocationExpressionParameterRedefinition`, pilot `Must correspond to one input parameter of the invoked type`): a named argument naming an `out` or `return` parameter, a feature that is no parameter, or a general's parameter the invoked function's own redefines by position; and a positional argument past the last `in` parameter, in a function, a `calc def`, a step or a feature typed by a behavior, with inherited and redefined parameters counted through `effectiveInParameters` | `passes/typecheck_expr.go` `checkArguments`, `checkNamedArguments` (`msgInvocationParameterRedefinition`) | `passes/duplicate_binding_test.go` `TestInvocationParameterRedefinitionKerML`; census probe `validateInvocationExpressionParameterRedefinition.kerml`; corpus `semantic/k32-too-many-arguments.kerml` | ✅ Faithful; the pilot's wording heads ours, followed by the cause (`…: F has no parameter named "y"`, `…: F takes 1 argument(s), found 2`) at the offending argument. A required parameter left unbound (`F()`) draws OpenSysML's own `unbound-parameter` advisory (warning), a check the pilot does not make | | A constructor expression binds each feature of the instantiated type at most once (KerML 1.1 §8.3.4.8 `validateConstructorExpressionNoDuplicateFeatureRedefinition`, pilot `Feature already bound`): `new C(x = 1, x = 2)`, an alias or a qualified name of the same feature, and an inherited feature bound under its own and its redefining name all count; reported at the second binding, `x of C is already bound by an earlier argument`, and the same rule reaches a constructor nested in a send payload (under a conditional, a sequence, a feature chain, a call argument or a body expression) or in a feature chain's operand | `passes/typecheck_expr.go` `inferConstructor` (the constructor row below), `inferFeatureChain` | `passes/duplicate_binding_test.go` (`TestConstructorDuplicateFeatureBindingKerML`, `TestConstructorDuplicateFeatureBindingSysML`); corpus `semantic/k34-constructor-feature-bound-twice.kerml`; census probe `validateConstructorExpressionNoDuplicateFeatureRedefinition.kerml` | ✅ Faithful (a call's named argument is not indexed as a member reference of the called type, so editor rename and find-references do not reach `f(F::x = …)` — an editor follow-up; constructor labels are) | | Invocation overload selection: a name visible as several function/calc declarations (owned, inherited, imported or re-exported — a library function only where the model imports its package) selects the one whose effective input parameters take the arguments — each argument, by position or name, to a distinct parameter it conforms to; a candidate the arguments bind every default-less parameter of is preferred to one they leave a parameter of, which stays applicable and draws the `unbound-parameter` advisory, so `pick("a")` against `pick(in x : Integer)` and `pick(in s : String; in y : Real)` binds to the second rather than reporting a mismatch against the first, `pick(2)` against `pick(in x : Real; in y : Real)` and `pick(in x : Integer)` runs the complete one, and `pick()` against two candidates it fits alike is `invocation-ambiguous` — and by type conformance (the `ScalarValues` lattice, strings, booleans, collections, quantities and declared types through `Model.Conforms` — a non-literal argument's type only bounds its values, so a broader declared type fits a narrower parameter, while declared types neither of which conforms to the other, `MassValue` to a `VolumeValue` parameter, never bind, as the pinned validator judges a binding; a `Collections` parameter takes any sequence, as the runtime reads it, and a `KerML::Root::Element` parameter the element any argument names, as `DocumentQueries` bind it, ranked between a declared type and `Anything`); among several fits the most specific wins, a tie or incomparable pair is reported as `invocation-ambiguous` naming the tied candidates (`InvocationSelection.Tied`, the applicable ones none is more specific than, so a broader overload the arguments also fit is not among them), and no fit keeps the argument diagnostic against the first candidate, naming the others considered. The selection is memoized in a side table keyed by the invocation node and scope, the runtime dispatches the declaration selected there, and a named argument written twice is reported (`binds parameter twice` for a calc call, `ErrDuplicateArgument` for a nested action call) and dispatched by none. A feature typed by a behavior is a candidate performing that behavior, so `ref pick : Twice;` is selected beside a same-named calc when only its signature fits. An expression evaluates to a calc's result, so a call in expression context whose name denotes a calc selects among the calcs alone, whatever the import order and however closely a same-named action's inputs fit — the arguments are then checked and dispatched against the calc, and the action is never evaluated — while `action call = tag(3);` and `perform tag(3);` select among actions only. A bodiless calc specializing a library function (`calc def Renamed :> sqrt { in y :>> x; }`, or a feature typed by one) is computed by that function through its own effective signature: the runtime binds the written arguments to the specialization's parameters as any calc's are bound — renamed, defaulted, optional; each default evaluated where it is declared with the earlier parameters in scope (`in y :>> y = x + 1.0;`), each value checked against the effective type and multiplicity the specialization states (`in n : Integer :>> x;`, `[1]`) before the library computes it — and lays them out in the library's order (`runtime/invoke_calc.go` `libraryCalcPerformed`, `effectiveParameter`, `invokeLibraryPerformance`), for an expression, a `send` and the `InvokeCalc`/`InvokeCalcNamed` API alike; a calc with a body of its own is never redirected. A nested action call with an explicit empty argument list (`action call = tag();`) binds nothing — a required input is unbound, a defaulted one takes its default — where only the bare `perform tag;` and `action call : Tag;` forms read the caller's same-named values | `resolve/invocation.go` `Resolver.InvocationCandidates` (all matches of the winning visibility category), `semantics/invocation.go` `Model.SelectInvocation`, `passes/typecheck_expr.go` `inferInvocation`, `passes/invocation.go` `argument`, `runtime/eval.go` `invocationTarget` and `runtime/invoke_action.go` `resolveActionSymbol` for a nested `action call = A(...)` or `perform A(...)`, whose receiver `x->A()` is the first argument (`ErrAmbiguousInvocation`, `ErrReceiverWithNamedArgs`), `model/workspace.go` `calledDeclaration` and `Workspace.AmbiguousInvocationInDoc` for editor navigation (a call selecting one overload navigates to it; a tied call lists every tied overload under go-to-definition and hover, and is left out of find-references and rename, which refuses to start from it), `semantics/invocation.go` `Model.SelectCall` (the checker's argument typing installed by `passes.NewArgumentTyper`) for `queryplan/compiler.go` `compileInvocation` and `columns.go` `compileColumn` (a tie is `ErrorAmbiguousInvocation`) and `runtime/signal.go` `invokesCalc` (a send names a signal unless the call selects a calc) | `passes/invocation_test.go` (`TestInvocationOverloadRedeclaredLibraryInputs`, `TestInvocationOverloadOmittedInputSelectsTheFittingCandidate`, `TestInvocationOverloadCompleteCandidateBeatsOmission`, `TestInvocationOverloadOmittedInputsAreAmbiguous`), `runtime/invocation_selection_test.go` (`calc_call_binds_a_library_function_through_redeclared_inputs`, `calc_call_selects_a_calc_over_a_more_specific_action`, `calc_call_omitting_an_input_runs_the_fitting_candidate`, `calc_call_omitting_every_input_is_ambiguous`), `queryplan/overload_test.go`, `runtime/signal_test.go:TestActionSendCallsACalcSharingASignalsName`, `lsp/invocation_navigation_test.go`, `lsp/ambiguous_invocation_navigation_test.go`, conformance `calc_library_overload_by_argument_type`, `calc_library_overload_partial_import`, `calc_library_overload_ambiguous`, `calc_library_function_shadowed_by_model`, `action_node_invocation_overload_inherited_input` (the winning action's matching input is inherited, and the runtime frame binds it), `lsp/library_invocation_test.go:TestPublishDiagnosticsSelectsLibraryOverloadByArgumentType`, pilot-exec-diff `w6d:complex-abs`, `:complex-is-zero` | ⚠️ Approximate: a model's own `calc` of the name still shadows every library declaration, and an argument whose type is statically unknown (an unresolved parameter type, an untyped feature) keeps today's first-visible selection with no new diagnostic — the corpus gates hold at zero movement. The pinned pilot evaluates none of the probes (`ToInteger`, `abs`, `isZero` over their overload sets come back as the unevaluated `InvocationExpression`), so the most-specific rule is self-assessed against KerML 1.1 §8.3.4.8 (a decimal literal is a `LiteralRational`, so `ToInteger(7.9)` selects `RationalFunctions::ToInteger` over `RealFunctions`'). A parameter's `[m..n]` multiplicity decides only whether a call may omit it: one admitting none, or one a default reaches along its redefinitions (`Model.OptionalParameter`, `Model.ParameterDefault`), is optional to the checker and to `runtime/action_frame.go` `checkInputsBound` alike, and the omitted input is read as empty or as the inherited default (`passes/invocation_test.go:TestInvocationPerformedActionOptionalInputs`, `runtime/invocation_selection_test.go:action_call_omits_optional_inputs`). Not done: a value's element count is not checked against the multiplicity | -| A collection operation's static result is typed by the argument the library's declaration hands through, not by the `Anything[0..*]` it declares (KerML 1.1 §8.3.4.8 `checkSelectExpressionResultSpecialization`: a select's result subsets the collection's; §9.2 Kernel Function Library `ControlFunctions.kerml`, `collect`'s result "the collection of results" of `mapper`, `select`/`reject`/`selectOne` elements of `collection`, `reduce` the `reducer`'s result, `forAll`/`exists` `Boolean[1]`): `xs->collect {in x : C; x.mass}` and `xs.{in x : C; x.mass}` are typed `MassValue`, `xs->collect {in x : C; x.name}` `String`, a nested collect by the innermost body, a body answering a sequence `(x.mass, x.name)` by every element type, `xs->collect Mass` by the named function's result, `xs->select {…}`/`->reject {…}`/`->selectOne {…}` as `xs` is, `->reduce '+'` by the reducer's result and `->forAll`/`->exists` `Boolean`; the operation is the resolved library declaration, in the receiver, plain and named-argument notations alike, and a body whose result cannot be typed — its parameter declares no type — leaves the library's `Anything`, never a guess. The multiplicity stays the declaration's (`collect`/`select`/`reject`/`reduce` `[0..*]`, `selectOne` `[0..1]`, `forAll`/`exists` `[1]`). A body reading the feature it values (`total = cs->collect {in x : C; total}->reduce '+'`) terminates under the same guard as an argument that does | `semantics/collection.go` `Model.collectionResultTypes`, `collectResultTypes`, `argumentTo`, `appliedResultTypes`, `bodyResultTypes`, `elementResultTypes`, reached from `semantics/valuetype.go` `Model.ExprResultType`, `exprConformance`, `invocationConformance` and `semantics/operator_conformance.go` `resultTypes`; `semantics/invocation.go` `SelectCall`/`callArguments` (`typingArgs`); `passes/typecheck_value.go` `exprChecker.invocationResultParameter` (selects under the chains being typed) | `semantics/collection_test.go` `TestCollectionResultTypes`, `TestCollectionResultMultiplicity`, `TestCollectionNestedAndSequenceBodies`, `TestCollectionArgumentNotations`, `TestCollectionUntypedBodyFallsBackToLibraryResult`, `TestCollectionSelfReferentialBodyTerminates`, `TestCollectionResultConformance`; `passes/typecheck_dimension_test.go:TestRecursiveRollupThroughACall`, `:TestRecursiveRollupThroughACollectBody`; `passes/typecheck_trigger_test.go:TestCollectAndSelectTriggerArguments`; `passes/w7g_one_type_test.go:TestW7GASelectedEnumeratedValueKeepsItsOperandType`; grammar `PrimaryExpression` (`'->' … BodyExpression`, `'.' BodyExpression`, `'.?' BodyExpression`) | ✅ Faithful for `select`/`reject`/`selectOne`/`forAll`/`exists` (the pinned pilot subsets a select's result from its collection and types the rest by the declared result); ⚠️ Approximate for `collect`/`reduce` (self-assessed: the specification declares their result `Anything[0..*]` and the pilot types it so, giving `xs.{in x : C; x.mass}` no static type; typing it by the body is stricter than the reference, so a diagnostic it raises — a `when` trigger over a collect of Integers, an enumerated value collecting Reals — the pilot stays silent on) | +| A collection operation's static result is typed by the argument the library's declaration hands through, not by the `Anything[0..*]` it declares (KerML 1.1 §8.3.4.8 `checkSelectExpressionResultSpecialization`: a select's result subsets the collection's; §9.2 Kernel Function Library `ControlFunctions.kerml`, `collect`'s result "the collection of results" of `mapper`, `select`/`reject`/`selectOne` elements of `collection`, `reduce` the `reducer`'s result, `forAll`/`exists` `Boolean[1]`): `xs->collect {in x : C; x.mass}` and `xs.{in x : C; x.mass}` are typed `MassValue`, `xs->collect {in x : C; x.name}` `String`, a nested collect by the innermost body, a body answering a sequence `(x.mass, x.name)` by every element type, `xs->collect Mass` by the named function's result, `xs->select {…}`/`->reject {…}`/`->selectOne {…}` as `xs` is, `->reduce '+'` by the reducer's result — and, as the runtime hands a one-element collection back unreduced, by the element too unless the collection is known to hold two or more (a `[2..*]` feature, a sequence of two literals) — and `->forAll`/`->exists` `Boolean`; the operation is the resolved library declaration, in the receiver, plain and named-argument notations alike, and a body whose result cannot be typed — its parameter declares no type — leaves the library's `Anything`, never a guess. The multiplicity stays the declaration's (`collect`/`select`/`reject`/`reduce` `[0..*]`, `selectOne` `[0..1]`, `forAll`/`exists` `[1]`). A collection value bound to a typed feature or passed as an argument is judged by these element types, each element a sequence-valued body answers on its own, so `part b : Boat = vs->select {in v : Vehicle; true}` and `Sail(vs.{in v : Vehicle; v})` are refused where the declared `Anything` would bind. A body reading the feature it values (`total = cs->collect {in x : C; total}->reduce '+'`) terminates under the same guard as an argument that does | `semantics/collection.go` `Model.CollectionResultTypes`, `CollectionElementTypes`, `sourcesOf`, `invocationSources`, `holdsAtLeastTwo`, `collectSources`, `appliedSources`, `bodySources`, `argumentTo`, `sourcesElementTypes`, reached from `semantics/valuetype.go` `Model.ExprResultType`, `exprConformance`, `invocationConformance` and `semantics/operator_conformance.go` `resultTypes`; `semantics/invocation.go` `SelectCall`/`callArguments` (`typingArgs`); `passes/typecheck_value.go` `exprChecker.checkValueConformance`, `unboundElementTypes`, `invocationResultTypeSymbol`, `invocationResultParameter` (selects under the chains being typed) | `semantics/collection_test.go` `TestCollectionResultTypes`, `TestCollectionResultMultiplicity`, `TestCollectionNestedAndSequenceBodies`, `TestCollectionArgumentNotations`, `TestCollectionUntypedBodyFallsBackToLibraryResult`, `TestCollectionSelfReferentialBodyTerminates`, `TestCollectionResultConformance`, `TestCollectionReduceMayReturnTheElement`; `passes/typecheck_value_test.go:TestValueCollectionResultIsJudged`, `:TestValueReduceResultIsJudged`, `:TestArgumentCollectionResultIsJudged`; `passes/typecheck_dimension_test.go:TestRecursiveRollupThroughACall`, `:TestRecursiveRollupThroughACollectBody`; `passes/typecheck_trigger_test.go:TestCollectAndSelectTriggerArguments`; `passes/w7g_one_type_test.go:TestW7GASelectedEnumeratedValueKeepsItsOperandType`; grammar `PrimaryExpression` (`'->' … BodyExpression`, `'.' BodyExpression`, `'.?' BodyExpression`) | ✅ Faithful for `select`/`reject`/`selectOne`/`forAll`/`exists` (the pinned pilot subsets a select's result from its collection and types the rest by the declared result); ⚠️ Approximate for `collect`/`reduce` (self-assessed: the specification declares their result `Anything[0..*]` and the pilot types it so, giving `xs.{in x : C; x.mass}` no static type; typing it by the body is stricter than the reference, so a diagnostic it raises — a `when` trigger over a collect of Integers, an enumerated value collecting Reals — the pilot stays silent on) | | An unqualified call to a Kernel Function Library function the model does not import is `unresolved` for the checker and the runtime alike: the library packages are members of the root namespace but not implicitly imported, so a bare `sqrt(4.0)` with no `import RealFunctions::*;` draws the `unresolved` diagnostic offering the imports that would resolve it and fails to evaluate with the same text, `RealFunctions::sqrt(4.0)` resolves anywhere, and under the import the call checks clean, is selected among the imported overloads and evaluates; the OpenSysML extension functions (`exp`, `ln`, `log`, `atan2`) are gated by the same rule | `resolve/invocation.go` `ResolveInvocationName` (the name resolves as any other), `runtime/eval.go` `invocationTarget` (dispatch by the declaration resolved, `passes.SelectInvocation` choosing among several) | `passes/invocation_test.go:TestInvocationUnimportedLibraryFunction`, `:TestInvocationQualifiedLibraryFunctionArgumentsChecked`, `:TestInvocationOverloadOnlyImportedPackagesContribute`, `runtime/library_functions_test.go:TestLibraryFunctionUnqualifiedNames`, `:TestLibraryFunctionQualifiedCallNeedsNoImport`, conformance `calc_library_function_unimported`, `calc_library_function_imported`, `lsp/library_invocation_test.go:TestPublishDiagnosticsAgreesWithRuntimeOnLibraryCall`, `:TestPublishDiagnosticsKeepsExtensionFunctionImportGated`, `lsp/invocation_navigation_test.go:TestDefinitionReachesImportedLibraryFunction` | ✅ Faithful. Observed against the pinned pilot: it reports `Couldn't resolve reference to Element 'sqrt'` for the unimported call and resolves the qualified one | | No false positives on the shipped library and examples | corpus guard | `model/typecheck_expr_corpus_test.go` | ✅ Faithful | | `[` is not an index in KerML (pilot `validateOperatorExpressionBracketOperator`, `Use #(...) for indexing`; the specification gives `[` no KerML function — `BaseFunctions::'['` is abstract): every bracket in a `.kerml` document warns at the operator expression, a `.sysml` document is judged by the quantity rule instead | `passes/typecheck_expr.go` `exprChecker.checkBracket`; code `bracket-operator` | `typecheck_operator_test.go:TestBracketOperatorInKerML`, `TestBracketOperatorIsKerMLOnly`; census probe `validateOperatorExpressionBracketOperator.kerml` | ✅ Faithful (warning, as the pilot) | diff --git a/internal/core/passes/typecheck_expr.go b/internal/core/passes/typecheck_expr.go index 74746b58d..b2dea5079 100644 --- a/internal/core/passes/typecheck_expr.go +++ b/internal/core/passes/typecheck_expr.go @@ -234,7 +234,8 @@ func (ec *exprChecker) checkCondition(scope *symbols.Scope, n ast.Node, code, fo // redefined duration, a quantity. func (ec *exprChecker) checkNonScalarCondition(scope *symbols.Scope, n ast.Node, code, format string, mustType bool) { typeSym := ec.valueTypeSymbol(scope, n) - if typeSym == nil { + // A collection value is judged element by element by the model below. + if _, collection := ec.model.CollectionResultTypes(scope, n); typeSym == nil && !collection { typeSym = ec.invocationResultTypeSymbol(scope, n) } if typeSym == nil { diff --git a/internal/core/passes/typecheck_value.go b/internal/core/passes/typecheck_value.go index 1f9396c24..cdea2ef85 100644 --- a/internal/core/passes/typecheck_value.go +++ b/internal/core/passes/typecheck_value.go @@ -1,6 +1,7 @@ package passes import ( + "slices" "strings" "github.com/Open-MBEE/OpenSysML/internal/core/ast" @@ -43,6 +44,14 @@ func (ec *exprChecker) checkValueConformance(valueScope, declScope *symbols.Scop } continue } + if elements, collection := ec.model.CollectionElementTypes(valueScope, value); collection { + // Every element a collection value may hold binds, not the Anything the + // library declares; the lattice does not type them. + if gots := ec.unboundElementTypes(elements, wants); len(gots) > 0 { + ec.errorf(value.Span(), "cannot bind a value of type %s to a feature typed by %s", typeNames(gots), typeNames(wants)) + } + continue + } if result := ec.invocationResultParameter(valueScope, value); result != nil { gots := ec.featureValueTypes(result) if len(gots) > 0 && !(scalar && ec.anyScalar(gots)) && !ec.boundTypesConform(result, gots, wants) { @@ -234,6 +243,23 @@ func (ec *exprChecker) boundTypesConform(feature *symbols.Symbol, gots, wants [] return false } +// unboundElementTypes is the types of those elements of a collection value, given element by +// element, none of whose types binds to a feature typed by wants; an untyped element binds. +func (ec *exprChecker) unboundElementTypes(elements [][]*symbols.Symbol, wants []*symbols.Symbol) []*symbols.Symbol { + var out []*symbols.Symbol + for _, gots := range elements { + if len(gots) == 0 || ec.boundTypesConform(nil, gots, wants) { + continue + } + for _, got := range gots { + if !slices.Contains(out, got) { + out = append(out, got) + } + } + } + return out +} + // typeNames joins the names of types as a declaration lists them. func typeNames(types []*symbols.Symbol) string { names := make([]string, 0, len(types)) @@ -310,8 +336,12 @@ func (ec *exprChecker) featureValueTypes(sym *symbols.Symbol) []*symbols.Symbol } // invocationResultTypeSymbol returns the type of the result parameter of the -// behavior an invocation names, which is the type of the value it produces. +// behavior an invocation names, which is the type of the value it produces; for +// a collection value (`xs.{…}`, a collection function call) the one type its elements have. func (ec *exprChecker) invocationResultTypeSymbol(scope *symbols.Scope, value ast.Node) *symbols.Symbol { + if types, collection := ec.model.CollectionResultTypes(scope, value); collection && len(types) == 1 { + return types[0] + } result := ec.invocationResultParameter(scope, value) if result == nil { return nil diff --git a/internal/core/passes/typecheck_value_test.go b/internal/core/passes/typecheck_value_test.go index d92a17608..cf16ab912 100644 --- a/internal/core/passes/typecheck_value_test.go +++ b/internal/core/passes/typecheck_value_test.go @@ -1,6 +1,9 @@ package passes -import "testing" +import ( + "slices" + "testing" +) // enumPrelude declares types outside the scalar lattice: an enumeration, a // structural hierarchy, and an unrelated definition. @@ -290,3 +293,102 @@ func TestValueIndexedCollectionElementIsUnknown(t *testing.T) { part v : M::Vehicle = bs#(1); }`, "cannot bind a value of type Boat to a feature typed by Vehicle") } + +// collectionValueDiags is the type diagnostics of a model of vehicles and boats whose +// collection values, in members, are judged element by element against the library. +func collectionValueDiags(t *testing.T, members string) []string { + t.Helper() + diags := libraryTypeDiags(t, `package P { + private import ScalarValues::*; + private import ControlFunctions::*; + part def Vehicle; part def Truck :> Vehicle; part def Boat; + part vs : Vehicle[*]; + part one : Vehicle[1]; + part two : Vehicle[2..*]; + part boat : Boat; + function Boats { in v : Vehicle; return r : Boat; } + function Sail { in b : Boat; return r : Boat; } + function Drive { in v : Vehicle; return r : Vehicle; } + `+members+` + }`) + var got []string + for _, d := range diags { + got = append(got, d.Message) + } + return got +} + +func wantCollectionValueDiags(t *testing.T, members string, want ...string) { + t.Helper() + if got := collectionValueDiags(t, members); !slices.Equal(got, want) { + t.Errorf("%s:\n got %q\nwant %q", members, got, want) + } +} + +// A collection value binds by the elements it maps to or keeps, not by the Anything the +// library declares its result: collect and `xs.{…}` by the body's result, select and +// selectOne by the collection's elements, a sequence-valued body element by element. +func TestValueCollectionResultIsJudged(t *testing.T) { + wantCollectionValueDiags(t, `part b : Boat = vs.{ in v : Vehicle; v };`, + "cannot bind a value of type Vehicle to a feature typed by Boat") + wantCollectionValueDiags(t, `part b : Boat = vs->collect { in v : Vehicle; v };`, + "cannot bind a value of type Vehicle to a feature typed by Boat") + wantCollectionValueDiags(t, `part b : Boat = vs->collect Drive;`, + "cannot bind a value of type Vehicle to a feature typed by Boat") + wantCollectionValueDiags(t, `part b : Boat = vs->select { in v : Vehicle; true };`, + "cannot bind a value of type Vehicle to a feature typed by Boat") + wantCollectionValueDiags(t, `part b : Boat = vs->selectOne { in v : Vehicle; true };`, + "cannot bind a value of type Vehicle to a feature typed by Boat") + wantCollectionValueDiags(t, `attribute i : Integer = vs.{ in v : Vehicle; true };`, + "cannot bind a value of type Boolean to a feature typed by Integer") + wantCollectionValueDiags(t, `attribute i : Integer = vs->collect { in v : Vehicle; (true, 1) };`, + "cannot bind a value of type Boolean to a feature typed by Integer") + wantCollectionValueDiags(t, `part b : Boat = vs.{ in v : Vehicle; (v, boat) };`, + "cannot bind a value of type Vehicle to a feature typed by Boat") + wantCollectionValueDiags(t, ` + part t : Truck = vs->collect { in v : Vehicle; v }; + part v : Vehicle = vs->select { in v : Vehicle; true }; + part b : Boat = vs->collect { in v : Vehicle; boat }; + part b2 : Boat = vs->collect Boats; + attribute i : Integer = vs->collect { in v : Vehicle; (1, 2) }; + attribute b3 : Boolean = vs->forAll { in v : Vehicle; true }; + part open : Boat = vs.{ in v; v }; + part open2 : Boat = vs.{ in v; (v, boat) };`) +} + +// reduce returns the reducer's result, or the collection's one element unreduced: +// both bind unless the collection is known to hold two or more. +func TestValueReduceResultIsJudged(t *testing.T) { + wantCollectionValueDiags(t, `part b : Boat = vs->reduce { in a : Vehicle; in b : Vehicle; boat };`, + "cannot bind a value of type Vehicle to a feature typed by Boat") + wantCollectionValueDiags(t, `part b : Boat = one->reduce { in a : Vehicle; in b : Vehicle; boat };`, + "cannot bind a value of type Vehicle to a feature typed by Boat") + wantCollectionValueDiags(t, `part v : Vehicle = vs->reduce { in a : Vehicle; in b : Vehicle; boat };`, + "cannot bind a value of type Boat to a feature typed by Vehicle") + wantCollectionValueDiags(t, `attribute s : String = (1, 2)->reduce { in a : Integer; in b : Integer; 3 };`, + "cannot bind a value of type Integer to a feature typed by String") + wantCollectionValueDiags(t, ` + part b : Boat = two->reduce { in a : Vehicle; in b : Vehicle; boat }; + part v : Vehicle = vs->reduce { in a : Vehicle; in b : Vehicle; a }; + attribute s : String = (1, 2)->reduce { in a : Integer; in b : Integer; "s" };`) +} + +// A collection value passed as an argument is typed by its elements, so the parameter +// it binds is judged and the overload selected by them. +func TestArgumentCollectionResultIsJudged(t *testing.T) { + wantCollectionValueDiags(t, `part b = Sail(vs->collect { in v : Vehicle; v });`, + "argument 1 of Sail expects Boat, found Vehicle") + wantCollectionValueDiags(t, `part b = Sail(vs->select { in v : Vehicle; true });`, + "argument 1 of Sail expects Boat, found Vehicle") + wantCollectionValueDiags(t, `part b = Sail(vs->selectOne { in v : Vehicle; true });`, + "argument 1 of Sail expects Boat, found Vehicle") + wantCollectionValueDiags(t, `part b = Sail(vs.{ in v : Vehicle; v });`, + "argument 1 of Sail expects Boat, found Vehicle") + wantCollectionValueDiags(t, `part v = Drive(two->reduce { in a : Vehicle; in b : Vehicle; boat });`, + "argument 1 of Drive expects Vehicle, found Boat") + wantCollectionValueDiags(t, ` + part b = Sail(vs->collect { in v : Vehicle; boat }); + part v = Drive(vs->select { in v : Vehicle; true }); + part v2 = Drive(vs->reduce { in a : Vehicle; in b : Vehicle; boat }); + part v3 = Drive(vs->reduce { in a : Vehicle; in b : Vehicle; a });`) +} diff --git a/internal/core/semantics/collection.go b/internal/core/semantics/collection.go index 639822277..bc5e9d240 100644 --- a/internal/core/semantics/collection.go +++ b/internal/core/semantics/collection.go @@ -25,53 +25,116 @@ type collectionSource struct { result *symbols.Symbol } -// invocationSource is the source of a call of a ControlFunctions collection function: collect -// and reduce the body or function applied, select/reject/selectOne the collection; not ok -// where the arguments say nothing and the declaration alone decides. -func (m *Model) invocationSource(scope *symbols.Scope, e *ast.InvocationExpr, fn *symbols.Symbol) (collectionSource, bool) { +// CollectionResultTypes is the types every element of a collection value — `xs.{…}` or a call +// of a ControlFunctions collection function — has, and whether its arguments decide the value +// rather than the declared result alone; nil types where they are unknown or say nothing. +func (m *Model) CollectionResultTypes(scope *symbols.Scope, node ast.Node) ([]*symbols.Symbol, bool) { + if m == nil || m.resolver == nil || node == nil { + return nil, false + } + srcs, ok := m.sourcesOf(scope, node) + if !ok { + return nil, false + } + return m.sourcesTypes(srcs), true +} + +// sourcesOf is the sources of a collection value: `xs.{…}` by its body, a collection function +// call by its arguments; not ok for any other value. +func (m *Model) sourcesOf(scope *symbols.Scope, node ast.Node) ([]collectionSource, bool) { + switch n := node.(type) { + case *ast.CollectExpr: + return m.collectSources(scope, n) + case *ast.InvocationExpr: + return m.invocationSources(scope, n, m.invocationCallee(scope, n)) + } + return nil, false +} + +// invocationSources are the sources of a call of a ControlFunctions collection function: collect +// the body or function applied, select/reject/selectOne the collection, reduce the reducer and, +// unless the collection holds two or more, the one element it returns unreduced; not ok where +// the arguments say nothing and the declaration alone decides. +func (m *Model) invocationSources(scope *symbols.Scope, e *ast.InvocationExpr, fn *symbols.Symbol) ([]collectionSource, bool) { if fn == nil || e == nil { - return collectionSource{}, false + return nil, false } switch fn { - case m.libSymbol(fqnCollect), m.libSymbol(fqnReduce): - return m.appliedSource(scope, m.argumentTo(scope, e, fn, 1)) + case m.libSymbol(fqnCollect): + return m.appliedSources(scope, m.argumentTo(scope, e, fn, 1)) + case m.libSymbol(fqnReduce): + srcs, ok := m.appliedSources(scope, m.argumentTo(scope, e, fn, 1)) + if !ok { + return nil, false + } + if collection := m.argumentTo(scope, e, fn, 0); collection != nil && !m.holdsAtLeastTwo(scope, collection) { + srcs = append(srcs, collectionSource{scope: scope, node: collection}) + } + return srcs, true case m.libSymbol(fqnSelect), m.libSymbol(fqnReject), m.libSymbol(fqnSelectOne): if collection := m.argumentTo(scope, e, fn, 0); collection != nil { - return collectionSource{scope: scope, node: collection}, true + return []collectionSource{{scope: scope, node: collection}}, true } } - return collectionSource{}, false + return nil, false } -// collectSource is the source of `xs.{ in x; … }`: its body's result. -func (m *Model) collectSource(scope *symbols.Scope, e *ast.CollectExpr) (collectionSource, bool) { +// holdsAtLeastTwo reports a collection statically known to hold two elements or more: a +// sequence of that many literals, or a feature whose multiplicity's lower bound says so. +func (m *Model) holdsAtLeastTwo(scope *symbols.Scope, collection ast.Node) bool { + switch n := collection.(type) { + case *ast.SequenceExpr: + if len(n.Elements) < 2 { + return false + } + for _, element := range n.Elements { + switch element.(type) { + case *ast.LiteralInteger, *ast.LiteralReal, *ast.LiteralString, *ast.LiteralBool: + default: + return false + } + } + return true + case *ast.FeatureReference, *ast.QualifiedName: + sym, ok := m.resolver.ResolveTarget(scope, n) + if !ok || sym == nil { + return false + } + lower := m.EffectiveMultiplicityOf(sym).Lower + return lower.Known && !lower.Infinite && lower.Value >= 2 + } + return false +} + +// collectSources is the source of `xs.{ in x; … }`: its body's result. +func (m *Model) collectSources(scope *symbols.Scope, e *ast.CollectExpr) ([]collectionSource, bool) { body, ok := e.Body.(*ast.BodyExpr) if !ok { - return collectionSource{}, false + return nil, false } - return m.bodySource(scope, body) + return m.bodySources(scope, body) } -// appliedSource is the source of the expression applied to each element: a body +// appliedSources is the source of the expression applied to each element: a body // `{ in x; … }` by its result, a function or expression named by its result parameter. -func (m *Model) appliedSource(scope *symbols.Scope, applied ast.Node) (collectionSource, bool) { +func (m *Model) appliedSources(scope *symbols.Scope, applied ast.Node) ([]collectionSource, bool) { switch n := applied.(type) { case *ast.BodyExpr: - return m.bodySource(scope, n) + return m.bodySources(scope, n) case *ast.FeatureReference, *ast.QualifiedName, *ast.FeatureChainExpr: if result := m.appliedResult(scope, n); result != nil { - return collectionSource{result: result}, true + return []collectionSource{{result: result}}, true } } - return collectionSource{}, false + return nil, false } -// bodySource is the result of a body `{ in x; … }`, resolved among its parameters. -func (m *Model) bodySource(scope *symbols.Scope, body *ast.BodyExpr) (collectionSource, bool) { +// bodySources is the result of a body `{ in x; … }`, resolved among its parameters. +func (m *Model) bodySources(scope *symbols.Scope, body *ast.BodyExpr) ([]collectionSource, bool) { if body == nil || body.Result == nil || m.resolver == nil { - return collectionSource{}, false + return nil, false } - return collectionSource{scope: symbols.BodyExprScope(scope, body), node: body.Result}, true + return []collectionSource{{scope: symbols.BodyExprScope(scope, body), node: body.Result}}, true } // appliedResult is the result parameter of the function or expression the name applied denotes. @@ -115,42 +178,72 @@ func (m *Model) argumentTo(scope *symbols.Scope, e *ast.InvocationExpr, fn *symb // collectionResultTypes is the types every element of a collection function call's result // has; nil where they are unknown or say nothing. func (m *Model) collectionResultTypes(scope *symbols.Scope, e *ast.InvocationExpr, fn *symbols.Symbol) []*symbols.Symbol { - src, ok := m.invocationSource(scope, e, fn) + srcs, ok := m.invocationSources(scope, e, fn) if !ok { return nil } - return m.sourceTypes(src) + return m.sourcesTypes(srcs) } // collectResultTypes is the types every element of `xs.{ in x; … }` has; nil when unknown. func (m *Model) collectResultTypes(scope *symbols.Scope, e *ast.CollectExpr) []*symbols.Symbol { - src, ok := m.collectSource(scope, e) + srcs, ok := m.collectSources(scope, e) if !ok { return nil } - return m.sourceTypes(src) + return m.sourcesTypes(srcs) } -func (m *Model) sourceTypes(src collectionSource) []*symbols.Symbol { - if src.result != nil { - return informativeTypes(m.featureResultTypes(src.result)) +// CollectionElementTypes is the types of each element a collection value may hold, nil where +// unknown, and whether the value is one whose arguments decide it rather than its declared result. +func (m *Model) CollectionElementTypes(scope *symbols.Scope, node ast.Node) ([][]*symbols.Symbol, bool) { + if m == nil || m.resolver == nil || node == nil { + return nil, false + } + srcs, ok := m.sourcesOf(scope, node) + if !ok { + return nil, false } - return informativeTypes(m.elementResultTypes(src.scope, src.node)) + return m.sourcesElementTypes(srcs), true } -// elementResultTypes is the types every element a value contributes has: the expression's own, -// or for a sequence `(a, b)` those each element conforms to; nil when any element is unknown. -func (m *Model) elementResultTypes(scope *symbols.Scope, node ast.Node) []*symbols.Symbol { +// sourcesTypes is the types every element of every source conforms to; nil when unknown or +// when only Anything, which says nothing. +func (m *Model) sourcesTypes(srcs []collectionSource) []*symbols.Symbol { + return informativeTypes(m.sharedAmong(m.sourcesElementTypes(srcs))) +} + +// sourcesElementTypes is the types of each element of every source: a result parameter's +// own, a sequence `(a, b)` those of each element in turn; nil where they say nothing. +func (m *Model) sourcesElementTypes(srcs []collectionSource) [][]*symbols.Symbol { + var out [][]*symbols.Symbol + for _, src := range srcs { + if src.result != nil { + out = append(out, informativeTypes(m.featureResultTypes(src.result))) + continue + } + out = append(out, m.elementTypes(src.scope, src.node)...) + } + return out +} + +func (m *Model) elementTypes(scope *symbols.Scope, node ast.Node) [][]*symbols.Symbol { seq, ok := node.(*ast.SequenceExpr) if !ok { - return m.resultTypes(scope, node) + return [][]*symbols.Symbol{informativeTypes(m.resultTypes(scope, node))} } + var out [][]*symbols.Symbol + for _, element := range seq.Elements { + out = append(out, m.elementTypes(scope, element)...) + } + return out +} + +// sharedAmong is the types a value of every list conforms to; nil when there is no list or +// one is empty. +func (m *Model) sharedAmong(lists [][]*symbols.Symbol) []*symbols.Symbol { var common []*symbols.Symbol - for i, element := range seq.Elements { - types := m.elementResultTypes(scope, element) - if len(types) == 0 { - return nil - } + for i, types := range lists { if i > 0 { types = m.sharedTypes(common, types) } @@ -183,27 +276,27 @@ func (m *Model) anyConforms(types []*symbols.Symbol, want *symbols.Symbol) bool return false } -// collectionConformance judges the value of a collection function call by its source: every -// element must conform. Not ok where the source is unknown and the declaration decides. +// collectionConformance judges the value of a collection function call by its sources: every +// element must conform. Not ok where the sources are unknown and the declaration decides. func (m *Model) collectionConformance(scope *symbols.Scope, e *ast.InvocationExpr, fn *symbols.Symbol, want *symbols.Symbol, byUnit bool) (Conformance, bool) { - src, ok := m.invocationSource(scope, e, fn) + srcs, ok := m.invocationSources(scope, e, fn) if !ok { return conformanceUnknown(), false } - return m.sourceConformance(src, want, byUnit) + return m.sourcesConformance(srcs, want, byUnit) } // collectConformance judges the value of `xs.{ in x; … }` by its body's result: every element must conform. func (m *Model) collectConformance(scope *symbols.Scope, e *ast.CollectExpr, want *symbols.Symbol, byUnit bool) (Conformance, bool) { - src, ok := m.collectSource(scope, e) + srcs, ok := m.collectSources(scope, e) if !ok { return conformanceUnknown(), false } - return m.sourceConformance(src, want, byUnit) + return m.sourcesConformance(srcs, want, byUnit) } -func (m *Model) sourceConformance(src collectionSource, want *symbols.Symbol, byUnit bool) (Conformance, bool) { - judged := m.judgeSource(src, +func (m *Model) sourcesConformance(srcs []collectionSource, want *symbols.Symbol, byUnit bool) (Conformance, bool) { + judged := m.judgeSources(srcs, func(scope *symbols.Scope, node ast.Node) Conformance { return m.elementConformance(scope, node, want, byUnit) }, @@ -212,21 +305,14 @@ func (m *Model) sourceConformance(src collectionSource, want *symbols.Symbol, by } // collectionCastConformance judges `xs.{…} as T` or a collection function call cast by its -// source: the cast is sound when some element's types and T specialize one another. Not ok -// where the source is unknown and the declaration decides. +// sources: the cast is sound when some element's types and T specialize one another. Not ok +// where the sources are unknown and the declaration decides. func (m *Model) collectionCastConformance(scope *symbols.Scope, operand ast.Node, target *symbols.Symbol) (Conformance, bool) { - var src collectionSource - var ok bool - switch n := operand.(type) { - case *ast.CollectExpr: - src, ok = m.collectSource(scope, n) - case *ast.InvocationExpr: - src, ok = m.invocationSource(scope, n, m.invocationCallee(scope, n)) - } + srcs, ok := m.sourcesOf(scope, operand) if !ok { return conformanceUnknown(), false } - judged := m.judgeSource(src, + judged := m.judgeSources(srcs, func(scope *symbols.Scope, node ast.Node) Conformance { return m.castConformance(scope, node, target) }, func(result *symbols.Symbol) Conformance { return m.castTypesConformance(m.featureResultTypes(result), target) @@ -234,13 +320,18 @@ func (m *Model) collectionCastConformance(scope *symbols.Scope, operand ast.Node return decided(anyHolds(judged)) } -// judgeSource judges each element of a source: a result parameter as the feature, an +// judgeSources judges each element of every source: a result parameter as the feature, an // expression by itself, a sequence `(a, b)` element by element. -func (m *Model) judgeSource(src collectionSource, byNode func(*symbols.Scope, ast.Node) Conformance, byResult func(*symbols.Symbol) Conformance) []Conformance { - if src.result != nil { - return []Conformance{byResult(src.result)} +func (m *Model) judgeSources(srcs []collectionSource, byNode func(*symbols.Scope, ast.Node) Conformance, byResult func(*symbols.Symbol) Conformance) []Conformance { + var out []Conformance + for _, src := range srcs { + if src.result != nil { + out = append(out, byResult(src.result)) + continue + } + out = append(out, elementJudgements(src.scope, src.node, byNode)...) } - return elementJudgements(src.scope, src.node, byNode) + return out } func elementJudgements(scope *symbols.Scope, node ast.Node, judge func(*symbols.Scope, ast.Node) Conformance) []Conformance { diff --git a/internal/core/semantics/collection_test.go b/internal/core/semantics/collection_test.go index d328d80fb..a6bbc621c 100644 --- a/internal/core/semantics/collection_test.go +++ b/internal/core/semantics/collection_test.go @@ -249,3 +249,43 @@ func TestCollectionResultConformance(t *testing.T) { t.Errorf("unknown as String: %+v, want untyped Anything", c) } } + +// reduce returns what its reducer does, or the collection's one element unreduced, so +// the result is what both conform to — the library's Anything where they share nothing — +// unless the collection holds two or more: a declared `[2..*]` or a sequence of two literals. +func TestCollectionReduceMayReturnTheElement(t *testing.T) { + m, s := collectionModel(t, ` + part two : C[2..*]; + part one : C[1]; + attribute names = cs->reduce { in a : C; in b : C; a.name }; + attribute names1 = one->reduce { in a : C; in b : C; a.name }; + attribute names2 = two->reduce { in a : C; in b : C; a.name }; + attribute lits = (1, 2)->reduce { in a : Integer; in b : Integer; "s" }; + attribute same = cs->reduce { in a : C; in b : C; a }; + attribute cast = cs->reduce { in a : C; in b : C; a.name } as String; + attribute cast2 = two->reduce { in a : C; in b : C; a.name } as C;`) + wantValueTypes(t, m, s, "names", "Anything") + wantValueTypes(t, m, s, "names1", "Anything") + wantValueTypes(t, m, s, "names2", "String") + wantValueTypes(t, m, s, "lits", "String") + wantValueTypes(t, m, s, "same", "C") + elements, ok := m.CollectionElementTypes(s, valueOf(t, s, "names")) + if !ok || len(elements) != 2 || len(elements[0]) != 1 || len(elements[1]) != 1 || + leafName(elements[0][0].Name) != "String" || leafName(elements[1][0].Name) != "C" { + t.Errorf("names: element types %v, want String then C", elements) + } + for _, name := range []string{"names", "names1"} { + if c := m.ExprConformsToLibrary(s, valueOf(t, s, name), fqnString); !c.Known || c.Holds || c.Untyped || c.Found != "C" { + t.Errorf("%s as String: %+v, want known, not holding, found C", name, c) + } + } + if c := m.ExprConformsToLibrary(s, valueOf(t, s, "names2"), fqnString); !c.Known || !c.Holds { + t.Errorf("names2 as String: %+v, want it to hold", c) + } + if c := m.CastConformance(s, valueOf(t, s, "cast").(*ast.OperatorExpr)); !c.Known || !c.Holds { + t.Errorf("cast to String: %+v, want it to hold, the element may be one", c) + } + if c := m.CastConformance(s, valueOf(t, s, "cast2").(*ast.OperatorExpr)); !c.Known || c.Holds || c.Found != "String" { + t.Errorf("cast of two to C: %+v, want known, not holding, found String", c) + } +} From 0c99280579a8e4b87c74d857ffcf03bd1590a253 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 02:02:28 +0000 Subject: [PATCH 04/22] fix(semantics): keep collection body literals exact and read reduce's size through chains A collection value's elements keep the expression that produced them, so a scalar literal a body writes out binds as exactly as one bound directly: a decimal no longer binds to an Integer feature through bidirectional conformance. reduce's known-size check reads a feature's multiplicity through redefinition and multiplies it through a feature chain, so a collection every such feature proves to hold two or more no longer admits the unreduced element. Co-Authored-By: jason.han --- .../collection-body-result-type.fixed.md | 2 +- internal/core/passes/typecheck_expr.go | 29 +++-- internal/core/passes/typecheck_value.go | 19 ++-- internal/core/passes/typecheck_value_test.go | 45 +++++++- internal/core/semantics/collection.go | 102 +++++++++++++----- internal/core/semantics/collection_test.go | 25 ++++- 6 files changed, 176 insertions(+), 46 deletions(-) diff --git a/changes/unreleased/collection-body-result-type.fixed.md b/changes/unreleased/collection-body-result-type.fixed.md index bf07b655a..0d26f1e69 100644 --- a/changes/unreleased/collection-body-result-type.fixed.md +++ b/changes/unreleased/collection-body-result-type.fixed.md @@ -1 +1 @@ -- **A collection operation's static type follows what its declaration hands through, not the element type of the collection.** `xs->collect { in x : C; x.mass }` and `xs.{ in x : C; x.mass }` are typed by the body's result (`MassValue`), a nested collect by its innermost body, a body answering a sequence by every element type and `xs->collect f` by the named function's result; `select`, `reject` and `selectOne` keep the elements of `xs`, `reduce` follows its reducer's result — and the element a one-element collection hands back unreduced, unless the collection is known to hold two or more — and `forAll`/`exists` stay `Boolean`, each with the multiplicity the Kernel Function Library declares. A body whose result cannot be typed keeps the library's `Anything`. Value conformance, a feature's bound value, invocation arguments, trigger arguments and enumerated values are judged by the specialized type, so `accept when counts.{in n : Integer; n}` is refused where it was silent, and `when counts.{in n; n > 3}` is accepted where it was refused. A body reading the feature it values terminates as a self-referential argument does. +- **A collection operation's static type follows what its declaration hands through, not the element type of the collection.** `xs->collect { in x : C; x.mass }` and `xs.{ in x : C; x.mass }` are typed by the body's result (`MassValue`), a nested collect by its innermost body, a body answering a sequence by every element type and `xs->collect f` by the named function's result; `select`, `reject` and `selectOne` keep the elements of `xs`, `reduce` follows its reducer's result — and the element a one-element collection hands back unreduced, unless the collection is known to hold two or more, by its own multiplicity, one it inherits by redefinition, or a chain through such features — and `forAll`/`exists` stay `Boolean`, each with the multiplicity the Kernel Function Library declares. A body whose result cannot be typed keeps the library's `Anything`. Value conformance, a feature's bound value, invocation arguments, trigger arguments and enumerated values are judged by the specialized type, so `accept when counts.{in n : Integer; n}` is refused where it was silent, and `when counts.{in n; n > 3}` is accepted where it was refused. A scalar literal a body writes out is as exact as one bound directly: `attribute i : Integer = xs.{ in x : C; 1.5 }` is refused. A body reading the feature it values terminates as a self-referential argument does. diff --git a/internal/core/passes/typecheck_expr.go b/internal/core/passes/typecheck_expr.go index b2dea5079..8eb330de7 100644 --- a/internal/core/passes/typecheck_expr.go +++ b/internal/core/passes/typecheck_expr.go @@ -118,24 +118,41 @@ func (ec *exprChecker) checkBoundValue(valueScope, declScope *symbols.Scope, d f // A collection literal binds elementwise, so each element is checked // against the feature's type rather than the sequence as a whole. for _, element := range valueElements(value) { + // A collection value binds the elements its body or collection produces; inferring + // the value itself still checks what its body declares. + if elements, collection := ec.model.CollectionElements(valueScope, element); collection { + ec.infer(valueScope, element) + for _, produced := range elements { + if produced.Node != nil { + ec.checkScalarBinding(produced.Node, ec.infer(produced.Scope, produced.Node), want) + } + } + continue + } var got semantics.PrimType if inv, ok := element.(*ast.InvocationExpr); ok && node != nil { got = ec.inferNodeInvocation(valueScope, inv, node) } else { got = ec.infer(valueScope, element) } - if want == semantics.PrimUnknown || got == semantics.PrimUnknown { - continue - } - if !bindable(element, got, want) { - ec.errorf(element.Span(), "cannot bind %s value to a feature typed by %s", got, want) - } + ec.checkScalarBinding(element, got, want) } ec.checkValueConformance(valueScope, declScope, d, value) ec.checkValueDimension(valueScope, declScope, d, value) ec.checkValueCount(declScope, d, value) } +// checkScalarBinding reports a got-typed value that may not bind to a want-typed feature, +// where both are known. +func (ec *exprChecker) checkScalarBinding(value ast.Node, got, want semantics.PrimType) { + if want == semantics.PrimUnknown || got == semantics.PrimUnknown { + return + } + if !bindable(value, got, want) { + ec.errorf(value.Span(), "cannot bind %s value to a feature typed by %s", got, want) + } +} + // bindable reports whether a got-typed value may bind to a want-typed feature: a literal's // type is exact, an expression's only bounds its values (7 / 2 is Rational, 4 / 2 whole). func bindable(value ast.Node, got, want semantics.PrimType) bool { diff --git a/internal/core/passes/typecheck_value.go b/internal/core/passes/typecheck_value.go index cdea2ef85..1d723cb13 100644 --- a/internal/core/passes/typecheck_value.go +++ b/internal/core/passes/typecheck_value.go @@ -44,10 +44,10 @@ func (ec *exprChecker) checkValueConformance(valueScope, declScope *symbols.Scop } continue } - if elements, collection := ec.model.CollectionElementTypes(valueScope, value); collection { + if elements, collection := ec.model.CollectionElements(valueScope, value); collection { // Every element a collection value may hold binds, not the Anything the - // library declares; the lattice does not type them. - if gots := ec.unboundElementTypes(elements, wants); len(gots) > 0 { + // library declares; a scalar one written out is the lattice rules' to report. + if gots := ec.unboundElementTypes(elements, wants, scalar); len(gots) > 0 { ec.errorf(value.Span(), "cannot bind a value of type %s to a feature typed by %s", typeNames(gots), typeNames(wants)) } continue @@ -243,14 +243,19 @@ func (ec *exprChecker) boundTypesConform(feature *symbols.Symbol, gots, wants [] return false } -// unboundElementTypes is the types of those elements of a collection value, given element by -// element, none of whose types binds to a feature typed by wants; an untyped element binds. -func (ec *exprChecker) unboundElementTypes(elements [][]*symbols.Symbol, wants []*symbols.Symbol) []*symbols.Symbol { +// unboundElementTypes is the types of those elements of a collection value none of whose +// types binds to a feature typed by wants; an untyped element binds, and a scalar expression +// bound to a scalar feature is left to the lattice rules. +func (ec *exprChecker) unboundElementTypes(elements []semantics.CollectionElement, wants []*symbols.Symbol, scalar bool) []*symbols.Symbol { var out []*symbols.Symbol - for _, gots := range elements { + for _, element := range elements { + gots := element.Types if len(gots) == 0 || ec.boundTypesConform(nil, gots, wants) { continue } + if scalar && element.Node != nil && ec.anyScalar(gots) { + continue + } for _, got := range gots { if !slices.Contains(out, got) { out = append(out, got) diff --git a/internal/core/passes/typecheck_value_test.go b/internal/core/passes/typecheck_value_test.go index cf16ab912..7d9ff12d8 100644 --- a/internal/core/passes/typecheck_value_test.go +++ b/internal/core/passes/typecheck_value_test.go @@ -309,6 +309,12 @@ func collectionValueDiags(t *testing.T, members string) []string { function Boats { in v : Vehicle; return r : Boat; } function Sail { in b : Boat; return r : Boat; } function Drive { in v : Vehicle; return r : Vehicle; } + function Half { in v : Vehicle; return r : Real; } + function Name { in v : Vehicle; return r : String; } + part def Pair { part items : Vehicle[2]; part item : Vehicle[1]; } + part def Pairs :> Pair { part :>> items; part :>> item; } + part pair : Pairs[1]; + part couple : Pairs[2]; `+members+` }`) var got []string @@ -340,9 +346,11 @@ func TestValueCollectionResultIsJudged(t *testing.T) { wantCollectionValueDiags(t, `part b : Boat = vs->selectOne { in v : Vehicle; true };`, "cannot bind a value of type Vehicle to a feature typed by Boat") wantCollectionValueDiags(t, `attribute i : Integer = vs.{ in v : Vehicle; true };`, - "cannot bind a value of type Boolean to a feature typed by Integer") + "cannot bind Boolean value to a feature typed by Integer") wantCollectionValueDiags(t, `attribute i : Integer = vs->collect { in v : Vehicle; (true, 1) };`, - "cannot bind a value of type Boolean to a feature typed by Integer") + "cannot bind Boolean value to a feature typed by Integer") + wantCollectionValueDiags(t, `attribute i : Integer = vs->collect Name;`, + "cannot bind a value of type String to a feature typed by Integer") wantCollectionValueDiags(t, `part b : Boat = vs.{ in v : Vehicle; (v, boat) };`, "cannot bind a value of type Vehicle to a feature typed by Boat") wantCollectionValueDiags(t, ` @@ -356,6 +364,33 @@ func TestValueCollectionResultIsJudged(t *testing.T) { part open2 : Boat = vs.{ in v; (v, boat) };`) } +// A scalar element a collection value spells out is exact, as a literal bound directly is: +// a decimal does not bind to an Integer feature because Integer values are Real. An +// element a feature or function result types only bounds its values, so it binds either way. +func TestValueCollectionElementLiteralIsExact(t *testing.T) { + wantCollectionValueDiags(t, `attribute i : Integer = vs.{ in v : Vehicle; 1.5 };`, + "cannot bind Rational value to a feature typed by Integer") + wantCollectionValueDiags(t, `attribute i : Integer = vs->collect { in v : Vehicle; 1.5 };`, + "cannot bind Rational value to a feature typed by Integer") + wantCollectionValueDiags(t, `attribute i : Integer = vs->collect { in v : Vehicle; (1, 2.5) };`, + "cannot bind Rational value to a feature typed by Integer") + wantCollectionValueDiags(t, `attribute n : Natural = vs.{ in v : Vehicle; -1 };`, + "cannot bind Integer value to a feature typed by Natural") + wantCollectionValueDiags(t, `attribute i : Integer = (1, 2)->reduce { in a : Integer; in b : Integer; 1.5 };`, + "cannot bind Rational value to a feature typed by Integer") + wantCollectionValueDiags(t, `attribute i : Integer = (1.5, 2.5)->select { in a : Real; true };`, + "cannot bind Rational value to a feature typed by Integer", + "cannot bind Rational value to a feature typed by Integer") + wantCollectionValueDiags(t, ` + attribute r : Real; + attribute i : Integer = vs.{ in v : Vehicle; r }; + attribute i2 : Integer = vs->collect { in v : Vehicle; r / 2 }; + attribute i3 : Integer = vs->collect Half; + attribute i4 : Integer = vs.{ in v : Vehicle; 2 }; + attribute r2 : Real = vs.{ in v : Vehicle; 2 }; + attribute i5 : Integer = (1, 2)->select { in a : Integer; true };`) +} + // reduce returns the reducer's result, or the collection's one element unreduced: // both bind unless the collection is known to hold two or more. func TestValueReduceResultIsJudged(t *testing.T) { @@ -366,9 +401,13 @@ func TestValueReduceResultIsJudged(t *testing.T) { wantCollectionValueDiags(t, `part v : Vehicle = vs->reduce { in a : Vehicle; in b : Vehicle; boat };`, "cannot bind a value of type Boat to a feature typed by Vehicle") wantCollectionValueDiags(t, `attribute s : String = (1, 2)->reduce { in a : Integer; in b : Integer; 3 };`, - "cannot bind a value of type Integer to a feature typed by String") + "cannot bind Natural value to a feature typed by String") + wantCollectionValueDiags(t, `part b : Boat = pair.item->reduce { in a : Vehicle; in b : Vehicle; boat };`, + "cannot bind a value of type Vehicle to a feature typed by Boat") wantCollectionValueDiags(t, ` part b : Boat = two->reduce { in a : Vehicle; in b : Vehicle; boat }; + part b2 : Boat = pair.items->reduce { in a : Vehicle; in b : Vehicle; boat }; + part b3 : Boat = couple.item->reduce { in a : Vehicle; in b : Vehicle; boat }; part v : Vehicle = vs->reduce { in a : Vehicle; in b : Vehicle; a }; attribute s : String = (1, 2)->reduce { in a : Integer; in b : Integer; "s" };`) } diff --git a/internal/core/semantics/collection.go b/internal/core/semantics/collection.go index bc5e9d240..d4180bd4b 100644 --- a/internal/core/semantics/collection.go +++ b/internal/core/semantics/collection.go @@ -80,14 +80,13 @@ func (m *Model) invocationSources(scope *symbols.Scope, e *ast.InvocationExpr, f } // holdsAtLeastTwo reports a collection statically known to hold two elements or more: a -// sequence of that many literals, or a feature whose multiplicity's lower bound says so. +// sequence of that many literals, or a feature or chain whose multiplicity's lower bound says so. func (m *Model) holdsAtLeastTwo(scope *symbols.Scope, collection ast.Node) bool { - switch n := collection.(type) { - case *ast.SequenceExpr: - if len(n.Elements) < 2 { + if seq, ok := collection.(*ast.SequenceExpr); ok { + if len(seq.Elements) < 2 { return false } - for _, element := range n.Elements { + for _, element := range seq.Elements { switch element.(type) { case *ast.LiteralInteger, *ast.LiteralReal, *ast.LiteralString, *ast.LiteralBool: default: @@ -95,15 +94,58 @@ func (m *Model) holdsAtLeastTwo(scope *symbols.Scope, collection ast.Node) bool } } return true + } + lower, ok := m.fewestValuesOf(scope, collection) + return ok && lower >= 2 +} + +// fewestValuesOf is the lower bound of the values a feature reference or chain holds: a chain +// holds, through each value of its operand, the values of its last feature. +func (m *Model) fewestValuesOf(scope *symbols.Scope, node ast.Node) (int64, bool) { + switch n := node.(type) { case *ast.FeatureReference, *ast.QualifiedName: sym, ok := m.resolver.ResolveTarget(scope, n) if !ok || sym == nil { - return false + return 0, false + } + return m.fewestValuesHeld(sym) + case *ast.FeatureChainExpr: + sym, ok := m.resolver.ResolveTarget(scope, n) + if !ok || sym == nil { + return 0, false + } + last, ok := m.fewestValuesHeld(sym) + if !ok { + return 0, false + } + through, ok := m.fewestValuesOf(scope, n.Operand) + if !ok { + return 0, false } - lower := m.EffectiveMultiplicityOf(sym).Lower - return lower.Known && !lower.Infinite && lower.Value >= 2 + return through * last, true } - return false + return 0, false +} + +// fewestValuesHeld is the lower bound of the multiplicity governing a feature: the one it +// declares, or the one it inherits from a feature it redefines; one where it declares none. +func (m *Model) fewestValuesHeld(sym *symbols.Symbol) (int64, bool) { + if r, ok := m.MultiplicityOf(sym); ok { + return knownLower(r) + } + for _, redefined := range m.redefinedTransitively(sym) { + if r, ok := m.MultiplicityOf(redefined); ok { + return knownLower(r) + } + } + return knownLower(AssumedRange()) +} + +func knownLower(r Range) (int64, bool) { + if !r.Lower.Known || r.Lower.Infinite { + return 0, false + } + return r.Lower.Value, true } // collectSources is the source of `xs.{ in x; … }`: its body's result. @@ -194,9 +236,17 @@ func (m *Model) collectResultTypes(scope *symbols.Scope, e *ast.CollectExpr) []* return m.sourcesTypes(srcs) } -// CollectionElementTypes is the types of each element a collection value may hold, nil where -// unknown, and whether the value is one whose arguments decide it rather than its declared result. -func (m *Model) CollectionElementTypes(scope *symbols.Scope, node ast.Node) ([][]*symbols.Symbol, bool) { +// CollectionElement is one element a collection value may hold: the expression producing it, +// in its scope, or nil where a function's result parameter does; and its types, nil where unknown. +type CollectionElement struct { + Scope *symbols.Scope + Node ast.Node + Types []*symbols.Symbol +} + +// CollectionElements is each element a collection value may hold, and whether the value is +// one whose arguments decide it rather than its declared result. +func (m *Model) CollectionElements(scope *symbols.Scope, node ast.Node) ([]CollectionElement, bool) { if m == nil || m.resolver == nil || node == nil { return nil, false } @@ -204,37 +254,41 @@ func (m *Model) CollectionElementTypes(scope *symbols.Scope, node ast.Node) ([][ if !ok { return nil, false } - return m.sourcesElementTypes(srcs), true + return m.sourcesElements(srcs), true } // sourcesTypes is the types every element of every source conforms to; nil when unknown or // when only Anything, which says nothing. func (m *Model) sourcesTypes(srcs []collectionSource) []*symbols.Symbol { - return informativeTypes(m.sharedAmong(m.sourcesElementTypes(srcs))) + var lists [][]*symbols.Symbol + for _, element := range m.sourcesElements(srcs) { + lists = append(lists, element.Types) + } + return informativeTypes(m.sharedAmong(lists)) } -// sourcesElementTypes is the types of each element of every source: a result parameter's -// own, a sequence `(a, b)` those of each element in turn; nil where they say nothing. -func (m *Model) sourcesElementTypes(srcs []collectionSource) [][]*symbols.Symbol { - var out [][]*symbols.Symbol +// sourcesElements is each element of every source: a result parameter by its own types, a +// sequence `(a, b)` each element in turn; types nil where they say nothing. +func (m *Model) sourcesElements(srcs []collectionSource) []CollectionElement { + var out []CollectionElement for _, src := range srcs { if src.result != nil { - out = append(out, informativeTypes(m.featureResultTypes(src.result))) + out = append(out, CollectionElement{Types: informativeTypes(m.featureResultTypes(src.result))}) continue } - out = append(out, m.elementTypes(src.scope, src.node)...) + out = append(out, m.elementsOf(src.scope, src.node)...) } return out } -func (m *Model) elementTypes(scope *symbols.Scope, node ast.Node) [][]*symbols.Symbol { +func (m *Model) elementsOf(scope *symbols.Scope, node ast.Node) []CollectionElement { seq, ok := node.(*ast.SequenceExpr) if !ok { - return [][]*symbols.Symbol{informativeTypes(m.resultTypes(scope, node))} + return []CollectionElement{{Scope: scope, Node: node, Types: informativeTypes(m.resultTypes(scope, node))}} } - var out [][]*symbols.Symbol + var out []CollectionElement for _, element := range seq.Elements { - out = append(out, m.elementTypes(scope, element)...) + out = append(out, m.elementsOf(scope, element)...) } return out } diff --git a/internal/core/semantics/collection_test.go b/internal/core/semantics/collection_test.go index a6bbc621c..0dd4ae0c1 100644 --- a/internal/core/semantics/collection_test.go +++ b/internal/core/semantics/collection_test.go @@ -252,14 +252,24 @@ func TestCollectionResultConformance(t *testing.T) { // reduce returns what its reducer does, or the collection's one element unreduced, so // the result is what both conform to — the library's Anything where they share nothing — -// unless the collection holds two or more: a declared `[2..*]` or a sequence of two literals. +// unless the collection holds two or more: a declared `[2..*]`, one inherited by +// redefinition, a chain through such a feature, or a sequence of two literals. func TestCollectionReduceMayReturnTheElement(t *testing.T) { m, s := collectionModel(t, ` part two : C[2..*]; part one : C[1]; + part def Pair { part items : C[2]; part item : C[1]; } + part def Pairs :> Pair { part :>> items; part :>> item; } + part pair : Pairs[1]; + part pairs : Pairs[*]; + part couple : Pairs[2]; attribute names = cs->reduce { in a : C; in b : C; a.name }; attribute names1 = one->reduce { in a : C; in b : C; a.name }; attribute names2 = two->reduce { in a : C; in b : C; a.name }; + attribute inherited = pair.items->reduce { in a : C; in b : C; a.name }; + attribute chained = couple.item->reduce { in a : C; in b : C; a.name }; + attribute chained1 = pair.item->reduce { in a : C; in b : C; a.name }; + attribute chainedAny = pairs.items->reduce { in a : C; in b : C; a.name }; attribute lits = (1, 2)->reduce { in a : Integer; in b : Integer; "s" }; attribute same = cs->reduce { in a : C; in b : C; a }; attribute cast = cs->reduce { in a : C; in b : C; a.name } as String; @@ -267,12 +277,17 @@ func TestCollectionReduceMayReturnTheElement(t *testing.T) { wantValueTypes(t, m, s, "names", "Anything") wantValueTypes(t, m, s, "names1", "Anything") wantValueTypes(t, m, s, "names2", "String") + wantValueTypes(t, m, s, "inherited", "String") + wantValueTypes(t, m, s, "chained", "String") + wantValueTypes(t, m, s, "chained1", "Anything") + wantValueTypes(t, m, s, "chainedAny", "Anything") wantValueTypes(t, m, s, "lits", "String") wantValueTypes(t, m, s, "same", "C") - elements, ok := m.CollectionElementTypes(s, valueOf(t, s, "names")) - if !ok || len(elements) != 2 || len(elements[0]) != 1 || len(elements[1]) != 1 || - leafName(elements[0][0].Name) != "String" || leafName(elements[1][0].Name) != "C" { - t.Errorf("names: element types %v, want String then C", elements) + elements, ok := m.CollectionElements(s, valueOf(t, s, "names")) + if !ok || len(elements) != 2 || len(elements[0].Types) != 1 || len(elements[1].Types) != 1 || + leafName(elements[0].Types[0].Name) != "String" || leafName(elements[1].Types[0].Name) != "C" || + elements[0].Node == nil || elements[1].Node == nil { + t.Errorf("names: elements %v, want the reducer's String then the collection's C", elements) } for _, name := range []string{"names", "names1"} { if c := m.ExprConformsToLibrary(s, valueOf(t, s, name), fqnString); !c.Known || c.Holds || c.Untyped || c.Found != "C" { From 74ccaab3a1b5a28ddfb4c05f9e259449b8594a7b Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 02:24:25 +0000 Subject: [PATCH 05/22] fix(semantics): bind select shorthand, empty collections and body quantities by their elements xs.?{...} is judged by the elements of xs as xs->select {...} is; a collection over () or a feature admitting no value applies nothing, so neither the reducer nor the element types it; a quantity a body writes out is measured against the target's dimension; the produced elements' types are read silently once the value itself has been checked, so an invalid body reports once. Co-Authored-By: jason.han --- .../collection-body-result-type.fixed.md | 2 +- internal/core/passes/typecheck_dimension.go | 45 +++-- .../core/passes/typecheck_dimension_test.go | 24 +++ internal/core/passes/typecheck_expr.go | 7 +- .../core/passes/typecheck_operator_test.go | 3 + .../core/passes/typecheck_trigger_test.go | 7 + internal/core/passes/typecheck_value_test.go | 43 ++++- internal/core/semantics/collection.go | 169 ++++++++++++------ internal/core/semantics/collection_test.go | 61 +++++++ 9 files changed, 282 insertions(+), 79 deletions(-) diff --git a/changes/unreleased/collection-body-result-type.fixed.md b/changes/unreleased/collection-body-result-type.fixed.md index 0d26f1e69..506ca12e4 100644 --- a/changes/unreleased/collection-body-result-type.fixed.md +++ b/changes/unreleased/collection-body-result-type.fixed.md @@ -1 +1 @@ -- **A collection operation's static type follows what its declaration hands through, not the element type of the collection.** `xs->collect { in x : C; x.mass }` and `xs.{ in x : C; x.mass }` are typed by the body's result (`MassValue`), a nested collect by its innermost body, a body answering a sequence by every element type and `xs->collect f` by the named function's result; `select`, `reject` and `selectOne` keep the elements of `xs`, `reduce` follows its reducer's result — and the element a one-element collection hands back unreduced, unless the collection is known to hold two or more, by its own multiplicity, one it inherits by redefinition, or a chain through such features — and `forAll`/`exists` stay `Boolean`, each with the multiplicity the Kernel Function Library declares. A body whose result cannot be typed keeps the library's `Anything`. Value conformance, a feature's bound value, invocation arguments, trigger arguments and enumerated values are judged by the specialized type, so `accept when counts.{in n : Integer; n}` is refused where it was silent, and `when counts.{in n; n > 3}` is accepted where it was refused. A scalar literal a body writes out is as exact as one bound directly: `attribute i : Integer = xs.{ in x : C; 1.5 }` is refused. A body reading the feature it values terminates as a self-referential argument does. +- **A collection operation's static type follows what its declaration hands through, not the element type of the collection.** `xs->collect { in x : C; x.mass }` and `xs.{ in x : C; x.mass }` are typed by the body's result (`MassValue`), a nested collect by its innermost body, a body answering a sequence by every element type and `xs->collect f` by the named function's result; `select`, `reject` and `selectOne` keep the elements of `xs`, `reduce` follows its reducer's result — and the element a one-element collection hands back unreduced, unless the collection is known to hold two or more, by its own multiplicity, one it inherits by redefinition, or a chain through such features — and `forAll`/`exists` stay `Boolean`, each with the multiplicity the Kernel Function Library declares. A body whose result cannot be typed keeps the library's `Anything`. Value conformance, a feature's bound value, invocation arguments, trigger arguments and enumerated values are judged by the specialized type, so `accept when counts.{in n : Integer; n}` is refused where it was silent, and `when counts.{in n; n > 3}` is accepted where it was refused. A scalar literal a body writes out is as exact as one bound directly: `attribute i : Integer = xs.{ in x : C; 1.5 }` is refused, and a quantity it writes out is measured against the target's dimension: `attribute t : DurationValue = xs.{ in x : C; 5 [m] }` is refused. `xs.?{…}` binds as `xs->select {…}` does. A collection over `()` or a feature admitting no value applies nothing, so nothing types it. A body reading the feature it values terminates as a self-referential argument does. diff --git a/internal/core/passes/typecheck_dimension.go b/internal/core/passes/typecheck_dimension.go index f26f459bb..dcf43ead4 100644 --- a/internal/core/passes/typecheck_dimension.go +++ b/internal/core/passes/typecheck_dimension.go @@ -55,24 +55,39 @@ func (ec *exprChecker) checkValueDimension(valueScope, declScope *symbols.Scope, } want, known := ec.model.DimensionOfType(declared) for _, element := range valueElements(value) { - if ec.judgedByType(valueScope, element) { - // A named value is judged against the target by specialization, - // which reports the same mismatch as a clash of types. - continue - } - if statesNoMeasurement(element) { - continue - } - if ec.judgedAsMeasurementRef(valueScope, declared, element) || ec.judgedAsFramedQuantity(valueScope, declared, element) || !known { - continue - } - got, ok := ec.model.DimensionOfExpr(valueScope, element) - if !ok || want.Term.Commensurable(got.Term) { + // A collection value binds each element it may hold, which is measured on its own. + if elements, collection := ec.model.CollectionElements(valueScope, element); collection { + for _, produced := range elements { + if produced.Node != nil { + ec.checkElementDimension(produced.Scope, declared, want, known, produced.Node) + } + } continue } - ec.errorf(element.Span(), msgIncommensurableBinding, - describeDimension(got), describeDimension(want)) + ec.checkElementDimension(valueScope, declared, want, known, element) + } +} + +// checkElementDimension reports one bound element measured in a dimension the target's +// declared type, of dimension want where known, does not measure in. +func (ec *exprChecker) checkElementDimension(scope *symbols.Scope, declared *symbols.Symbol, want semantics.Dimension, known bool, element ast.Node) { + if ec.judgedByType(scope, element) { + // A named value is judged against the target by specialization, + // which reports the same mismatch as a clash of types. + return + } + if statesNoMeasurement(element) { + return + } + if ec.judgedAsMeasurementRef(scope, declared, element) || ec.judgedAsFramedQuantity(scope, declared, element) || !known { + return + } + got, ok := ec.model.DimensionOfExpr(scope, element) + if !ok || want.Term.Commensurable(got.Term) { + return } + ec.errorf(element.Span(), msgIncommensurableBinding, + describeDimension(got), describeDimension(want)) } // judgedAsMeasurementRef judges a unit composed by `*`, `/` or `**` as the DerivedUnit diff --git a/internal/core/passes/typecheck_dimension_test.go b/internal/core/passes/typecheck_dimension_test.go index e0c41b5d1..3f8d151d1 100644 --- a/internal/core/passes/typecheck_dimension_test.go +++ b/internal/core/passes/typecheck_dimension_test.go @@ -47,6 +47,30 @@ func TestBoundNestedCollectionQuantityOfAnotherDimension(t *testing.T) { wantNoDimensionDiags(t, `attribute ls : LengthValue[*] = (1 [m], (2 [m], 3 [mm]));`) } +// TestBoundCollectionQuantityOfAnotherDimension: a collection value binds each element its +// body produces, or keeps, so a unit written in the body is measured against the target too. +func TestBoundCollectionQuantityOfAnotherDimension(t *testing.T) { + const parts = `private import ControlFunctions::*; part def Part; part parts : Part[*];` + wantOneDimensionError(t, parts+`attribute t : DurationValue = parts.{ in p : Part; 5 [m] };`, + "cannot bind m (dimension L) to a feature typed by DurationValue (dimension T)") + wantOneDimensionError(t, parts+`attribute t : DurationValue = parts->collect { in p : Part; 5 [m] };`, + "cannot bind m (dimension L) to a feature typed by DurationValue (dimension T)") + wantOneDimensionError(t, parts+`attribute t : DurationValue = parts.{ in p : Part; (5 [s], 5 [m]) };`, + "cannot bind m (dimension L) to a feature typed by DurationValue (dimension T)") + wantOneDimensionError(t, parts+`attribute t : DurationValue = (5 [m], 6 [s]).?{ in l : LengthValue; true };`, + "cannot bind m (dimension L) to a feature typed by DurationValue (dimension T)") + wantOneDimensionError(t, parts+`attribute t : DurationValue = (5 [s], 6 [m])->select { in l : LengthValue; true };`, + "cannot bind m (dimension L) to a feature typed by DurationValue (dimension T)") + wantOneDimensionError(t, parts+`attribute t : DurationValue = (5 [m])->reduce { in a : LengthValue; in b : LengthValue; 1 [s] };`, + "cannot bind m (dimension L) to a feature typed by DurationValue (dimension T)") + wantNoDimensionDiags(t, parts+` + attribute t : DurationValue = parts.{ in p : Part; 5 [min] }; + attribute t2 : DurationValue = parts->collect { in p : Part; (5 [s], 5 [min]) }; + attribute t3 : DurationValue = (5 [s], 6 [min]).?{ in d : DurationValue; true }; + attribute t4 : DurationValue = (5 [s], 6 [min])->reduce { in a : DurationValue; in b : DurationValue; 1 [min] }; + attribute l : LengthValue = parts.{ in p : Part; 5 [m] };`) +} + // TestBoundQuantityOfTheSameDimensionAtAnotherScale: a dimension has no scale, // so any unit measuring in it conforms. func TestBoundQuantityOfTheSameDimensionAtAnotherScale(t *testing.T) { diff --git a/internal/core/passes/typecheck_expr.go b/internal/core/passes/typecheck_expr.go index 8eb330de7..21bb8bf14 100644 --- a/internal/core/passes/typecheck_expr.go +++ b/internal/core/passes/typecheck_expr.go @@ -118,13 +118,14 @@ func (ec *exprChecker) checkBoundValue(valueScope, declScope *symbols.Scope, d f // A collection literal binds elementwise, so each element is checked // against the feature's type rather than the sequence as a whole. for _, element := range valueElements(value) { - // A collection value binds the elements its body or collection produces; inferring - // the value itself still checks what its body declares. + // A collection value binds the elements its body or collection produces. Inferring + // the value checks and reports on them; their types are then read silently. if elements, collection := ec.model.CollectionElements(valueScope, element); collection { ec.infer(valueScope, element) + silent := exprChecker{resolver: ec.resolver, model: ec.model, lang: ec.lang, chaining: ec.chaining, performed: ec.performed} for _, produced := range elements { if produced.Node != nil { - ec.checkScalarBinding(produced.Node, ec.infer(produced.Scope, produced.Node), want) + ec.checkScalarBinding(produced.Node, silent.infer(produced.Scope, produced.Node), want) } } continue diff --git a/internal/core/passes/typecheck_operator_test.go b/internal/core/passes/typecheck_operator_test.go index de1655cac..cb2deff46 100644 --- a/internal/core/passes/typecheck_operator_test.go +++ b/internal/core/passes/typecheck_operator_test.go @@ -163,6 +163,9 @@ func TestCastConformanceCollectionBody(t *testing.T) { castDiags(t, "", `feature some = xs.{in x : A; (true, 1)} as Integer; feature other = xs.{in x : A; (x, 1)} as B;`) castDiags(t, "", `feature open = xs.{in x; (x, 1)} as String; feature open2 = xs.{in x; (untyped, 1)} as String;`) castDiags(t, "", `feature kept = xs->ControlFunctions::select {in x : A; true} as B;`) + castDiags(t, "", `feature kept = xs.?{in x : A; true} as B;`) + castDiags(t, "", `feature bad = xs.?{in x : A; true} as String;`, "16:16 cast argument is typed by A, unrelated to the target String") + castDiags(t, "", `feature none = ()->ControlFunctions::reduce {in x : A; in y : A; s} as C;`) } // A cast up, down, or sideways through one of several types conforms; so does diff --git a/internal/core/passes/typecheck_trigger_test.go b/internal/core/passes/typecheck_trigger_test.go index 55f27d09b..c4d4e3ffa 100644 --- a/internal/core/passes/typecheck_trigger_test.go +++ b/internal/core/passes/typecheck_trigger_test.go @@ -505,6 +505,7 @@ func TestIndexedTriggerArgument(t *testing.T) { // collect shapes kept silent too, and accepts the select shapes kept silent. func TestCollectAndSelectTriggerArguments(t *testing.T) { const collected = "found a collection `.{…}` maps to, typed Anything" + const nothing = "found an empty value over a collection holding nothing, typed Anything" for _, tc := range []struct{ trigger, code, found string }{ {"when counts.{in n : Integer; n}", "trigger-when-boolean", "found Integer"}, {"when flags.{in f; f}", "trigger-when-boolean", collected}, @@ -521,6 +522,10 @@ func TestCollectAndSelectTriggerArguments(t *testing.T) { {"when flags.{in f : Boolean; (f, (1, label))}", "trigger-when-boolean", "found Natural and String"}, {"when flags.{in f; (f, label)}", "trigger-when-boolean", "found String"}, {"when counts.{in n : Integer; (n > 3, untyped)}", "trigger-when-boolean", collected}, + {"when counts->ControlFunctions::reduce {in a : Integer; in b : Integer; 5}", "trigger-when-boolean", "found Natural"}, + {"when ()->ControlFunctions::reduce {in a : Integer; in b : Integer; 5}", "trigger-when-boolean", nothing}, + {"when ()->ControlFunctions::collect {in a : Integer; 5}", "trigger-when-boolean", nothing}, + {"when ().{in a : Integer; 5}", "trigger-when-boolean", nothing}, } { wantTriggerDiag(t, "transition first a accept "+tc.trigger+" then b;", tc.code, tc.found) } @@ -540,6 +545,8 @@ func TestCollectAndSelectTriggerArguments(t *testing.T) { } { wantTriggerSilent(t, "transition first a accept "+trigger+" then b;") } + wantTriggerSilent(t, "entry action { if ()->ControlFunctions::reduce {in a : Integer; in b : Integer; 5} { assign x := 1; } }") + wantTriggerSilent(t, "entry action { if ().{in a : Integer; 5} { assign x := 1; } }") } // `{ … }` written as a trigger argument is the expression itself, an Evaluation, diff --git a/internal/core/passes/typecheck_value_test.go b/internal/core/passes/typecheck_value_test.go index 7d9ff12d8..e7f3f8d85 100644 --- a/internal/core/passes/typecheck_value_test.go +++ b/internal/core/passes/typecheck_value_test.go @@ -305,6 +305,7 @@ func collectionValueDiags(t *testing.T, members string) []string { part vs : Vehicle[*]; part one : Vehicle[1]; part two : Vehicle[2..*]; + part none : Vehicle[0]; part boat : Boat; function Boats { in v : Vehicle; return r : Boat; } function Sail { in b : Boat; return r : Boat; } @@ -345,6 +346,8 @@ func TestValueCollectionResultIsJudged(t *testing.T) { "cannot bind a value of type Vehicle to a feature typed by Boat") wantCollectionValueDiags(t, `part b : Boat = vs->selectOne { in v : Vehicle; true };`, "cannot bind a value of type Vehicle to a feature typed by Boat") + wantCollectionValueDiags(t, `part b : Boat = vs.?{ in v : Vehicle; true };`, + "cannot bind a value of type Vehicle to a feature typed by Boat") wantCollectionValueDiags(t, `attribute i : Integer = vs.{ in v : Vehicle; true };`, "cannot bind Boolean value to a feature typed by Integer") wantCollectionValueDiags(t, `attribute i : Integer = vs->collect { in v : Vehicle; (true, 1) };`, @@ -356,6 +359,7 @@ func TestValueCollectionResultIsJudged(t *testing.T) { wantCollectionValueDiags(t, ` part t : Truck = vs->collect { in v : Vehicle; v }; part v : Vehicle = vs->select { in v : Vehicle; true }; + part v2 : Vehicle = vs.?{ in v : Vehicle; true }; part b : Boat = vs->collect { in v : Vehicle; boat }; part b2 : Boat = vs->collect Boats; attribute i : Integer = vs->collect { in v : Vehicle; (1, 2) }; @@ -381,6 +385,9 @@ func TestValueCollectionElementLiteralIsExact(t *testing.T) { wantCollectionValueDiags(t, `attribute i : Integer = (1.5, 2.5)->select { in a : Real; true };`, "cannot bind Rational value to a feature typed by Integer", "cannot bind Rational value to a feature typed by Integer") + wantCollectionValueDiags(t, `attribute i : Integer = (1.5, 2.5).?{ in a : Real; true };`, + "cannot bind Rational value to a feature typed by Integer", + "cannot bind Rational value to a feature typed by Integer") wantCollectionValueDiags(t, ` attribute r : Real; attribute i : Integer = vs.{ in v : Vehicle; r }; @@ -388,7 +395,23 @@ func TestValueCollectionElementLiteralIsExact(t *testing.T) { attribute i3 : Integer = vs->collect Half; attribute i4 : Integer = vs.{ in v : Vehicle; 2 }; attribute r2 : Real = vs.{ in v : Vehicle; 2 }; - attribute i5 : Integer = (1, 2)->select { in a : Integer; true };`) + attribute i5 : Integer = (1, 2)->select { in a : Integer; true }; + attribute i6 : Integer = (1, 2).?{ in a : Integer; true };`) +} + +// A collection value's body is checked once, as inferring the value: reading the types of +// the elements it produces to judge their binding reports nothing again. +func TestValueCollectionBodyIsCheckedOnce(t *testing.T) { + wantCollectionValueDiags(t, `attribute i : Integer = vs.{ in v : Vehicle; 1 + true };`, + "operator '+' is not defined for Natural and Boolean") + wantCollectionValueDiags(t, `attribute i : Integer = vs->collect { in v : Vehicle; 1 + true };`, + "operator '+' is not defined for Natural and Boolean") + wantCollectionValueDiags(t, `attribute i : Integer = vs->collect { in v : Vehicle; (1 + true, 2.5) };`, + "operator '+' is not defined for Natural and Boolean", + "cannot bind Rational value to a feature typed by Integer") + wantCollectionValueDiags(t, `attribute i : Integer = one->reduce { in a : Vehicle; in b : Vehicle; 1 + true };`, + "cannot bind a value of type Vehicle to a feature typed by Integer", + "operator '+' is not defined for Natural and Boolean") } // reduce returns the reducer's result, or the collection's one element unreduced: @@ -409,7 +432,20 @@ func TestValueReduceResultIsJudged(t *testing.T) { part b2 : Boat = pair.items->reduce { in a : Vehicle; in b : Vehicle; boat }; part b3 : Boat = couple.item->reduce { in a : Vehicle; in b : Vehicle; boat }; part v : Vehicle = vs->reduce { in a : Vehicle; in b : Vehicle; a }; - attribute s : String = (1, 2)->reduce { in a : Integer; in b : Integer; "s" };`) + attribute s : String = (1, 2)->reduce { in a : Integer; in b : Integer; "s" }; + attribute s2 : String = (one, boat)->reduce { in a : Vehicle; in b : Vehicle; "s" };`) +} + +// reduce over a collection known to hold nothing returns nothing, and never applies the +// reducer: neither the reducer's result nor the collection's element is bound. +func TestValueReduceOfNothingIsJudgedByNeither(t *testing.T) { + wantCollectionValueDiags(t, ` + attribute s : String = ()->reduce { in a : Integer; in b : Integer; 3 }; + attribute s2 : String = none->reduce { in a : Vehicle; in b : Vehicle; 3 }; + part b : Boat = none->reduce { in a : Vehicle; in b : Vehicle; a }; + part b2 : Boat = none.item->reduce { in a : Vehicle; in b : Vehicle; a }; + attribute s3 : String = ()->collect { in a : Integer; 3 }; + part b3 = Sail(none->reduce { in a : Vehicle; in b : Vehicle; a });`) } // A collection value passed as an argument is typed by its elements, so the parameter @@ -423,11 +459,14 @@ func TestArgumentCollectionResultIsJudged(t *testing.T) { "argument 1 of Sail expects Boat, found Vehicle") wantCollectionValueDiags(t, `part b = Sail(vs.{ in v : Vehicle; v });`, "argument 1 of Sail expects Boat, found Vehicle") + wantCollectionValueDiags(t, `part b = Sail(vs.?{ in v : Vehicle; true });`, + "argument 1 of Sail expects Boat, found Vehicle") wantCollectionValueDiags(t, `part v = Drive(two->reduce { in a : Vehicle; in b : Vehicle; boat });`, "argument 1 of Drive expects Vehicle, found Boat") wantCollectionValueDiags(t, ` part b = Sail(vs->collect { in v : Vehicle; boat }); part v = Drive(vs->select { in v : Vehicle; true }); + part v4 = Drive(vs.?{ in v : Vehicle; true }); part v2 = Drive(vs->reduce { in a : Vehicle; in b : Vehicle; boat }); part v3 = Drive(vs->reduce { in a : Vehicle; in b : Vehicle; a });`) } diff --git a/internal/core/semantics/collection.go b/internal/core/semantics/collection.go index d4180bd4b..68276a2dc 100644 --- a/internal/core/semantics/collection.go +++ b/internal/core/semantics/collection.go @@ -39,12 +39,14 @@ func (m *Model) CollectionResultTypes(scope *symbols.Scope, node ast.Node) ([]*s return m.sourcesTypes(srcs), true } -// sourcesOf is the sources of a collection value: `xs.{…}` by its body, a collection function -// call by its arguments; not ok for any other value. +// sourcesOf is the sources of a collection value: `xs.{…}` by its body, `xs.?{…}` by xs, a +// collection function call by its arguments; not ok for any other value. func (m *Model) sourcesOf(scope *symbols.Scope, node ast.Node) ([]collectionSource, bool) { switch n := node.(type) { case *ast.CollectExpr: return m.collectSources(scope, n) + case *ast.SelectExpr: + return m.keptSources(scope, n.Operand) case *ast.InvocationExpr: return m.invocationSources(scope, n, m.invocationCallee(scope, n)) } @@ -53,107 +55,148 @@ func (m *Model) sourcesOf(scope *symbols.Scope, node ast.Node) ([]collectionSour // invocationSources are the sources of a call of a ControlFunctions collection function: collect // the body or function applied, select/reject/selectOne the collection, reduce the reducer and, -// unless the collection holds two or more, the one element it returns unreduced; not ok where -// the arguments say nothing and the declaration alone decides. +// unless the collection holds two or more, the one element it returns unreduced; none over a +// collection known to hold nothing, which nothing is applied to. Not ok where the arguments say +// nothing and the declaration alone decides. func (m *Model) invocationSources(scope *symbols.Scope, e *ast.InvocationExpr, fn *symbols.Symbol) ([]collectionSource, bool) { if fn == nil || e == nil { return nil, false } switch fn { case m.libSymbol(fqnCollect): + if m.holdsNothing(scope, m.argumentTo(scope, e, fn, 0)) { + return nil, true + } return m.appliedSources(scope, m.argumentTo(scope, e, fn, 1)) case m.libSymbol(fqnReduce): + collection := m.argumentTo(scope, e, fn, 0) + if m.holdsNothing(scope, collection) { + return nil, true + } srcs, ok := m.appliedSources(scope, m.argumentTo(scope, e, fn, 1)) if !ok { return nil, false } - if collection := m.argumentTo(scope, e, fn, 0); collection != nil && !m.holdsAtLeastTwo(scope, collection) { + if collection != nil && !m.holdsAtLeastTwo(scope, collection) { srcs = append(srcs, collectionSource{scope: scope, node: collection}) } return srcs, true case m.libSymbol(fqnSelect), m.libSymbol(fqnReject), m.libSymbol(fqnSelectOne): - if collection := m.argumentTo(scope, e, fn, 0); collection != nil { - return []collectionSource{{scope: scope, node: collection}}, true - } + return m.keptSources(scope, m.argumentTo(scope, e, fn, 0)) } return nil, false } -// holdsAtLeastTwo reports a collection statically known to hold two elements or more: a -// sequence of that many literals, or a feature or chain whose multiplicity's lower bound says so. -func (m *Model) holdsAtLeastTwo(scope *symbols.Scope, collection ast.Node) bool { - if seq, ok := collection.(*ast.SequenceExpr); ok { - if len(seq.Elements) < 2 { - return false - } - for _, element := range seq.Elements { - switch element.(type) { - case *ast.LiteralInteger, *ast.LiteralReal, *ast.LiteralString, *ast.LiteralBool: - default: - return false - } - } - return true +// keptSources is the source of a selection: the collection whose elements it keeps. +func (m *Model) keptSources(scope *symbols.Scope, collection ast.Node) ([]collectionSource, bool) { + if collection == nil { + return nil, false } - lower, ok := m.fewestValuesOf(scope, collection) - return ok && lower >= 2 + return []collectionSource{{scope: scope, node: collection}}, true } -// fewestValuesOf is the lower bound of the values a feature reference or chain holds: a chain -// holds, through each value of its operand, the values of its last feature. -func (m *Model) fewestValuesOf(scope *symbols.Scope, node ast.Node) (int64, bool) { +// holdsAtLeastTwo reports a collection statically known to hold two elements or more. +func (m *Model) holdsAtLeastTwo(scope *symbols.Scope, collection ast.Node) bool { + r, ok := m.valuesHeldBy(scope, collection) + return ok && (r.Lower.Infinite || r.Lower.Value >= 2) +} + +// holdsNothing reports a collection statically known to hold no element: `()`, or a feature +// whose multiplicity admits none. +func (m *Model) holdsNothing(scope *symbols.Scope, collection ast.Node) bool { + r, ok := m.valuesHeldBy(scope, collection) + return ok && !r.Upper.Infinite && r.Upper.Value == 0 +} + +// valuesHeldBy is how many values a collection expression holds: `()` none, a literal one, a +// sequence the sum over its elements, a feature or chain the multiplicity governing it, a chain +// holding through each value of its operand the values of its last feature; not ok where unknown. +func (m *Model) valuesHeldBy(scope *symbols.Scope, node ast.Node) (Range, bool) { switch n := node.(type) { - case *ast.FeatureReference, *ast.QualifiedName: - sym, ok := m.resolver.ResolveTarget(scope, n) - if !ok || sym == nil { - return 0, false + case *ast.NullExpr: + return exactly(0), true + case *ast.LiteralInteger, *ast.LiteralReal, *ast.LiteralString, *ast.LiteralBool: + return exactly(1), true + case *ast.SequenceExpr: + sum := exactly(0) + for _, element := range n.Elements { + r, ok := m.valuesHeldBy(scope, element) + if !ok { + return Range{}, false + } + sum = Range{Lower: addBounds(sum.Lower, r.Lower), Upper: addBounds(sum.Upper, r.Upper)} } - return m.fewestValuesHeld(sym) + return sum, true + case *ast.FeatureReference, *ast.QualifiedName: + return m.valuesHeldByFeature(scope, n) case *ast.FeatureChainExpr: - sym, ok := m.resolver.ResolveTarget(scope, n) - if !ok || sym == nil { - return 0, false - } - last, ok := m.fewestValuesHeld(sym) + last, ok := m.valuesHeldByFeature(scope, n) if !ok { - return 0, false + return Range{}, false } - through, ok := m.fewestValuesOf(scope, n.Operand) + through, ok := m.valuesHeldBy(scope, n.Operand) if !ok { - return 0, false + return Range{}, false } - return through * last, true + return Range{Lower: mulBounds(through.Lower, last.Lower), Upper: mulBounds(through.Upper, last.Upper)}, true } - return 0, false + return Range{}, false } -// fewestValuesHeld is the lower bound of the multiplicity governing a feature: the one it -// declares, or the one it inherits from a feature it redefines; one where it declares none. -func (m *Model) fewestValuesHeld(sym *symbols.Symbol) (int64, bool) { - if r, ok := m.MultiplicityOf(sym); ok { - return knownLower(r) - } - for _, redefined := range m.redefinedTransitively(sym) { - if r, ok := m.MultiplicityOf(redefined); ok { - return knownLower(r) +// valuesHeldByFeature is the multiplicity governing the feature a name or chain resolves to: +// the one it declares, or inherits from a feature it redefines; one where it declares none. +func (m *Model) valuesHeldByFeature(scope *symbols.Scope, node ast.Node) (Range, bool) { + sym, ok := m.resolver.ResolveTarget(scope, node) + if !ok || sym == nil { + return Range{}, false + } + r := AssumedRange() + if declared, ok := m.MultiplicityOf(sym); ok { + r = declared + } else { + for _, redefined := range m.redefinedTransitively(sym) { + if inherited, ok := m.MultiplicityOf(redefined); ok { + r = inherited + break + } } } - return knownLower(AssumedRange()) + return r, r.Lower.Known && r.Upper.Known +} + +func exactly(n int64) Range { + b := Bound{Value: n, Known: true} + return Range{Lower: b, Upper: b} +} + +func addBounds(a, b Bound) Bound { + if a.Infinite || b.Infinite { + return Bound{Infinite: true, Known: true} + } + return Bound{Value: a.Value + b.Value, Known: true} } -func knownLower(r Range) (int64, bool) { - if !r.Lower.Known || r.Lower.Infinite { - return 0, false +// mulBounds is the values held through a values, each holding b: none through none. +func mulBounds(a, b Bound) Bound { + if (!a.Infinite && a.Value == 0) || (!b.Infinite && b.Value == 0) { + return Bound{Known: true} + } + if a.Infinite || b.Infinite { + return Bound{Infinite: true, Known: true} } - return r.Lower.Value, true + return Bound{Value: a.Value * b.Value, Known: true} } -// collectSources is the source of `xs.{ in x; … }`: its body's result. +// collectSources is the source of `xs.{ in x; … }`: its body's result; none where xs is known +// to hold nothing. func (m *Model) collectSources(scope *symbols.Scope, e *ast.CollectExpr) ([]collectionSource, bool) { body, ok := e.Body.(*ast.BodyExpr) if !ok { return nil, false } + if m.holdsNothing(scope, e.Operand) { + return nil, true + } return m.bodySources(scope, body) } @@ -349,7 +392,17 @@ func (m *Model) collectConformance(scope *symbols.Scope, e *ast.CollectExpr, wan return m.sourcesConformance(srcs, want, byUnit) } +// sourcesConformance judges a collection value by its sources: every element must conform. A +// value with no source holds nothing, an empty value typed Anything as `null` is. func (m *Model) sourcesConformance(srcs []collectionSource, want *symbols.Symbol, byUnit bool) (Conformance, bool) { + if len(srcs) == 0 { + c := m.typeConformance(m.libSymbol(fqnAnything), want) + if c.Known && !c.Holds { + c.Found = "an empty value over a collection holding nothing, typed Anything" + c.Untyped = true + } + return c, true + } judged := m.judgeSources(srcs, func(scope *symbols.Scope, node ast.Node) Conformance { return m.elementConformance(scope, node, want, byUnit) diff --git a/internal/core/semantics/collection_test.go b/internal/core/semantics/collection_test.go index 0dd4ae0c1..5f7a86e49 100644 --- a/internal/core/semantics/collection_test.go +++ b/internal/core/semantics/collection_test.go @@ -304,3 +304,64 @@ func TestCollectionReduceMayReturnTheElement(t *testing.T) { t.Errorf("cast of two to C: %+v, want known, not holding, found String", c) } } + +// reduce over a collection known to hold nothing — `()`, a feature admitting no value, a +// chain through one — returns nothing without applying the reducer, so neither the reducer's +// result nor the element types it: the declared Anything stands, and nothing is judged. +func TestCollectionReduceOfNothing(t *testing.T) { + m, s := collectionModel(t, ` + part none : C[0]; + part one : C[1]; + part def Holder { part item : C[1]; } + part holders : Holder[0..0]; + attribute empty = ()->reduce { in a : Integer; in b : Integer; "s" }; + attribute empty2 = none->reduce { in a : C; in b : C; a.name }; + attribute empty3 = holders.item->reduce { in a : C; in b : C; a.name }; + attribute emptyCollect = ()->collect { in a : Integer; "s" }; + attribute cast = none->reduce { in a : C; in b : C; a.name } as Integer; + attribute two = (none, one, one)->reduce { in a : C; in b : C; a.name }; + attribute maybe = (none, one)->reduce { in a : C; in b : C; a.name };`) + for _, name := range []string{"empty", "empty2", "empty3", "emptyCollect"} { + wantValueTypes(t, m, s, name, "Anything") + elements, ok := m.CollectionElements(s, valueOf(t, s, name)) + if !ok || len(elements) != 0 { + t.Errorf("%s: elements %v, want none", name, elements) + } + for _, want := range []string{fqnString, fqnInteger} { + if c := m.ExprConformsToLibrary(s, valueOf(t, s, name), want); c.Known && !c.Untyped { + t.Errorf("%s as %s: %+v, want nothing decided", name, want, c) + } + } + } + if c := m.CastConformance(s, valueOf(t, s, "cast").(*ast.OperatorExpr)); c.Known && !c.Holds { + t.Errorf("cast of nothing to Integer: %+v, want it not to fail", c) + } + wantValueTypes(t, m, s, "two", "String") + wantValueTypes(t, m, s, "maybe", "Anything") +} + +// `xs.?{…}` is select written out: its elements are the collection's, so a binding or an +// argument is judged by them as `xs->select {…}` is. +func TestCollectionSelectShorthandElements(t *testing.T) { + m, s := collectionModel(t, ` + attribute heavy = cs.?{ in x : C; x.mass > 1 [kg] }; + attribute heavy2 = cs->select { in x : C; x.mass > 1 [kg] }; + attribute lits = (1, 2.5).?{ in x : Real; true };`) + for _, name := range []string{"heavy", "heavy2"} { + wantValueTypes(t, m, s, name, "C") + elements, ok := m.CollectionElements(s, valueOf(t, s, name)) + if !ok || len(elements) != 1 || len(elements[0].Types) != 1 || leafName(elements[0].Types[0].Name) != "C" || elements[0].Node == nil { + t.Errorf("%s: elements %v, want the collection's C", name, elements) + } + } + elements, ok := m.CollectionElements(s, valueOf(t, s, "lits")) + if !ok || len(elements) != 2 { + t.Fatalf("lits: elements %v, want one per literal", elements) + } + if _, isInt := elements[0].Node.(*ast.LiteralInteger); !isInt { + t.Errorf("lits: first element %T, want the literal 1", elements[0].Node) + } + if _, isReal := elements[1].Node.(*ast.LiteralReal); !isReal { + t.Errorf("lits: second element %T, want the literal 2.5", elements[1].Node) + } +} From bc77cd58c3727c9cf6996bcb32eac56d372cab84 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 02:43:24 +0000 Subject: [PATCH 06/22] fix(semantics): type empty collections by their body and bind nested collection elements Co-Authored-By: jason.han --- .../collection-body-result-type.fixed.md | 2 +- internal/core/passes/typecheck_value_test.go | 26 +++- internal/core/semantics/collection.go | 114 ++++++++++++------ internal/core/semantics/collection_test.go | 50 +++++++- internal/core/semantics/valuetype.go | 4 +- 5 files changed, 146 insertions(+), 50 deletions(-) diff --git a/changes/unreleased/collection-body-result-type.fixed.md b/changes/unreleased/collection-body-result-type.fixed.md index 506ca12e4..6aacad969 100644 --- a/changes/unreleased/collection-body-result-type.fixed.md +++ b/changes/unreleased/collection-body-result-type.fixed.md @@ -1 +1 @@ -- **A collection operation's static type follows what its declaration hands through, not the element type of the collection.** `xs->collect { in x : C; x.mass }` and `xs.{ in x : C; x.mass }` are typed by the body's result (`MassValue`), a nested collect by its innermost body, a body answering a sequence by every element type and `xs->collect f` by the named function's result; `select`, `reject` and `selectOne` keep the elements of `xs`, `reduce` follows its reducer's result — and the element a one-element collection hands back unreduced, unless the collection is known to hold two or more, by its own multiplicity, one it inherits by redefinition, or a chain through such features — and `forAll`/`exists` stay `Boolean`, each with the multiplicity the Kernel Function Library declares. A body whose result cannot be typed keeps the library's `Anything`. Value conformance, a feature's bound value, invocation arguments, trigger arguments and enumerated values are judged by the specialized type, so `accept when counts.{in n : Integer; n}` is refused where it was silent, and `when counts.{in n; n > 3}` is accepted where it was refused. A scalar literal a body writes out is as exact as one bound directly: `attribute i : Integer = xs.{ in x : C; 1.5 }` is refused, and a quantity it writes out is measured against the target's dimension: `attribute t : DurationValue = xs.{ in x : C; 5 [m] }` is refused. `xs.?{…}` binds as `xs->select {…}` does. A collection over `()` or a feature admitting no value applies nothing, so nothing types it. A body reading the feature it values terminates as a self-referential argument does. +- **A collection operation's static type follows what its declaration hands through, not the element type of the collection.** `xs->collect { in x : C; x.mass }` and `xs.{ in x : C; x.mass }` are typed by the body's result (`MassValue`), a nested collect by its innermost body, a body answering a sequence by every element type and `xs->collect f` by the named function's result; `select`, `reject` and `selectOne` keep the elements of `xs`, `reduce` follows its reducer's result — and the element a one-element collection hands back unreduced, unless the collection is known to hold two or more, by its own multiplicity, one it inherits by redefinition, or a chain through such features — and `forAll`/`exists` stay `Boolean`, each with the multiplicity the Kernel Function Library declares. A body whose result cannot be typed keeps the library's `Anything`. Value conformance, a feature's bound value, invocation arguments, trigger arguments and enumerated values are judged by the specialized type, so `accept when counts.{in n : Integer; n}` is refused where it was silent, and `when counts.{in n; n > 3}` is accepted where it was refused. A scalar literal a body writes out is as exact as one bound directly: `attribute i : Integer = xs.{ in x : C; 1.5 }` is refused, and a quantity it writes out is measured against the target's dimension: `attribute t : DurationValue = xs.{ in x : C; 5 [m] }` is refused. `xs.?{…}` binds as `xs->select {…}` does, and an element that is itself a collection value binds by the elements it holds, so `attribute i : Integer = xs.{ in x : C; xs.{ in y : C; 1.5 } }` is refused. A collection over `()` or a feature admitting no value keeps its declared type — `()->collect { in a : Integer; "s" }` is a `String` collection — but holds no element, so none is judged and its `reduce` takes nothing from an element it would hand back unreduced. A body reading the feature it values terminates as a self-referential argument does. diff --git a/internal/core/passes/typecheck_value_test.go b/internal/core/passes/typecheck_value_test.go index e7f3f8d85..3ee0fd8fc 100644 --- a/internal/core/passes/typecheck_value_test.go +++ b/internal/core/passes/typecheck_value_test.go @@ -399,6 +399,27 @@ func TestValueCollectionElementLiteralIsExact(t *testing.T) { attribute i6 : Integer = (1, 2).?{ in a : Integer; true };`) } +// An element that is itself a collection value binds by the elements it holds: a scalar +// literal nested in an inner collect, or kept by a selection, is judged as if written out. +func TestValueNestedCollectionElementsAreJudged(t *testing.T) { + wantCollectionValueDiags(t, `attribute i : Integer = vs.{ in v : Vehicle; vs.{ in w : Vehicle; 1.5 } };`, + "cannot bind Rational value to a feature typed by Integer") + wantCollectionValueDiags(t, `attribute i : Integer = vs->collect { in v : Vehicle; vs->collect { in w : Vehicle; 1.5 } };`, + "cannot bind Rational value to a feature typed by Integer") + wantCollectionValueDiags(t, `attribute i : Integer = vs->select { in v : Vehicle; true }.{ in w : Vehicle; (1, 2.5) };`, + "cannot bind Rational value to a feature typed by Integer") + wantCollectionValueDiags(t, `attribute i : Integer = (vs.{ in v : Vehicle; 1.5 }).?{ in r : Real; true };`, + "cannot bind Rational value to a feature typed by Integer") + wantCollectionValueDiags(t, `attribute i : Integer = (vs.{ in v : Vehicle; 1.5 })->select { in r : Real; true };`, + "cannot bind Rational value to a feature typed by Integer") + wantCollectionValueDiags(t, `part b : Boat = vs.{ in v : Vehicle; vs->select { in w : Vehicle; true } };`, + "cannot bind a value of type Vehicle to a feature typed by Boat") + wantCollectionValueDiags(t, ` + attribute i : Integer = vs.{ in v : Vehicle; vs.{ in w : Vehicle; 2 } }; + attribute r : Real = vs.{ in v : Vehicle; vs.{ in w : Vehicle; 1.5 } }; + attribute i2 : Integer = vs.{ in v : Vehicle; ().{ in w : Integer; 1.5 } };`) +} + // A collection value's body is checked once, as inferring the value: reading the types of // the elements it produces to judge their binding reports nothing again. func TestValueCollectionBodyIsCheckedOnce(t *testing.T) { @@ -436,8 +457,8 @@ func TestValueReduceResultIsJudged(t *testing.T) { attribute s2 : String = (one, boat)->reduce { in a : Vehicle; in b : Vehicle; "s" };`) } -// reduce over a collection known to hold nothing returns nothing, and never applies the -// reducer: neither the reducer's result nor the collection's element is bound. +// A collection operation over a collection known to hold nothing returns nothing, and never +// applies the reducer or body: no element is bound, though the value is still typed by them. func TestValueReduceOfNothingIsJudgedByNeither(t *testing.T) { wantCollectionValueDiags(t, ` attribute s : String = ()->reduce { in a : Integer; in b : Integer; 3 }; @@ -445,6 +466,7 @@ func TestValueReduceOfNothingIsJudgedByNeither(t *testing.T) { part b : Boat = none->reduce { in a : Vehicle; in b : Vehicle; a }; part b2 : Boat = none.item->reduce { in a : Vehicle; in b : Vehicle; a }; attribute s3 : String = ()->collect { in a : Integer; 3 }; + attribute s4 : String = none.{ in a : Vehicle; 3 }; part b3 = Sail(none->reduce { in a : Vehicle; in b : Vehicle; a });`) } diff --git a/internal/core/semantics/collection.go b/internal/core/semantics/collection.go index 68276a2dc..6775ee8db 100644 --- a/internal/core/semantics/collection.go +++ b/internal/core/semantics/collection.go @@ -39,7 +39,7 @@ func (m *Model) CollectionResultTypes(scope *symbols.Scope, node ast.Node) ([]*s return m.sourcesTypes(srcs), true } -// sourcesOf is the sources of a collection value: `xs.{…}` by its body, `xs.?{…}` by xs, a +// sourcesOf is the sources typing a collection value: `xs.{…}` by its body, `xs.?{…}` by xs, a // collection function call by its arguments; not ok for any other value. func (m *Model) sourcesOf(scope *symbols.Scope, node ast.Node) ([]collectionSource, bool) { switch n := node.(type) { @@ -53,31 +53,58 @@ func (m *Model) sourcesOf(scope *symbols.Scope, node ast.Node) ([]collectionSour return nil, false } -// invocationSources are the sources of a call of a ControlFunctions collection function: collect -// the body or function applied, select/reject/selectOne the collection, reduce the reducer and, -// unless the collection holds two or more, the one element it returns unreduced; none over a -// collection known to hold nothing, which nothing is applied to. Not ok where the arguments say -// nothing and the declaration alone decides. +// heldSourcesOf is the sources of the elements a collection value holds: none over a collection +// known to hold nothing, which nothing is applied to; otherwise those typing it. +func (m *Model) heldSourcesOf(scope *symbols.Scope, node ast.Node) ([]collectionSource, bool) { + if collection, ok := m.collectionOf(scope, node); ok && m.holdsNothing(scope, collection) { + return nil, true + } + return m.sourcesOf(scope, node) +} + +// collectionOf is the collection a collection value operates over: the operand of `xs.{…}` or +// `xs.?{…}`, the first argument of a collection function call; not ok for any other value. +func (m *Model) collectionOf(scope *symbols.Scope, node ast.Node) (ast.Node, bool) { + switch n := node.(type) { + case *ast.CollectExpr: + return n.Operand, true + case *ast.SelectExpr: + return n.Operand, true + case *ast.InvocationExpr: + fn := m.invocationCallee(scope, n) + if fn == nil || !m.isCollectionFunction(fn) { + return nil, false + } + return m.argumentTo(scope, n, fn, 0), true + } + return nil, false +} + +func (m *Model) isCollectionFunction(fn *symbols.Symbol) bool { + for _, fqn := range []string{fqnCollect, fqnSelect, fqnReject, fqnSelectOne, fqnReduce} { + if fn == m.libSymbol(fqn) { + return true + } + } + return false +} + +// invocationSources type a collection function call: collect by what it applies, select/reject/ +// selectOne by the collection, reduce by the reducer plus the one element it may return unreduced. func (m *Model) invocationSources(scope *symbols.Scope, e *ast.InvocationExpr, fn *symbols.Symbol) ([]collectionSource, bool) { if fn == nil || e == nil { return nil, false } switch fn { case m.libSymbol(fqnCollect): - if m.holdsNothing(scope, m.argumentTo(scope, e, fn, 0)) { - return nil, true - } return m.appliedSources(scope, m.argumentTo(scope, e, fn, 1)) case m.libSymbol(fqnReduce): - collection := m.argumentTo(scope, e, fn, 0) - if m.holdsNothing(scope, collection) { - return nil, true - } srcs, ok := m.appliedSources(scope, m.argumentTo(scope, e, fn, 1)) if !ok { return nil, false } - if collection != nil && !m.holdsAtLeastTwo(scope, collection) { + collection := m.argumentTo(scope, e, fn, 0) + if collection != nil && !m.holdsAtLeastTwo(scope, collection) && !m.holdsNothing(scope, collection) { srcs = append(srcs, collectionSource{scope: scope, node: collection}) } return srcs, true @@ -187,16 +214,12 @@ func mulBounds(a, b Bound) Bound { return Bound{Value: a.Value * b.Value, Known: true} } -// collectSources is the source of `xs.{ in x; … }`: its body's result; none where xs is known -// to hold nothing. +// collectSources is the source of `xs.{ in x; … }`: its body's result. func (m *Model) collectSources(scope *symbols.Scope, e *ast.CollectExpr) ([]collectionSource, bool) { body, ok := e.Body.(*ast.BodyExpr) if !ok { return nil, false } - if m.holdsNothing(scope, e.Operand) { - return nil, true - } return m.bodySources(scope, body) } @@ -287,17 +310,35 @@ type CollectionElement struct { Types []*symbols.Symbol } -// CollectionElements is each element a collection value may hold, and whether the value is -// one whose arguments decide it rather than its declared result. +// CollectionElements is each element a collection value may hold (a nested collection value by +// its own elements), and whether the value's arguments decide it rather than its declaration. func (m *Model) CollectionElements(scope *symbols.Scope, node ast.Node) ([]CollectionElement, bool) { if m == nil || m.resolver == nil || node == nil { return nil, false } - srcs, ok := m.sourcesOf(scope, node) + srcs, ok := m.heldSourcesOf(scope, node) if !ok { return nil, false } - return m.sourcesElements(srcs), true + return m.heldElements(srcs), true +} + +// heldElements is each element the sources hold, a collection-valued one by its elements in turn. +func (m *Model) heldElements(srcs []collectionSource) []CollectionElement { + var out []CollectionElement + for _, element := range m.sourcesElements(srcs) { + if element.Node == nil { + out = append(out, element) + continue + } + inner, ok := m.heldSourcesOf(element.Scope, element.Node) + if !ok { + out = append(out, element) + continue + } + out = append(out, m.heldElements(inner)...) + } + return out } // sourcesTypes is the types every element of every source conforms to; nil when unknown or @@ -373,19 +414,10 @@ func (m *Model) anyConforms(types []*symbols.Symbol, want *symbols.Symbol) bool return false } -// collectionConformance judges the value of a collection function call by its sources: every -// element must conform. Not ok where the sources are unknown and the declaration decides. -func (m *Model) collectionConformance(scope *symbols.Scope, e *ast.InvocationExpr, fn *symbols.Symbol, want *symbols.Symbol, byUnit bool) (Conformance, bool) { - srcs, ok := m.invocationSources(scope, e, fn) - if !ok { - return conformanceUnknown(), false - } - return m.sourcesConformance(srcs, want, byUnit) -} - -// collectConformance judges the value of `xs.{ in x; … }` by its body's result: every element must conform. -func (m *Model) collectConformance(scope *symbols.Scope, e *ast.CollectExpr, want *symbols.Symbol, byUnit bool) (Conformance, bool) { - srcs, ok := m.collectSources(scope, e) +// collectionConformance judges `xs.{…}` or a collection function call by the elements it +// holds: every one must conform. Not ok where they are unknown and the declaration decides. +func (m *Model) collectionConformance(scope *symbols.Scope, node ast.Node, want *symbols.Symbol, byUnit bool) (Conformance, bool) { + srcs, ok := m.heldSourcesOf(scope, node) if !ok { return conformanceUnknown(), false } @@ -411,14 +443,16 @@ func (m *Model) sourcesConformance(srcs []collectionSource, want *symbols.Symbol return decided(everyHolds(judged)) } -// collectionCastConformance judges `xs.{…} as T` or a collection function call cast by its -// sources: the cast is sound when some element's types and T specialize one another. Not ok -// where the sources are unknown and the declaration decides. +// collectionCastConformance judges a collection value cast to T by the elements it holds: sound +// when some element and T specialize one another, or none is held (as `null as T` is). func (m *Model) collectionCastConformance(scope *symbols.Scope, operand ast.Node, target *symbols.Symbol) (Conformance, bool) { - srcs, ok := m.sourcesOf(scope, operand) + srcs, ok := m.heldSourcesOf(scope, operand) if !ok { return conformanceUnknown(), false } + if len(srcs) == 0 { + return Conformance{Known: true, Holds: true}, true + } judged := m.judgeSources(srcs, func(scope *symbols.Scope, node ast.Node) Conformance { return m.castConformance(scope, node, target) }, func(result *symbols.Symbol) Conformance { diff --git a/internal/core/semantics/collection_test.go b/internal/core/semantics/collection_test.go index 5f7a86e49..25f94fa02 100644 --- a/internal/core/semantics/collection_test.go +++ b/internal/core/semantics/collection_test.go @@ -305,9 +305,8 @@ func TestCollectionReduceMayReturnTheElement(t *testing.T) { } } -// reduce over a collection known to hold nothing — `()`, a feature admitting no value, a -// chain through one — returns nothing without applying the reducer, so neither the reducer's -// result nor the element types it: the declared Anything stands, and nothing is judged. +// Over a collection known to hold nothing, reduce is typed by the reducer alone and collect by +// its body; but the value holds no element, so nothing is judged and it casts to anything. func TestCollectionReduceOfNothing(t *testing.T) { m, s := collectionModel(t, ` part none : C[0]; @@ -318,11 +317,13 @@ func TestCollectionReduceOfNothing(t *testing.T) { attribute empty2 = none->reduce { in a : C; in b : C; a.name }; attribute empty3 = holders.item->reduce { in a : C; in b : C; a.name }; attribute emptyCollect = ()->collect { in a : Integer; "s" }; + attribute emptyCollect2 = none.{ in a : C; a.name }; + attribute emptyCollect3 = holders.item->collect { in a : C; a.name }; attribute cast = none->reduce { in a : C; in b : C; a.name } as Integer; attribute two = (none, one, one)->reduce { in a : C; in b : C; a.name }; attribute maybe = (none, one)->reduce { in a : C; in b : C; a.name };`) - for _, name := range []string{"empty", "empty2", "empty3", "emptyCollect"} { - wantValueTypes(t, m, s, name, "Anything") + for _, name := range []string{"empty", "empty2", "empty3", "emptyCollect", "emptyCollect2", "emptyCollect3"} { + wantValueTypes(t, m, s, name, "String") elements, ok := m.CollectionElements(s, valueOf(t, s, name)) if !ok || len(elements) != 0 { t.Errorf("%s: elements %v, want none", name, elements) @@ -365,3 +366,42 @@ func TestCollectionSelectShorthandElements(t *testing.T) { t.Errorf("lits: second element %T, want the literal 2.5", elements[1].Node) } } + +// An element that is itself a collection value contributes the elements it holds: a nested +// collect's literals, a selection's elements, none from one over nothing. +func TestCollectionNestedElements(t *testing.T) { + m, s := collectionModel(t, ` + attribute nested = cs.{ in x : C; cs.{ in y : C; 1.5 } }; + attribute nested2 = cs->collect { in x : C; (x.name, cs->select { in y : C; true }) }; + attribute selected = (cs.{ in x : C; 2 }).?{ in n : Integer; true }; + attribute nestedEmpty = cs.{ in x : C; ().{ in y : Integer; 1.5 } };`) + wantValueTypes(t, m, s, "nested", "Real") + elements, ok := m.CollectionElements(s, valueOf(t, s, "nested")) + if !ok || len(elements) != 1 { + t.Fatalf("nested: elements %v, want the inner literal", elements) + } + if _, isReal := elements[0].Node.(*ast.LiteralReal); !isReal { + t.Errorf("nested: element %T, want the literal 1.5", elements[0].Node) + } + elements, ok = m.CollectionElements(s, valueOf(t, s, "nested2")) + if !ok || len(elements) != 2 { + t.Fatalf("nested2: elements %v, want the name and the selection's C", elements) + } + if _, isRef := elements[0].Node.(*ast.FeatureChainExpr); !isRef || len(elements[0].Types) != 1 || leafName(elements[0].Types[0].Name) != "String" { + t.Errorf("nested2: first element %T %v, want x.name : String", elements[0].Node, elements[0].Types) + } + if len(elements[1].Types) != 1 || leafName(elements[1].Types[0].Name) != "C" { + t.Errorf("nested2: second element %v, want the selection's C", elements[1].Types) + } + elements, ok = m.CollectionElements(s, valueOf(t, s, "selected")) + if !ok || len(elements) != 1 { + t.Fatalf("selected: elements %v, want the collected literal", elements) + } + if _, isInt := elements[0].Node.(*ast.LiteralInteger); !isInt { + t.Errorf("selected: element %T, want the literal 2", elements[0].Node) + } + wantValueTypes(t, m, s, "nestedEmpty", "Real") + if elements, ok := m.CollectionElements(s, valueOf(t, s, "nestedEmpty")); !ok || len(elements) != 0 { + t.Errorf("nestedEmpty: elements %v, want none", elements) + } +} diff --git a/internal/core/semantics/valuetype.go b/internal/core/semantics/valuetype.go index fe694e718..526f18deb 100644 --- a/internal/core/semantics/valuetype.go +++ b/internal/core/semantics/valuetype.go @@ -143,7 +143,7 @@ func (m *Model) exprConformance(scope *symbols.Scope, node ast.Node, want *symbo return c case *ast.CollectExpr: // `xs.{…}` is the result of ControlFunctions::collect: the body's results, else Anything. - if c, ok := m.collectConformance(scope, n, want, byUnit); ok { + if c, ok := m.collectionConformance(scope, n, want, byUnit); ok { return c } c := m.typeConformance(m.libSymbol(fqnAnything), want) @@ -970,7 +970,7 @@ func (m *Model) incommensurableSum(scope *symbols.Scope, node ast.Node) (*ast.Op // result parameter of the overload it calls. func (m *Model) invocationConformance(scope *symbols.Scope, e *ast.InvocationExpr, want *symbols.Symbol, byUnit bool) Conformance { called := m.invocationCallee(scope, e) - if c, ok := m.collectionConformance(scope, e, called, want, byUnit); ok { + if c, ok := m.collectionConformance(scope, e, want, byUnit); ok { return c } result := m.ResultParameterOf(called) From 8cc6d32ee411ce95216ec3b6ee49aa914c572333 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 03:08:48 +0000 Subject: [PATCH 07/22] fix(semantics): hold no element from a mapping to nothing A collect whose body or function yields a [0] feature or result, and any operation over such a collection, holds no element: none is judged as a bound value or an argument, while the static result type is kept. Co-Authored-By: jason.han --- .../collection-body-result-type.fixed.md | 2 +- .../core/passes/typecheck_dimension_test.go | 14 ++ .../core/passes/typecheck_operator_test.go | 4 +- .../core/passes/typecheck_trigger_test.go | 7 + internal/core/passes/typecheck_value.go | 10 +- internal/core/passes/typecheck_value_test.go | 32 +++- internal/core/semantics/collection.go | 140 +++++++++++++++++- internal/core/semantics/collection_test.go | 28 +++- 8 files changed, 214 insertions(+), 23 deletions(-) diff --git a/changes/unreleased/collection-body-result-type.fixed.md b/changes/unreleased/collection-body-result-type.fixed.md index 6aacad969..0bc736235 100644 --- a/changes/unreleased/collection-body-result-type.fixed.md +++ b/changes/unreleased/collection-body-result-type.fixed.md @@ -1 +1 @@ -- **A collection operation's static type follows what its declaration hands through, not the element type of the collection.** `xs->collect { in x : C; x.mass }` and `xs.{ in x : C; x.mass }` are typed by the body's result (`MassValue`), a nested collect by its innermost body, a body answering a sequence by every element type and `xs->collect f` by the named function's result; `select`, `reject` and `selectOne` keep the elements of `xs`, `reduce` follows its reducer's result — and the element a one-element collection hands back unreduced, unless the collection is known to hold two or more, by its own multiplicity, one it inherits by redefinition, or a chain through such features — and `forAll`/`exists` stay `Boolean`, each with the multiplicity the Kernel Function Library declares. A body whose result cannot be typed keeps the library's `Anything`. Value conformance, a feature's bound value, invocation arguments, trigger arguments and enumerated values are judged by the specialized type, so `accept when counts.{in n : Integer; n}` is refused where it was silent, and `when counts.{in n; n > 3}` is accepted where it was refused. A scalar literal a body writes out is as exact as one bound directly: `attribute i : Integer = xs.{ in x : C; 1.5 }` is refused, and a quantity it writes out is measured against the target's dimension: `attribute t : DurationValue = xs.{ in x : C; 5 [m] }` is refused. `xs.?{…}` binds as `xs->select {…}` does, and an element that is itself a collection value binds by the elements it holds, so `attribute i : Integer = xs.{ in x : C; xs.{ in y : C; 1.5 } }` is refused. A collection over `()` or a feature admitting no value keeps its declared type — `()->collect { in a : Integer; "s" }` is a `String` collection — but holds no element, so none is judged and its `reduce` takes nothing from an element it would hand back unreduced. A body reading the feature it values terminates as a self-referential argument does. +- **A collection operation's static type follows what its declaration hands through, not the element type of the collection.** `xs->collect { in x : C; x.mass }` and `xs.{ in x : C; x.mass }` are typed by the body's result (`MassValue`), a nested collect by its innermost body, a body answering a sequence by every element type and `xs->collect f` by the named function's result; `select`, `reject` and `selectOne` keep the elements of `xs`, `reduce` follows its reducer's result — and the element a one-element collection hands back unreduced, unless the collection is known to hold two or more, by its own multiplicity, one it inherits by redefinition, or a chain through such features — and `forAll`/`exists` stay `Boolean`, each with the multiplicity the Kernel Function Library declares. A body whose result cannot be typed keeps the library's `Anything`. Value conformance, a feature's bound value, invocation arguments, trigger arguments and enumerated values are judged by the specialized type, so `accept when counts.{in n : Integer; n}` is refused where it was silent, and `when counts.{in n; n > 3}` is accepted where it was refused. A scalar literal a body writes out is as exact as one bound directly: `attribute i : Integer = xs.{ in x : C; 1.5 }` is refused, and a quantity it writes out is measured against the target's dimension: `attribute t : DurationValue = xs.{ in x : C; 5 [m] }` is refused. `xs.?{…}` binds as `xs->select {…}` does, and an element that is itself a collection value binds by the elements it holds, so `attribute i : Integer = xs.{ in x : C; xs.{ in y : C; 1.5 } }` is refused. A collection over `()` or a feature admitting no value keeps its declared type — `()->collect { in a : Integer; "s" }` is a `String` collection — but holds no element, so none is judged and its `reduce` takes nothing from an element it would hand back unreduced; so does one mapping every element to a `[0]` feature or function result, or any operation over such a collection, and an argument holding nothing is judged against no parameter type. A body reading the feature it values terminates as a self-referential argument does. diff --git a/internal/core/passes/typecheck_dimension_test.go b/internal/core/passes/typecheck_dimension_test.go index 3f8d151d1..35d388720 100644 --- a/internal/core/passes/typecheck_dimension_test.go +++ b/internal/core/passes/typecheck_dimension_test.go @@ -71,6 +71,20 @@ func TestBoundCollectionQuantityOfAnotherDimension(t *testing.T) { attribute l : LengthValue = parts.{ in p : Part; 5 [m] };`) } +// A body mapping every element to a quantity held by no value, or an operation over such a +// collection, binds no quantity, so no dimension is measured against the target. +func TestBoundCollectionQuantityOfNothing(t *testing.T) { + const parts = `private import ControlFunctions::*; part def Part { attribute none : LengthValue[0]; } part parts : Part[*];` + wantNoDimensionDiags(t, parts+` + attribute t : DurationValue = parts.{ in p : Part; p.none }; + attribute t2 : DurationValue = parts->collect { in p : Part; (p.none, p.none) }; + attribute t3 : DurationValue = (parts.{ in p : Part; p.none }).{ in l : LengthValue; 5 [m] }; + attribute t4 : DurationValue = (parts.{ in p : Part; p.none }).?{ in l : LengthValue; true }; + attribute t5 : DurationValue = (parts.{ in p : Part; p.none })->reduce { in a : LengthValue; in b : LengthValue; 1 [m] };`) + wantOneDimensionError(t, parts+`attribute t : DurationValue = parts.{ in p : Part; (p.none, 5 [m]) };`, + "cannot bind m (dimension L) to a feature typed by DurationValue (dimension T)") +} + // TestBoundQuantityOfTheSameDimensionAtAnotherScale: a dimension has no scale, // so any unit measuring in it conforms. func TestBoundQuantityOfTheSameDimensionAtAnotherScale(t *testing.T) { diff --git a/internal/core/passes/typecheck_operator_test.go b/internal/core/passes/typecheck_operator_test.go index cb2deff46..f002faddd 100644 --- a/internal/core/passes/typecheck_operator_test.go +++ b/internal/core/passes/typecheck_operator_test.go @@ -104,7 +104,7 @@ const castFixture = `package P { classifier Box { feature base : A; feature str : String; } feature box : Box; classifier Box2 :> Box { feature :>> base; %s } - function F { return r : A; } + function F { return r : A; } function Nothing { in x : A; return r : String[0]; } feature nothing : String[0]; classifier Q; classifier R :> Q; classifier CQ ~ Q; feature cq : CQ; feature xs : A[*]; feature d : D; feature b : B; datatype U unions B, C; datatype I intersects A, C; datatype NI intersects I, B; datatype Diff differences A, C; feature u : U; feature dd : Diff; @@ -166,6 +166,8 @@ func TestCastConformanceCollectionBody(t *testing.T) { castDiags(t, "", `feature kept = xs.?{in x : A; true} as B;`) castDiags(t, "", `feature bad = xs.?{in x : A; true} as String;`, "16:16 cast argument is typed by A, unrelated to the target String") castDiags(t, "", `feature none = ()->ControlFunctions::reduce {in x : A; in y : A; s} as C;`) + castDiags(t, "", `feature none = xs.{in x : A; nothing} as C; feature none2 = xs->ControlFunctions::collect Nothing as C;`) + castDiags(t, "", `feature none = (().{in x : A; x}).{in y : A; s} as C; feature none2 = (xs.{in x : A; nothing}).?{in y : String; true} as C;`) } // A cast up, down, or sideways through one of several types conforms; so does diff --git a/internal/core/passes/typecheck_trigger_test.go b/internal/core/passes/typecheck_trigger_test.go index c4d4e3ffa..cd1039230 100644 --- a/internal/core/passes/typecheck_trigger_test.go +++ b/internal/core/passes/typecheck_trigger_test.go @@ -71,6 +71,7 @@ const triggerFixture = `package P { attribute pairWait = (5 [s], 6 [s]); attribute flags : Boolean[2]; attribute counts : Integer[2]; + attribute noCount : Integer[0]; attribute waits : DurationValue[2]; attribute times : TimeInstantValue[2]; attribute firstFlag = flags#(1); @@ -526,6 +527,10 @@ func TestCollectAndSelectTriggerArguments(t *testing.T) { {"when ()->ControlFunctions::reduce {in a : Integer; in b : Integer; 5}", "trigger-when-boolean", nothing}, {"when ()->ControlFunctions::collect {in a : Integer; 5}", "trigger-when-boolean", nothing}, {"when ().{in a : Integer; 5}", "trigger-when-boolean", nothing}, + {"when counts.{in n : Integer; noCount}", "trigger-when-boolean", nothing}, + {"when (counts.{in n : Integer; noCount}).{in n : Integer; 5}", "trigger-when-boolean", nothing}, + {"when (().{in a : Integer; a}).?{in n : Integer; true}", "trigger-when-boolean", nothing}, + {"when counts.{in n : Integer; (noCount, 5)}", "trigger-when-boolean", "found Natural"}, } { wantTriggerDiag(t, "transition first a accept "+tc.trigger+" then b;", tc.code, tc.found) } @@ -547,6 +552,8 @@ func TestCollectAndSelectTriggerArguments(t *testing.T) { } wantTriggerSilent(t, "entry action { if ()->ControlFunctions::reduce {in a : Integer; in b : Integer; 5} { assign x := 1; } }") wantTriggerSilent(t, "entry action { if ().{in a : Integer; 5} { assign x := 1; } }") + wantTriggerSilent(t, "entry action { if counts.{in n : Integer; noCount} { assign x := 1; } }") + wantTriggerSilent(t, "entry action { if (counts.{in n : Integer; noCount}).{in n : Integer; 5} { assign x := 1; } }") } // `{ … }` written as a trigger argument is the expression itself, an Evaluation, diff --git a/internal/core/passes/typecheck_value.go b/internal/core/passes/typecheck_value.go index 1d723cb13..669d3a60e 100644 --- a/internal/core/passes/typecheck_value.go +++ b/internal/core/passes/typecheck_value.go @@ -342,10 +342,14 @@ func (ec *exprChecker) featureValueTypes(sym *symbols.Symbol) []*symbols.Symbol // invocationResultTypeSymbol returns the type of the result parameter of the // behavior an invocation names, which is the type of the value it produces; for -// a collection value (`xs.{…}`, a collection function call) the one type its elements have. +// a collection value (`xs.{…}`, a collection function call) the one type the elements it holds +// have — none when it holds nothing. func (ec *exprChecker) invocationResultTypeSymbol(scope *symbols.Scope, value ast.Node) *symbols.Symbol { - if types, collection := ec.model.CollectionResultTypes(scope, value); collection && len(types) == 1 { - return types[0] + if types, collection := ec.model.CollectionHeldTypes(scope, value); collection { + if len(types) == 1 { + return types[0] + } + return nil } result := ec.invocationResultParameter(scope, value) if result == nil { diff --git a/internal/core/passes/typecheck_value_test.go b/internal/core/passes/typecheck_value_test.go index 3ee0fd8fc..a9143a9f7 100644 --- a/internal/core/passes/typecheck_value_test.go +++ b/internal/core/passes/typecheck_value_test.go @@ -294,11 +294,11 @@ func TestValueIndexedCollectionElementIsUnknown(t *testing.T) { }`, "cannot bind a value of type Boat to a feature typed by Vehicle") } -// collectionValueDiags is the type diagnostics of a model of vehicles and boats whose -// collection values, in members, are judged element by element against the library. +// collectionValueDiags is the name-resolution and type diagnostics of a model of vehicles and +// boats whose collection values, in members, are judged element by element against the library. func collectionValueDiags(t *testing.T, members string) []string { t.Helper() - diags := libraryTypeDiags(t, `package P { + diags := libraryDiags(t, `package P { private import ScalarValues::*; private import ControlFunctions::*; part def Vehicle; part def Truck :> Vehicle; part def Boat; @@ -312,10 +312,13 @@ func collectionValueDiags(t *testing.T, members string) []string { function Drive { in v : Vehicle; return r : Vehicle; } function Half { in v : Vehicle; return r : Real; } function Name { in v : Vehicle; return r : String; } + function Nobody { in v : Vehicle; return r : Vehicle[0]; } + attribute nothing : Integer[0]; part def Pair { part items : Vehicle[2]; part item : Vehicle[1]; } part def Pairs :> Pair { part :>> items; part :>> item; } part pair : Pairs[1]; part couple : Pairs[2]; + part nobody : Pairs[0]; `+members+` }`) var got []string @@ -464,12 +467,33 @@ func TestValueReduceOfNothingIsJudgedByNeither(t *testing.T) { attribute s : String = ()->reduce { in a : Integer; in b : Integer; 3 }; attribute s2 : String = none->reduce { in a : Vehicle; in b : Vehicle; 3 }; part b : Boat = none->reduce { in a : Vehicle; in b : Vehicle; a }; - part b2 : Boat = none.item->reduce { in a : Vehicle; in b : Vehicle; a }; + part b2 : Boat = nobody.item->reduce { in a : Vehicle; in b : Vehicle; a }; attribute s3 : String = ()->collect { in a : Integer; 3 }; attribute s4 : String = none.{ in a : Vehicle; 3 }; part b3 = Sail(none->reduce { in a : Vehicle; in b : Vehicle; a });`) } +// A body mapping every element to nothing — a `[0]` feature or function result — holds nothing +// either, as does any operation over such a collection: no element is judged. +func TestValueMappingToNothingIsJudgedByNeither(t *testing.T) { + wantCollectionValueDiags(t, ` + attribute s : String = vs.{ in v : Vehicle; nothing }; + attribute s2 : String = vs->collect { in v : Vehicle; (nothing, nothing) }; + part b : Boat = vs->collect Nobody; + part b2 : Boat = vs.{ in v : Vehicle; Nobody(v) }; + attribute s3 : String = (vs.{ in v : Vehicle; nothing }).{ in n : Integer; 3 }; + attribute s4 : String = (().{ in a : Integer; a }).{ in b : Integer; 3 }; + part b3 : Boat = (none->select { in v : Vehicle; true })->reduce { in a : Vehicle; in b : Vehicle; a }; + part b4 : Boat = (vs->collect Nobody).?{ in v : Vehicle; true }; + part b5 : Boat = (vs->selectOne { in v : Vehicle; true }).{ in v : Vehicle; Nobody(v) }->collect { in v : Vehicle; v }; + part b6 = Sail(vs.{ in v : Vehicle; Nobody(v) }); + part b7 = Sail((vs->collect Nobody)->select { in v : Vehicle; true });`) + wantCollectionValueDiags(t, `attribute s : String = vs.{ in v : Vehicle; (nothing, 3) };`, + "cannot bind Natural value to a feature typed by String") + wantCollectionValueDiags(t, `part b : Boat = (vs.{ in v : Vehicle; v }).{ in v : Vehicle; v };`, + "cannot bind a value of type Vehicle to a feature typed by Boat") +} + // A collection value passed as an argument is typed by its elements, so the parameter // it binds is judged and the overload selected by them. func TestArgumentCollectionResultIsJudged(t *testing.T) { diff --git a/internal/core/semantics/collection.go b/internal/core/semantics/collection.go index 6775ee8db..fe5e47109 100644 --- a/internal/core/semantics/collection.go +++ b/internal/core/semantics/collection.go @@ -53,13 +53,30 @@ func (m *Model) sourcesOf(scope *symbols.Scope, node ast.Node) ([]collectionSour return nil, false } -// heldSourcesOf is the sources of the elements a collection value holds: none over a collection -// known to hold nothing, which nothing is applied to; otherwise those typing it. +// heldSourcesOf is the sources of the elements a collection value holds: those typing it, less +// any known to yield nothing — all of them over a collection holding nothing. func (m *Model) heldSourcesOf(scope *symbols.Scope, node ast.Node) ([]collectionSource, bool) { if collection, ok := m.collectionOf(scope, node); ok && m.holdsNothing(scope, collection) { return nil, true } - return m.sourcesOf(scope, node) + srcs, ok := m.sourcesOf(scope, node) + if !ok { + return nil, false + } + held := make([]collectionSource, 0, len(srcs)) + for _, src := range srcs { + if src.result != nil && m.resultHoldsNothing(src.result) || src.result == nil && m.holdsNothing(src.scope, src.node) { + continue + } + held = append(held, src) + } + return held, true +} + +// resultHoldsNothing reports a result parameter whose multiplicity admits no value. +func (m *Model) resultHoldsNothing(result *symbols.Symbol) bool { + r, ok := m.MultiplicityOf(result) + return ok && r.Upper.Known && !r.Upper.Infinite && r.Upper.Value == 0 } // collectionOf is the collection a collection value operates over: the operand of `xs.{…}` or @@ -137,11 +154,18 @@ func (m *Model) holdsNothing(scope *symbols.Scope, collection ast.Node) bool { // valuesHeldBy is how many values a collection expression holds: `()` none, a literal one, a // sequence the sum over its elements, a feature or chain the multiplicity governing it, a chain -// holding through each value of its operand the values of its last feature; not ok where unknown. +// holding through each value of its operand the values of its last feature, a collection +// operation what it maps to, keeps or reduces; not ok where unknown. func (m *Model) valuesHeldBy(scope *symbols.Scope, node ast.Node) (Range, bool) { switch n := node.(type) { case *ast.NullExpr: return exactly(0), true + case *ast.CollectExpr: + return m.valuesMappedBy(scope, n.Operand, n.Body) + case *ast.SelectExpr: + return m.valuesKeptFrom(scope, n.Operand, Bound{Infinite: true, Known: true}) + case *ast.InvocationExpr: + return m.valuesHeldByCall(scope, n) case *ast.LiteralInteger, *ast.LiteralReal, *ast.LiteralString, *ast.LiteralBool: return exactly(1), true case *ast.SequenceExpr: @@ -170,6 +194,76 @@ func (m *Model) valuesHeldBy(scope *symbols.Scope, node ast.Node) (Range, bool) return Range{}, false } +// valuesHeldByCall is how many values a call holds: collect what it maps to, select/reject up to +// all, selectOne up to one, reduce one unless the collection holds none, any other function what +// its result parameter declares. +func (m *Model) valuesHeldByCall(scope *symbols.Scope, e *ast.InvocationExpr) (Range, bool) { + fn := m.invocationCallee(scope, e) + if fn == nil { + return Range{}, false + } + switch fn { + case m.libSymbol(fqnCollect): + return m.valuesMappedBy(scope, m.argumentTo(scope, e, fn, 0), m.argumentTo(scope, e, fn, 1)) + case m.libSymbol(fqnSelect), m.libSymbol(fqnReject): + return m.valuesKeptFrom(scope, m.argumentTo(scope, e, fn, 0), Bound{Infinite: true, Known: true}) + case m.libSymbol(fqnSelectOne): + return m.valuesKeptFrom(scope, m.argumentTo(scope, e, fn, 0), Bound{Value: 1, Known: true}) + case m.libSymbol(fqnReduce): + through, ok := m.valuesHeldBy(scope, m.argumentTo(scope, e, fn, 0)) + if !ok { + return Range{}, false + } + return Range{Lower: minBound(through.Lower, Bound{Value: 1, Known: true}), Upper: minBound(through.Upper, Bound{Value: 1, Known: true})}, true + } + if result := m.ResultParameterOf(fn); result != nil { + if r, ok := m.MultiplicityOf(result); ok && r.Lower.Known && r.Upper.Known { + return r, true + } + } + return Range{}, false +} + +// valuesMappedBy is how many values mapping each of a collection's values through applied +// yields: as many as the body's result or the function's result parameter holds per value. +func (m *Model) valuesMappedBy(scope *symbols.Scope, collection, applied ast.Node) (Range, bool) { + if collection == nil { + return Range{}, false + } + through, ok := m.valuesHeldBy(scope, collection) + if !ok { + return Range{}, false + } + per := Range{Lower: Bound{Known: true}, Upper: Bound{Infinite: true, Known: true}} + switch a := applied.(type) { + case *ast.BodyExpr: + if a.Result != nil { + if r, ok := m.valuesHeldBy(symbols.BodyExprScope(scope, a), a.Result); ok { + per = r + } + } + case *ast.FeatureReference, *ast.QualifiedName, *ast.FeatureChainExpr: + if result := m.appliedResult(scope, a); result != nil { + if r, ok := m.MultiplicityOf(result); ok && r.Lower.Known && r.Upper.Known { + per = r + } + } + } + return Range{Lower: mulBounds(through.Lower, per.Lower), Upper: mulBounds(through.Upper, per.Upper)}, true +} + +// valuesKeptFrom is how many values a selection keeps: none up to the collection's, capped at most. +func (m *Model) valuesKeptFrom(scope *symbols.Scope, collection ast.Node, most Bound) (Range, bool) { + if collection == nil { + return Range{}, false + } + through, ok := m.valuesHeldBy(scope, collection) + if !ok { + return Range{}, false + } + return Range{Lower: Bound{Known: true}, Upper: minBound(through.Upper, most)}, true +} + // valuesHeldByFeature is the multiplicity governing the feature a name or chain resolves to: // the one it declares, or inherits from a feature it redefines; one where it declares none. func (m *Model) valuesHeldByFeature(scope *symbols.Scope, node ast.Node) (Range, bool) { @@ -203,6 +297,16 @@ func addBounds(a, b Bound) Bound { return Bound{Value: a.Value + b.Value, Known: true} } +func minBound(a, b Bound) Bound { + switch { + case a.Infinite: + return b + case b.Infinite || a.Value <= b.Value: + return a + } + return b +} + // mulBounds is the values held through a values, each holding b: none through none. func mulBounds(a, b Bound) Bound { if (!a.Infinite && a.Value == 0) || (!b.Infinite && b.Value == 0) { @@ -323,6 +427,20 @@ func (m *Model) CollectionElements(scope *symbols.Scope, node ast.Node) ([]Colle return m.heldElements(srcs), true } +// CollectionHeldTypes is the types every element a collection value holds has, and whether the +// value's arguments decide it; nil types where it holds nothing or they are unknown. +func (m *Model) CollectionHeldTypes(scope *symbols.Scope, node ast.Node) ([]*symbols.Symbol, bool) { + elements, ok := m.CollectionElements(scope, node) + if !ok { + return nil, false + } + lists := make([][]*symbols.Symbol, 0, len(elements)) + for _, element := range elements { + lists = append(lists, element.Types) + } + return informativeTypes(m.sharedAmong(lists)), true +} + // heldElements is each element the sources hold, a collection-valued one by its elements in turn. func (m *Model) heldElements(srcs []collectionSource) []CollectionElement { var out []CollectionElement @@ -331,6 +449,9 @@ func (m *Model) heldElements(srcs []collectionSource) []CollectionElement { out = append(out, element) continue } + if m.holdsNothing(element.Scope, element.Node) { + continue + } inner, ok := m.heldSourcesOf(element.Scope, element.Node) if !ok { out = append(out, element) @@ -462,7 +583,7 @@ func (m *Model) collectionCastConformance(scope *symbols.Scope, operand ast.Node } // judgeSources judges each element of every source: a result parameter as the feature, an -// expression by itself, a sequence `(a, b)` element by element. +// expression by itself, a sequence `(a, b)` element by element, one holding nothing not at all. func (m *Model) judgeSources(srcs []collectionSource, byNode func(*symbols.Scope, ast.Node) Conformance, byResult func(*symbols.Symbol) Conformance) []Conformance { var out []Conformance for _, src := range srcs { @@ -470,19 +591,22 @@ func (m *Model) judgeSources(srcs []collectionSource, byNode func(*symbols.Scope out = append(out, byResult(src.result)) continue } - out = append(out, elementJudgements(src.scope, src.node, byNode)...) + out = append(out, m.elementJudgements(src.scope, src.node, byNode)...) } return out } -func elementJudgements(scope *symbols.Scope, node ast.Node, judge func(*symbols.Scope, ast.Node) Conformance) []Conformance { +func (m *Model) elementJudgements(scope *symbols.Scope, node ast.Node, judge func(*symbols.Scope, ast.Node) Conformance) []Conformance { + if m.holdsNothing(scope, node) { + return nil + } seq, ok := node.(*ast.SequenceExpr) if !ok { return []Conformance{judge(scope, node)} } var out []Conformance for _, element := range seq.Elements { - out = append(out, elementJudgements(scope, element, judge)...) + out = append(out, m.elementJudgements(scope, element, judge)...) } return out } diff --git a/internal/core/semantics/collection_test.go b/internal/core/semantics/collection_test.go index 25f94fa02..f31f6a358 100644 --- a/internal/core/semantics/collection_test.go +++ b/internal/core/semantics/collection_test.go @@ -19,7 +19,7 @@ func collectionModel(t *testing.T, attributes string) (*Model, *symbols.Scope) { private import SI::*; private import ControlFunctions::*; private import ScalarFunctions::'+'; - part def C { attribute mass :> ISQ::mass; attribute name : String; } + part def C { attribute mass :> ISQ::mass; attribute name : String; attribute nothing : String[0]; } part def S { part cs : C[*]; `+attributes+` @@ -305,8 +305,8 @@ func TestCollectionReduceMayReturnTheElement(t *testing.T) { } } -// Over a collection known to hold nothing, reduce is typed by the reducer alone and collect by -// its body; but the value holds no element, so nothing is judged and it casts to anything. +// Over a collection known to hold nothing — or mapping each element to nothing — reduce is typed +// by the reducer alone and collect by its body; but no element is held, judged or refused a cast. func TestCollectionReduceOfNothing(t *testing.T) { m, s := collectionModel(t, ` part none : C[0]; @@ -319,26 +319,42 @@ func TestCollectionReduceOfNothing(t *testing.T) { attribute emptyCollect = ()->collect { in a : Integer; "s" }; attribute emptyCollect2 = none.{ in a : C; a.name }; attribute emptyCollect3 = holders.item->collect { in a : C; a.name }; + attribute emptyBody = cs.{ in x : C; x.nothing }; + attribute emptyBody2 = cs->collect { in x : C; (x.nothing, x.nothing) }; + attribute emptyNested = (().{ in a : Integer; a }).{ in b : Integer; "s" }; + attribute emptyNested2 = (none->select { in a : C; true })->reduce { in a : C; in b : C; "s" }; + attribute emptyNested3 = (cs.{ in x : C; x.nothing }).?{ in s : String; true }; + attribute emptyNested4 = (cs->selectOne { in x : C; true }).{ in x : C; x.nothing }->collect { in s : String; s }; attribute cast = none->reduce { in a : C; in b : C; a.name } as Integer; + attribute cast2 = cs.{ in x : C; x.nothing } as Integer; attribute two = (none, one, one)->reduce { in a : C; in b : C; a.name }; attribute maybe = (none, one)->reduce { in a : C; in b : C; a.name };`) - for _, name := range []string{"empty", "empty2", "empty3", "emptyCollect", "emptyCollect2", "emptyCollect3"} { + for _, name := range []string{"empty", "empty2", "empty3", "emptyCollect", "emptyCollect2", "emptyCollect3", + "emptyBody", "emptyBody2", "emptyNested", "emptyNested2", "emptyNested3", "emptyNested4"} { wantValueTypes(t, m, s, name, "String") elements, ok := m.CollectionElements(s, valueOf(t, s, name)) if !ok || len(elements) != 0 { t.Errorf("%s: elements %v, want none", name, elements) } + if types, ok := m.CollectionHeldTypes(s, valueOf(t, s, name)); !ok || len(types) != 0 { + t.Errorf("%s: held types %v, want none", name, types) + } for _, want := range []string{fqnString, fqnInteger} { if c := m.ExprConformsToLibrary(s, valueOf(t, s, name), want); c.Known && !c.Untyped { t.Errorf("%s as %s: %+v, want nothing decided", name, want, c) } } } - if c := m.CastConformance(s, valueOf(t, s, "cast").(*ast.OperatorExpr)); c.Known && !c.Holds { - t.Errorf("cast of nothing to Integer: %+v, want it not to fail", c) + for _, name := range []string{"cast", "cast2"} { + if c := m.CastConformance(s, valueOf(t, s, name).(*ast.OperatorExpr)); c.Known && !c.Holds { + t.Errorf("%s of nothing to Integer: %+v, want it not to fail", name, c) + } } wantValueTypes(t, m, s, "two", "String") wantValueTypes(t, m, s, "maybe", "Anything") + if types, ok := m.CollectionHeldTypes(s, valueOf(t, s, "two")); !ok || len(types) != 1 || leafName(types[0].Name) != "String" { + t.Errorf("two: held types %v, want String", types) + } } // `xs.?{…}` is select written out: its elements are the collection's, so a binding or an From 89448b35a1c37f3ff0f61d7e91ddd904752e1da9 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 03:19:02 +0000 Subject: [PATCH 08/22] fix(semantics): read collection sizes through aliases and redefinition How many values a feature or a mapper's result holds is read through an alias and, where none is declared, from the feature it redefines, so a result inheriting [0] and an aliased empty collection hold nothing. Co-Authored-By: jason.han --- .../collection-body-result-type.fixed.md | 2 +- internal/core/passes/typecheck_value_test.go | 7 ++- internal/core/semantics/collection.go | 47 ++++++++++++------- internal/core/semantics/collection_test.go | 35 ++++++++++++++ 4 files changed, 71 insertions(+), 20 deletions(-) diff --git a/changes/unreleased/collection-body-result-type.fixed.md b/changes/unreleased/collection-body-result-type.fixed.md index 0bc736235..767eb14fd 100644 --- a/changes/unreleased/collection-body-result-type.fixed.md +++ b/changes/unreleased/collection-body-result-type.fixed.md @@ -1 +1 @@ -- **A collection operation's static type follows what its declaration hands through, not the element type of the collection.** `xs->collect { in x : C; x.mass }` and `xs.{ in x : C; x.mass }` are typed by the body's result (`MassValue`), a nested collect by its innermost body, a body answering a sequence by every element type and `xs->collect f` by the named function's result; `select`, `reject` and `selectOne` keep the elements of `xs`, `reduce` follows its reducer's result — and the element a one-element collection hands back unreduced, unless the collection is known to hold two or more, by its own multiplicity, one it inherits by redefinition, or a chain through such features — and `forAll`/`exists` stay `Boolean`, each with the multiplicity the Kernel Function Library declares. A body whose result cannot be typed keeps the library's `Anything`. Value conformance, a feature's bound value, invocation arguments, trigger arguments and enumerated values are judged by the specialized type, so `accept when counts.{in n : Integer; n}` is refused where it was silent, and `when counts.{in n; n > 3}` is accepted where it was refused. A scalar literal a body writes out is as exact as one bound directly: `attribute i : Integer = xs.{ in x : C; 1.5 }` is refused, and a quantity it writes out is measured against the target's dimension: `attribute t : DurationValue = xs.{ in x : C; 5 [m] }` is refused. `xs.?{…}` binds as `xs->select {…}` does, and an element that is itself a collection value binds by the elements it holds, so `attribute i : Integer = xs.{ in x : C; xs.{ in y : C; 1.5 } }` is refused. A collection over `()` or a feature admitting no value keeps its declared type — `()->collect { in a : Integer; "s" }` is a `String` collection — but holds no element, so none is judged and its `reduce` takes nothing from an element it would hand back unreduced; so does one mapping every element to a `[0]` feature or function result, or any operation over such a collection, and an argument holding nothing is judged against no parameter type. A body reading the feature it values terminates as a self-referential argument does. +- **A collection operation's static type follows what its declaration hands through, not the element type of the collection.** `xs->collect { in x : C; x.mass }` and `xs.{ in x : C; x.mass }` are typed by the body's result (`MassValue`), a nested collect by its innermost body, a body answering a sequence by every element type and `xs->collect f` by the named function's result; `select`, `reject` and `selectOne` keep the elements of `xs`, `reduce` follows its reducer's result — and the element a one-element collection hands back unreduced, unless the collection is known to hold two or more, by its own multiplicity, one it inherits by redefinition, or a chain through such features — and `forAll`/`exists` stay `Boolean`, each with the multiplicity the Kernel Function Library declares. A body whose result cannot be typed keeps the library's `Anything`. Value conformance, a feature's bound value, invocation arguments, trigger arguments and enumerated values are judged by the specialized type, so `accept when counts.{in n : Integer; n}` is refused where it was silent, and `when counts.{in n; n > 3}` is accepted where it was refused. A scalar literal a body writes out is as exact as one bound directly: `attribute i : Integer = xs.{ in x : C; 1.5 }` is refused, and a quantity it writes out is measured against the target's dimension: `attribute t : DurationValue = xs.{ in x : C; 5 [m] }` is refused. `xs.?{…}` binds as `xs->select {…}` does, and an element that is itself a collection value binds by the elements it holds, so `attribute i : Integer = xs.{ in x : C; xs.{ in y : C; 1.5 } }` is refused. A collection over `()` or a feature admitting no value keeps its declared type — `()->collect { in a : Integer; "s" }` is a `String` collection — but holds no element, so none is judged and its `reduce` takes nothing from an element it would hand back unreduced; so does one mapping every element to a `[0]` feature or function result — the multiplicity read through an alias, or from the feature or result a redefinition inherits it from — or any operation over such a collection, and an argument holding nothing is judged against no parameter type. A body reading the feature it values terminates as a self-referential argument does. diff --git a/internal/core/passes/typecheck_value_test.go b/internal/core/passes/typecheck_value_test.go index a9143a9f7..f2fca7c7e 100644 --- a/internal/core/passes/typecheck_value_test.go +++ b/internal/core/passes/typecheck_value_test.go @@ -313,6 +313,8 @@ func collectionValueDiags(t *testing.T, members string) []string { function Half { in v : Vehicle; return r : Real; } function Name { in v : Vehicle; return r : String; } function Nobody { in v : Vehicle; return r : Vehicle[0]; } + function Nobody2 :> Nobody { in v : Vehicle; return r :>> r; } + alias noone for none; attribute nothing : Integer[0]; part def Pair { part items : Vehicle[2]; part item : Vehicle[1]; } part def Pairs :> Pair { part :>> items; part :>> item; } @@ -487,7 +489,10 @@ func TestValueMappingToNothingIsJudgedByNeither(t *testing.T) { part b4 : Boat = (vs->collect Nobody).?{ in v : Vehicle; true }; part b5 : Boat = (vs->selectOne { in v : Vehicle; true }).{ in v : Vehicle; Nobody(v) }->collect { in v : Vehicle; v }; part b6 = Sail(vs.{ in v : Vehicle; Nobody(v) }); - part b7 = Sail((vs->collect Nobody)->select { in v : Vehicle; true });`) + part b7 = Sail((vs->collect Nobody)->select { in v : Vehicle; true }); + part b8 : Boat = vs->collect Nobody2; + part b9 : Boat = noone.{ in v : Vehicle; v }; + part b10 : Boat = noone->reduce { in a : Vehicle; in b : Vehicle; a };`) wantCollectionValueDiags(t, `attribute s : String = vs.{ in v : Vehicle; (nothing, 3) };`, "cannot bind Natural value to a feature typed by String") wantCollectionValueDiags(t, `part b : Boat = (vs.{ in v : Vehicle; v }).{ in v : Vehicle; v };`, diff --git a/internal/core/semantics/collection.go b/internal/core/semantics/collection.go index fe5e47109..0c2d2200b 100644 --- a/internal/core/semantics/collection.go +++ b/internal/core/semantics/collection.go @@ -75,10 +75,31 @@ func (m *Model) heldSourcesOf(scope *symbols.Scope, node ast.Node) ([]collection // resultHoldsNothing reports a result parameter whose multiplicity admits no value. func (m *Model) resultHoldsNothing(result *symbols.Symbol) bool { - r, ok := m.MultiplicityOf(result) + r, ok := m.governingMultiplicity(result) return ok && r.Upper.Known && !r.Upper.Infinite && r.Upper.Value == 0 } +// governingMultiplicity is the multiplicity a feature declares, or inherits from a feature it +// redefines, read through an alias; not ok where it declares and inherits none. +func (m *Model) governingMultiplicity(sym *symbols.Symbol) (Range, bool) { + if alias, ok := m.resolver.ResolveAliasTarget(sym); ok && alias != nil { + sym = alias + } + if declared, ok := m.MultiplicityOf(sym); ok { + return declared, true + } + for _, redefined := range m.redefinedTransitively(sym) { + if inherited, ok := m.MultiplicityOf(redefined); ok { + return inherited, true + } + } + return Range{}, false +} + +func knownRange(r Range, ok bool) (Range, bool) { + return r, ok && r.Lower.Known && r.Upper.Known +} + // collectionOf is the collection a collection value operates over: the operand of `xs.{…}` or // `xs.?{…}`, the first argument of a collection function call; not ok for any other value. func (m *Model) collectionOf(scope *symbols.Scope, node ast.Node) (ast.Node, bool) { @@ -217,9 +238,7 @@ func (m *Model) valuesHeldByCall(scope *symbols.Scope, e *ast.InvocationExpr) (R return Range{Lower: minBound(through.Lower, Bound{Value: 1, Known: true}), Upper: minBound(through.Upper, Bound{Value: 1, Known: true})}, true } if result := m.ResultParameterOf(fn); result != nil { - if r, ok := m.MultiplicityOf(result); ok && r.Lower.Known && r.Upper.Known { - return r, true - } + return knownRange(m.governingMultiplicity(result)) } return Range{}, false } @@ -244,7 +263,7 @@ func (m *Model) valuesMappedBy(scope *symbols.Scope, collection, applied ast.Nod } case *ast.FeatureReference, *ast.QualifiedName, *ast.FeatureChainExpr: if result := m.appliedResult(scope, a); result != nil { - if r, ok := m.MultiplicityOf(result); ok && r.Lower.Known && r.Upper.Known { + if r, ok := knownRange(m.governingMultiplicity(result)); ok { per = r } } @@ -264,25 +283,17 @@ func (m *Model) valuesKeptFrom(scope *symbols.Scope, collection ast.Node, most B return Range{Lower: Bound{Known: true}, Upper: minBound(through.Upper, most)}, true } -// valuesHeldByFeature is the multiplicity governing the feature a name or chain resolves to: -// the one it declares, or inherits from a feature it redefines; one where it declares none. +// valuesHeldByFeature is the multiplicity governing the feature a name or chain resolves to, +// through an alias: the one it declares or inherits by redefinition; one where it has none. func (m *Model) valuesHeldByFeature(scope *symbols.Scope, node ast.Node) (Range, bool) { sym, ok := m.resolver.ResolveTarget(scope, node) if !ok || sym == nil { return Range{}, false } - r := AssumedRange() - if declared, ok := m.MultiplicityOf(sym); ok { - r = declared - } else { - for _, redefined := range m.redefinedTransitively(sym) { - if inherited, ok := m.MultiplicityOf(redefined); ok { - r = inherited - break - } - } + if r, ok := m.governingMultiplicity(sym); ok { + return knownRange(r, true) } - return r, r.Lower.Known && r.Upper.Known + return AssumedRange(), true } func exactly(n int64) Range { diff --git a/internal/core/semantics/collection_test.go b/internal/core/semantics/collection_test.go index f31f6a358..0d66ab6aa 100644 --- a/internal/core/semantics/collection_test.go +++ b/internal/core/semantics/collection_test.go @@ -357,6 +357,41 @@ func TestCollectionReduceOfNothing(t *testing.T) { } } +// How many values a feature or a mapper's result holds is read through an alias and, where +// it declares no multiplicity, from the feature it redefines. +func TestCollectionSizeThroughAliasAndRedefinition(t *testing.T) { + m, s := collectionModel(t, ` + part none : C[0]; + part two : C[2]; + alias nobody for none; + alias pair for two; + function Nobody { in c : C; return r : C[0]; } + function Nobody2 :> Nobody { in c : C; return r :>> r; } + function Named { in c : C; return r : String; } + attribute viaAlias = nobody.{ in c : C; c.name }; + attribute viaAlias2 = pair->reduce { in a : C; in b : C; a.name }; + attribute inherited = cs->collect Nobody2; + attribute inherited2 = (cs->collect Nobody2)->reduce { in a : C; in b : C; a.name }; + attribute named = cs->collect Named;`) + for _, name := range []string{"viaAlias", "inherited2"} { + wantValueTypes(t, m, s, name, "String") + if elements, ok := m.CollectionElements(s, valueOf(t, s, name)); !ok || len(elements) != 0 { + t.Errorf("%s: elements %v, want none", name, elements) + } + } + wantValueTypes(t, m, s, "viaAlias2", "String") + if elements, ok := m.CollectionElements(s, valueOf(t, s, "viaAlias2")); !ok || len(elements) != 1 || leafName(elements[0].Types[0].Name) != "String" { + t.Errorf("viaAlias2: elements %v, want the reducer's String alone", elements) + } + wantValueTypes(t, m, s, "inherited", "C") + if elements, ok := m.CollectionElements(s, valueOf(t, s, "inherited")); !ok || len(elements) != 0 { + t.Errorf("inherited: elements %v, want none", elements) + } + if elements, ok := m.CollectionElements(s, valueOf(t, s, "named")); !ok || len(elements) != 1 { + t.Errorf("named: elements %v, want the result parameter", elements) + } +} + // `xs.?{…}` is select written out: its elements are the collection's, so a binding or an // argument is judged by them as `xs->select {…}` is. func TestCollectionSelectShorthandElements(t *testing.T) { From 252b87e6f12110e90961381bb978677b61724aa2 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 03:28:34 +0000 Subject: [PATCH 09/22] fix(semantics): inherit a result's multiplicity through its implicit redefinition A specialized function's result parameter redefines the general's by position, so a result declaring no multiplicity inherits the [0] of the result it replaces and a collect through it holds nothing. Co-Authored-By: jason.han --- internal/core/passes/typecheck_value_test.go | 2 ++ internal/core/semantics/collection.go | 4 ++-- internal/core/semantics/collection_test.go | 15 ++++++++++----- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/internal/core/passes/typecheck_value_test.go b/internal/core/passes/typecheck_value_test.go index f2fca7c7e..0bc382f5e 100644 --- a/internal/core/passes/typecheck_value_test.go +++ b/internal/core/passes/typecheck_value_test.go @@ -314,6 +314,7 @@ func collectionValueDiags(t *testing.T, members string) []string { function Name { in v : Vehicle; return r : String; } function Nobody { in v : Vehicle; return r : Vehicle[0]; } function Nobody2 :> Nobody { in v : Vehicle; return r :>> r; } + function Nobody3 :> Nobody { in v : Vehicle; return r : Vehicle; } alias noone for none; attribute nothing : Integer[0]; part def Pair { part items : Vehicle[2]; part item : Vehicle[1]; } @@ -491,6 +492,7 @@ func TestValueMappingToNothingIsJudgedByNeither(t *testing.T) { part b6 = Sail(vs.{ in v : Vehicle; Nobody(v) }); part b7 = Sail((vs->collect Nobody)->select { in v : Vehicle; true }); part b8 : Boat = vs->collect Nobody2; + part b11 : Boat = vs->collect Nobody3; part b9 : Boat = noone.{ in v : Vehicle; v }; part b10 : Boat = noone->reduce { in a : Vehicle; in b : Vehicle; a };`) wantCollectionValueDiags(t, `attribute s : String = vs.{ in v : Vehicle; (nothing, 3) };`, diff --git a/internal/core/semantics/collection.go b/internal/core/semantics/collection.go index 0c2d2200b..893b2ca77 100644 --- a/internal/core/semantics/collection.go +++ b/internal/core/semantics/collection.go @@ -80,7 +80,7 @@ func (m *Model) resultHoldsNothing(result *symbols.Symbol) bool { } // governingMultiplicity is the multiplicity a feature declares, or inherits from a feature it -// redefines, read through an alias; not ok where it declares and inherits none. +// redefines by clause or position, read through an alias; not ok where it declares and inherits none. func (m *Model) governingMultiplicity(sym *symbols.Symbol) (Range, bool) { if alias, ok := m.resolver.ResolveAliasTarget(sym); ok && alias != nil { sym = alias @@ -88,7 +88,7 @@ func (m *Model) governingMultiplicity(sym *symbols.Symbol) (Range, bool) { if declared, ok := m.MultiplicityOf(sym); ok { return declared, true } - for _, redefined := range m.redefinedTransitively(sym) { + for _, redefined := range m.AllRedefinedFeatures(sym) { if inherited, ok := m.MultiplicityOf(redefined); ok { return inherited, true } diff --git a/internal/core/semantics/collection_test.go b/internal/core/semantics/collection_test.go index 0d66ab6aa..7575015e4 100644 --- a/internal/core/semantics/collection_test.go +++ b/internal/core/semantics/collection_test.go @@ -358,7 +358,7 @@ func TestCollectionReduceOfNothing(t *testing.T) { } // How many values a feature or a mapper's result holds is read through an alias and, where -// it declares no multiplicity, from the feature it redefines. +// it declares no multiplicity, from the feature it redefines by clause or by position. func TestCollectionSizeThroughAliasAndRedefinition(t *testing.T) { m, s := collectionModel(t, ` part none : C[0]; @@ -367,13 +367,16 @@ func TestCollectionSizeThroughAliasAndRedefinition(t *testing.T) { alias pair for two; function Nobody { in c : C; return r : C[0]; } function Nobody2 :> Nobody { in c : C; return r :>> r; } + function Nobody3 :> Nobody { in c : C; return r : C; } function Named { in c : C; return r : String; } attribute viaAlias = nobody.{ in c : C; c.name }; attribute viaAlias2 = pair->reduce { in a : C; in b : C; a.name }; attribute inherited = cs->collect Nobody2; attribute inherited2 = (cs->collect Nobody2)->reduce { in a : C; in b : C; a.name }; + attribute implicit = cs->collect Nobody3; + attribute implicit2 = (cs->collect Nobody3)->reduce { in a : C; in b : C; a.name }; attribute named = cs->collect Named;`) - for _, name := range []string{"viaAlias", "inherited2"} { + for _, name := range []string{"viaAlias", "inherited2", "implicit2"} { wantValueTypes(t, m, s, name, "String") if elements, ok := m.CollectionElements(s, valueOf(t, s, name)); !ok || len(elements) != 0 { t.Errorf("%s: elements %v, want none", name, elements) @@ -383,9 +386,11 @@ func TestCollectionSizeThroughAliasAndRedefinition(t *testing.T) { if elements, ok := m.CollectionElements(s, valueOf(t, s, "viaAlias2")); !ok || len(elements) != 1 || leafName(elements[0].Types[0].Name) != "String" { t.Errorf("viaAlias2: elements %v, want the reducer's String alone", elements) } - wantValueTypes(t, m, s, "inherited", "C") - if elements, ok := m.CollectionElements(s, valueOf(t, s, "inherited")); !ok || len(elements) != 0 { - t.Errorf("inherited: elements %v, want none", elements) + for _, name := range []string{"inherited", "implicit"} { + wantValueTypes(t, m, s, name, "C") + if elements, ok := m.CollectionElements(s, valueOf(t, s, name)); !ok || len(elements) != 0 { + t.Errorf("%s: elements %v, want none", name, elements) + } } if elements, ok := m.CollectionElements(s, valueOf(t, s, "named")); !ok || len(elements) != 1 { t.Errorf("named: elements %v, want the result parameter", elements) From 65e7467d68d1acbcba53b13042f1f50ba0135320 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:50:10 +0000 Subject: [PATCH 10/22] fix(semantics): follow the exported IsAnything predicate in collection typing Co-Authored-By: jason.han --- internal/core/semantics/collection.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/core/semantics/collection.go b/internal/core/semantics/collection.go index 893b2ca77..8274ef80e 100644 --- a/internal/core/semantics/collection.go +++ b/internal/core/semantics/collection.go @@ -690,7 +690,7 @@ func decided(c Conformance) (Conformance, bool) { // informativeTypes is types unless every one is Anything, which says nothing; then nil. func informativeTypes(types []*symbols.Symbol) []*symbols.Symbol { for _, typ := range types { - if !isAnything(typ) { + if !IsAnything(typ) { return types } } From 0ce7a1b88a7a7ef49e4f1aa768fc208e4d5dbe57 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:07:06 +0000 Subject: [PATCH 11/22] fix(semantics): count a named mapper's result as one, saturate collection sizes past int64 and let any later element decide a cast Co-Authored-By: jason.han --- internal/core/passes/typecheck_value_test.go | 3 +- internal/core/semantics/collection.go | 64 +++++++++++++++----- internal/core/semantics/collection_test.go | 64 ++++++++++++++++++++ 3 files changed, 116 insertions(+), 15 deletions(-) diff --git a/internal/core/passes/typecheck_value_test.go b/internal/core/passes/typecheck_value_test.go index 0bc382f5e..8932989b0 100644 --- a/internal/core/passes/typecheck_value_test.go +++ b/internal/core/passes/typecheck_value_test.go @@ -460,7 +460,8 @@ func TestValueReduceResultIsJudged(t *testing.T) { part b3 : Boat = couple.item->reduce { in a : Vehicle; in b : Vehicle; boat }; part v : Vehicle = vs->reduce { in a : Vehicle; in b : Vehicle; a }; attribute s : String = (1, 2)->reduce { in a : Integer; in b : Integer; "s" }; - attribute s2 : String = (one, boat)->reduce { in a : Vehicle; in b : Vehicle; "s" };`) + attribute s2 : String = (one, boat)->reduce { in a : Vehicle; in b : Vehicle; "s" }; + attribute i : Integer = (two->collect Name)->reduce { in a : String; in b : String; 3 };`) } // A collection operation over a collection known to hold nothing returns nothing, and never diff --git a/internal/core/semantics/collection.go b/internal/core/semantics/collection.go index 8274ef80e..8450a83e7 100644 --- a/internal/core/semantics/collection.go +++ b/internal/core/semantics/collection.go @@ -1,6 +1,7 @@ package semantics import ( + "math" "strings" "github.com/Open-MBEE/OpenSysML/internal/core/ast" @@ -196,7 +197,7 @@ func (m *Model) valuesHeldBy(scope *symbols.Scope, node ast.Node) (Range, bool) if !ok { return Range{}, false } - sum = Range{Lower: addBounds(sum.Lower, r.Lower), Upper: addBounds(sum.Upper, r.Upper)} + sum = addRanges(sum, r) } return sum, true case *ast.FeatureReference, *ast.QualifiedName: @@ -210,7 +211,7 @@ func (m *Model) valuesHeldBy(scope *symbols.Scope, node ast.Node) (Range, bool) if !ok { return Range{}, false } - return Range{Lower: mulBounds(through.Lower, last.Lower), Upper: mulBounds(through.Upper, last.Upper)}, true + return mulRanges(through, last), true } return Range{}, false } @@ -238,7 +239,7 @@ func (m *Model) valuesHeldByCall(scope *symbols.Scope, e *ast.InvocationExpr) (R return Range{Lower: minBound(through.Lower, Bound{Value: 1, Known: true}), Upper: minBound(through.Upper, Bound{Value: 1, Known: true})}, true } if result := m.ResultParameterOf(fn); result != nil { - return knownRange(m.governingMultiplicity(result)) + return m.valuesGoverning(result) } return Range{}, false } @@ -263,12 +264,12 @@ func (m *Model) valuesMappedBy(scope *symbols.Scope, collection, applied ast.Nod } case *ast.FeatureReference, *ast.QualifiedName, *ast.FeatureChainExpr: if result := m.appliedResult(scope, a); result != nil { - if r, ok := knownRange(m.governingMultiplicity(result)); ok { + if r, ok := m.valuesGoverning(result); ok { per = r } } } - return Range{Lower: mulBounds(through.Lower, per.Lower), Upper: mulBounds(through.Upper, per.Upper)}, true + return mulRanges(through, per), true } // valuesKeptFrom is how many values a selection keeps: none up to the collection's, capped at most. @@ -283,13 +284,19 @@ func (m *Model) valuesKeptFrom(scope *symbols.Scope, collection ast.Node, most B return Range{Lower: Bound{Known: true}, Upper: minBound(through.Upper, most)}, true } -// valuesHeldByFeature is the multiplicity governing the feature a name or chain resolves to, -// through an alias: the one it declares or inherits by redefinition; one where it has none. +// valuesHeldByFeature is how many values the feature a name or chain resolves to holds. func (m *Model) valuesHeldByFeature(scope *symbols.Scope, node ast.Node) (Range, bool) { sym, ok := m.resolver.ResolveTarget(scope, node) if !ok || sym == nil { return Range{}, false } + return m.valuesGoverning(sym) +} + +// valuesGoverning is how many values a feature holds: the multiplicity governing it, through an +// alias, declared or inherited by redefinition — one where it has none; not ok while a bound +// it declares is not evaluable. +func (m *Model) valuesGoverning(sym *symbols.Symbol) (Range, bool) { if r, ok := m.governingMultiplicity(sym); ok { return knownRange(r, true) } @@ -301,9 +308,30 @@ func exactly(n int64) Range { return Range{Lower: b, Upper: b} } -func addBounds(a, b Bound) Bound { +// addRanges is the values two collections hold together; a sum past int64 is unbounded above +// and at least MaxInt64 below. +func addRanges(a, b Range) Range { + return Range{Lower: addBounds(a.Lower, b.Lower, mostFinite), Upper: addBounds(a.Upper, b.Upper, unbounded)} +} + +// mulRanges is the values held through each value of a, each holding b; a product past int64 +// is unbounded above and at least MaxInt64 below. +func mulRanges(a, b Range) Range { + return Range{Lower: mulBounds(a.Lower, b.Lower, mostFinite), Upper: mulBounds(a.Upper, b.Upper, unbounded)} +} + +var ( + unbounded = Bound{Infinite: true, Known: true} + mostFinite = Bound{Value: math.MaxInt64, Known: true} +) + +// addBounds is a + b, or past where int64 reaches. +func addBounds(a, b, past Bound) Bound { if a.Infinite || b.Infinite { - return Bound{Infinite: true, Known: true} + return unbounded + } + if a.Value > math.MaxInt64-b.Value { + return past } return Bound{Value: a.Value + b.Value, Known: true} } @@ -318,13 +346,16 @@ func minBound(a, b Bound) Bound { return b } -// mulBounds is the values held through a values, each holding b: none through none. -func mulBounds(a, b Bound) Bound { +// mulBounds is a × b, none through none, or past where int64 reaches. +func mulBounds(a, b, past Bound) Bound { if (!a.Infinite && a.Value == 0) || (!b.Infinite && b.Value == 0) { return Bound{Known: true} } if a.Infinite || b.Infinite { - return Bound{Infinite: true, Known: true} + return unbounded + } + if a.Value > math.MaxInt64/b.Value { + return past } return Bound{Value: a.Value * b.Value, Known: true} } @@ -669,14 +700,19 @@ func anyHolds(judged []Conformance) Conformance { return conformanceUnknown() } var found []string + unknown := false for _, c := range judged { switch { case c.Known && c.Holds: return c case !c.Known || c.Untyped: - return conformanceUnknown() + unknown = true + default: + found = appendUnique(found, c.Found) } - found = appendUnique(found, c.Found) + } + if unknown { + return conformanceUnknown() } return Conformance{Known: true, Found: strings.Join(found, " and ")} } diff --git a/internal/core/semantics/collection_test.go b/internal/core/semantics/collection_test.go index 7575015e4..e0d97cdc0 100644 --- a/internal/core/semantics/collection_test.go +++ b/internal/core/semantics/collection_test.go @@ -1,6 +1,7 @@ package semantics import ( + "math" "slices" "strings" "testing" @@ -461,3 +462,66 @@ func TestCollectionNestedElements(t *testing.T) { t.Errorf("nestedEmpty: elements %v, want none", elements) } } + +// A mapper named by function holds one value per element where its result declares no +// multiplicity, as a feature does — not any number — while a result whose declared bound is not +// evaluable holds an unknown number; a count past int64 stays at least as many as it is. +func TestCollectionSizeThroughNamedMapperAndPastInt64(t *testing.T) { + m, s := collectionModel(t, ` + part two : C[2]; + attribute n : Positive; + function Named { in c : C; return r : String; } + function Unsure { in c : C; return r : String[n]; } + part def H { part many : C[9223372036854775807]; } + part hs : H[2]; + part huge : C[9223372036854775807]; + attribute named = (two->collect Named)->reduce { in a : String; in b : String; 1 }; + attribute unsure = (two->collect Unsure)->reduce { in a : String; in b : String; 1 }; + attribute sum = (huge, huge)->reduce { in a : C; in b : C; a.name }; + attribute product = hs.many->reduce { in a : C; in b : C; a.name };`) + wantValueTypes(t, m, s, "named", "Integer") + if elements, ok := m.CollectionElements(s, valueOf(t, s, "named")); !ok || len(elements) != 1 || leafName(elements[0].Types[0].Name) != "Integer" { + t.Errorf("named: elements %v, want the reducer's Integer alone", elements) + } + wantValueTypes(t, m, s, "unsure", "Anything") + if elements, ok := m.CollectionElements(s, valueOf(t, s, "unsure")); !ok || len(elements) != 2 { + t.Errorf("unsure: elements %v, want the reducer's Integer and the mapper's String", elements) + } + for _, name := range []string{"sum", "product"} { + wantValueTypes(t, m, s, name, "String") + if elements, ok := m.CollectionElements(s, valueOf(t, s, name)); !ok || len(elements) != 1 || leafName(elements[0].Types[0].Name) != "String" { + t.Errorf("%s: elements %v, want the reducer's String alone", name, elements) + } + collection := valueOf(t, s, name).(*ast.InvocationExpr).Operand + r, ok := m.valuesHeldBy(s, collection) + if !ok || !r.Lower.Known || r.Lower.Infinite || r.Lower.Value != math.MaxInt64 || !r.Upper.Known || !r.Upper.Infinite { + t.Errorf("%s: values held %s, want [%d..*]", name, r.Text(), int64(math.MaxInt64)) + } + } +} + +// A cast holds once any element it may hold specializes the target, whichever comes first: an +// untyped element before an Integer leaves the Integer to decide; one beside a String leaves the +// cast undecided, and none at all names every element refused. +func TestCollectionCastDecidedByAnyElement(t *testing.T) { + holds := Conformance{Known: true, Holds: true} + if c := anyHolds([]Conformance{conformanceUnknown(), holds}); !c.Known || !c.Holds { + t.Errorf("unknown then holding: %+v, want it to hold", c) + } + if c := anyHolds([]Conformance{{Known: true, Found: "String"}, conformanceUnknown()}); c.Known { + t.Errorf("refused then unknown: %+v, want unknown", c) + } + if c := anyHolds([]Conformance{{Known: true, Found: "String"}, {Known: true, Found: "C"}}); !c.Known || c.Holds || c.Found != "String and C" { + t.Errorf("refused twice: %+v, want known, not holding, found String and C", c) + } + m, s := collectionModel(t, ` + attribute loose; + attribute cast = cs.{ in x : C; (loose, 1) } as Integer; + attribute cast2 = cs.{ in x : C; (x.name, "s") } as Integer;`) + if c := m.CastConformance(s, valueOf(t, s, "cast").(*ast.OperatorExpr)); !c.Known || !c.Holds { + t.Errorf("cast to Integer: %+v, want it to hold by the literal 1", c) + } + if c := m.CastConformance(s, valueOf(t, s, "cast2").(*ast.OperatorExpr)); !c.Known || c.Holds || c.Found != "String" { + t.Errorf("cast2 to Integer: %+v, want known, not holding, found String", c) + } +} From fa53d3b7c77ea924c9902b9a3d8c824d9eebc4d5 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:11:52 +0000 Subject: [PATCH 12/22] fix(passes): count a collection value's known size against the bound multiplicity Co-Authored-By: jason.han --- internal/core/passes/typecheck_expr.go | 4 +-- internal/core/passes/typecheck_value.go | 22 +++++++++----- internal/core/passes/typecheck_value_test.go | 29 ++++++++++++++++++ internal/core/semantics/collection.go | 12 ++++++++ internal/core/semantics/collection_test.go | 32 ++++++++++++++++++++ internal/core/semantics/multiplicity.go | 8 +++++ 6 files changed, 97 insertions(+), 10 deletions(-) diff --git a/internal/core/passes/typecheck_expr.go b/internal/core/passes/typecheck_expr.go index 21bb8bf14..e610486a7 100644 --- a/internal/core/passes/typecheck_expr.go +++ b/internal/core/passes/typecheck_expr.go @@ -140,7 +140,7 @@ func (ec *exprChecker) checkBoundValue(valueScope, declScope *symbols.Scope, d f } ec.checkValueConformance(valueScope, declScope, d, value) ec.checkValueDimension(valueScope, declScope, d, value) - ec.checkValueCount(declScope, d, value) + ec.checkValueCount(valueScope, declScope, d, value) } // checkScalarBinding reports a got-typed value that may not bind to a want-typed feature, @@ -1066,7 +1066,7 @@ func (ec *exprChecker) checkFeatureBinding(scope *symbols.Scope, arg ast.Node, g } else { ec.checkObjectBinding(scope, arg, feature, typ) } - if count, known := exactCount(arg); known { + if count, known := ec.exactCount(scope, arg); known { if r, ok := ec.effectiveRange(feature.OwnerScope, usageDecl(u), 0); ok { if msg := r.CountViolation(count); msg != "" { ec.errorf(arg.Span(), "%s of %s: %s", feature.Name, typ.Name, msg) diff --git a/internal/core/passes/typecheck_value.go b/internal/core/passes/typecheck_value.go index 669d3a60e..0c833c18f 100644 --- a/internal/core/passes/typecheck_value.go +++ b/internal/core/passes/typecheck_value.go @@ -97,8 +97,8 @@ func literalPrimType(value ast.Node) semantics.PrimType { // checkValueCount checks a bound value's element count against the multiplicity // governing the feature. -func (ec *exprChecker) checkValueCount(declScope *symbols.Scope, d featureDecl, value ast.Node) { - count, known := exactCount(value) +func (ec *exprChecker) checkValueCount(valueScope, declScope *symbols.Scope, d featureDecl, value ast.Node) { + count, known := ec.exactCount(valueScope, value) if !known { return } @@ -145,11 +145,12 @@ func (ec *exprChecker) effectiveRange(scope *symbols.Scope, d featureDecl, depth } // exactCount returns how many values a bound expression produces, and whether -// that is statically known. A literal contributes one value and a collection -// literal the values of its elements; anything else (a feature reference, an -// invocation) may itself be multi-valued, so its count is unknown — as is that -// of a collection holding one. -func exactCount(value ast.Node) (int64, bool) { +// that is statically known. A literal contributes one value, a collection +// literal the values of its elements, and a collection operation the values it +// is known to hold — none over `()`, one per element mapped; anything else (a +// feature reference, an invocation) may itself be multi-valued, so its count is +// unknown — as is that of a collection holding one. +func (ec *exprChecker) exactCount(scope *symbols.Scope, value ast.Node) (int64, bool) { if value == nil { return 0, false } @@ -161,7 +162,7 @@ func exactCount(value ast.Node) (int64, bool) { if seq, ok := value.(*ast.SequenceExpr); ok { var total int64 for _, element := range seq.Elements { - n, ok := exactCount(element) + n, ok := ec.exactCount(scope, element) if !ok { return 0, false } @@ -172,6 +173,11 @@ func exactCount(value ast.Node) (int64, bool) { if literalPrimType(value) != semantics.PrimUnknown { return 1, true } + if r, ok := ec.model.CollectionValues(scope, value); ok { + if exact, ok := r.Exactly(); ok { + return exact, true + } + } return 0, false } diff --git a/internal/core/passes/typecheck_value_test.go b/internal/core/passes/typecheck_value_test.go index 8932989b0..fe84116fb 100644 --- a/internal/core/passes/typecheck_value_test.go +++ b/internal/core/passes/typecheck_value_test.go @@ -464,6 +464,35 @@ func TestValueReduceResultIsJudged(t *testing.T) { attribute i : Integer = (two->collect Name)->reduce { in a : String; in b : String; 3 };`) } +// A collection value binds as many values as it is known to hold: none over a collection +// holding none, one per element a collect maps over a known count, one from a reduce; a +// count that is not exact is left to evaluation. +func TestValueCollectionCountIsJudged(t *testing.T) { + wantCollectionValueDiags(t, `part b : Boat[1] = none.{ in v : Vehicle; boat };`, + "0 value(s) bound to a feature with multiplicity lower bound 1") + wantCollectionValueDiags(t, `part b : Boat[1..2] = ()->collect { in v : Vehicle; boat };`, + "0 value(s) bound to a feature with multiplicity lower bound 1") + wantCollectionValueDiags(t, `part b : Boat[2] = none->select { in v : Vehicle; true };`, + "0 value(s) bound to a feature with multiplicity lower bound 2") + wantCollectionValueDiags(t, `part b : Boat[1] = ()->reduce { in a : Boat; in b : Boat; a };`, + "0 value(s) bound to a feature with multiplicity lower bound 1") + wantCollectionValueDiags(t, `part b : Boat[1] = pair.items.{ in v : Vehicle; boat };`, + "2 value(s) bound to a feature with multiplicity upper bound 1") + wantCollectionValueDiags(t, `part b : Boat[0..1] = pair.items->collect { in v : Vehicle; (boat, boat) };`, + "4 value(s) bound to a feature with multiplicity upper bound 1") + wantCollectionValueDiags(t, `attribute s : String[3] = ("a", one.{ in v : Vehicle; "b" });`, + "2 value(s) bound to a feature with multiplicity lower bound 3") + wantCollectionValueDiags(t, `part b : Boat[3] = pair.items->collect Boats;`, + "2 value(s) bound to a feature with multiplicity lower bound 3") + wantCollectionValueDiags(t, ` + part b : Boat[2] = pair.items.{ in v : Vehicle; boat }; + part b2 : Boat[1] = pair.items->reduce { in a : Vehicle; in b : Vehicle; boat }; + part v : Vehicle[0..1] = pair.items->selectOne { in v : Vehicle; true }; + part b3 : Boat[1] = vs.{ in v : Vehicle; boat }; + part v2 : Vehicle[3] = pair.items->select { in v : Vehicle; true }; + part b4 : Boat[2] = pair.items->collect Boats;`) +} + // A collection operation over a collection known to hold nothing returns nothing, and never // applies the reducer or body: no element is bound, though the value is still typed by them. func TestValueReduceOfNothingIsJudgedByNeither(t *testing.T) { diff --git a/internal/core/semantics/collection.go b/internal/core/semantics/collection.go index 8450a83e7..0963d96ea 100644 --- a/internal/core/semantics/collection.go +++ b/internal/core/semantics/collection.go @@ -40,6 +40,18 @@ func (m *Model) CollectionResultTypes(scope *symbols.Scope, node ast.Node) ([]*s return m.sourcesTypes(srcs), true } +// CollectionValues is how many values a collection value — `xs.{…}`, `xs.?{…}` or a call of a +// ControlFunctions collection function — holds, where the value is one and its size is known. +func (m *Model) CollectionValues(scope *symbols.Scope, node ast.Node) (Range, bool) { + if m == nil || m.resolver == nil || node == nil { + return Range{}, false + } + if _, ok := m.collectionOf(scope, node); !ok { + return Range{}, false + } + return m.valuesHeldBy(scope, node) +} + // sourcesOf is the sources typing a collection value: `xs.{…}` by its body, `xs.?{…}` by xs, a // collection function call by its arguments; not ok for any other value. func (m *Model) sourcesOf(scope *symbols.Scope, node ast.Node) ([]collectionSource, bool) { diff --git a/internal/core/semantics/collection_test.go b/internal/core/semantics/collection_test.go index e0d97cdc0..3ceef070f 100644 --- a/internal/core/semantics/collection_test.go +++ b/internal/core/semantics/collection_test.go @@ -398,6 +398,38 @@ func TestCollectionSizeThroughAliasAndRedefinition(t *testing.T) { } } +// CollectionValues sizes a collection value alone — an operation over a known collection — and +// says nothing of a literal or a feature, whose sizes are read elsewhere. +func TestCollectionValues(t *testing.T) { + m, s := collectionModel(t, ` + part none : C[0]; + part two : C[2]; + attribute mapped = two.{ in c : C; (c.name, c.name) }; + attribute reduced = two->reduce { in a : C; in b : C; a.name }; + attribute nothing = none->select { in c : C; true }; + attribute atMost = two->selectOne { in c : C; true }; + attribute open = cs.{ in c : C; c.name }; + attribute literal = (1, 2); + attribute named = two;`) + for name, want := range map[string]string{"mapped": "[4]", "reduced": "[1]", "nothing": "[0]", "atMost": "[0..1]", "open": "[0..*]"} { + r, ok := m.CollectionValues(s, valueOf(t, s, name)) + if !ok || r.Text() != want { + t.Errorf("%s: %s %v, want %s", name, r.Text(), ok, want) + } + } + for _, name := range []string{"literal", "named"} { + if _, ok := m.CollectionValues(s, valueOf(t, s, name)); ok { + t.Errorf("%s: sized as a collection value", name) + } + } + if n, ok := exactly(3).Exactly(); !ok || n != 3 { + t.Errorf("[3] exactly %d %v, want 3", n, ok) + } + if _, ok := (Range{Lower: Bound{Value: 1, Known: true}, Upper: unbounded}).Exactly(); ok { + t.Error("[1..*] is exact") + } +} + // `xs.?{…}` is select written out: its elements are the collection's, so a binding or an // argument is judged by them as `xs->select {…}` is. func TestCollectionSelectShorthandElements(t *testing.T) { diff --git a/internal/core/semantics/multiplicity.go b/internal/core/semantics/multiplicity.go index 2a3422d31..1e8048156 100644 --- a/internal/core/semantics/multiplicity.go +++ b/internal/core/semantics/multiplicity.go @@ -195,6 +195,14 @@ func (r Range) CountViolation(count int64) string { return "" } +// Exactly is the one count the range admits, ok where both bounds are that finite count. +func (r Range) Exactly() (int64, bool) { + if !r.Lower.Known || !r.Upper.Known || r.Lower.Infinite || r.Upper.Infinite || r.Lower.Value != r.Upper.Value { + return 0, false + } + return r.Lower.Value, true +} + // HasBounds reports whether the range is exactly lower..upper — the spec's // multiplicityHasBounds (SysML v2 8.3.3.1). ok is false when a bound is not // evaluable, so callers can skip the check. From 961e3af7ae347436519a17388d157716ff588435 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:20:47 +0000 Subject: [PATCH 13/22] fix(passes): saturate a bound value's count past int64 Co-Authored-By: jason.han --- internal/core/passes/typecheck_value.go | 10 ++++++++-- internal/core/passes/typecheck_value_test.go | 4 ++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/internal/core/passes/typecheck_value.go b/internal/core/passes/typecheck_value.go index 0c833c18f..8e1cc8ce2 100644 --- a/internal/core/passes/typecheck_value.go +++ b/internal/core/passes/typecheck_value.go @@ -1,6 +1,7 @@ package passes import ( + "math" "slices" "strings" @@ -149,7 +150,8 @@ func (ec *exprChecker) effectiveRange(scope *symbols.Scope, d featureDecl, depth // literal the values of its elements, and a collection operation the values it // is known to hold — none over `()`, one per element mapped; anything else (a // feature reference, an invocation) may itself be multi-valued, so its count is -// unknown — as is that of a collection holding one. +// unknown — as is that of a collection holding one. A total past int64 stays +// at its maximum, which still exceeds every finite bound. func (ec *exprChecker) exactCount(scope *symbols.Scope, value ast.Node) (int64, bool) { if value == nil { return 0, false @@ -166,7 +168,11 @@ func (ec *exprChecker) exactCount(scope *symbols.Scope, value ast.Node) (int64, if !ok { return 0, false } - total += n + if total > math.MaxInt64-n { + total = math.MaxInt64 + } else { + total += n + } } return total, true } diff --git a/internal/core/passes/typecheck_value_test.go b/internal/core/passes/typecheck_value_test.go index fe84116fb..1887ea114 100644 --- a/internal/core/passes/typecheck_value_test.go +++ b/internal/core/passes/typecheck_value_test.go @@ -484,6 +484,10 @@ func TestValueCollectionCountIsJudged(t *testing.T) { "2 value(s) bound to a feature with multiplicity lower bound 3") wantCollectionValueDiags(t, `part b : Boat[3] = pair.items->collect Boats;`, "2 value(s) bound to a feature with multiplicity lower bound 3") + wantCollectionValueDiags(t, ` + part huge : Vehicle[9223372036854775807]; + attribute s : String[1] = (huge.{ in v : Vehicle; "a" }, "b");`, + "9223372036854775807 value(s) bound to a feature with multiplicity upper bound 1") wantCollectionValueDiags(t, ` part b : Boat[2] = pair.items.{ in v : Vehicle; boat }; part b2 : Boat[1] = pair.items->reduce { in a : Vehicle; in b : Vehicle; boat }; From 8e679e57eb993db842bc465ebec0733e15eae31b Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:32:01 +0000 Subject: [PATCH 14/22] fix(passes): judge a bound collection's count as a range, unbounded past int64 A summed or multiplied count past int64 exceeds every multiplicity bound, so it is now unbounded rather than saturated at MaxInt64, which a feature with that upper bound wrongly admitted. checkValueCount and constructor arguments judge the held range: its fewest against the upper bound, its most against the lower. Co-Authored-By: jason.han --- internal/core/passes/typecheck_expr.go | 4 +- internal/core/passes/typecheck_value.go | 45 +++++++----------- internal/core/passes/typecheck_value_test.go | 16 +++++-- internal/core/semantics/collection.go | 49 +++++++++----------- internal/core/semantics/collection_test.go | 35 ++++++++++++-- internal/core/semantics/multiplicity.go | 20 ++++++++ 6 files changed, 103 insertions(+), 66 deletions(-) diff --git a/internal/core/passes/typecheck_expr.go b/internal/core/passes/typecheck_expr.go index e610486a7..83167cd00 100644 --- a/internal/core/passes/typecheck_expr.go +++ b/internal/core/passes/typecheck_expr.go @@ -1066,9 +1066,9 @@ func (ec *exprChecker) checkFeatureBinding(scope *symbols.Scope, arg ast.Node, g } else { ec.checkObjectBinding(scope, arg, feature, typ) } - if count, known := ec.exactCount(scope, arg); known { + if held, known := ec.heldCount(scope, arg); known { if r, ok := ec.effectiveRange(feature.OwnerScope, usageDecl(u), 0); ok { - if msg := r.CountViolation(count); msg != "" { + if msg := r.HeldViolation(held); msg != "" { ec.errorf(arg.Span(), "%s of %s: %s", feature.Name, typ.Name, msg) } } diff --git a/internal/core/passes/typecheck_value.go b/internal/core/passes/typecheck_value.go index 8e1cc8ce2..080f4b96d 100644 --- a/internal/core/passes/typecheck_value.go +++ b/internal/core/passes/typecheck_value.go @@ -1,7 +1,6 @@ package passes import ( - "math" "slices" "strings" @@ -99,7 +98,7 @@ func literalPrimType(value ast.Node) semantics.PrimType { // checkValueCount checks a bound value's element count against the multiplicity // governing the feature. func (ec *exprChecker) checkValueCount(valueScope, declScope *symbols.Scope, d featureDecl, value ast.Node) { - count, known := ec.exactCount(valueScope, value) + held, known := ec.heldCount(valueScope, value) if !known { return } @@ -107,7 +106,7 @@ func (ec *exprChecker) checkValueCount(valueScope, declScope *symbols.Scope, d f if !ok { return } - if msg := r.CountViolation(count); msg != "" { + if msg := r.HeldViolation(held); msg != "" { ec.errorf(value.Span(), "%s", msg) } } @@ -145,46 +144,36 @@ func (ec *exprChecker) effectiveRange(scope *symbols.Scope, d featureDecl, depth return semantics.Range{}, false } -// exactCount returns how many values a bound expression produces, and whether -// that is statically known. A literal contributes one value, a collection -// literal the values of its elements, and a collection operation the values it -// is known to hold — none over `()`, one per element mapped; anything else (a -// feature reference, an invocation) may itself be multi-valued, so its count is -// unknown — as is that of a collection holding one. A total past int64 stays -// at its maximum, which still exceeds every finite bound. -func (ec *exprChecker) exactCount(scope *symbols.Scope, value ast.Node) (int64, bool) { +// heldCount returns how many values a bound expression produces, where that is +// statically bounded. A literal contributes one value, a collection literal the +// values of its elements, and a collection operation the values it holds — none +// over `()`, one per element mapped, at least two over a `[2..*]` collection; +// anything else (a feature reference, an invocation) may itself be multi-valued, +// so its count is unknown — as is that of a collection holding one. +func (ec *exprChecker) heldCount(scope *symbols.Scope, value ast.Node) (semantics.Range, bool) { if value == nil { - return 0, false + return semantics.Range{}, false } if _, ok := value.(*ast.NullExpr); ok { - return 0, true + return semantics.CountRange(0), true } // Binding flattens a collection into the values its elements produce, so a // nested literal contributes its own elements rather than one value. if seq, ok := value.(*ast.SequenceExpr); ok { - var total int64 + total := semantics.CountRange(0) for _, element := range seq.Elements { - n, ok := ec.exactCount(scope, element) + held, ok := ec.heldCount(scope, element) if !ok { - return 0, false - } - if total > math.MaxInt64-n { - total = math.MaxInt64 - } else { - total += n + return semantics.Range{}, false } + total = total.Plus(held) } return total, true } if literalPrimType(value) != semantics.PrimUnknown { - return 1, true - } - if r, ok := ec.model.CollectionValues(scope, value); ok { - if exact, ok := r.Exactly(); ok { - return exact, true - } + return semantics.CountRange(1), true } - return 0, false + return ec.model.CollectionValues(scope, value) } // valueElements returns the values a bound expression contributes: the elements diff --git a/internal/core/passes/typecheck_value_test.go b/internal/core/passes/typecheck_value_test.go index 1887ea114..bd9151260 100644 --- a/internal/core/passes/typecheck_value_test.go +++ b/internal/core/passes/typecheck_value_test.go @@ -465,8 +465,8 @@ func TestValueReduceResultIsJudged(t *testing.T) { } // A collection value binds as many values as it is known to hold: none over a collection -// holding none, one per element a collect maps over a known count, one from a reduce; a -// count that is not exact is left to evaluation. +// holding none, one per element a collect maps over a known count, one from a reduce; one +// only bounded is reported where even its fewest or its most cannot fit, else left to evaluation. func TestValueCollectionCountIsJudged(t *testing.T) { wantCollectionValueDiags(t, `part b : Boat[1] = none.{ in v : Vehicle; boat };`, "0 value(s) bound to a feature with multiplicity lower bound 1") @@ -486,14 +486,20 @@ func TestValueCollectionCountIsJudged(t *testing.T) { "2 value(s) bound to a feature with multiplicity lower bound 3") wantCollectionValueDiags(t, ` part huge : Vehicle[9223372036854775807]; - attribute s : String[1] = (huge.{ in v : Vehicle; "a" }, "b");`, - "9223372036854775807 value(s) bound to a feature with multiplicity upper bound 1") + attribute s : String[9223372036854775807] = (huge.{ in v : Vehicle; "a" }, "b");`, + "more than 9223372036854775807 value(s) bound to a feature with multiplicity upper bound 9223372036854775807") + wantCollectionValueDiags(t, `part b : Boat[1] = two.{ in v : Vehicle; boat };`, + "at least 2 value(s) bound to a feature with multiplicity upper bound 1") + wantCollectionValueDiags(t, `part v : Vehicle[3] = one->select { in v : Vehicle; true };`, + "at most 1 value(s) bound to a feature with multiplicity lower bound 3") + wantCollectionValueDiags(t, `part v : Vehicle[2] = pair.items->selectOne { in v : Vehicle; true };`, + "at most 1 value(s) bound to a feature with multiplicity lower bound 2") wantCollectionValueDiags(t, ` part b : Boat[2] = pair.items.{ in v : Vehicle; boat }; part b2 : Boat[1] = pair.items->reduce { in a : Vehicle; in b : Vehicle; boat }; part v : Vehicle[0..1] = pair.items->selectOne { in v : Vehicle; true }; part b3 : Boat[1] = vs.{ in v : Vehicle; boat }; - part v2 : Vehicle[3] = pair.items->select { in v : Vehicle; true }; + part v2 : Vehicle[0..2] = pair.items->select { in v : Vehicle; true }; part b4 : Boat[2] = pair.items->collect Boats;`) } diff --git a/internal/core/semantics/collection.go b/internal/core/semantics/collection.go index 0963d96ea..213b1ea03 100644 --- a/internal/core/semantics/collection.go +++ b/internal/core/semantics/collection.go @@ -193,7 +193,7 @@ func (m *Model) holdsNothing(scope *symbols.Scope, collection ast.Node) bool { func (m *Model) valuesHeldBy(scope *symbols.Scope, node ast.Node) (Range, bool) { switch n := node.(type) { case *ast.NullExpr: - return exactly(0), true + return CountRange(0), true case *ast.CollectExpr: return m.valuesMappedBy(scope, n.Operand, n.Body) case *ast.SelectExpr: @@ -201,9 +201,9 @@ func (m *Model) valuesHeldBy(scope *symbols.Scope, node ast.Node) (Range, bool) case *ast.InvocationExpr: return m.valuesHeldByCall(scope, n) case *ast.LiteralInteger, *ast.LiteralReal, *ast.LiteralString, *ast.LiteralBool: - return exactly(1), true + return CountRange(1), true case *ast.SequenceExpr: - sum := exactly(0) + sum := CountRange(0) for _, element := range n.Elements { r, ok := m.valuesHeldBy(scope, element) if !ok { @@ -315,36 +315,36 @@ func (m *Model) valuesGoverning(sym *symbols.Symbol) (Range, bool) { return AssumedRange(), true } -func exactly(n int64) Range { +// CountRange is the range admitting n values alone. +func CountRange(n int64) Range { b := Bound{Value: n, Known: true} return Range{Lower: b, Upper: b} } -// addRanges is the values two collections hold together; a sum past int64 is unbounded above -// and at least MaxInt64 below. +// Plus is the values two collections hold together: the sum of their bounds. +func (r Range) Plus(o Range) Range { + return addRanges(r, o) +} + +// addRanges is the values two collections hold together; a bound summing past int64 exceeds +// every multiplicity bound, so it is unbounded. func addRanges(a, b Range) Range { - return Range{Lower: addBounds(a.Lower, b.Lower, mostFinite), Upper: addBounds(a.Upper, b.Upper, unbounded)} + return Range{Lower: addBounds(a.Lower, b.Lower), Upper: addBounds(a.Upper, b.Upper)} } -// mulRanges is the values held through each value of a, each holding b; a product past int64 -// is unbounded above and at least MaxInt64 below. +// mulRanges is the values held through each value of a, each holding b; a bound multiplying +// past int64 exceeds every multiplicity bound, so it is unbounded. func mulRanges(a, b Range) Range { - return Range{Lower: mulBounds(a.Lower, b.Lower, mostFinite), Upper: mulBounds(a.Upper, b.Upper, unbounded)} + return Range{Lower: mulBounds(a.Lower, b.Lower), Upper: mulBounds(a.Upper, b.Upper)} } -var ( - unbounded = Bound{Infinite: true, Known: true} - mostFinite = Bound{Value: math.MaxInt64, Known: true} -) +var unbounded = Bound{Infinite: true, Known: true} -// addBounds is a + b, or past where int64 reaches. -func addBounds(a, b, past Bound) Bound { - if a.Infinite || b.Infinite { +// addBounds is a + b, unbounded past where int64 reaches. +func addBounds(a, b Bound) Bound { + if a.Infinite || b.Infinite || a.Value > math.MaxInt64-b.Value { return unbounded } - if a.Value > math.MaxInt64-b.Value { - return past - } return Bound{Value: a.Value + b.Value, Known: true} } @@ -358,17 +358,14 @@ func minBound(a, b Bound) Bound { return b } -// mulBounds is a × b, none through none, or past where int64 reaches. -func mulBounds(a, b, past Bound) Bound { +// mulBounds is a × b, none through none, unbounded past where int64 reaches. +func mulBounds(a, b Bound) Bound { if (!a.Infinite && a.Value == 0) || (!b.Infinite && b.Value == 0) { return Bound{Known: true} } - if a.Infinite || b.Infinite { + if a.Infinite || b.Infinite || a.Value > math.MaxInt64/b.Value { return unbounded } - if a.Value > math.MaxInt64/b.Value { - return past - } return Bound{Value: a.Value * b.Value, Known: true} } diff --git a/internal/core/semantics/collection_test.go b/internal/core/semantics/collection_test.go index 3ceef070f..7fc9b6343 100644 --- a/internal/core/semantics/collection_test.go +++ b/internal/core/semantics/collection_test.go @@ -422,12 +422,37 @@ func TestCollectionValues(t *testing.T) { t.Errorf("%s: sized as a collection value", name) } } - if n, ok := exactly(3).Exactly(); !ok || n != 3 { + if n, ok := CountRange(3).Exactly(); !ok || n != 3 { t.Errorf("[3] exactly %d %v, want 3", n, ok) } - if _, ok := (Range{Lower: Bound{Value: 1, Known: true}, Upper: unbounded}).Exactly(); ok { + atLeastOne := Range{Lower: Bound{Value: 1, Known: true}, Upper: unbounded} + if _, ok := atLeastOne.Exactly(); ok { t.Error("[1..*] is exact") } + if sum := CountRange(math.MaxInt64).Plus(CountRange(1)); !sum.Lower.Infinite || !sum.Upper.Infinite { + t.Errorf("[%d] + [1] is %s, want [*..*]", int64(math.MaxInt64), sum.Text()) + } + one := CountRange(1) + for _, c := range []struct{ held, want string }{ + {"[2]", "2 value(s) bound to a feature with multiplicity upper bound 1"}, + {"[0]", "0 value(s) bound to a feature with multiplicity lower bound 1"}, + {"[2..*]", "at least 2 value(s) bound to a feature with multiplicity upper bound 1"}, + {"[*..*]", "more than 9223372036854775807 value(s) bound to a feature with multiplicity upper bound 1"}, + {"[0..*]", ""}, + {"[0..1]", ""}, + } { + held := map[string]Range{ + "[2]": CountRange(2), "[0]": CountRange(0), "[0..1]": {Lower: CountRange(0).Lower, Upper: one.Upper}, + "[2..*]": {Lower: CountRange(2).Lower, Upper: unbounded}, "[*..*]": {Lower: unbounded, Upper: unbounded}, + "[0..*]": {Lower: CountRange(0).Lower, Upper: unbounded}, + }[c.held] + if got := one.HeldViolation(held); got != c.want { + t.Errorf("%s held by [1]: %q, want %q", c.held, got, c.want) + } + } + if got := CountRange(3).HeldViolation(Range{Lower: CountRange(0).Lower, Upper: CountRange(2).Upper}); got != "at most 2 value(s) bound to a feature with multiplicity lower bound 3" { + t.Errorf("[0..2] held by [3]: %q", got) + } } // `xs.?{…}` is select written out: its elements are the collection's, so a binding or an @@ -497,7 +522,7 @@ func TestCollectionNestedElements(t *testing.T) { // A mapper named by function holds one value per element where its result declares no // multiplicity, as a feature does — not any number — while a result whose declared bound is not -// evaluable holds an unknown number; a count past int64 stays at least as many as it is. +// evaluable holds an unknown number; a count past int64 exceeds every bound. func TestCollectionSizeThroughNamedMapperAndPastInt64(t *testing.T) { m, s := collectionModel(t, ` part two : C[2]; @@ -526,8 +551,8 @@ func TestCollectionSizeThroughNamedMapperAndPastInt64(t *testing.T) { } collection := valueOf(t, s, name).(*ast.InvocationExpr).Operand r, ok := m.valuesHeldBy(s, collection) - if !ok || !r.Lower.Known || r.Lower.Infinite || r.Lower.Value != math.MaxInt64 || !r.Upper.Known || !r.Upper.Infinite { - t.Errorf("%s: values held %s, want [%d..*]", name, r.Text(), int64(math.MaxInt64)) + if !ok || !r.Lower.Known || !r.Lower.Infinite || !r.Upper.Known || !r.Upper.Infinite { + t.Errorf("%s: values held %s, want [*..*]", name, r.Text()) } } } diff --git a/internal/core/semantics/multiplicity.go b/internal/core/semantics/multiplicity.go index 1e8048156..a6911e9af 100644 --- a/internal/core/semantics/multiplicity.go +++ b/internal/core/semantics/multiplicity.go @@ -2,6 +2,7 @@ package semantics import ( "fmt" + "math" "strconv" "github.com/Open-MBEE/OpenSysML/internal/core/ast" @@ -195,6 +196,25 @@ func (r Range) CountViolation(count int64) string { return "" } +// HeldViolation is CountViolation for a value whose count is only bounded: reported where even +// its fewest values exceed the upper bound, or its most fall short of the lower. +func (r Range) HeldViolation(held Range) string { + if n, ok := held.Exactly(); ok { + return r.CountViolation(n) + } + if r.Upper.Known && !r.Upper.Infinite && held.Lower.Known && (held.Lower.Infinite || held.Lower.Value > r.Upper.Value) { + fewest := "at least " + held.Lower.Text() + if held.Lower.Infinite { + fewest = fmt.Sprintf("more than %d", int64(math.MaxInt64)) + } + return fmt.Sprintf("%s value(s) bound to a feature with multiplicity upper bound %d", fewest, r.Upper.Value) + } + if r.Lower.Known && !r.Lower.Infinite && held.Upper.Known && !held.Upper.Infinite && held.Upper.Value < r.Lower.Value { + return fmt.Sprintf("at most %d value(s) bound to a feature with multiplicity lower bound %d", held.Upper.Value, r.Lower.Value) + } + return "" +} + // Exactly is the one count the range admits, ok where both bounds are that finite count. func (r Range) Exactly() (int64, bool) { if !r.Lower.Known || !r.Upper.Known || r.Lower.Infinite || r.Upper.Infinite || r.Lower.Value != r.Upper.Value { From 51d07bbcc9267f0602d144864d361c6c48d88186 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:33:30 +0000 Subject: [PATCH 15/22] docs(passes): tighten heldCount doc comment Co-Authored-By: jason.han --- internal/core/passes/typecheck_value.go | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/internal/core/passes/typecheck_value.go b/internal/core/passes/typecheck_value.go index 080f4b96d..4121d9f65 100644 --- a/internal/core/passes/typecheck_value.go +++ b/internal/core/passes/typecheck_value.go @@ -144,12 +144,8 @@ func (ec *exprChecker) effectiveRange(scope *symbols.Scope, d featureDecl, depth return semantics.Range{}, false } -// heldCount returns how many values a bound expression produces, where that is -// statically bounded. A literal contributes one value, a collection literal the -// values of its elements, and a collection operation the values it holds — none -// over `()`, one per element mapped, at least two over a `[2..*]` collection; -// anything else (a feature reference, an invocation) may itself be multi-valued, -// so its count is unknown — as is that of a collection holding one. +// heldCount is how many values a bound expression produces, where statically bounded: one per +// literal, the sum over a collection literal, what a collection operation holds; else unknown. func (ec *exprChecker) heldCount(scope *symbols.Scope, value ast.Node) (semantics.Range, bool) { if value == nil { return semantics.Range{}, false From b9854043545b2051e5c20b0c1b543c2c1d6a44e5 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:00:21 +0000 Subject: [PATCH 16/22] fix(passes): judge each element a collection argument holds A call or constructor argument that is a collection value was typed by the one type its held elements share, so a collection holding a Vehicle and a Boat, sharing none, was typed as unknown and bound unjudged, and a literal it held lost its exactness to the shared type. Each held element is now judged on its own, at its own position, by its scalar type or its declared types; the values written by an argument are still typed together, as a collection literal is, for selecting the overload. Co-Authored-By: jason.han --- .../collection-body-result-type.fixed.md | 2 +- internal/core/passes/invocation.go | 31 +-- internal/core/passes/typecheck_expr.go | 176 +++++++++++------- internal/core/passes/typecheck_value.go | 43 ++++- internal/core/passes/typecheck_value_test.go | 56 +++++- 5 files changed, 228 insertions(+), 80 deletions(-) diff --git a/changes/unreleased/collection-body-result-type.fixed.md b/changes/unreleased/collection-body-result-type.fixed.md index 767eb14fd..4c4966991 100644 --- a/changes/unreleased/collection-body-result-type.fixed.md +++ b/changes/unreleased/collection-body-result-type.fixed.md @@ -1 +1 @@ -- **A collection operation's static type follows what its declaration hands through, not the element type of the collection.** `xs->collect { in x : C; x.mass }` and `xs.{ in x : C; x.mass }` are typed by the body's result (`MassValue`), a nested collect by its innermost body, a body answering a sequence by every element type and `xs->collect f` by the named function's result; `select`, `reject` and `selectOne` keep the elements of `xs`, `reduce` follows its reducer's result — and the element a one-element collection hands back unreduced, unless the collection is known to hold two or more, by its own multiplicity, one it inherits by redefinition, or a chain through such features — and `forAll`/`exists` stay `Boolean`, each with the multiplicity the Kernel Function Library declares. A body whose result cannot be typed keeps the library's `Anything`. Value conformance, a feature's bound value, invocation arguments, trigger arguments and enumerated values are judged by the specialized type, so `accept when counts.{in n : Integer; n}` is refused where it was silent, and `when counts.{in n; n > 3}` is accepted where it was refused. A scalar literal a body writes out is as exact as one bound directly: `attribute i : Integer = xs.{ in x : C; 1.5 }` is refused, and a quantity it writes out is measured against the target's dimension: `attribute t : DurationValue = xs.{ in x : C; 5 [m] }` is refused. `xs.?{…}` binds as `xs->select {…}` does, and an element that is itself a collection value binds by the elements it holds, so `attribute i : Integer = xs.{ in x : C; xs.{ in y : C; 1.5 } }` is refused. A collection over `()` or a feature admitting no value keeps its declared type — `()->collect { in a : Integer; "s" }` is a `String` collection — but holds no element, so none is judged and its `reduce` takes nothing from an element it would hand back unreduced; so does one mapping every element to a `[0]` feature or function result — the multiplicity read through an alias, or from the feature or result a redefinition inherits it from — or any operation over such a collection, and an argument holding nothing is judged against no parameter type. A body reading the feature it values terminates as a self-referential argument does. +- **A collection operation's static type follows what its declaration hands through, not the element type of the collection.** `xs->collect { in x : C; x.mass }` and `xs.{ in x : C; x.mass }` are typed by the body's result (`MassValue`), a nested collect by its innermost body, a body answering a sequence by every element type and `xs->collect f` by the named function's result; `select`, `reject` and `selectOne` keep the elements of `xs`, `reduce` follows its reducer's result — and the element a one-element collection hands back unreduced, unless the collection is known to hold two or more, by its own multiplicity, one it inherits by redefinition, or a chain through such features — and `forAll`/`exists` stay `Boolean`, each with the multiplicity the Kernel Function Library declares. A body whose result cannot be typed keeps the library's `Anything`. Value conformance, a feature's bound value, invocation arguments, trigger arguments and enumerated values are judged by the specialized type, so `accept when counts.{in n : Integer; n}` is refused where it was silent, and `when counts.{in n; n > 3}` is accepted where it was refused. A scalar literal a body writes out is as exact as one bound directly: `attribute i : Integer = xs.{ in x : C; 1.5 }` is refused, and a quantity it writes out is measured against the target's dimension: `attribute t : DurationValue = xs.{ in x : C; 5 [m] }` is refused. `xs.?{…}` binds as `xs->select {…}` does, and an element that is itself a collection value binds by the elements it holds, so `attribute i : Integer = xs.{ in x : C; xs.{ in y : C; 1.5 } }` is refused. A collection over `()` or a feature admitting no value keeps its declared type — `()->collect { in a : Integer; "s" }` is a `String` collection — but holds no element, so none is judged and its `reduce` takes nothing from an element it would hand back unreduced; so does one mapping every element to a `[0]` feature or function result — the multiplicity read through an alias, or from the feature or result a redefinition inherits it from — or any operation over such a collection, and an argument holding nothing is judged against no parameter type. An argument or constructor value that is a collection binds each element it holds on its own, so `Sail(vs.{ in v : Vehicle; (v, boat) })` and `new Fleet(vs.{ in v : Vehicle; 1.5 })` are refused by the element that does not bind where they were silent. A body reading the feature it values terminates as a self-referential argument does. diff --git a/internal/core/passes/invocation.go b/internal/core/passes/invocation.go index bedd3b68a..0e4222f48 100644 --- a/internal/core/passes/invocation.go +++ b/internal/core/passes/invocation.go @@ -108,19 +108,22 @@ func (t argumentTypes) arguments() []semantics.Argument { // of the feature it names, or of the result of the call it makes. A collection // literal binds its elements, so it is typed by the type they have in common. func (ec *exprChecker) argument(scope *symbols.Scope, value ast.Node, name *ast.QualifiedName) semantics.Argument { + elements := []ast.Node{value} if seq, ok := value.(*ast.SequenceExpr); ok { - return semantics.Argument{ - Prim: ec.commonElementType(scope, seq), - Type: ec.commonElementTypeSymbol(scope, seq), - Exact: len(seq.Elements) > 0 && allSpellOneValue(seq.Elements), - Name: name, - } + elements = seq.Elements } + arg := ec.argumentOf(scope, elements) + arg.Exact = len(elements) > 0 && allSpellOneValue(elements) + arg.Name = name + return arg +} + +// argumentOf types the values an argument writes together: by the scalar type and the declared +// type they share, unknown where they share none. +func (ec *exprChecker) argumentOf(scope *symbols.Scope, elements []ast.Node) semantics.Argument { return semantics.Argument{ - Prim: ec.infer(scope, value), - Type: ec.declaredValueType(scope, value), - Exact: spellsOneValue(value), - Name: name, + Prim: ec.commonElementType(scope, elements), + Type: ec.commonElementTypeSymbol(scope, elements), } } @@ -144,11 +147,11 @@ func (ec *exprChecker) declaredValueType(scope *symbols.Scope, value ast.Node) * return ec.invocationResultTypeSymbol(scope, value) } -// commonElementTypeSymbol is the declared type every element of seq conforms to, -// nil when one has none or they share none. -func (ec *exprChecker) commonElementTypeSymbol(scope *symbols.Scope, seq *ast.SequenceExpr) *symbols.Symbol { +// commonElementTypeSymbol is the declared type every element conforms to, nil when one +// has none or they share none. +func (ec *exprChecker) commonElementTypeSymbol(scope *symbols.Scope, elements []ast.Node) *symbols.Symbol { var common *symbols.Symbol - for _, el := range seq.Elements { + for _, el := range elements { elem := ec.declaredValueType(scope, el) switch { case elem == nil: diff --git a/internal/core/passes/typecheck_expr.go b/internal/core/passes/typecheck_expr.go index 83167cd00..f3619b24f 100644 --- a/internal/core/passes/typecheck_expr.go +++ b/internal/core/passes/typecheck_expr.go @@ -2,6 +2,7 @@ package passes import ( "fmt" + "slices" "strconv" "github.com/Open-MBEE/OpenSysML/internal/core/ast" @@ -122,7 +123,7 @@ func (ec *exprChecker) checkBoundValue(valueScope, declScope *symbols.Scope, d f // the value checks and reports on them; their types are then read silently. if elements, collection := ec.model.CollectionElements(valueScope, element); collection { ec.infer(valueScope, element) - silent := exprChecker{resolver: ec.resolver, model: ec.model, lang: ec.lang, chaining: ec.chaining, performed: ec.performed} + silent := ec.silent() for _, produced := range elements { if produced.Node != nil { ec.checkScalarBinding(produced.Node, silent.infer(produced.Scope, produced.Node), want) @@ -348,7 +349,7 @@ func (ec *exprChecker) inferIndex(scope *symbols.Scope, e *ast.IndexExpr) semant // what walks them, so they are not inferred a second time below. var elem semantics.PrimType if seq, isSeq := e.Operand.(*ast.SequenceExpr); isSeq { - elem = ec.commonElementType(scope, seq) + elem = ec.commonElementType(scope, seq.Elements) } else { elem = ec.infer(scope, e.Operand) } @@ -410,10 +411,10 @@ func isLiteral(n ast.Node) bool { // expression conforms to, or PrimUnknown where they have none in common or any // one of them has no known type: PrimConforms holds of PrimUnknown either way // round, so it decides conformance but cannot merge types. -func (ec *exprChecker) commonElementType(scope *symbols.Scope, seq *ast.SequenceExpr) semantics.PrimType { +func (ec *exprChecker) commonElementType(scope *symbols.Scope, elements []ast.Node) semantics.PrimType { common := semantics.PrimUnknown unknown := false - for i, el := range seq.Elements { + for i, el := range elements { elem := ec.infer(scope, el) if elem == semantics.PrimUnknown { // Every element is still typed, so an error inside one is reported. @@ -818,10 +819,10 @@ func (ec *exprChecker) inferInvocation(scope *symbols.Scope, e *ast.InvocationEx // call). func (ec *exprChecker) inferNodeInvocation(scope *symbols.Scope, e *ast.InvocationExpr, node *symbols.Symbol) semantics.PrimType { args := InvocationArgs(e) - // Typed once and reused by checkArguments, so nested errors report once. + // Typed once, for selecting the overload, so nested errors report once. argTypes := ec.argumentTypes(scope, e) if chain := ChainCallee(e); chain != nil { - return ec.inferChainInvocation(scope, e, chain, args, argTypes, node) + return ec.inferChainInvocation(scope, e, chain, args, node) } if e.Type == nil { for _, arg := range e.NamedArgs { @@ -883,7 +884,7 @@ func (ec *exprChecker) inferNodeInvocation(scope *symbols.Scope, e *ast.Invocati if !ok { return semantics.PrimUnknown } - ec.checkArguments(scope, invocation{e, sym, args, argTypes, params}, considered) + ec.checkArguments(scope, invocation{e, sym, args, params}, considered) if considered != nil { return semantics.PrimUnknown } @@ -892,7 +893,7 @@ func (ec *exprChecker) inferNodeInvocation(scope *symbols.Scope, e *ast.Invocati // inferChainInvocation is inferNodeInvocation for `x.f(a)`: the chain names the // calc feature applied, whose effective inputs the arguments bind. -func (ec *exprChecker) inferChainInvocation(scope *symbols.Scope, e *ast.InvocationExpr, chain *ast.FeatureChainExpr, args []ast.Node, argTypes argumentTypes, node *symbols.Symbol) semantics.PrimType { +func (ec *exprChecker) inferChainInvocation(scope *symbols.Scope, e *ast.InvocationExpr, chain *ast.FeatureChainExpr, args []ast.Node, node *symbols.Symbol) semantics.PrimType { ec.infer(scope, chain) sym, ok := ec.resolver.ResolveTarget(scope, chain) if !ok || sym == nil { @@ -917,7 +918,7 @@ func (ec *exprChecker) inferChainInvocation(scope *symbols.Scope, e *ast.Invocat if !ok { return semantics.PrimUnknown } - ec.checkArguments(scope, invocation{e, sym, args, argTypes, params}, nil) + ec.checkArguments(scope, invocation{e, sym, args, params}, nil) return ec.model.PrimTypeOf(ec.model.ResultParameterOf(sym)) } @@ -940,19 +941,18 @@ func listing(report reporter, considered []*symbols.Symbol) reporter { const msgInvocationParameterRedefinition = "Must correspond to one input parameter of the invoked type" // invocation is a call under argument checking: the expression, the behavior it names, -// its positional arguments and their types, and the `in` parameters. +// its positional arguments, and the `in` parameters. type invocation struct { - e *ast.InvocationExpr - sym *symbols.Symbol - args []ast.Node - argTypes argumentTypes - params []parameter + e *ast.InvocationExpr + sym *symbols.Symbol + args []ast.Node + params []parameter } // checkArguments reports the arguments of the call that do not bind to its `in` parameters, // listing considered (the other declarations the call could name) on each report. func (ec *exprChecker) checkArguments(scope *symbols.Scope, call invocation, considered []*symbols.Symbol) { - e, sym, args, argTypes, params := call.e, call.sym, call.args, call.argTypes, call.params + e, sym, args, params := call.e, call.sym, call.args, call.params report := listing(ec.errorf, considered) advise := listing(func(span source.Span, format string, args ...any) { ec.warnCode(CodeUnboundParameter, span, format, args...) @@ -966,8 +966,8 @@ func (ec *exprChecker) checkArguments(scope *symbols.Scope, call invocation, con return } for i, arg := range args { - if mismatch := ec.argumentMismatch(arg, argTypes.positional[i], params[i]); mismatch != "" { - report(arg.Span(), "argument %d of %s %s", i+1, sym.Name, mismatch) + for _, m := range ec.argumentMismatches(scope, arg, params[i]) { + report(m.span, "argument %d of %s %s", i+1, sym.Name, m.why) } } // Arguments bind in order, so the parameters past the last one are unbound. @@ -985,13 +985,11 @@ const msgUnboundParameter = "%s leaves parameter %s unbound, so the call cannot // positional argument binds the feature at its position, a label the feature it // names, and a feature is bound at most once. func (ec *exprChecker) inferConstructor(scope *symbols.Scope, e *ast.ConstructorExpr) semantics.PrimType { - argTypes := make([]semantics.PrimType, len(e.Args)) - for i, a := range e.Args { - argTypes[i] = ec.infer(scope, a) + for _, a := range e.Args { + ec.infer(scope, a) } - namedTypes := make([]semantics.PrimType, len(e.NamedArgs)) - for i, na := range e.NamedArgs { - namedTypes[i] = ec.infer(scope, na.Value) + for _, na := range e.NamedArgs { + ec.infer(scope, na.Value) } if e.Type == nil { return semantics.PrimUnknown @@ -1019,9 +1017,9 @@ func (ec *exprChecker) inferConstructor(scope *symbols.Scope, e *ast.Constructor break } bound[features[i]] = true - ec.checkFeatureBinding(scope, arg, argTypes[i], features[i], typ) + ec.checkFeatureBinding(scope, arg, features[i], typ) } - for i, na := range e.NamedArgs { + for _, na := range e.NamedArgs { if na.Name == nil { continue } @@ -1045,26 +1043,41 @@ func (ec *exprChecker) inferConstructor(scope *symbols.Scope, e *ast.Constructor continue } bound[slot] = true - ec.checkFeatureBinding(scope, na.Value, namedTypes[i], feature, typ) + ec.checkFeatureBinding(scope, na.Value, feature, typ) } return semantics.PrimUnknown } -// checkFeatureBinding reports a constructor argument its feature cannot take: by -// scalar type, by conformance to its declared or inherited type, or by count. -func (ec *exprChecker) checkFeatureBinding(scope *symbols.Scope, arg ast.Node, got semantics.PrimType, feature, typ *symbols.Symbol) { +// checkFeatureBinding reports a constructor argument its feature cannot take: the values written +// by scalar type together and by conformance to the feature's declared or inherited types each, +// each element a collection value holds on its own, and the argument by count. +func (ec *exprChecker) checkFeatureBinding(scope *symbols.Scope, arg ast.Node, feature, typ *symbols.Symbol) { u, ok := feature.Decl.(*ast.Usage) if !ok { return } - // An argument with no scalar type (an object, a constructor) is checked by + want := ec.model.PrimTypeOf(feature) + wants := w8cMostSpecific(ec.model, ec.model.DeclaredFeatureTypes(feature)) + written, held := ec.argumentElements(scope, arg) + // A value with no scalar type (an object, a constructor) is checked by // conformance against the feature's types whether or not those are scalar. - if want := ec.model.PrimTypeOf(feature); want != semantics.PrimUnknown && got != semantics.PrimUnknown { + if got := ec.silent().commonElementType(scope, written); want != semantics.PrimUnknown && got != semantics.PrimUnknown { if !bindable(arg, got, want) { ec.errorf(arg.Span(), "%s of %s expects %s, found %s", feature.Name, typ.Name, want, got) } } else { - ec.checkObjectBinding(scope, arg, feature, typ) + for _, value := range written { + ec.checkObjectBinding(value, value.Span(), ec.argumentTypeSymbols(scope, value), wants, feature, typ) + } + } + for _, el := range held { + if got := ec.heldPrim(el); want != semantics.PrimUnknown && got != semantics.PrimUnknown { + if !bindable(el.Node, got, want) { + ec.errorf(heldSpan(el, arg), "%s of %s expects %s, found %s", feature.Name, typ.Name, want, got) + } + continue + } + ec.checkObjectBinding(el.Node, heldSpan(el, arg), el.Types, wants, feature, typ) } if held, known := ec.heldCount(scope, arg); known { if r, ok := ec.effectiveRange(feature.OwnerScope, usageDecl(u), 0); ok { @@ -1075,26 +1088,29 @@ func (ec *exprChecker) checkFeatureBinding(scope *symbols.Scope, arg ast.Node, g } } -// checkObjectBinding checks each value of a constructor argument against the -// non-scalar types its feature declares or inherits; a `new T(…)` is exactly a T. -func (ec *exprChecker) checkObjectBinding(scope *symbols.Scope, arg ast.Node, feature, typ *symbols.Symbol) { - wants := w8cMostSpecific(ec.model, ec.model.DeclaredFeatureTypes(feature)) - if len(wants) == 0 { +// checkObjectBinding checks one value a constructor argument binds, typed gots, against wants, +// the types its feature declares or inherits; a `new T(…)` is exactly a T, another may be a subtype. +func (ec *exprChecker) checkObjectBinding(value ast.Node, at source.Span, gots, wants []*symbols.Symbol, feature, typ *symbols.Symbol) { + if len(gots) == 0 { return } - for _, value := range valueElements(arg) { - got := ec.argumentTypeSymbol(scope, value) - if got == nil { + _, exact := value.(*ast.ConstructorExpr) + for _, want := range wants { + if slices.ContainsFunc(gots, func(got *symbols.Symbol) bool { + return ec.model.Conforms(got, want) || (!exact && ec.model.Conforms(want, got)) + }) { continue } - _, exact := value.(*ast.ConstructorExpr) - for _, want := range wants { - if ec.model.Conforms(got, want) || (!exact && ec.model.Conforms(want, got)) { - continue - } - ec.errorf(value.Span(), "%s of %s is typed by %s; cannot bind a value of type %s", feature.Name, typ.Name, want.Name, got.Name) - } + ec.errorf(at, "%s of %s is typed by %s; cannot bind a value of type %s", feature.Name, typ.Name, want.Name, typeNames(gots)) + } +} + +// argumentTypeSymbols is argumentTypeSymbol as a list, empty for none. +func (ec *exprChecker) argumentTypeSymbols(scope *symbols.Scope, value ast.Node) []*symbols.Symbol { + if got := ec.argumentTypeSymbol(scope, value); got != nil { + return []*symbols.Symbol{got} } + return nil } // argumentTypeSymbol resolves a value's type in scope (feature reference or @@ -1141,7 +1157,7 @@ func (ec *exprChecker) memberOf(typ, feature *symbols.Symbol) bool { // not bind to it, a parameter bound twice (by whichever name or position), and advises // of default-less parameters no argument names. func (ec *exprChecker) checkNamedArguments(scope *symbols.Scope, call invocation, report, advise reporter) { - e, sym, args, argTypes, params := call.e, call.sym, call.args, call.argTypes, call.params + e, sym, args, params := call.e, call.sym, call.args, call.params // A receiver binds by position, which named arguments leave unstated; runtime/eval.go // reports the same call. if e.Operand != nil && ChainCallee(e) == nil { @@ -1155,12 +1171,12 @@ func (ec *exprChecker) checkNamedArguments(scope *symbols.Scope, call invocation bound := make([]bool, len(params)) for i, arg := range args { bound[i] = true - if mismatch := ec.argumentMismatch(arg, argTypes.positional[i], params[i]); mismatch != "" { - report(arg.Span(), "argument %d of %s %s", i+1, sym.Name, mismatch) + for _, m := range ec.argumentMismatches(scope, arg, params[i]) { + report(m.span, "argument %d of %s %s", i+1, sym.Name, m.why) } } unknown := false - for i, arg := range e.NamedArgs { + for _, arg := range e.NamedArgs { if arg.Name == nil || len(arg.Name.Parts) == 0 { continue } @@ -1176,8 +1192,8 @@ func (ec *exprChecker) checkNamedArguments(scope *symbols.Scope, call invocation continue } bound[at] = true - if mismatch := ec.argumentMismatch(arg.Value, argTypes.named[i], p); mismatch != "" { - report(arg.Value.Span(), "argument %s of %s %s", p.name(), sym.Name, mismatch) + for _, m := range ec.argumentMismatches(scope, arg.Value, p) { + report(m.span, "argument %s of %s %s", p.name(), sym.Name, m.why) } } // A misspelt name is the likelier cause of a parameter left unbound. @@ -1221,22 +1237,56 @@ func (ec *exprChecker) parameterPrimType(p parameter) semantics.PrimType { return ec.declaredPrimType(p.scope(), p.usage.Relationships) } -// argumentMismatch says why value, typed got, does not bind to p ("" when it does or a type is -// unknown): a scalar parameter is judged by the lattice, any other by declared type, a Collection -// taking any sequence and Element the element any argument names. -func (ec *exprChecker) argumentMismatch(value ast.Node, got semantics.Argument, p parameter) string { +// misbinding is a value an argument binds that does not bind to its parameter, and why. +type misbinding struct { + span source.Span + why string +} + +// argumentMismatches says why value does not bind to p (none when it does or a type is unknown): +// the values written are judged together, by the type a collection literal's elements share; each +// element a collection value among them holds on its own. +func (ec *exprChecker) argumentMismatches(scope *symbols.Scope, value ast.Node, p parameter) []misbinding { + var out []misbinding + written, held := ec.argumentElements(scope, value) + if len(written) > 0 { + got := ec.silent().argumentOf(scope, written) + if why := ec.argumentMismatch(value, got.Prim, symbolList(got.Type), p); why != "" { + out = append(out, misbinding{value.Span(), why}) + } + } + for _, el := range held { + if why := ec.argumentMismatch(el.Node, ec.heldPrim(el), el.Types, p); why != "" { + out = append(out, misbinding{heldSpan(el, value), why}) + } + } + return out +} + +// symbolList is sym as a list, empty for nil. +func symbolList(sym *symbols.Symbol) []*symbols.Symbol { + if sym == nil { + return nil + } + return []*symbols.Symbol{sym} +} + +// argumentMismatch says why value, of scalar type prim and declared types, does not bind to p +// ("" when it does or a type is unknown): a scalar parameter is judged by the lattice, any other +// by declared type, a Collection taking any sequence and Element the element any argument names. +func (ec *exprChecker) argumentMismatch(value ast.Node, prim semantics.PrimType, types []*symbols.Symbol, p parameter) string { if want := ec.parameterPrimType(p); want != semantics.PrimUnknown { - if got.Prim == semantics.PrimUnknown || bindable(value, got.Prim, want) { + if prim == semantics.PrimUnknown || bindable(value, prim, want) { return "" } - return fmt.Sprintf("expects %s, found %s", want, got.Prim) + return fmt.Sprintf("expects %s, found %s", want, prim) } want := ec.declaredTypeSymbol(p.scope(), p.usage.Relationships) - if want == nil || got.Type == nil || semantics.IsCollection(want) || semantics.IsElementType(want) || - ec.model.Conforms(got.Type, want) || ec.model.Conforms(want, got.Type) { + if want == nil || len(types) == 0 || semantics.IsCollection(want) || semantics.IsElementType(want) || + ec.boundTypesConform(nil, types, []*symbols.Symbol{want}) { return "" } - return fmt.Sprintf("expects %s, found %s", want.Name, got.Type.Name) + return fmt.Sprintf("expects %s, found %s", want.Name, typeNames(types)) } // isBehaviorKind reports the behavior kinds whose parameter lists are checked. diff --git a/internal/core/passes/typecheck_value.go b/internal/core/passes/typecheck_value.go index 4121d9f65..5504a1d24 100644 --- a/internal/core/passes/typecheck_value.go +++ b/internal/core/passes/typecheck_value.go @@ -6,6 +6,7 @@ import ( "github.com/Open-MBEE/OpenSysML/internal/core/ast" "github.com/Open-MBEE/OpenSysML/internal/core/semantics" + "github.com/Open-MBEE/OpenSysML/internal/core/source" "github.com/Open-MBEE/OpenSysML/internal/core/symbols" ) @@ -191,6 +192,46 @@ func valueElements(value ast.Node) []ast.Node { return []ast.Node{value} } +// argumentElements is the values an argument binds: those written — the argument, or the elements +// of a collection literal — and, apart, each element a collection value among them holds. +func (ec *exprChecker) argumentElements(scope *symbols.Scope, value ast.Node) (written []ast.Node, held []semantics.CollectionElement) { + for _, element := range valueElements(value) { + if elements, collection := ec.model.CollectionElements(scope, element); collection { + held = append(held, elements...) + continue + } + written = append(written, element) + } + return written, held +} + +// heldSpan is where a held element is written, or the argument when a result parameter produces it. +func heldSpan(el semantics.CollectionElement, argument ast.Node) source.Span { + if el.Node != nil { + return el.Node.Span() + } + return argument.Span() +} + +// heldPrim is the scalar type of a held element, read silently — the collection value has been +// typed and reported once already; a result parameter's by its type. +func (ec *exprChecker) heldPrim(el semantics.CollectionElement) semantics.PrimType { + if el.Node != nil { + return ec.silent().infer(el.Scope, el.Node) + } + for _, t := range el.Types { + if prim := ec.model.PrimTypeOf(t); prim != semantics.PrimUnknown { + return prim + } + } + return semantics.PrimUnknown +} + +// silent is a checker typing as ec does, under the same chains and performances, reporting nothing. +func (ec *exprChecker) silent() *exprChecker { + return &exprChecker{resolver: ec.resolver, model: ec.model, lang: ec.lang, chaining: ec.chaining, performed: ec.performed} +} + // declaredTypeSymbol returns the symbol a usage is typed by, or nil. func (ec *exprChecker) declaredTypeSymbol(scope *symbols.Scope, rels []*ast.Relationship) *symbols.Symbol { if types := ec.declaredTypeSymbols(scope, rels); len(types) > 0 { @@ -372,7 +413,7 @@ func (ec *exprChecker) invocationResultParameter(scope *symbols.Scope, value ast } else if inv.Type != nil { // The arguments type silently, but under the chains being typed: one whose // body reads the feature being valued would otherwise type it again. - silent := exprChecker{resolver: ec.resolver, model: ec.model, lang: ec.lang, chaining: ec.chaining, performed: ec.performed} + silent := ec.silent() sym = silent.selectInvocation(scope, inv, silent.argumentTypes(scope, inv), ec.performs(inv)).Selected } if sym == nil || !ec.isInvocationBehavior(sym, map[*symbols.Symbol]bool{}) { diff --git a/internal/core/passes/typecheck_value_test.go b/internal/core/passes/typecheck_value_test.go index bd9151260..076af762f 100644 --- a/internal/core/passes/typecheck_value_test.go +++ b/internal/core/passes/typecheck_value_test.go @@ -556,10 +556,64 @@ func TestArgumentCollectionResultIsJudged(t *testing.T) { "argument 1 of Sail expects Boat, found Vehicle") wantCollectionValueDiags(t, `part v = Drive(two->reduce { in a : Vehicle; in b : Vehicle; boat });`, "argument 1 of Drive expects Vehicle, found Boat") + wantCollectionValueDiags(t, `part v = Drive(vs->reduce { in a : Vehicle; in b : Vehicle; boat });`, + "argument 1 of Drive expects Vehicle, found Boat") wantCollectionValueDiags(t, ` part b = Sail(vs->collect { in v : Vehicle; boat }); part v = Drive(vs->select { in v : Vehicle; true }); part v4 = Drive(vs.?{ in v : Vehicle; true }); - part v2 = Drive(vs->reduce { in a : Vehicle; in b : Vehicle; boat }); + part v2 = Drive(two->reduce { in a : Vehicle; in b : Vehicle; one }); part v3 = Drive(vs->reduce { in a : Vehicle; in b : Vehicle; a });`) } + +// Each element a collection value holds is judged on its own, so a collection whose elements +// share no type does not pass unjudged, and a literal an element spells binds exactly. +func TestArgumentCollectionElementsAreJudgedSeverally(t *testing.T) { + wantCollectionValueDiags(t, `part b = Sail(vs.{ in v : Vehicle; (v, boat) });`, + "argument 1 of Sail expects Boat, found Vehicle") + wantCollectionValueDiags(t, `part b = Sail((boat, vs.{ in v : Vehicle; v }));`, + "argument 1 of Sail expects Boat, found Vehicle") + wantCollectionValueDiags(t, `part v = Drive(vs->collect { in v : Vehicle; (boat, v) });`, + "argument 1 of Drive expects Vehicle, found Boat") + wantCollectionValueDiags(t, ` + function Count { in n : Integer; return r : Integer; } + part n = Count(vs.{ in v : Vehicle; 1.5 });`, + "argument 1 of Count expects Integer, found Rational") + wantCollectionValueDiags(t, ` + function Count { in n : Integer; return r : Integer; } + part n = Count(n = vs->collect { in v : Vehicle; (1, 1.5) });`, + "argument n of Count expects Integer, found Rational") + wantCollectionValueDiags(t, ` + function Count { in n : Integer; return r : Integer; } + attribute h : Real; + part n = Count(vs.{ in v : Vehicle; 1 }); + part n2 = Count(vs.{ in v : Vehicle; h }); + part b = Sail((boat, vs.{ in v : Vehicle; boat }));`) +} + +// A constructor argument binds each element a collection value holds to the feature: a mixed +// collection is refused by the element that does not conform, a collected literal by its exact type. +func TestConstructorCollectionElementsAreJudgedSeverally(t *testing.T) { + wantCollectionValueDiags(t, ` + part def Fleet { part b : Boat; } + part f = new Fleet(vs.{ in v : Vehicle; (v, boat) });`, + "b of Fleet is typed by Boat; cannot bind a value of type Vehicle") + wantCollectionValueDiags(t, ` + part def Fleet { part b : Boat; } + part f = new Fleet(b = (boat, vs.{ in v : Vehicle; v }));`, + "b of Fleet is typed by Boat; cannot bind a value of type Vehicle") + wantCollectionValueDiags(t, ` + part def Fleet { attribute n : Integer; } + part f = new Fleet(vs.{ in v : Vehicle; 1.5 });`, + "n of Fleet expects Integer, found Rational") + wantCollectionValueDiags(t, ` + part def Fleet { attribute n : Integer; } + part f = new Fleet(n = vs->collect { in v : Vehicle; (1, 1.5) });`, + "n of Fleet expects Integer, found Rational") + wantCollectionValueDiags(t, ` + part def Fleet { part b : Boat; attribute n : Integer; } + attribute h : Real; + part f = new Fleet(vs.{ in v : Vehicle; boat }, vs.{ in v : Vehicle; 1 }); + part f2 = new Fleet(n = vs.{ in v : Vehicle; h }); + part f3 = new Fleet((boat, vs.{ in v : Vehicle; boat }));`) +} From f70ff909e3d181d409b36b3b70d90f36b03eebcc Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:01:53 +0000 Subject: [PATCH 17/22] docs(passes): tighten binding helper comments Co-Authored-By: jason.han --- internal/core/passes/typecheck_expr.go | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/internal/core/passes/typecheck_expr.go b/internal/core/passes/typecheck_expr.go index f3619b24f..fdedb4309 100644 --- a/internal/core/passes/typecheck_expr.go +++ b/internal/core/passes/typecheck_expr.go @@ -1049,8 +1049,7 @@ func (ec *exprChecker) inferConstructor(scope *symbols.Scope, e *ast.Constructor } // checkFeatureBinding reports a constructor argument its feature cannot take: the values written -// by scalar type together and by conformance to the feature's declared or inherited types each, -// each element a collection value holds on its own, and the argument by count. +// together by scalar type or each by conformance, each held element on its own, and the count. func (ec *exprChecker) checkFeatureBinding(scope *symbols.Scope, arg ast.Node, feature, typ *symbols.Symbol) { u, ok := feature.Decl.(*ast.Usage) if !ok { @@ -1244,8 +1243,7 @@ type misbinding struct { } // argumentMismatches says why value does not bind to p (none when it does or a type is unknown): -// the values written are judged together, by the type a collection literal's elements share; each -// element a collection value among them holds on its own. +// the values written are judged together, as a collection literal is; each held element on its own. func (ec *exprChecker) argumentMismatches(scope *symbols.Scope, value ast.Node, p parameter) []misbinding { var out []misbinding written, held := ec.argumentElements(scope, value) @@ -1271,9 +1269,8 @@ func symbolList(sym *symbols.Symbol) []*symbols.Symbol { return []*symbols.Symbol{sym} } -// argumentMismatch says why value, of scalar type prim and declared types, does not bind to p -// ("" when it does or a type is unknown): a scalar parameter is judged by the lattice, any other -// by declared type, a Collection taking any sequence and Element the element any argument names. +// argumentMismatch says why value, of scalar type prim and declared types, does not bind to p ("" +// when it does or a type is unknown); a Collection parameter takes any sequence, Element any element. func (ec *exprChecker) argumentMismatch(value ast.Node, prim semantics.PrimType, types []*symbols.Symbol, p parameter) string { if want := ec.parameterPrimType(p); want != semantics.PrimUnknown { if prim == semantics.PrimUnknown || bindable(value, prim, want) { From 23e572e213c8d86bf48d6359d9da7e6f04bf849e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:14:50 +0000 Subject: [PATCH 18/22] fix(semantics): reduce over a collection holding one at most keeps the element alone A collection whose multiplicity caps it at one element is never reduced, so its reduce is typed by the element it hands back, not by the reducer; the reducer types the result only where two or more may be held or where the collection holds nothing. Co-Authored-By: jason.han --- .../collection-body-result-type.fixed.md | 2 +- docs/project/spec-compliance.md | 2 +- internal/core/passes/typecheck_value_test.go | 6 +++- internal/core/semantics/collection.go | 36 ++++++++++--------- internal/core/semantics/collection_test.go | 28 +++++++++++---- 5 files changed, 49 insertions(+), 25 deletions(-) diff --git a/changes/unreleased/collection-body-result-type.fixed.md b/changes/unreleased/collection-body-result-type.fixed.md index 4c4966991..a6b61751c 100644 --- a/changes/unreleased/collection-body-result-type.fixed.md +++ b/changes/unreleased/collection-body-result-type.fixed.md @@ -1 +1 @@ -- **A collection operation's static type follows what its declaration hands through, not the element type of the collection.** `xs->collect { in x : C; x.mass }` and `xs.{ in x : C; x.mass }` are typed by the body's result (`MassValue`), a nested collect by its innermost body, a body answering a sequence by every element type and `xs->collect f` by the named function's result; `select`, `reject` and `selectOne` keep the elements of `xs`, `reduce` follows its reducer's result — and the element a one-element collection hands back unreduced, unless the collection is known to hold two or more, by its own multiplicity, one it inherits by redefinition, or a chain through such features — and `forAll`/`exists` stay `Boolean`, each with the multiplicity the Kernel Function Library declares. A body whose result cannot be typed keeps the library's `Anything`. Value conformance, a feature's bound value, invocation arguments, trigger arguments and enumerated values are judged by the specialized type, so `accept when counts.{in n : Integer; n}` is refused where it was silent, and `when counts.{in n; n > 3}` is accepted where it was refused. A scalar literal a body writes out is as exact as one bound directly: `attribute i : Integer = xs.{ in x : C; 1.5 }` is refused, and a quantity it writes out is measured against the target's dimension: `attribute t : DurationValue = xs.{ in x : C; 5 [m] }` is refused. `xs.?{…}` binds as `xs->select {…}` does, and an element that is itself a collection value binds by the elements it holds, so `attribute i : Integer = xs.{ in x : C; xs.{ in y : C; 1.5 } }` is refused. A collection over `()` or a feature admitting no value keeps its declared type — `()->collect { in a : Integer; "s" }` is a `String` collection — but holds no element, so none is judged and its `reduce` takes nothing from an element it would hand back unreduced; so does one mapping every element to a `[0]` feature or function result — the multiplicity read through an alias, or from the feature or result a redefinition inherits it from — or any operation over such a collection, and an argument holding nothing is judged against no parameter type. An argument or constructor value that is a collection binds each element it holds on its own, so `Sail(vs.{ in v : Vehicle; (v, boat) })` and `new Fleet(vs.{ in v : Vehicle; 1.5 })` are refused by the element that does not bind where they were silent. A body reading the feature it values terminates as a self-referential argument does. +- **A collection operation's static type follows what its declaration hands through, not the element type of the collection.** `xs->collect { in x : C; x.mass }` and `xs.{ in x : C; x.mass }` are typed by the body's result (`MassValue`), a nested collect by its innermost body, a body answering a sequence by every element type and `xs->collect f` by the named function's result; `select`, `reject` and `selectOne` keep the elements of `xs`, `reduce` follows its reducer's result — and the element a one-element collection hands back unreduced, unless the collection is known to hold two or more, by its own multiplicity, one it inherits by redefinition, or a chain through such features; a collection holding one at most is never reduced, so its element alone is the result — and `forAll`/`exists` stay `Boolean`, each with the multiplicity the Kernel Function Library declares. A body whose result cannot be typed keeps the library's `Anything`. Value conformance, a feature's bound value, invocation arguments, trigger arguments and enumerated values are judged by the specialized type, so `accept when counts.{in n : Integer; n}` is refused where it was silent, and `when counts.{in n; n > 3}` is accepted where it was refused. A scalar literal a body writes out is as exact as one bound directly: `attribute i : Integer = xs.{ in x : C; 1.5 }` is refused, and a quantity it writes out is measured against the target's dimension: `attribute t : DurationValue = xs.{ in x : C; 5 [m] }` is refused. `xs.?{…}` binds as `xs->select {…}` does, and an element that is itself a collection value binds by the elements it holds, so `attribute i : Integer = xs.{ in x : C; xs.{ in y : C; 1.5 } }` is refused. A collection over `()` or a feature admitting no value keeps its declared type — `()->collect { in a : Integer; "s" }` is a `String` collection — but holds no element, so none is judged and its `reduce` takes nothing from an element it would hand back unreduced; so does one mapping every element to a `[0]` feature or function result — the multiplicity read through an alias, or from the feature or result a redefinition inherits it from — or any operation over such a collection, and an argument holding nothing is judged against no parameter type. An argument or constructor value that is a collection binds each element it holds on its own, so `Sail(vs.{ in v : Vehicle; (v, boat) })` and `new Fleet(vs.{ in v : Vehicle; 1.5 })` are refused by the element that does not bind where they were silent. A body reading the feature it values terminates as a self-referential argument does. diff --git a/docs/project/spec-compliance.md b/docs/project/spec-compliance.md index 09d58c50e..c154bf822 100644 --- a/docs/project/spec-compliance.md +++ b/docs/project/spec-compliance.md @@ -1462,7 +1462,7 @@ known, so unmodelled types never produce a false positive. | Each argument of an invocation corresponds to one `in` parameter of the invoked type (KerML 1.1 §8.3.4.8 `validateInvocationExpressionParameterRedefinition`, pilot `Must correspond to one input parameter of the invoked type`): a named argument naming an `out` or `return` parameter, a feature that is no parameter, or a general's parameter the invoked function's own redefines by position; and a positional argument past the last `in` parameter, in a function, a `calc def`, a step or a feature typed by a behavior, with inherited and redefined parameters counted through `effectiveInParameters` | `passes/typecheck_expr.go` `checkArguments`, `checkNamedArguments` (`msgInvocationParameterRedefinition`) | `passes/duplicate_binding_test.go` `TestInvocationParameterRedefinitionKerML`; census probe `validateInvocationExpressionParameterRedefinition.kerml`; corpus `semantic/k32-too-many-arguments.kerml` | ✅ Faithful; the pilot's wording heads ours, followed by the cause (`…: F has no parameter named "y"`, `…: F takes 1 argument(s), found 2`) at the offending argument. A required parameter left unbound (`F()`) draws OpenSysML's own `unbound-parameter` advisory (warning), a check the pilot does not make | | A constructor expression binds each feature of the instantiated type at most once (KerML 1.1 §8.3.4.8 `validateConstructorExpressionNoDuplicateFeatureRedefinition`, pilot `Feature already bound`): `new C(x = 1, x = 2)`, an alias or a qualified name of the same feature, and an inherited feature bound under its own and its redefining name all count; reported at the second binding, `x of C is already bound by an earlier argument`, and the same rule reaches a constructor nested in a send payload (under a conditional, a sequence, a feature chain, a call argument or a body expression) or in a feature chain's operand | `passes/typecheck_expr.go` `inferConstructor` (the constructor row below), `inferFeatureChain` | `passes/duplicate_binding_test.go` (`TestConstructorDuplicateFeatureBindingKerML`, `TestConstructorDuplicateFeatureBindingSysML`); corpus `semantic/k34-constructor-feature-bound-twice.kerml`; census probe `validateConstructorExpressionNoDuplicateFeatureRedefinition.kerml` | ✅ Faithful (a call's named argument is not indexed as a member reference of the called type, so editor rename and find-references do not reach `f(F::x = …)` — an editor follow-up; constructor labels are) | | Invocation overload selection: a name visible as several function/calc declarations (owned, inherited, imported or re-exported — a library function only where the model imports its package) selects the one whose effective input parameters take the arguments — each argument, by position or name, to a distinct parameter it conforms to; a candidate the arguments bind every default-less parameter of is preferred to one they leave a parameter of, which stays applicable and draws the `unbound-parameter` advisory, so `pick("a")` against `pick(in x : Integer)` and `pick(in s : String; in y : Real)` binds to the second rather than reporting a mismatch against the first, `pick(2)` against `pick(in x : Real; in y : Real)` and `pick(in x : Integer)` runs the complete one, and `pick()` against two candidates it fits alike is `invocation-ambiguous` — and by type conformance (the `ScalarValues` lattice, strings, booleans, collections, quantities and declared types through `Model.Conforms` — a non-literal argument's type only bounds its values, so a broader declared type fits a narrower parameter, while declared types neither of which conforms to the other, `MassValue` to a `VolumeValue` parameter, never bind, as the pinned validator judges a binding; a `Collections` parameter takes any sequence, as the runtime reads it, and a `KerML::Root::Element` parameter the element any argument names, as `DocumentQueries` bind it, ranked between a declared type and `Anything`); among several fits the most specific wins, a tie or incomparable pair is reported as `invocation-ambiguous` naming the tied candidates (`InvocationSelection.Tied`, the applicable ones none is more specific than, so a broader overload the arguments also fit is not among them), and no fit keeps the argument diagnostic against the first candidate, naming the others considered. The selection is memoized in a side table keyed by the invocation node and scope, the runtime dispatches the declaration selected there, and a named argument written twice is reported (`binds parameter twice` for a calc call, `ErrDuplicateArgument` for a nested action call) and dispatched by none. A feature typed by a behavior is a candidate performing that behavior, so `ref pick : Twice;` is selected beside a same-named calc when only its signature fits. An expression evaluates to a calc's result, so a call in expression context whose name denotes a calc selects among the calcs alone, whatever the import order and however closely a same-named action's inputs fit — the arguments are then checked and dispatched against the calc, and the action is never evaluated — while `action call = tag(3);` and `perform tag(3);` select among actions only. A bodiless calc specializing a library function (`calc def Renamed :> sqrt { in y :>> x; }`, or a feature typed by one) is computed by that function through its own effective signature: the runtime binds the written arguments to the specialization's parameters as any calc's are bound — renamed, defaulted, optional; each default evaluated where it is declared with the earlier parameters in scope (`in y :>> y = x + 1.0;`), each value checked against the effective type and multiplicity the specialization states (`in n : Integer :>> x;`, `[1]`) before the library computes it — and lays them out in the library's order (`runtime/invoke_calc.go` `libraryCalcPerformed`, `effectiveParameter`, `invokeLibraryPerformance`), for an expression, a `send` and the `InvokeCalc`/`InvokeCalcNamed` API alike; a calc with a body of its own is never redirected. A nested action call with an explicit empty argument list (`action call = tag();`) binds nothing — a required input is unbound, a defaulted one takes its default — where only the bare `perform tag;` and `action call : Tag;` forms read the caller's same-named values | `resolve/invocation.go` `Resolver.InvocationCandidates` (all matches of the winning visibility category), `semantics/invocation.go` `Model.SelectInvocation`, `passes/typecheck_expr.go` `inferInvocation`, `passes/invocation.go` `argument`, `runtime/eval.go` `invocationTarget` and `runtime/invoke_action.go` `resolveActionSymbol` for a nested `action call = A(...)` or `perform A(...)`, whose receiver `x->A()` is the first argument (`ErrAmbiguousInvocation`, `ErrReceiverWithNamedArgs`), `model/workspace.go` `calledDeclaration` and `Workspace.AmbiguousInvocationInDoc` for editor navigation (a call selecting one overload navigates to it; a tied call lists every tied overload under go-to-definition and hover, and is left out of find-references and rename, which refuses to start from it), `semantics/invocation.go` `Model.SelectCall` (the checker's argument typing installed by `passes.NewArgumentTyper`) for `queryplan/compiler.go` `compileInvocation` and `columns.go` `compileColumn` (a tie is `ErrorAmbiguousInvocation`) and `runtime/signal.go` `invokesCalc` (a send names a signal unless the call selects a calc) | `passes/invocation_test.go` (`TestInvocationOverloadRedeclaredLibraryInputs`, `TestInvocationOverloadOmittedInputSelectsTheFittingCandidate`, `TestInvocationOverloadCompleteCandidateBeatsOmission`, `TestInvocationOverloadOmittedInputsAreAmbiguous`), `runtime/invocation_selection_test.go` (`calc_call_binds_a_library_function_through_redeclared_inputs`, `calc_call_selects_a_calc_over_a_more_specific_action`, `calc_call_omitting_an_input_runs_the_fitting_candidate`, `calc_call_omitting_every_input_is_ambiguous`), `queryplan/overload_test.go`, `runtime/signal_test.go:TestActionSendCallsACalcSharingASignalsName`, `lsp/invocation_navigation_test.go`, `lsp/ambiguous_invocation_navigation_test.go`, conformance `calc_library_overload_by_argument_type`, `calc_library_overload_partial_import`, `calc_library_overload_ambiguous`, `calc_library_function_shadowed_by_model`, `action_node_invocation_overload_inherited_input` (the winning action's matching input is inherited, and the runtime frame binds it), `lsp/library_invocation_test.go:TestPublishDiagnosticsSelectsLibraryOverloadByArgumentType`, pilot-exec-diff `w6d:complex-abs`, `:complex-is-zero` | ⚠️ Approximate: a model's own `calc` of the name still shadows every library declaration, and an argument whose type is statically unknown (an unresolved parameter type, an untyped feature) keeps today's first-visible selection with no new diagnostic — the corpus gates hold at zero movement. The pinned pilot evaluates none of the probes (`ToInteger`, `abs`, `isZero` over their overload sets come back as the unevaluated `InvocationExpression`), so the most-specific rule is self-assessed against KerML 1.1 §8.3.4.8 (a decimal literal is a `LiteralRational`, so `ToInteger(7.9)` selects `RationalFunctions::ToInteger` over `RealFunctions`'). A parameter's `[m..n]` multiplicity decides only whether a call may omit it: one admitting none, or one a default reaches along its redefinitions (`Model.OptionalParameter`, `Model.ParameterDefault`), is optional to the checker and to `runtime/action_frame.go` `checkInputsBound` alike, and the omitted input is read as empty or as the inherited default (`passes/invocation_test.go:TestInvocationPerformedActionOptionalInputs`, `runtime/invocation_selection_test.go:action_call_omits_optional_inputs`). Not done: a value's element count is not checked against the multiplicity | -| A collection operation's static result is typed by the argument the library's declaration hands through, not by the `Anything[0..*]` it declares (KerML 1.1 §8.3.4.8 `checkSelectExpressionResultSpecialization`: a select's result subsets the collection's; §9.2 Kernel Function Library `ControlFunctions.kerml`, `collect`'s result "the collection of results" of `mapper`, `select`/`reject`/`selectOne` elements of `collection`, `reduce` the `reducer`'s result, `forAll`/`exists` `Boolean[1]`): `xs->collect {in x : C; x.mass}` and `xs.{in x : C; x.mass}` are typed `MassValue`, `xs->collect {in x : C; x.name}` `String`, a nested collect by the innermost body, a body answering a sequence `(x.mass, x.name)` by every element type, `xs->collect Mass` by the named function's result, `xs->select {…}`/`->reject {…}`/`->selectOne {…}` as `xs` is, `->reduce '+'` by the reducer's result — and, as the runtime hands a one-element collection back unreduced, by the element too unless the collection is known to hold two or more (a `[2..*]` feature, a sequence of two literals) — and `->forAll`/`->exists` `Boolean`; the operation is the resolved library declaration, in the receiver, plain and named-argument notations alike, and a body whose result cannot be typed — its parameter declares no type — leaves the library's `Anything`, never a guess. The multiplicity stays the declaration's (`collect`/`select`/`reject`/`reduce` `[0..*]`, `selectOne` `[0..1]`, `forAll`/`exists` `[1]`). A collection value bound to a typed feature or passed as an argument is judged by these element types, each element a sequence-valued body answers on its own, so `part b : Boat = vs->select {in v : Vehicle; true}` and `Sail(vs.{in v : Vehicle; v})` are refused where the declared `Anything` would bind. A body reading the feature it values (`total = cs->collect {in x : C; total}->reduce '+'`) terminates under the same guard as an argument that does | `semantics/collection.go` `Model.CollectionResultTypes`, `CollectionElementTypes`, `sourcesOf`, `invocationSources`, `holdsAtLeastTwo`, `collectSources`, `appliedSources`, `bodySources`, `argumentTo`, `sourcesElementTypes`, reached from `semantics/valuetype.go` `Model.ExprResultType`, `exprConformance`, `invocationConformance` and `semantics/operator_conformance.go` `resultTypes`; `semantics/invocation.go` `SelectCall`/`callArguments` (`typingArgs`); `passes/typecheck_value.go` `exprChecker.checkValueConformance`, `unboundElementTypes`, `invocationResultTypeSymbol`, `invocationResultParameter` (selects under the chains being typed) | `semantics/collection_test.go` `TestCollectionResultTypes`, `TestCollectionResultMultiplicity`, `TestCollectionNestedAndSequenceBodies`, `TestCollectionArgumentNotations`, `TestCollectionUntypedBodyFallsBackToLibraryResult`, `TestCollectionSelfReferentialBodyTerminates`, `TestCollectionResultConformance`, `TestCollectionReduceMayReturnTheElement`; `passes/typecheck_value_test.go:TestValueCollectionResultIsJudged`, `:TestValueReduceResultIsJudged`, `:TestArgumentCollectionResultIsJudged`; `passes/typecheck_dimension_test.go:TestRecursiveRollupThroughACall`, `:TestRecursiveRollupThroughACollectBody`; `passes/typecheck_trigger_test.go:TestCollectAndSelectTriggerArguments`; `passes/w7g_one_type_test.go:TestW7GASelectedEnumeratedValueKeepsItsOperandType`; grammar `PrimaryExpression` (`'->' … BodyExpression`, `'.' BodyExpression`, `'.?' BodyExpression`) | ✅ Faithful for `select`/`reject`/`selectOne`/`forAll`/`exists` (the pinned pilot subsets a select's result from its collection and types the rest by the declared result); ⚠️ Approximate for `collect`/`reduce` (self-assessed: the specification declares their result `Anything[0..*]` and the pilot types it so, giving `xs.{in x : C; x.mass}` no static type; typing it by the body is stricter than the reference, so a diagnostic it raises — a `when` trigger over a collect of Integers, an enumerated value collecting Reals — the pilot stays silent on) | +| A collection operation's static result is typed by the argument the library's declaration hands through, not by the `Anything[0..*]` it declares (KerML 1.1 §8.3.4.8 `checkSelectExpressionResultSpecialization`: a select's result subsets the collection's; §9.2 Kernel Function Library `ControlFunctions.kerml`, `collect`'s result "the collection of results" of `mapper`, `select`/`reject`/`selectOne` elements of `collection`, `reduce` the `reducer`'s result, `forAll`/`exists` `Boolean[1]`): `xs->collect {in x : C; x.mass}` and `xs.{in x : C; x.mass}` are typed `MassValue`, `xs->collect {in x : C; x.name}` `String`, a nested collect by the innermost body, a body answering a sequence `(x.mass, x.name)` by every element type, `xs->collect Mass` by the named function's result, `xs->select {…}`/`->reject {…}`/`->selectOne {…}` as `xs` is, `->reduce '+'` by the reducer's result — and, as the runtime hands a one-element collection back unreduced, by the element too unless the collection is known to hold two or more (a `[2..*]` feature, a sequence of two literals), and by the element alone where it holds one at most (`[1]`, `[0..1]`), the reducer never applied — and `->forAll`/`->exists` `Boolean`; the operation is the resolved library declaration, in the receiver, plain and named-argument notations alike, and a body whose result cannot be typed — its parameter declares no type — leaves the library's `Anything`, never a guess. The multiplicity stays the declaration's (`collect`/`select`/`reject`/`reduce` `[0..*]`, `selectOne` `[0..1]`, `forAll`/`exists` `[1]`). A collection value bound to a typed feature or passed as an argument is judged by these element types, each element a sequence-valued body answers on its own, so `part b : Boat = vs->select {in v : Vehicle; true}` and `Sail(vs.{in v : Vehicle; v})` are refused where the declared `Anything` would bind. A body reading the feature it values (`total = cs->collect {in x : C; total}->reduce '+'`) terminates under the same guard as an argument that does | `semantics/collection.go` `Model.CollectionResultTypes`, `CollectionElementTypes`, `sourcesOf`, `invocationSources`, `holdsAtLeastTwo`, `collectSources`, `appliedSources`, `bodySources`, `argumentTo`, `sourcesElementTypes`, reached from `semantics/valuetype.go` `Model.ExprResultType`, `exprConformance`, `invocationConformance` and `semantics/operator_conformance.go` `resultTypes`; `semantics/invocation.go` `SelectCall`/`callArguments` (`typingArgs`); `passes/typecheck_value.go` `exprChecker.checkValueConformance`, `unboundElementTypes`, `invocationResultTypeSymbol`, `invocationResultParameter` (selects under the chains being typed) | `semantics/collection_test.go` `TestCollectionResultTypes`, `TestCollectionResultMultiplicity`, `TestCollectionNestedAndSequenceBodies`, `TestCollectionArgumentNotations`, `TestCollectionUntypedBodyFallsBackToLibraryResult`, `TestCollectionSelfReferentialBodyTerminates`, `TestCollectionResultConformance`, `TestCollectionReduceMayReturnTheElement`; `passes/typecheck_value_test.go:TestValueCollectionResultIsJudged`, `:TestValueReduceResultIsJudged`, `:TestArgumentCollectionResultIsJudged`; `passes/typecheck_dimension_test.go:TestRecursiveRollupThroughACall`, `:TestRecursiveRollupThroughACollectBody`; `passes/typecheck_trigger_test.go:TestCollectAndSelectTriggerArguments`; `passes/w7g_one_type_test.go:TestW7GASelectedEnumeratedValueKeepsItsOperandType`; grammar `PrimaryExpression` (`'->' … BodyExpression`, `'.' BodyExpression`, `'.?' BodyExpression`) | ✅ Faithful for `select`/`reject`/`selectOne`/`forAll`/`exists` (the pinned pilot subsets a select's result from its collection and types the rest by the declared result); ⚠️ Approximate for `collect`/`reduce` (self-assessed: the specification declares their result `Anything[0..*]` and the pilot types it so, giving `xs.{in x : C; x.mass}` no static type; typing it by the body is stricter than the reference, so a diagnostic it raises — a `when` trigger over a collect of Integers, an enumerated value collecting Reals — the pilot stays silent on) | | An unqualified call to a Kernel Function Library function the model does not import is `unresolved` for the checker and the runtime alike: the library packages are members of the root namespace but not implicitly imported, so a bare `sqrt(4.0)` with no `import RealFunctions::*;` draws the `unresolved` diagnostic offering the imports that would resolve it and fails to evaluate with the same text, `RealFunctions::sqrt(4.0)` resolves anywhere, and under the import the call checks clean, is selected among the imported overloads and evaluates; the OpenSysML extension functions (`exp`, `ln`, `log`, `atan2`) are gated by the same rule | `resolve/invocation.go` `ResolveInvocationName` (the name resolves as any other), `runtime/eval.go` `invocationTarget` (dispatch by the declaration resolved, `passes.SelectInvocation` choosing among several) | `passes/invocation_test.go:TestInvocationUnimportedLibraryFunction`, `:TestInvocationQualifiedLibraryFunctionArgumentsChecked`, `:TestInvocationOverloadOnlyImportedPackagesContribute`, `runtime/library_functions_test.go:TestLibraryFunctionUnqualifiedNames`, `:TestLibraryFunctionQualifiedCallNeedsNoImport`, conformance `calc_library_function_unimported`, `calc_library_function_imported`, `lsp/library_invocation_test.go:TestPublishDiagnosticsAgreesWithRuntimeOnLibraryCall`, `:TestPublishDiagnosticsKeepsExtensionFunctionImportGated`, `lsp/invocation_navigation_test.go:TestDefinitionReachesImportedLibraryFunction` | ✅ Faithful. Observed against the pinned pilot: it reports `Couldn't resolve reference to Element 'sqrt'` for the unimported call and resolves the qualified one | | No false positives on the shipped library and examples | corpus guard | `model/typecheck_expr_corpus_test.go` | ✅ Faithful | | `[` is not an index in KerML (pilot `validateOperatorExpressionBracketOperator`, `Use #(...) for indexing`; the specification gives `[` no KerML function — `BaseFunctions::'['` is abstract): every bracket in a `.kerml` document warns at the operator expression, a `.sysml` document is judged by the quantity rule instead | `passes/typecheck_expr.go` `exprChecker.checkBracket`; code `bracket-operator` | `typecheck_operator_test.go:TestBracketOperatorInKerML`, `TestBracketOperatorIsKerMLOnly`; census probe `validateOperatorExpressionBracketOperator.kerml` | ✅ Faithful (warning, as the pilot) | diff --git a/internal/core/passes/typecheck_value_test.go b/internal/core/passes/typecheck_value_test.go index 076af762f..f0751be73 100644 --- a/internal/core/passes/typecheck_value_test.go +++ b/internal/core/passes/typecheck_value_test.go @@ -558,12 +558,16 @@ func TestArgumentCollectionResultIsJudged(t *testing.T) { "argument 1 of Drive expects Vehicle, found Boat") wantCollectionValueDiags(t, `part v = Drive(vs->reduce { in a : Vehicle; in b : Vehicle; boat });`, "argument 1 of Drive expects Vehicle, found Boat") + wantCollectionValueDiags(t, `part b = Sail(one->reduce { in a : Vehicle; in b : Vehicle; boat });`, + "argument 1 of Sail expects Boat, found Vehicle") wantCollectionValueDiags(t, ` part b = Sail(vs->collect { in v : Vehicle; boat }); part v = Drive(vs->select { in v : Vehicle; true }); part v4 = Drive(vs.?{ in v : Vehicle; true }); part v2 = Drive(two->reduce { in a : Vehicle; in b : Vehicle; one }); - part v3 = Drive(vs->reduce { in a : Vehicle; in b : Vehicle; a });`) + part v3 = Drive(vs->reduce { in a : Vehicle; in b : Vehicle; a }); + part v5 = Drive(one->reduce { in a : Vehicle; in b : Vehicle; boat }); + part v6 = Drive((none, one)->reduce { in a : Vehicle; in b : Vehicle; boat });`) } // Each element a collection value holds is judged on its own, so a collection whose elements diff --git a/internal/core/semantics/collection.go b/internal/core/semantics/collection.go index 213b1ea03..f2cb171fc 100644 --- a/internal/core/semantics/collection.go +++ b/internal/core/semantics/collection.go @@ -141,7 +141,7 @@ func (m *Model) isCollectionFunction(fn *symbols.Symbol) bool { } // invocationSources type a collection function call: collect by what it applies, select/reject/ -// selectOne by the collection, reduce by the reducer plus the one element it may return unreduced. +// selectOne by the collection, reduce by the reducer and the one element it may return unreduced. func (m *Model) invocationSources(scope *symbols.Scope, e *ast.InvocationExpr, fn *symbols.Symbol) ([]collectionSource, bool) { if fn == nil || e == nil { return nil, false @@ -150,21 +150,31 @@ func (m *Model) invocationSources(scope *symbols.Scope, e *ast.InvocationExpr, f case m.libSymbol(fqnCollect): return m.appliedSources(scope, m.argumentTo(scope, e, fn, 1)) case m.libSymbol(fqnReduce): - srcs, ok := m.appliedSources(scope, m.argumentTo(scope, e, fn, 1)) - if !ok { - return nil, false - } - collection := m.argumentTo(scope, e, fn, 0) - if collection != nil && !m.holdsAtLeastTwo(scope, collection) && !m.holdsNothing(scope, collection) { - srcs = append(srcs, collectionSource{scope: scope, node: collection}) - } - return srcs, true + return m.reducedSources(scope, m.argumentTo(scope, e, fn, 0), m.argumentTo(scope, e, fn, 1)) case m.libSymbol(fqnSelect), m.libSymbol(fqnReject), m.libSymbol(fqnSelectOne): return m.keptSources(scope, m.argumentTo(scope, e, fn, 0)) } return nil, false } +// reducedSources type a reduction: the reducer is applied only over two elements or more, the one +// element a collection may hold is returned unreduced; over nothing the reducer types the result. +func (m *Model) reducedSources(scope *symbols.Scope, collection, reducer ast.Node) ([]collectionSource, bool) { + var srcs []collectionSource + size, known := m.valuesHeldBy(scope, collection) + if !known || size.Upper.Infinite || size.Upper.Value != 1 { + applied, ok := m.appliedSources(scope, reducer) + if !ok { + return nil, false + } + srcs = applied + } + if collection != nil && (!known || !size.Lower.Infinite && size.Lower.Value <= 1 && (size.Upper.Infinite || size.Upper.Value >= 1)) { + srcs = append(srcs, collectionSource{scope: scope, node: collection}) + } + return srcs, true +} + // keptSources is the source of a selection: the collection whose elements it keeps. func (m *Model) keptSources(scope *symbols.Scope, collection ast.Node) ([]collectionSource, bool) { if collection == nil { @@ -173,12 +183,6 @@ func (m *Model) keptSources(scope *symbols.Scope, collection ast.Node) ([]collec return []collectionSource{{scope: scope, node: collection}}, true } -// holdsAtLeastTwo reports a collection statically known to hold two elements or more. -func (m *Model) holdsAtLeastTwo(scope *symbols.Scope, collection ast.Node) bool { - r, ok := m.valuesHeldBy(scope, collection) - return ok && (r.Lower.Infinite || r.Lower.Value >= 2) -} - // holdsNothing reports a collection statically known to hold no element: `()`, or a feature // whose multiplicity admits none. func (m *Model) holdsNothing(scope *symbols.Scope, collection ast.Node) bool { diff --git a/internal/core/semantics/collection_test.go b/internal/core/semantics/collection_test.go index 7fc9b6343..3adda39bc 100644 --- a/internal/core/semantics/collection_test.go +++ b/internal/core/semantics/collection_test.go @@ -254,11 +254,13 @@ func TestCollectionResultConformance(t *testing.T) { // reduce returns what its reducer does, or the collection's one element unreduced, so // the result is what both conform to — the library's Anything where they share nothing — // unless the collection holds two or more: a declared `[2..*]`, one inherited by -// redefinition, a chain through such a feature, or a sequence of two literals. +// redefinition, a chain through such a feature, or a sequence of two literals; a collection +// holding one at most, `[1]` or `[0..1]`, is never reduced, so its element alone is the result. func TestCollectionReduceMayReturnTheElement(t *testing.T) { m, s := collectionModel(t, ` part two : C[2..*]; part one : C[1]; + part atMost : C[0..1]; part def Pair { part items : C[2]; part item : C[1]; } part def Pairs :> Pair { part :>> items; part :>> item; } part pair : Pairs[1]; @@ -266,6 +268,8 @@ func TestCollectionReduceMayReturnTheElement(t *testing.T) { part couple : Pairs[2]; attribute names = cs->reduce { in a : C; in b : C; a.name }; attribute names1 = one->reduce { in a : C; in b : C; a.name }; + attribute names01 = atMost->reduce { in a : C; in b : C; a.name }; + attribute names1lit = (one)->reduce { in a : C; in b : C; "s" }; attribute names2 = two->reduce { in a : C; in b : C; a.name }; attribute inherited = pair.items->reduce { in a : C; in b : C; a.name }; attribute chained = couple.item->reduce { in a : C; in b : C; a.name }; @@ -274,13 +278,16 @@ func TestCollectionReduceMayReturnTheElement(t *testing.T) { attribute lits = (1, 2)->reduce { in a : Integer; in b : Integer; "s" }; attribute same = cs->reduce { in a : C; in b : C; a }; attribute cast = cs->reduce { in a : C; in b : C; a.name } as String; - attribute cast2 = two->reduce { in a : C; in b : C; a.name } as C;`) + attribute cast2 = two->reduce { in a : C; in b : C; a.name } as C; + attribute cast1 = one->reduce { in a : C; in b : C; a.name } as String;`) wantValueTypes(t, m, s, "names", "Anything") - wantValueTypes(t, m, s, "names1", "Anything") + wantValueTypes(t, m, s, "names1", "C") + wantValueTypes(t, m, s, "names01", "C") + wantValueTypes(t, m, s, "names1lit", "C") wantValueTypes(t, m, s, "names2", "String") wantValueTypes(t, m, s, "inherited", "String") wantValueTypes(t, m, s, "chained", "String") - wantValueTypes(t, m, s, "chained1", "Anything") + wantValueTypes(t, m, s, "chained1", "C") wantValueTypes(t, m, s, "chainedAny", "Anything") wantValueTypes(t, m, s, "lits", "String") wantValueTypes(t, m, s, "same", "C") @@ -290,11 +297,17 @@ func TestCollectionReduceMayReturnTheElement(t *testing.T) { elements[0].Node == nil || elements[1].Node == nil { t.Errorf("names: elements %v, want the reducer's String then the collection's C", elements) } - for _, name := range []string{"names", "names1"} { + for _, name := range []string{"names", "names1", "names01"} { if c := m.ExprConformsToLibrary(s, valueOf(t, s, name), fqnString); !c.Known || c.Holds || c.Untyped || c.Found != "C" { t.Errorf("%s as String: %+v, want known, not holding, found C", name, c) } } + for _, name := range []string{"names1", "names01"} { + elements, ok := m.CollectionElements(s, valueOf(t, s, name)) + if !ok || len(elements) != 1 || len(elements[0].Types) != 1 || leafName(elements[0].Types[0].Name) != "C" { + t.Errorf("%s: elements %v, want the collection's C alone", name, elements) + } + } if c := m.ExprConformsToLibrary(s, valueOf(t, s, "names2"), fqnString); !c.Known || !c.Holds { t.Errorf("names2 as String: %+v, want it to hold", c) } @@ -304,6 +317,9 @@ func TestCollectionReduceMayReturnTheElement(t *testing.T) { if c := m.CastConformance(s, valueOf(t, s, "cast2").(*ast.OperatorExpr)); !c.Known || c.Holds || c.Found != "String" { t.Errorf("cast of two to C: %+v, want known, not holding, found String", c) } + if c := m.CastConformance(s, valueOf(t, s, "cast1").(*ast.OperatorExpr)); !c.Known || c.Holds || c.Found != "C" { + t.Errorf("cast of one to String: %+v, want known, not holding, found C", c) + } } // Over a collection known to hold nothing — or mapping each element to nothing — reduce is typed @@ -352,7 +368,7 @@ func TestCollectionReduceOfNothing(t *testing.T) { } } wantValueTypes(t, m, s, "two", "String") - wantValueTypes(t, m, s, "maybe", "Anything") + wantValueTypes(t, m, s, "maybe", "C") if types, ok := m.CollectionHeldTypes(s, valueOf(t, s, "two")); !ok || len(types) != 1 || leafName(types[0].Name) != "String" { t.Errorf("two: held types %v, want String", types) } From 43a509c8d62e9f68f018c857857d9f824ad6a406 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:29:08 +0000 Subject: [PATCH 19/22] fix(semantics): size a reduce by its reducer's result over two or more elements A reduction over a collection capped at one element yields that element unreduced; over two or more it yields what the reducer returns, which may be empty or multi-valued; over a collection that may hold either, both possibilities are combined. Co-Authored-By: jason.han --- .../collection-body-result-type.fixed.md | 2 +- internal/core/passes/typecheck_value_test.go | 18 +++++- internal/core/semantics/collection.go | 56 +++++++++++++++---- internal/core/semantics/collection_test.go | 20 ++++++- 4 files changed, 80 insertions(+), 16 deletions(-) diff --git a/changes/unreleased/collection-body-result-type.fixed.md b/changes/unreleased/collection-body-result-type.fixed.md index a6b61751c..fab73c5a3 100644 --- a/changes/unreleased/collection-body-result-type.fixed.md +++ b/changes/unreleased/collection-body-result-type.fixed.md @@ -1 +1 @@ -- **A collection operation's static type follows what its declaration hands through, not the element type of the collection.** `xs->collect { in x : C; x.mass }` and `xs.{ in x : C; x.mass }` are typed by the body's result (`MassValue`), a nested collect by its innermost body, a body answering a sequence by every element type and `xs->collect f` by the named function's result; `select`, `reject` and `selectOne` keep the elements of `xs`, `reduce` follows its reducer's result — and the element a one-element collection hands back unreduced, unless the collection is known to hold two or more, by its own multiplicity, one it inherits by redefinition, or a chain through such features; a collection holding one at most is never reduced, so its element alone is the result — and `forAll`/`exists` stay `Boolean`, each with the multiplicity the Kernel Function Library declares. A body whose result cannot be typed keeps the library's `Anything`. Value conformance, a feature's bound value, invocation arguments, trigger arguments and enumerated values are judged by the specialized type, so `accept when counts.{in n : Integer; n}` is refused where it was silent, and `when counts.{in n; n > 3}` is accepted where it was refused. A scalar literal a body writes out is as exact as one bound directly: `attribute i : Integer = xs.{ in x : C; 1.5 }` is refused, and a quantity it writes out is measured against the target's dimension: `attribute t : DurationValue = xs.{ in x : C; 5 [m] }` is refused. `xs.?{…}` binds as `xs->select {…}` does, and an element that is itself a collection value binds by the elements it holds, so `attribute i : Integer = xs.{ in x : C; xs.{ in y : C; 1.5 } }` is refused. A collection over `()` or a feature admitting no value keeps its declared type — `()->collect { in a : Integer; "s" }` is a `String` collection — but holds no element, so none is judged and its `reduce` takes nothing from an element it would hand back unreduced; so does one mapping every element to a `[0]` feature or function result — the multiplicity read through an alias, or from the feature or result a redefinition inherits it from — or any operation over such a collection, and an argument holding nothing is judged against no parameter type. An argument or constructor value that is a collection binds each element it holds on its own, so `Sail(vs.{ in v : Vehicle; (v, boat) })` and `new Fleet(vs.{ in v : Vehicle; 1.5 })` are refused by the element that does not bind where they were silent. A body reading the feature it values terminates as a self-referential argument does. +- **A collection operation's static type follows what its declaration hands through, not the element type of the collection.** `xs->collect { in x : C; x.mass }` and `xs.{ in x : C; x.mass }` are typed by the body's result (`MassValue`), a nested collect by its innermost body, a body answering a sequence by every element type and `xs->collect f` by the named function's result; `select`, `reject` and `selectOne` keep the elements of `xs`, `reduce` follows its reducer's result — and the element a one-element collection hands back unreduced, unless the collection is known to hold two or more, by its own multiplicity, one it inherits by redefinition, or a chain through such features; a collection holding one at most is never reduced, so its element alone is the result — and `forAll`/`exists` stay `Boolean`, each with the multiplicity the Kernel Function Library declares. A body whose result cannot be typed keeps the library's `Anything`. Value conformance, a feature's bound value, invocation arguments, trigger arguments and enumerated values are judged by the specialized type, so `accept when counts.{in n : Integer; n}` is refused where it was silent, and `when counts.{in n; n > 3}` is accepted where it was refused. A scalar literal a body writes out is as exact as one bound directly: `attribute i : Integer = xs.{ in x : C; 1.5 }` is refused, and a quantity it writes out is measured against the target's dimension: `attribute t : DurationValue = xs.{ in x : C; 5 [m] }` is refused. `xs.?{…}` binds as `xs->select {…}` does, and an element that is itself a collection value binds by the elements it holds, so `attribute i : Integer = xs.{ in x : C; xs.{ in y : C; 1.5 } }` is refused. A collection over `()` or a feature admitting no value keeps its declared type — `()->collect { in a : Integer; "s" }` is a `String` collection — but holds no element, so none is judged and its `reduce` takes nothing from an element it would hand back unreduced; so does one mapping every element to a `[0]` feature or function result — the multiplicity read through an alias, or from the feature or result a redefinition inherits it from — or any operation over such a collection, and an argument holding nothing is judged against no parameter type. An argument or constructor value that is a collection binds each element it holds on its own, so `Sail(vs.{ in v : Vehicle; (v, boat) })` and `new Fleet(vs.{ in v : Vehicle; 1.5 })` are refused by the element that does not bind where they were silent. A collection value binds as many values as it is known to hold, counted against the feature's multiplicity — `part b : Boat[1] = pair.items.{ in v : Vehicle; boat }` binds two — a `reduce` counting the one element it hands back unreduced or what its reducer yields over two or more, so `pair.items->reduce { in a : Vehicle; in b : Vehicle; (a, b) }` binds two and one over `()` none. A body reading the feature it values terminates as a self-referential argument does. diff --git a/internal/core/passes/typecheck_value_test.go b/internal/core/passes/typecheck_value_test.go index f0751be73..9d09a6afe 100644 --- a/internal/core/passes/typecheck_value_test.go +++ b/internal/core/passes/typecheck_value_test.go @@ -465,8 +465,9 @@ func TestValueReduceResultIsJudged(t *testing.T) { } // A collection value binds as many values as it is known to hold: none over a collection -// holding none, one per element a collect maps over a known count, one from a reduce; one -// only bounded is reported where even its fewest or its most cannot fit, else left to evaluation. +// holding none, one per element a collect maps over a known count, from a reduce the one element +// unreduced or as many as the reducer yields over two or more; one only bounded is reported where +// even its fewest or its most cannot fit, else left to evaluation. func TestValueCollectionCountIsJudged(t *testing.T) { wantCollectionValueDiags(t, `part b : Boat[1] = none.{ in v : Vehicle; boat };`, "0 value(s) bound to a feature with multiplicity lower bound 1") @@ -494,9 +495,22 @@ func TestValueCollectionCountIsJudged(t *testing.T) { "at most 1 value(s) bound to a feature with multiplicity lower bound 3") wantCollectionValueDiags(t, `part v : Vehicle[2] = pair.items->selectOne { in v : Vehicle; true };`, "at most 1 value(s) bound to a feature with multiplicity lower bound 2") + wantCollectionValueDiags(t, `part b : Boat[1] = pair.items->reduce { in a : Vehicle; in b : Vehicle; (boat, boat) };`, + "2 value(s) bound to a feature with multiplicity upper bound 1") + wantCollectionValueDiags(t, `part b : Boat[1] = pair.items->reduce { in a : Vehicle; in b : Vehicle; () };`, + "0 value(s) bound to a feature with multiplicity lower bound 1") + wantCollectionValueDiags(t, `part b : Boat[1] = two->reduce { in a : Vehicle; in b : Vehicle; (boat, boat) };`, + "2 value(s) bound to a feature with multiplicity upper bound 1") + wantCollectionValueDiags(t, `part v : Vehicle[3] = vs->reduce { in a : Vehicle; in b : Vehicle; (a, b) };`, + "at most 2 value(s) bound to a feature with multiplicity lower bound 3") wantCollectionValueDiags(t, ` part b : Boat[2] = pair.items.{ in v : Vehicle; boat }; part b2 : Boat[1] = pair.items->reduce { in a : Vehicle; in b : Vehicle; boat }; + part b5 : Boat[2] = pair.items->reduce { in a : Vehicle; in b : Vehicle; (boat, boat) }; + part b6 : Boat[0..1] = pair.items->reduce { in a : Vehicle; in b : Vehicle; () }; + part v3 : Vehicle[1..2] = vs->reduce { in a : Vehicle; in b : Vehicle; (a, b) }; + part v4 : Vehicle[1] = one->reduce { in a : Vehicle; in b : Vehicle; (a, b) }; + part v5 : Vehicle[0..1] = pair.items->reduce { in a : Vehicle; in b : Vehicle; a }; part v : Vehicle[0..1] = pair.items->selectOne { in v : Vehicle; true }; part b3 : Boat[1] = vs.{ in v : Vehicle; boat }; part v2 : Vehicle[0..2] = pair.items->select { in v : Vehicle; true }; diff --git a/internal/core/semantics/collection.go b/internal/core/semantics/collection.go index f2cb171fc..59474314a 100644 --- a/internal/core/semantics/collection.go +++ b/internal/core/semantics/collection.go @@ -233,8 +233,8 @@ func (m *Model) valuesHeldBy(scope *symbols.Scope, node ast.Node) (Range, bool) } // valuesHeldByCall is how many values a call holds: collect what it maps to, select/reject up to -// all, selectOne up to one, reduce one unless the collection holds none, any other function what -// its result parameter declares. +// all, selectOne up to one, reduce what it reduces to, any other function what its result +// parameter declares. func (m *Model) valuesHeldByCall(scope *symbols.Scope, e *ast.InvocationExpr) (Range, bool) { fn := m.invocationCallee(scope, e) if fn == nil { @@ -248,11 +248,7 @@ func (m *Model) valuesHeldByCall(scope *symbols.Scope, e *ast.InvocationExpr) (R case m.libSymbol(fqnSelectOne): return m.valuesKeptFrom(scope, m.argumentTo(scope, e, fn, 0), Bound{Value: 1, Known: true}) case m.libSymbol(fqnReduce): - through, ok := m.valuesHeldBy(scope, m.argumentTo(scope, e, fn, 0)) - if !ok { - return Range{}, false - } - return Range{Lower: minBound(through.Lower, Bound{Value: 1, Known: true}), Upper: minBound(through.Upper, Bound{Value: 1, Known: true})}, true + return m.valuesReducedFrom(scope, m.argumentTo(scope, e, fn, 0), m.argumentTo(scope, e, fn, 1)) } if result := m.ResultParameterOf(fn); result != nil { return m.valuesGoverning(result) @@ -270,22 +266,50 @@ func (m *Model) valuesMappedBy(scope *symbols.Scope, collection, applied ast.Nod if !ok { return Range{}, false } - per := Range{Lower: Bound{Known: true}, Upper: Bound{Infinite: true, Known: true}} + return mulRanges(through, m.valuesApplied(scope, applied)), true +} + +// valuesReducedFrom is how many values reducing a collection yields: none from none, the one +// element it holds unreduced, the reducer's result over two or more; either where it may hold +// one or more than one. +func (m *Model) valuesReducedFrom(scope *symbols.Scope, collection, reducer ast.Node) (Range, bool) { + if collection == nil { + return Range{}, false + } + through, ok := m.valuesHeldBy(scope, collection) + if !ok { + return Range{}, false + } + one := Bound{Value: 1, Known: true} + unreduced := Range{Lower: minBound(through.Lower, one), Upper: minBound(through.Upper, one)} + if !through.Upper.Infinite && through.Upper.Value <= 1 { + return unreduced, true + } + reduced := m.valuesApplied(scope, reducer) + if through.Lower.Infinite || through.Lower.Value >= 2 { + return reduced, true + } + return Range{Lower: minBound(unreduced.Lower, reduced.Lower), Upper: maxBound(unreduced.Upper, reduced.Upper)}, true +} + +// valuesApplied is how many values one application of a body or named function yields: as many +// as the body's result or the function's result parameter holds; any number where unknown. +func (m *Model) valuesApplied(scope *symbols.Scope, applied ast.Node) Range { switch a := applied.(type) { case *ast.BodyExpr: if a.Result != nil { if r, ok := m.valuesHeldBy(symbols.BodyExprScope(scope, a), a.Result); ok { - per = r + return r } } case *ast.FeatureReference, *ast.QualifiedName, *ast.FeatureChainExpr: if result := m.appliedResult(scope, a); result != nil { if r, ok := m.valuesGoverning(result); ok { - per = r + return r } } } - return mulRanges(through, per), true + return Range{Lower: Bound{Known: true}, Upper: unbounded} } // valuesKeptFrom is how many values a selection keeps: none up to the collection's, capped at most. @@ -362,6 +386,16 @@ func minBound(a, b Bound) Bound { return b } +func maxBound(a, b Bound) Bound { + switch { + case a.Infinite || b.Infinite: + return unbounded + case a.Value >= b.Value: + return a + } + return b +} + // mulBounds is a × b, none through none, unbounded past where int64 reaches. func mulBounds(a, b Bound) Bound { if (!a.Infinite && a.Value == 0) || (!b.Infinite && b.Value == 0) { diff --git a/internal/core/semantics/collection_test.go b/internal/core/semantics/collection_test.go index 3adda39bc..c8708775a 100644 --- a/internal/core/semantics/collection_test.go +++ b/internal/core/semantics/collection_test.go @@ -415,19 +415,35 @@ func TestCollectionSizeThroughAliasAndRedefinition(t *testing.T) { } // CollectionValues sizes a collection value alone — an operation over a known collection — and -// says nothing of a literal or a feature, whose sizes are read elsewhere. +// says nothing of a literal or a feature, whose sizes are read elsewhere. A reduce yields the one +// element it holds unreduced, or what its reducer yields over two or more, or either. func TestCollectionValues(t *testing.T) { m, s := collectionModel(t, ` part none : C[0]; + part one : C[1]; + part maybe : C[0..1]; part two : C[2]; + function Pair { in a : C; in b : C; return r : C[2]; } attribute mapped = two.{ in c : C; (c.name, c.name) }; attribute reduced = two->reduce { in a : C; in b : C; a.name }; + attribute reducedTwo = two->reduce { in a : C; in b : C; (a, b) }; + attribute reducedNone = two->reduce { in a : C; in b : C; () }; + attribute reducedNamed = two->reduce Pair; + attribute reducedOpen = two->reduce { in a : C; in b : C; a.name + b.name }; + attribute reducedOne = one->reduce { in a : C; in b : C; (a, b) }; + attribute reducedMaybe = maybe->reduce { in a : C; in b : C; (a, b) }; + attribute reducedAny = cs->reduce { in a : C; in b : C; (a, b) }; + attribute reducedEmpty = none->reduce { in a : C; in b : C; (a, b) }; attribute nothing = none->select { in c : C; true }; attribute atMost = two->selectOne { in c : C; true }; attribute open = cs.{ in c : C; c.name }; attribute literal = (1, 2); attribute named = two;`) - for name, want := range map[string]string{"mapped": "[4]", "reduced": "[1]", "nothing": "[0]", "atMost": "[0..1]", "open": "[0..*]"} { + for name, want := range map[string]string{ + "mapped": "[4]", "reduced": "[1]", "nothing": "[0]", "atMost": "[0..1]", "open": "[0..*]", + "reducedTwo": "[2]", "reducedNone": "[0]", "reducedNamed": "[2]", "reducedOpen": "[0..*]", + "reducedOne": "[1]", "reducedMaybe": "[0..1]", "reducedAny": "[0..2]", "reducedEmpty": "[0]", + } { r, ok := m.CollectionValues(s, valueOf(t, s, name)) if !ok || r.Text() != want { t.Errorf("%s: %s %v, want %s", name, r.Text(), ok, want) From a8ba2367b50e77d2b40b6746b5fb3c937d59cf4e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:46:03 +0000 Subject: [PATCH 20/22] fix(semantics): type a shorthand selection by the elements it keeps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `xs.?{…}` over a sequence written out is typed by the type its elements share, as `xs->select {…}` is, instead of the Anything the sequence is, so a result expression, subject or cast bound to a feature valued by it is judged by that type. Co-Authored-By: jason.han --- .../collection-body-result-type.fixed.md | 2 +- internal/core/passes/typecheck_value_test.go | 31 +++++++++++++++++++ internal/core/semantics/collection.go | 10 ++++++ internal/core/semantics/collection_test.go | 13 +++++++- .../core/semantics/operator_conformance.go | 3 ++ internal/core/semantics/valuetype.go | 6 ++++ 6 files changed, 63 insertions(+), 2 deletions(-) diff --git a/changes/unreleased/collection-body-result-type.fixed.md b/changes/unreleased/collection-body-result-type.fixed.md index fab73c5a3..9e138b516 100644 --- a/changes/unreleased/collection-body-result-type.fixed.md +++ b/changes/unreleased/collection-body-result-type.fixed.md @@ -1 +1 @@ -- **A collection operation's static type follows what its declaration hands through, not the element type of the collection.** `xs->collect { in x : C; x.mass }` and `xs.{ in x : C; x.mass }` are typed by the body's result (`MassValue`), a nested collect by its innermost body, a body answering a sequence by every element type and `xs->collect f` by the named function's result; `select`, `reject` and `selectOne` keep the elements of `xs`, `reduce` follows its reducer's result — and the element a one-element collection hands back unreduced, unless the collection is known to hold two or more, by its own multiplicity, one it inherits by redefinition, or a chain through such features; a collection holding one at most is never reduced, so its element alone is the result — and `forAll`/`exists` stay `Boolean`, each with the multiplicity the Kernel Function Library declares. A body whose result cannot be typed keeps the library's `Anything`. Value conformance, a feature's bound value, invocation arguments, trigger arguments and enumerated values are judged by the specialized type, so `accept when counts.{in n : Integer; n}` is refused where it was silent, and `when counts.{in n; n > 3}` is accepted where it was refused. A scalar literal a body writes out is as exact as one bound directly: `attribute i : Integer = xs.{ in x : C; 1.5 }` is refused, and a quantity it writes out is measured against the target's dimension: `attribute t : DurationValue = xs.{ in x : C; 5 [m] }` is refused. `xs.?{…}` binds as `xs->select {…}` does, and an element that is itself a collection value binds by the elements it holds, so `attribute i : Integer = xs.{ in x : C; xs.{ in y : C; 1.5 } }` is refused. A collection over `()` or a feature admitting no value keeps its declared type — `()->collect { in a : Integer; "s" }` is a `String` collection — but holds no element, so none is judged and its `reduce` takes nothing from an element it would hand back unreduced; so does one mapping every element to a `[0]` feature or function result — the multiplicity read through an alias, or from the feature or result a redefinition inherits it from — or any operation over such a collection, and an argument holding nothing is judged against no parameter type. An argument or constructor value that is a collection binds each element it holds on its own, so `Sail(vs.{ in v : Vehicle; (v, boat) })` and `new Fleet(vs.{ in v : Vehicle; 1.5 })` are refused by the element that does not bind where they were silent. A collection value binds as many values as it is known to hold, counted against the feature's multiplicity — `part b : Boat[1] = pair.items.{ in v : Vehicle; boat }` binds two — a `reduce` counting the one element it hands back unreduced or what its reducer yields over two or more, so `pair.items->reduce { in a : Vehicle; in b : Vehicle; (a, b) }` binds two and one over `()` none. A body reading the feature it values terminates as a self-referential argument does. +- **A collection operation's static type follows what its declaration hands through, not the element type of the collection.** `xs->collect { in x : C; x.mass }` and `xs.{ in x : C; x.mass }` are typed by the body's result (`MassValue`), a nested collect by its innermost body, a body answering a sequence by every element type and `xs->collect f` by the named function's result; `select`, `reject` and `selectOne` keep the elements of `xs`, `reduce` follows its reducer's result — and the element a one-element collection hands back unreduced, unless the collection is known to hold two or more, by its own multiplicity, one it inherits by redefinition, or a chain through such features; a collection holding one at most is never reduced, so its element alone is the result — and `forAll`/`exists` stay `Boolean`, each with the multiplicity the Kernel Function Library declares. A body whose result cannot be typed keeps the library's `Anything`. Value conformance, a feature's bound value, invocation arguments, trigger arguments and enumerated values are judged by the specialized type, so `accept when counts.{in n : Integer; n}` is refused where it was silent, and `when counts.{in n; n > 3}` is accepted where it was refused. A scalar literal a body writes out is as exact as one bound directly: `attribute i : Integer = xs.{ in x : C; 1.5 }` is refused, and a quantity it writes out is measured against the target's dimension: `attribute t : DurationValue = xs.{ in x : C; 5 [m] }` is refused. `xs.?{…}` binds and is typed as `xs->select {…}` is — `(v1, v2).?{ in v : Vehicle; true }` is a `Vehicle` collection, not the `Anything` the sequence is — and an element that is itself a collection value binds by the elements it holds, so `attribute i : Integer = xs.{ in x : C; xs.{ in y : C; 1.5 } }` is refused. A collection over `()` or a feature admitting no value keeps its declared type — `()->collect { in a : Integer; "s" }` is a `String` collection — but holds no element, so none is judged and its `reduce` takes nothing from an element it would hand back unreduced; so does one mapping every element to a `[0]` feature or function result — the multiplicity read through an alias, or from the feature or result a redefinition inherits it from — or any operation over such a collection, and an argument holding nothing is judged against no parameter type. An argument or constructor value that is a collection binds each element it holds on its own, so `Sail(vs.{ in v : Vehicle; (v, boat) })` and `new Fleet(vs.{ in v : Vehicle; 1.5 })` are refused by the element that does not bind where they were silent. A collection value binds as many values as it is known to hold, counted against the feature's multiplicity — `part b : Boat[1] = pair.items.{ in v : Vehicle; boat }` binds two — a `reduce` counting the one element it hands back unreduced or what its reducer yields over two or more, so `pair.items->reduce { in a : Vehicle; in b : Vehicle; (a, b) }` binds two and one over `()` none. A body reading the feature it values terminates as a self-referential argument does. diff --git a/internal/core/passes/typecheck_value_test.go b/internal/core/passes/typecheck_value_test.go index 9d09a6afe..c1779f1aa 100644 --- a/internal/core/passes/typecheck_value_test.go +++ b/internal/core/passes/typecheck_value_test.go @@ -426,6 +426,37 @@ func TestValueNestedCollectionElementsAreJudged(t *testing.T) { attribute i2 : Integer = vs.{ in v : Vehicle; ().{ in w : Integer; 1.5 } };`) } +// A feature valued by `xs.?{…}` over a sequence written out takes the type its elements share, +// as one valued by `xs->select {…}` does, so a result, a subject or a cast it is bound to is +// judged by that type rather than by the Anything the sequence is. +func TestValueSelectShorthandOfSequenceTypesFeature(t *testing.T) { + for _, keep := range []string{`.?{ in v : Vehicle; true }`, `->select { in v : Vehicle; true }`} { + wantCollectionValueDiags(t, `function F { return r : Boat; (one, one)`+keep+` }`, + "Bound features should have conforming types") + wantCollectionValueDiags(t, ` + part picked = (one, one)`+keep+`; + function F { return r : Boat; picked }`, + "Bound features should have conforming types") + wantCollectionValueDiags(t, ` + part picked = (one, one)`+keep+`; + requirement def R { subject s : Boat; } + requirement r : R { subject s = picked; }`, + "Bound features should have conforming types") + wantCollectionValueDiags(t, ` + part picked = (one, one)`+keep+`; + function F { return r : Vehicle; picked } + requirement def R { subject s : Vehicle; } + requirement r : R { subject s = picked; }`) + } + wantCollectionValueDiags(t, ` + attribute picked = (1, 2).?{ in v : Integer; true }; + part b = picked as Boat;`, + "cast argument is typed by Integer, unrelated to the target Boat: neither type specializes the other, so the cast selects no value") + wantCollectionValueDiags(t, ` + part any = (one, boat).?{ in v; true }; + function F { return r : Boat; any }`) +} + // A collection value's body is checked once, as inferring the value: reading the types of // the elements it produces to judge their binding reports nothing again. func TestValueCollectionBodyIsCheckedOnce(t *testing.T) { diff --git a/internal/core/semantics/collection.go b/internal/core/semantics/collection.go index 59474314a..d1221ddba 100644 --- a/internal/core/semantics/collection.go +++ b/internal/core/semantics/collection.go @@ -495,6 +495,16 @@ func (m *Model) collectResultTypes(scope *symbols.Scope, e *ast.CollectExpr) []* return m.sourcesTypes(srcs) } +// selectResultTypes is the types every element of `xs.?{ in x; … }` has: those of the elements +// of xs, as `xs->select {…}` keeps them; nil when unknown. +func (m *Model) selectResultTypes(scope *symbols.Scope, e *ast.SelectExpr) []*symbols.Symbol { + srcs, ok := m.keptSources(scope, e.Operand) + if !ok { + return nil + } + return m.sourcesTypes(srcs) +} + // CollectionElement is one element a collection value may hold: the expression producing it, // in its scope, or nil where a function's result parameter does; and its types, nil where unknown. type CollectionElement struct { diff --git a/internal/core/semantics/collection_test.go b/internal/core/semantics/collection_test.go index c8708775a..8493d27fe 100644 --- a/internal/core/semantics/collection_test.go +++ b/internal/core/semantics/collection_test.go @@ -488,11 +488,17 @@ func TestCollectionValues(t *testing.T) { } // `xs.?{…}` is select written out: its elements are the collection's, so a binding or an -// argument is judged by them as `xs->select {…}` is. +// argument is judged by them as `xs->select {…}` is, and a sequence written out is typed by +// what its elements share rather than the Anything the sequence itself is. func TestCollectionSelectShorthandElements(t *testing.T) { m, s := collectionModel(t, ` + part c1 : C; + part c2 : C; attribute heavy = cs.?{ in x : C; x.mass > 1 [kg] }; attribute heavy2 = cs->select { in x : C; x.mass > 1 [kg] }; + attribute picked = (c1, c2).?{ in x : C; true }; + attribute picked2 = (c1, c2)->select { in x : C; true }; + attribute mixed = (c1, "s").?{ in x; true }; attribute lits = (1, 2.5).?{ in x : Real; true };`) for _, name := range []string{"heavy", "heavy2"} { wantValueTypes(t, m, s, name, "C") @@ -501,6 +507,11 @@ func TestCollectionSelectShorthandElements(t *testing.T) { t.Errorf("%s: elements %v, want the collection's C", name, elements) } } + for _, name := range []string{"picked", "picked2"} { + wantValueTypes(t, m, s, name, "C") + } + wantValueTypes(t, m, s, "mixed", "Anything") + wantValueTypes(t, m, s, "lits", "Real") elements, ok := m.CollectionElements(s, valueOf(t, s, "lits")) if !ok || len(elements) != 2 { t.Fatalf("lits: elements %v, want one per literal", elements) diff --git a/internal/core/semantics/operator_conformance.go b/internal/core/semantics/operator_conformance.go index a2adb6945..3bb8303e0 100644 --- a/internal/core/semantics/operator_conformance.go +++ b/internal/core/semantics/operator_conformance.go @@ -85,6 +85,9 @@ func (m *Model) resultTypes(scope *symbols.Scope, node ast.Node) []*symbols.Symb return m.indexResultTypes(scope, n) } case *ast.SelectExpr: + if types := m.selectResultTypes(scope, n); len(types) > 0 { + return types + } return m.resultTypes(scope, n.Operand) } if typ := m.ExprResultType(scope, node); typ != nil { diff --git a/internal/core/semantics/valuetype.go b/internal/core/semantics/valuetype.go index 526f18deb..5a8535e10 100644 --- a/internal/core/semantics/valuetype.go +++ b/internal/core/semantics/valuetype.go @@ -154,6 +154,9 @@ func (m *Model) exprConformance(scope *symbols.Scope, node ast.Node, want *symbo return c case *ast.SelectExpr: // `xs.?{…}` keeps elements of xs (KerML checkSelectExpressionResultSpecialization). + if c, ok := m.collectionConformance(scope, n, want, byUnit); ok { + return c + } return m.exprConformance(scope, n.Operand, want, byUnit) case *ast.BodyExpr: c := m.typeConformance(m.bodyExprType(scope, n), want) @@ -220,6 +223,9 @@ func (m *Model) ExprResultType(scope *symbols.Scope, node ast.Node) *symbols.Sym return m.indexResultType(scope, n) case *ast.SelectExpr: // `xs.?{…}` keeps elements of xs (KerML checkSelectExpressionResultSpecialization). + if types := m.selectResultTypes(scope, n); len(types) > 0 { + return types[0] + } return m.ExprResultType(scope, n.Operand) case *ast.CollectExpr: // `xs.{…}` is the result of ControlFunctions::collect: the body's results, else Anything. From bb6165490fac22cd05ea55814d40df1b6b0107ba Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:05:18 +0000 Subject: [PATCH 21/22] fix(semantics): type sibling collection elements by their nearest shared supertype MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Elements of a collection value whose types are siblings — a Truck and a Car, a MassValue and a String — shared no listed type, so the value fell back to Anything and a feature valued by it lost its common parent. sharedTypes now falls back to the nearest supertypes every element conforms to, so (truck, car).?{…} is a Vehicle collection and a body answering (x.mass, x.name) is a ScalarValue collection. Co-Authored-By: jason.han --- .../collection-body-result-type.fixed.md | 2 +- internal/core/passes/typecheck_value_test.go | 27 +++++++++++++- internal/core/semantics/collection.go | 36 ++++++++++++++++++- internal/core/semantics/collection_test.go | 28 ++++++++++++--- 4 files changed, 86 insertions(+), 7 deletions(-) diff --git a/changes/unreleased/collection-body-result-type.fixed.md b/changes/unreleased/collection-body-result-type.fixed.md index 9e138b516..83ffba9e2 100644 --- a/changes/unreleased/collection-body-result-type.fixed.md +++ b/changes/unreleased/collection-body-result-type.fixed.md @@ -1 +1 @@ -- **A collection operation's static type follows what its declaration hands through, not the element type of the collection.** `xs->collect { in x : C; x.mass }` and `xs.{ in x : C; x.mass }` are typed by the body's result (`MassValue`), a nested collect by its innermost body, a body answering a sequence by every element type and `xs->collect f` by the named function's result; `select`, `reject` and `selectOne` keep the elements of `xs`, `reduce` follows its reducer's result — and the element a one-element collection hands back unreduced, unless the collection is known to hold two or more, by its own multiplicity, one it inherits by redefinition, or a chain through such features; a collection holding one at most is never reduced, so its element alone is the result — and `forAll`/`exists` stay `Boolean`, each with the multiplicity the Kernel Function Library declares. A body whose result cannot be typed keeps the library's `Anything`. Value conformance, a feature's bound value, invocation arguments, trigger arguments and enumerated values are judged by the specialized type, so `accept when counts.{in n : Integer; n}` is refused where it was silent, and `when counts.{in n; n > 3}` is accepted where it was refused. A scalar literal a body writes out is as exact as one bound directly: `attribute i : Integer = xs.{ in x : C; 1.5 }` is refused, and a quantity it writes out is measured against the target's dimension: `attribute t : DurationValue = xs.{ in x : C; 5 [m] }` is refused. `xs.?{…}` binds and is typed as `xs->select {…}` is — `(v1, v2).?{ in v : Vehicle; true }` is a `Vehicle` collection, not the `Anything` the sequence is — and an element that is itself a collection value binds by the elements it holds, so `attribute i : Integer = xs.{ in x : C; xs.{ in y : C; 1.5 } }` is refused. A collection over `()` or a feature admitting no value keeps its declared type — `()->collect { in a : Integer; "s" }` is a `String` collection — but holds no element, so none is judged and its `reduce` takes nothing from an element it would hand back unreduced; so does one mapping every element to a `[0]` feature or function result — the multiplicity read through an alias, or from the feature or result a redefinition inherits it from — or any operation over such a collection, and an argument holding nothing is judged against no parameter type. An argument or constructor value that is a collection binds each element it holds on its own, so `Sail(vs.{ in v : Vehicle; (v, boat) })` and `new Fleet(vs.{ in v : Vehicle; 1.5 })` are refused by the element that does not bind where they were silent. A collection value binds as many values as it is known to hold, counted against the feature's multiplicity — `part b : Boat[1] = pair.items.{ in v : Vehicle; boat }` binds two — a `reduce` counting the one element it hands back unreduced or what its reducer yields over two or more, so `pair.items->reduce { in a : Vehicle; in b : Vehicle; (a, b) }` binds two and one over `()` none. A body reading the feature it values terminates as a self-referential argument does. +- **A collection operation's static type follows what its declaration hands through, not the element type of the collection.** `xs->collect { in x : C; x.mass }` and `xs.{ in x : C; x.mass }` are typed by the body's result (`MassValue`), a nested collect by its innermost body, a body answering a sequence by every element type and `xs->collect f` by the named function's result; `select`, `reject` and `selectOne` keep the elements of `xs`, `reduce` follows its reducer's result — and the element a one-element collection hands back unreduced, unless the collection is known to hold two or more, by its own multiplicity, one it inherits by redefinition, or a chain through such features; a collection holding one at most is never reduced, so its element alone is the result — and `forAll`/`exists` stay `Boolean`, each with the multiplicity the Kernel Function Library declares. A body whose result cannot be typed keeps the library's `Anything`. Value conformance, a feature's bound value, invocation arguments, trigger arguments and enumerated values are judged by the specialized type, so `accept when counts.{in n : Integer; n}` is refused where it was silent, and `when counts.{in n; n > 3}` is accepted where it was refused. A scalar literal a body writes out is as exact as one bound directly: `attribute i : Integer = xs.{ in x : C; 1.5 }` is refused, and a quantity it writes out is measured against the target's dimension: `attribute t : DurationValue = xs.{ in x : C; 5 [m] }` is refused. `xs.?{…}` binds and is typed as `xs->select {…}` is — `(v1, v2).?{ in v : Vehicle; true }` is a `Vehicle` collection, not the `Anything` the sequence is; elements of sibling types share their nearest common supertype, so `(truck, car).?{ in v : Vehicle; true }` is a `Vehicle` collection too — and an element that is itself a collection value binds by the elements it holds, so `attribute i : Integer = xs.{ in x : C; xs.{ in y : C; 1.5 } }` is refused. A collection over `()` or a feature admitting no value keeps its declared type — `()->collect { in a : Integer; "s" }` is a `String` collection — but holds no element, so none is judged and its `reduce` takes nothing from an element it would hand back unreduced; so does one mapping every element to a `[0]` feature or function result — the multiplicity read through an alias, or from the feature or result a redefinition inherits it from — or any operation over such a collection, and an argument holding nothing is judged against no parameter type. An argument or constructor value that is a collection binds each element it holds on its own, so `Sail(vs.{ in v : Vehicle; (v, boat) })` and `new Fleet(vs.{ in v : Vehicle; 1.5 })` are refused by the element that does not bind where they were silent. A collection value binds as many values as it is known to hold, counted against the feature's multiplicity — `part b : Boat[1] = pair.items.{ in v : Vehicle; boat }` binds two — a `reduce` counting the one element it hands back unreduced or what its reducer yields over two or more, so `pair.items->reduce { in a : Vehicle; in b : Vehicle; (a, b) }` binds two and one over `()` none. A body reading the feature it values terminates as a self-referential argument does. diff --git a/internal/core/passes/typecheck_value_test.go b/internal/core/passes/typecheck_value_test.go index c1779f1aa..53d6b1cf3 100644 --- a/internal/core/passes/typecheck_value_test.go +++ b/internal/core/passes/typecheck_value_test.go @@ -301,8 +301,10 @@ func collectionValueDiags(t *testing.T, members string) []string { diags := libraryDiags(t, `package P { private import ScalarValues::*; private import ControlFunctions::*; - part def Vehicle; part def Truck :> Vehicle; part def Boat; + part def Vehicle; part def Truck :> Vehicle; part def Car :> Vehicle; part def Boat; part vs : Vehicle[*]; + part truck : Truck; + part car : Car; part one : Vehicle[1]; part two : Vehicle[2..*]; part none : Vehicle[0]; @@ -457,6 +459,29 @@ func TestValueSelectShorthandOfSequenceTypesFeature(t *testing.T) { function F { return r : Boat; any }`) } +// Sibling elements kept or mapped to share their nearest supertype: a feature valued by a +// selection of a Truck and a Car is a Vehicle, so it is judged as one where it is bound or cast. +func TestValueSiblingElementsShareSupertype(t *testing.T) { + for _, keep := range []string{`.?{ in v : Vehicle; true }`, `->select { in v : Vehicle; true }`} { + wantCollectionValueDiags(t, ` + part kin = (truck, car)`+keep+`; + function F { return r : Boat; kin }`, + "Bound features should have conforming types") + wantCollectionValueDiags(t, ` + part kin = (truck, car)`+keep+`; + part b = kin as Boat;`, + "cast argument is typed by Vehicle, unrelated to the target Boat: neither type specializes the other, so the cast selects no value") + wantCollectionValueDiags(t, ` + part kin = (truck, car)`+keep+`; + function F { return r : Vehicle; kin } + part t = kin as Truck;`) + } + wantCollectionValueDiags(t, ` + part kin = vs.{ in v : Vehicle; (truck, car) }; + function F { return r : Boat; kin }`, + "Bound features should have conforming types") +} + // A collection value's body is checked once, as inferring the value: reading the types of // the elements it produces to judge their binding reports nothing again. func TestValueCollectionBodyIsCheckedOnce(t *testing.T) { diff --git a/internal/core/semantics/collection.go b/internal/core/semantics/collection.go index d1221ddba..48c5d247b 100644 --- a/internal/core/semantics/collection.go +++ b/internal/core/semantics/collection.go @@ -612,7 +612,8 @@ func (m *Model) sharedAmong(lists [][]*symbols.Symbol) []*symbols.Symbol { return common } -// sharedTypes is the types of either list a value of each list conforms to. +// sharedTypes is the most specific types a value of each list conforms to: those of either +// list, else the nearest supertypes the lists share (Truck and Car share Vehicle). func (m *Model) sharedTypes(a, b []*symbols.Symbol) []*symbols.Symbol { var out []*symbols.Symbol for _, list := range [][]*symbols.Symbol{a, b} { @@ -622,9 +623,42 @@ func (m *Model) sharedTypes(a, b []*symbols.Symbol) []*symbols.Symbol { } } } + if len(out) > 0 { + return out + } + return m.nearestShared(a, b) +} + +// nearestShared is the supertypes of a's types a value of each list conforms to, less any a +// more specific one among them specializes. +func (m *Model) nearestShared(a, b []*symbols.Symbol) []*symbols.Symbol { + var shared []*symbols.Symbol + for _, typ := range a { + for _, sup := range m.AllSupertypes(typ) { + if !containsElement(shared, sup) && m.anyConforms(a, sup) && m.anyConforms(b, sup) { + shared = append(shared, sup) + } + } + } + var out []*symbols.Symbol + for _, typ := range shared { + if !m.anySpecializes(shared, typ) { + out = append(out, typ) + } + } return out } +// anySpecializes reports a type among types conforming to want that want does not conform to. +func (m *Model) anySpecializes(types []*symbols.Symbol, want *symbols.Symbol) bool { + for _, typ := range types { + if typ != want && m.Conforms(typ, want) && !m.Conforms(want, typ) { + return true + } + } + return false +} + func (m *Model) anyConforms(types []*symbols.Symbol, want *symbols.Symbol) bool { for _, typ := range types { if m.Conforms(typ, want) { diff --git a/internal/core/semantics/collection_test.go b/internal/core/semantics/collection_test.go index 8493d27fe..44d21a91b 100644 --- a/internal/core/semantics/collection_test.go +++ b/internal/core/semantics/collection_test.go @@ -137,8 +137,8 @@ func TestCollectionResultMultiplicity(t *testing.T) { } // A nested collect types the outer body by the inner result; a body returning a -// sequence is typed by what every element conforms to — nothing, so Anything, where -// the elements share no type. +// sequence is typed by what every element conforms to — the nearest supertype the +// elements share, Anything where an element is untyped. func TestCollectionNestedAndSequenceBodies(t *testing.T) { m, s := collectionModel(t, ` attribute nested = cs->collect { in x : C; cs->collect { in y : C; y.name } }; @@ -152,7 +152,7 @@ func TestCollectionNestedAndSequenceBodies(t *testing.T) { wantValueTypes(t, m, s, "nested2", "MassValue") wantValueTypes(t, m, s, "pairs", "MassValue") wantValueTypes(t, m, s, "widened", "Real") - wantValueTypes(t, m, s, "mixed", "Anything") + wantValueTypes(t, m, s, "mixed", "ScalarValue") wantValueTypes(t, m, s, "partly", "Anything") wantValueTypes(t, m, s, "chained", "String") } @@ -524,6 +524,26 @@ func TestCollectionSelectShorthandElements(t *testing.T) { } } +// Elements of sibling types share their nearest common supertype, not Anything: a selection or +// a body keeping a Truck and a Car is a collection of Vehicles; one keeping a Truck and a +// String shares nothing worth saying. +func TestCollectionSiblingElementsShareSupertype(t *testing.T) { + m, s := collectionModel(t, ` + part def T :> C; part def U :> C; part def V :> U; + part t : T; part u : U; part v : V; + attribute kin = (t, u).?{ in x : C; true }; + attribute kin2 = (t, u)->select { in x : C; true }; + attribute kin3 = cs.{ in x : C; (t, v) }; + attribute kin4 = (t, u, v)->reject { in x : C; false }; + attribute line = (u, v).?{ in x : C; true }; + attribute apart = (t, "s").?{ in x; true };`) + for _, name := range []string{"kin", "kin2", "kin3", "kin4"} { + wantValueTypes(t, m, s, name, "C") + } + wantValueTypes(t, m, s, "line", "U") + wantValueTypes(t, m, s, "apart", "Anything") +} + // An element that is itself a collection value contributes the elements it holds: a nested // collect's literals, a selection's elements, none from one over nothing. func TestCollectionNestedElements(t *testing.T) { @@ -583,7 +603,7 @@ func TestCollectionSizeThroughNamedMapperAndPastInt64(t *testing.T) { if elements, ok := m.CollectionElements(s, valueOf(t, s, "named")); !ok || len(elements) != 1 || leafName(elements[0].Types[0].Name) != "Integer" { t.Errorf("named: elements %v, want the reducer's Integer alone", elements) } - wantValueTypes(t, m, s, "unsure", "Anything") + wantValueTypes(t, m, s, "unsure", "ScalarValue") if elements, ok := m.CollectionElements(s, valueOf(t, s, "unsure")); !ok || len(elements) != 2 { t.Errorf("unsure: elements %v, want the reducer's Integer and the mapper's String", elements) } From 98c51ece05ca55164bb5089ac3b95e7ef59bb91c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:15:43 +0000 Subject: [PATCH 22/22] fix(semantics): find the shared supertype of union-typed collection elements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nearestShared read the supertypes of the first list's types alone, so a union-typed element — conforming through its unioning types to a supertype it does not declare — shared nothing with a sibling that followed it, while the reverse order found the supertype. Candidates are now drawn from both lists' types and, for a union, its unioning types in turn, so the shared type no longer depends on element order. Co-Authored-By: jason.han --- internal/core/semantics/collection.go | 41 ++++++++++++++++++---- internal/core/semantics/collection_test.go | 21 +++++++++++ 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/internal/core/semantics/collection.go b/internal/core/semantics/collection.go index 48c5d247b..47d5811ff 100644 --- a/internal/core/semantics/collection.go +++ b/internal/core/semantics/collection.go @@ -629,15 +629,13 @@ func (m *Model) sharedTypes(a, b []*symbols.Symbol) []*symbols.Symbol { return m.nearestShared(a, b) } -// nearestShared is the supertypes of a's types a value of each list conforms to, less any a -// more specific one among them specializes. +// nearestShared is the types a value of each list conforms to, among all either list's types +// may conform to, less any a more specific one among them specializes. func (m *Model) nearestShared(a, b []*symbols.Symbol) []*symbols.Symbol { var shared []*symbols.Symbol - for _, typ := range a { - for _, sup := range m.AllSupertypes(typ) { - if !containsElement(shared, sup) && m.anyConforms(a, sup) && m.anyConforms(b, sup) { - shared = append(shared, sup) - } + for _, sup := range m.conformableFrom(append(append([]*symbols.Symbol(nil), a...), b...)) { + if m.anyConforms(a, sup) && m.anyConforms(b, sup) { + shared = append(shared, sup) } } var out []*symbols.Symbol @@ -649,6 +647,35 @@ func (m *Model) nearestShared(a, b []*symbols.Symbol) []*symbols.Symbol { return out } +// conformableFrom is every type the types may conform to: their supertypes, and — a union +// conforming to whatever all its unioning types do — those of their unioning types in turn. +func (m *Model) conformableFrom(types []*symbols.Symbol) []*symbols.Symbol { + var out []*symbols.Symbol + visited := make(map[*symbols.Symbol]bool) + var visit func(sym *symbols.Symbol) + visit = func(sym *symbols.Symbol) { + if visited[sym] { + return + } + visited[sym] = true + for _, sup := range m.AllSupertypes(sym) { + if !containsElement(out, sup) { + out = append(out, sup) + } + } + for _, u := range m.UnioningTypes(sym) { + if !containsElement(out, u) { + out = append(out, u) + } + visit(u) + } + } + for _, typ := range types { + visit(typ) + } + return out +} + // anySpecializes reports a type among types conforming to want that want does not conform to. func (m *Model) anySpecializes(types []*symbols.Symbol, want *symbols.Symbol) bool { for _, typ := range types { diff --git a/internal/core/semantics/collection_test.go b/internal/core/semantics/collection_test.go index 44d21a91b..d7650d308 100644 --- a/internal/core/semantics/collection_test.go +++ b/internal/core/semantics/collection_test.go @@ -544,6 +544,27 @@ func TestCollectionSiblingElementsShareSupertype(t *testing.T) { wantValueTypes(t, m, s, "apart", "Anything") } +// A union conforms to whatever all its unioning types do, so elements typed by a union and by a +// sibling share the unioning types' supertype whichever comes first, as do two such unions; a +// union whose members share nothing with the sibling stays Anything. +func TestCollectionUnionElementsShareSupertype(t *testing.T) { + m, s := collectionModel(t, ` + part def T :> C; part def U :> C; part def V :> C; + classifier TU unions T, U; + classifier UV unions U, V; + classifier TS unions T, String; + part tu : TU; part uv : UV; part ts : TS; part v : V; + attribute unionFirst = (tu, v).?{ in x; true }; + attribute unionLast = (v, tu).?{ in x; true }; + attribute unions = (tu, uv)->select { in x; true }; + attribute unions2 = cs.{ in x : C; (uv, tu) }; + attribute apart = (ts, v).?{ in x; true };`) + for _, name := range []string{"unionFirst", "unionLast", "unions", "unions2"} { + wantValueTypes(t, m, s, name, "C") + } + wantValueTypes(t, m, s, "apart", "Anything") +} + // An element that is itself a collection value contributes the elements it holds: a nested // collect's literals, a selection's elements, none from one over nothing. func TestCollectionNestedElements(t *testing.T) {