Skip to content

fix(semantics): specialize collection body result types - #112

Merged
HuiJun merged 22 commits into
mainfrom
fix/collection-body-result-type
Sep 8, 2026
Merged

fix(semantics): specialize collection body result types#112
HuiJun merged 22 commits into
mainfrom
fix/collection-body-result-type

Conversation

@devin-ai-integration

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

Copy link
Copy Markdown
Contributor

What and why

The checker typed xs->collect { in x : C; x.mass } (and xs.{ in x : C; x.mass }) by the library's declared result, Anything[0..*], so the value never carried the body's type and every collect was indistinguishable from an untyped one — a MassValue rollup through a collect could not be checked, and an enumerated value or trigger argument written over a collect passed or failed only by the collection.

The typer now specializes each collection operation's static result by the argument the library's declaration hands through, identified by the resolved ControlFunctions declaration (not the spelling):

operation element type of the result multiplicity
collect (->collect {…}, xs.{…}, ->collect f) the body's / named function's result [0..*]
select, reject the elements of the collection [0..*]
selectOne the elements of the collection [0..1]
reduce the reducer's result, and the element a one-element collection hands back unreduced unless the collection provably holds ≥2 [0..*]
forAll, exists Boolean (declared; unchanged) [1]

Nested collects type by the innermost body; a body answering a sequence (x.mass, x.name) contributes every element type; receiver, plain and named-argument notations map to the same parameter through the declaration's signature. A body whose result cannot be typed (an untyped parameter) keeps the library's Anything rather than a guess. A collection over () or a feature admitting no value (part none : C[0]) applies nothing, so neither reducer nor element types it; it conforms as null does (an empty value typed Anything, untyped).

Element-wise judgement. Model.CollectionElements exposes the produced elements (node, scope, types) of a collection value — xs.{…}, xs.?{…}, and the collect/select/reject/selectOne/reduce calls — so a feature's bound value, an invocation argument, a cast and a bound quantity's dimension are judged per element rather than through the library's Anything: attribute i : Integer = xs.{ in x : C; 1.5 } is refused because the literal stays exact (no bidirectional conformance), attribute t : DurationValue = xs.{ in x : C; 5 [m] } is refused as L vs T, and the shorthand xs.?{…} binds exactly as xs->select {…}. The collection value is inferred once with the reporting checker; the produced elements' types are then read through a silent checker (carrying chaining/performed), so an invalid body reports once. The ≥2 analysis (valuesHeldBy) is a full range: () is 0, a literal 1, a sequence the sum, a feature its declared or redefinition-inherited multiplicity, a chain the product.

// semantics/collection.go
func (m *Model) collectionResultTypes(scope, e *ast.InvocationExpr, fn *symbols.Symbol) []*symbols.Symbol
    // collect, reduce  -> appliedResultTypes(argumentTo(e, fn, 1))   // body / function reference
    // select, reject, selectOne -> resultTypes(argumentTo(e, fn, 0)) // the collection
func (m *Model) collectResultTypes(scope, e *ast.CollectExpr) []*symbols.Symbol // xs.{…}

ExprResultType, exprConformance/invocationConformance (valuetype.go) and resultTypes (operator_conformance.go) consult these before the generic result-parameter typing, so non-collection invocations are typed as before.

Recursion. The SelectCall/callArguments typingArgs guard from the self-referential-argument fix is untouched and TestRecursiveRollupThroughACall is byte-for-byte unchanged. Typing bodies exposed one more cycle in the passes layer: exprChecker.invocationResultParameter selected the invocation through a fresh checker, dropping the chaining set of features being typed, so total = subcomponents->collect {in c; c.total}->reduce '+' recursed without bound. It now selects through a silent checker carrying chaining/performed; TestRecursiveRollupThroughACollectBody pins it.

Diagnostics that move (intentionally). Two passes tests encoded "every collect is Anything":

  • TestCollectAndSelectTriggerArguments: when counts.{in n; n > 3} was rejected (found … Anything) and is now accepted — the body returns Boolean. Explicitly typed bodies are now reported by their type (when counts.{in n : Integer; n}found Integer); untyped bodies still report Anything.
  • TestW7GASelectedEnumeratedValueKeepsItsOperandType: xs.{in r : Real; r} in a non-Real enumeration is now flagged (q, r, s); the same collect in RightNum :> Real (ok17) and an untyped one (ok1) are accepted.

The pinned pilot (2026-07) leaves every collect Anything, so it is silent where these new diagnostics fire; the compliance row records that as ⚠️ Approximate (stricter than the reference) for collect/reduce, ✅ for the rest.

No runtime, parser or IR change; collection.go is the only new non-test file.

Specification basis

KerML 1.1 §8.3.4.8 (checkSelectExpressionResultSpecialization: a select's result subsets the collection's) and §9.2 Kernel Function Library ControlFunctions.kerml (collect → "the collection of results" of mapper; select/reject/selectOne → elements of collection; reduce → the reducer's result; forAll/existsBoolean[1]). Pinned grammar PrimaryExpression ('->' InstantiatedTypeMember BodyExpression, '.' BodyExpression, '.?' BodyExpression). Adds one row under Static Expression Type Checking in docs/project/spec-compliance.md and amends the collection-body ⚠️ paragraph under the runtime collection section.

How it was verified

gofmt -l .        → (empty)
go build ./...    → ok
go vet ./...      → ok
go test ./...     → ok (67 packages)
staticcheck ./internal/core/semantics/... ./internal/core/passes/... → clean
python3 scripts/changelog.py check → ok
go run ./cmd/doc-counts -check → already current

./scripts/download-training-examples.sh && ./scripts/download-pilot-corpora.sh   (both already present, pinned)
OPENSYSML_REQUIRE_TRAINING_CORPUS=1 OPENSYSML_REQUIRE_PILOT_CORPORA=1 \
  go test -count=1 ./internal/core/model -run 'TestTrainingExamples|TestPilotCorpora'  → ok
OPENSYSML_REQUIRE_TRAINING_CORPUS=1 OPENSYSML_REQUIRE_PILOT_CORPORA=1 \
  go test -count=1 ./internal/core/export -run TestCorpusRoundTrip                    → ok

No corpus diagnostic moved: training_examples_expected.txt, the pilot ratchets and the RDF round-trip baselines are unchanged, so no per-file adjudication was needed in docs/project/training-examples.md / pilot-corpora.md.

New tests: internal/core/semantics/collection_test.go (each operation, nested and sequence-valued bodies, body type ≠ element type, positional/named/function-reference notations, untyped-body fallback, multiplicity, conformance, reduce returning the element, reduce/collect of nothing, shorthand select elements, self-referential body termination); in internal/core/passes: TestRecursiveRollupThroughACollectBody, TestBoundCollectionQuantityOfAnotherDimension, the collection cases of typecheck_value_test.go (bound values, arguments, exact literals, body checked once, known-empty reduce), the collection cast cases of typecheck_operator_test.go and the () trigger cases of typecheck_trigger_test.go. Two passes tests updated as described above (no assertion weakened — each moved case is replaced by the case that now holds, and the old shape is asserted under the type that makes it hold).

Known limitation: a body whose parameter declares no type (xs.{in x; x.mass}) is still Anything, because the checker does not derive a body parameter's type from the operand's element type; that is the pre-existing ⚠️ paragraph, now stated as such.

Checklist

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

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

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

✅ I will automatically:

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

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

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

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

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

@devin-ai-integration
devin-ai-integration Bot force-pushed the fix/collection-body-result-type branch from 46452fa to 825f2dd Compare September 8, 2026 12:50
devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration Bot and others added 11 commits September 8, 2026 13:54
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 <[email protected]>
…esult

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 <[email protected]>
…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 <[email protected]>
… 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 <[email protected]>
…ntities 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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
…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 <[email protected]>
…tion sizes past int64 and let any later element decide a cast

Co-Authored-By: jason.han <[email protected]>
@devin-ai-integration
devin-ai-integration Bot force-pushed the fix/collection-body-result-type branch from 37dd67b to 0ce7a1b Compare September 8, 2026 14:00
devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration Bot and others added 2 commits September 8, 2026 14:20
…ast 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 <[email protected]>
devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration Bot and others added 2 commits September 8, 2026 15:00
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 <[email protected]>
devin-ai-integration[bot]

This comment was marked as resolved.

…e 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 <[email protected]>
devin-ai-integration[bot]

This comment was marked as resolved.

…e 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 <[email protected]>
devin-ai-integration[bot]

This comment was marked as resolved.

`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 <[email protected]>
devin-ai-integration[bot]

This comment was marked as resolved.

…red supertype

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 <[email protected]>
devin-ai-integration[bot]

This comment was marked as resolved.

…lements

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 <[email protected]>
@HuiJun
HuiJun merged commit ea9c36a into main Sep 8, 2026
12 checks passed
@HuiJun
HuiJun deleted the fix/collection-body-result-type branch September 8, 2026 20:59
HuiJun added a commit that referenced this pull request Sep 9, 2026
Move the baseline from the v0.6.0 tag to main @ 1807734 (2026-09-09) and
bring every track to what that commit carries. Everything merged after the
tag is unreleased at this baseline and is the content of the next release.

Status movements, each backed by the pull request's merge commit on main:

- Track F closed: F1/F2 (#116), F3 (#120); #119 moved the synchronized step
  boundary. known_failures.txt is empty.
- Track S landed: S1 (#110), S2 (#123), S3 (#125), S4 (#134); #141 made
  region order a choice point, #138 wrote the guide. The REPL refuses
  `%schedule explore` (confirmed against bin/sysml).
- Track X: X3 (#115), X4 (#113), X6 (#122), X7's values (#121), X8's typing
  (#112) landed. Open: X2's chain-read half (interpolateLinear still refuses,
  confirmed against bin/sysml), X7's RDF literal form and native layout,
  X8's two harness halves (nothing under cmd/pilot-exec-diff or
  internal/core/export moved them).
- Track A: A6 (#117), A3 (#118), A2 (#133), A5 (#136) landed; A4 remains,
  unblocked by A5.
- Track L: L7 restated — Sample and domain-library calcs run, interpolateLinear
  is gated on X2.
- Track Q: Q3 unblocked by A5.
- Track E: deferral restated — F and S landed, no release carries them yet.
- Track R: R4's MSI build fixed by #127 (v0.6.0 shipped no .msi); the repeated
  test-suite figures in README.md and spec-compliance.md are still hand-typed.
- Track D: #774 was on the previous repository; no counterpart is open here
  and nothing under internal/core/rdf/ontology landed since the tag beyond
  #142's coverage wiring.
- CI statements follow .circleci/config.yml (four jobs since #132, main and
  tags only since #108, HEAD^1 / previous-tag wire baseline) and
  .github/workflows/pr.yml; coverage table re-measured after #142.

Counting commands used for "Where the repository stands":

  make docs-counts
  ls internal/core/runtime/testdata/conformance/*.sysml | wc -l          # 770
  go test -v -run TestExecutionConformance ./internal/core/runtime        # 770 run
  ls internal/core/runtime/testdata/conformance/*.trace.golden | wc -l   # 216
  grep -l '"outcomes"' internal/core/runtime/testdata/conformance/*.expected.json | wc -l  # 19
  ls internal/core/runtime/testdata/conformance/*.trace.order | wc -l    # 5
  cat internal/core/runtime/testdata/conformance/known_failures.txt      # 0
  go test -v -run TestRuntimeRobustness ./internal/core/runtime          # 369
  go test -v -run TestGolden ./internal/core/parser                      # 197
  go test -v -run TestNegative ./internal/core/parser                    # 249 / 338 / 396
  go test -v -run 'TestGRPCConformance|TestGRPCRobustness' ./internal/grpc  # 15 / 8
  docs/project/pilot-rejection-baseline.json, validation-census and
  rdf-corpus-roundtrip baselines as committed
  go test -count=1 -cover -pgo=off -timeout 60m ./...                    # coverage table

Verification: make build-sysml, make docs-counts, make docs-check,
python3 scripts/changelog.py check, gofmt -l . (empty), go build ./...,
go vet ./..., go test ./..., and the corpus gates with
OPENSYSML_REQUIRE_TRAINING_CORPUS=1 OPENSYSML_REQUIRE_PILOT_CORPORA=1 all pass.

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: jason.han <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant