From eaec8b5b86063f595018465f63c033cd3e666694 Mon Sep 17 00:00:00 2001 From: Julia Ortiz Date: Wed, 26 Aug 2026 22:52:13 -0300 Subject: [PATCH 1/3] fix(compiler): apply the 10 stability-review fixes across all three executors Converges the interpreter, wasm, and native backends on the reviewed defects and closes the semantic-analysis soundness holes: - Signed MIN % -1 traps on every executor (wasm emits an explicit guard, bootstrap trap-checks) and checkedRemainder(MIN, -1) is None everywhere; the native path no longer emits UB srem MIN,-1. - Wasm float remainder is exact IEEE fmod via an emitted Sterbenz-exact helper, replacing the overflowing div-trunc-mul-sub expansion. - Bootstrap rotate counts wrap unsigned modulo lane width. - Effect-block typing collects terminals inside unsafe blocks, joins all returns through Match.join (new SEM0163 for non-representable joins), and keeps generic value-parameter failures in the failure row. - effectCaptureFacts visits EnumValue arguments; Ownership.scanRunEnds covers PlaceReplace/EnumValue/EffectResult/EffectBindRequirement; both switches are exhaustiveness-guarded. - NativeType.lanesFor resolves EffectComposite through the registered calling shape; CoroutineFrame.stateLayout keys on the full instance key including contractRow; MirVerification.effectFieldLaneCount and WasmBackend hook release consume Layout's lane helpers instead of re-deriving the walk. - Discovered by the new tests: effect runners pass the layout to mirType so enum captures keep their representation (both runner lowerers), and MirLinearization.opensRuntimeContinuation gains the missing RunEffectComposite so post-composite locals get stack storage (fixes a native forward-value-reference bitcode failure). Adds cross-executor corpus programs, effect-block typing and ownership regression tests, and the openspec change artifacts (compiler-review-stability-fixes). Remaining review nits are tracked for a follow-up PR (see design.md). --- .../.openspec.yaml | 2 + .../compiler-review-stability-fixes/design.md | 60 ++++ .../proposal.md | 43 +++ .../bootstrap-floating-point-scalars/spec.md | 13 + .../specs/bootstrap-flow-functions/spec.md | 25 ++ .../specs/bootstrap-integer-scalars/spec.md | 21 ++ .../specs/bootstrap-ownership/spec.md | 9 + .../compiler-review-stability-fixes/tasks.md | 66 +++++ packages/compiler/src/BootstrapArithmetic.ts | 16 +- packages/compiler/src/BootstrapEvaluation.ts | 17 +- packages/compiler/src/CoroutineFrame.ts | 10 +- packages/compiler/src/Diagnostic.ts | 23 ++ packages/compiler/src/EntryAssembly.ts | 8 +- packages/compiler/src/ExpressionAnalysis.ts | 53 +++- packages/compiler/src/MirLinearization.ts | 1 + packages/compiler/src/MirVerification.ts | 26 +- .../compiler/src/NativeScalarOperation.ts | 5 +- packages/compiler/src/NativeType.ts | 5 + packages/compiler/src/Ownership.ts | 23 ++ .../src/ToolchainIntegrity.generated.ts | 2 +- packages/compiler/src/WasmBackend.ts | 263 +++++++++++++----- packages/compiler/src/WasmEmitContext.ts | 2 + .../compiler/test/EffectBlockTyping.test.ts | 97 +++++++ .../test/RuntimeSliceOwnership.test.ts | 21 ++ packages/compiler/test/WasmBackend.test.ts | 27 +- packages/compiler/test/support/corpus.ts | 148 ++++++++++ packages/language/docs/diagnostics.md | 5 +- 27 files changed, 872 insertions(+), 119 deletions(-) create mode 100644 openspec/changes/compiler-review-stability-fixes/.openspec.yaml create mode 100644 openspec/changes/compiler-review-stability-fixes/design.md create mode 100644 openspec/changes/compiler-review-stability-fixes/proposal.md create mode 100644 openspec/changes/compiler-review-stability-fixes/specs/bootstrap-floating-point-scalars/spec.md create mode 100644 openspec/changes/compiler-review-stability-fixes/specs/bootstrap-flow-functions/spec.md create mode 100644 openspec/changes/compiler-review-stability-fixes/specs/bootstrap-integer-scalars/spec.md create mode 100644 openspec/changes/compiler-review-stability-fixes/specs/bootstrap-ownership/spec.md create mode 100644 openspec/changes/compiler-review-stability-fixes/tasks.md create mode 100644 packages/compiler/test/EffectBlockTyping.test.ts diff --git a/openspec/changes/compiler-review-stability-fixes/.openspec.yaml b/openspec/changes/compiler-review-stability-fixes/.openspec.yaml new file mode 100644 index 00000000..701445b8 --- /dev/null +++ b/openspec/changes/compiler-review-stability-fixes/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-26 diff --git a/openspec/changes/compiler-review-stability-fixes/design.md b/openspec/changes/compiler-review-stability-fixes/design.md new file mode 100644 index 00000000..5bd4715f --- /dev/null +++ b/openspec/changes/compiler-review-stability-fixes/design.md @@ -0,0 +1,60 @@ +# Design + +## Context + +See proposal.md — Why. Ten confirmed review findings across three executor backends (bootstrap interpreter, wasm, native/LLVM), semantic analysis, ownership, and layout plumbing. Line numbers below are from the review at commit 60c01ed and must be re-anchored after merging latest main. + +## Goals / Non-Goals + +**Goals:** minimal, surgical edits per finding; three-executor convergence; one focused test per fix. + +**Non-Goals:** no refactors beyond what a fix requires (no WasmBackend split, no Mir operand-visitor API, no broader dedup of layout math — those stay tech debt); no new diagnostics infrastructure; no changes to the other 30 confirmed review items. + +## Decisions + +1. **`MIN % -1` traps; checked returns `None`** (findings 1, 6). The existing spec text ("trap on ... invalid division/remainder"), native's current behavior, and the wasm backend's comment (which wrongly assumed `rem_s` traps) all indicate trap was the intended semantics; checked ops return `None` exactly where ordinary ops trap (matching CheckedDivide). Alternative — converge on 0 (wasm `rem_s` spec behavior) — rejected: it contradicts the written spec and the checked/ordinary correspondence. + - `NativeScalarOperation.ts` (~552): widen the `CheckedDivide` MIN/-1 guard condition to also cover `CheckedRemainder`, so `invalid` includes overflow and `safeRight` is substituted — result `None`, no `srem MIN,-1` ever emitted. + - `WasmBackend.ts` checked path (~6807): widen the `signedOverflow` guard to `CheckedRemainder` the same way. + - `WasmBackend.ts` ordinary signed remainder (~141/169 emit sites): emit an explicit `if (left == MIN && right == -1) unreachable` guard before `rem_s`, mirroring how other invalid-arith traps are emitted; fix the false comment at ~134. + - `BootstrapArithmetic.ts` (~87/119): make the ordinary remainder path trap on MIN/-1 (the range check currently passes 0); checked path returns `None` for the same condition (~155). + +2. **Exact fmod on wasm via emitted helper** (finding 7). Wasm has no `frem` instruction; the div-trunc-mul-sub expansion is numerically wrong. Emit one synthetic module-local helper per float width implementing exact fmod with the standard exponent-aligned iterative algorithm (musl-style, integer bit manipulation on i32/i64 reinterpretations), and route both Remainder emit sites (~6261, ~6900) through calls to it. Alternative — import a host function — rejected: breaks self-contained wasm output. Alternative — keep expansion but clamp — rejected: still inexact on ordinary operands. + +3. **Bootstrap rotate count: Euclidean mod** (finding 8). One-line fix at `BootstrapArithmetic.ts:57`: `Number(((right % w) + w) % w)`. No changes to wasm/native (already mask correctly). + +4. **Effect-block typing** (finding 2), three edits in `ExpressionAnalysis.ts`: + - `collectTerminals` (~5008): add an `UnsafeStatement` arm recursing into its nested statements, mirroring `returnFlowOf` (StatementAnalysis.ts:1066-1071). + - Success type (~5024): join every collected return through `Match.join` — the language's one canonical result-join rule (match arms already use it), so effect blocks and match expressions cannot disagree. Joinable-but-different types form the canonical union (the surrounding context then rejects a mismatched use); a join with no representable form emits the new SEM0163 `effectBlockReturnMismatch` at the first disagreeing return. Alternative — first-return-wins with per-site equality checks — rejected in implementation: it would invent a second join rule beside `Match.join`. + - Failure filter (~5013): drop the `Type.isNominal` filter entirely — `FailStatement.failure` is only ever set after StatementAnalysis.ts:950-955 validated it (runtime-concrete or value-kind parameter), so re-filtering at collection can only lose information; `Type.effect` already partitions concrete vs symbolic failures. + +5. **EnumValue capture** (finding 3): add an `EnumValue` arm to the `effectCaptureFacts` switch (~4768) that visits `fact.argument`. Also add an exhaustiveness guard (`satisfies never` on the fallthrough) so the next missing fact kind is a compile error, not a silent non-capture — this is the root-cause fix for the bug class, at one line of cost. + +6. **Ownership scanRunEnds** (finding 4): add `PlaceReplace`, `EffectResult`, and `EffectBindRequirement` arms (~1445) that recurse into their operand facts the same way the existing composite arms do, so nested occurrences reach the Identifier case (1528-1558). Include the same `satisfies never` exhaustiveness guard if the fact union allows it; otherwise mirror the existing default handling. + +7. **Native EffectComposite lanes via callingShape** (finding 5): in `NativeType.lanesFor` (~24), resolve a registered `Layout.callingShape` for the composite first and derive lanes from it — exactly the `WasmLanes.laneKindsOf` pattern (WasmLanes.ts:34-36) — falling back to the current computation only when no shape is registered. Audit `NativeEffectOperation.ts` Pack/RunEffectComposite (~105-118, ~388-403): with overlapped MAX-payload lanes their slot-0 placement becomes consistent; adjust lane-type coercion to the unified payload types from the shape. + +8. **Coroutine frame keyed by full suspension key** (finding 9): change `CoroutineFrame.stateLayout` (~150) to match entries with `contractRow` included, using the same canonicalization as `pointKey`/`Instances.keyText` rather than a hand-rolled triple — one comparison via the existing key text kills the drift risk. + +9. **Environment lanes from Layout only** (finding 10): + - `MirVerification.effectFieldLaneCount` (~1108): delete the re-derivation; return `Layout.effectFieldLanes(...).length` (the exact pattern CleanupEmission.ts:126 already uses). + - `WasmBackend.hookReleaseInstructions` `environmentOffsets` (~1767): consume `Layout.effectFieldLanes`/`callableFieldLanePlacements` to enumerate field offsets and hook-bearing lanes instead of re-walking representations. If the Layout helpers don't currently expose per-field offset + hook info, add the minimal accessor to `Layout.ts` next to `effectFieldLanes` rather than widening the backend walk. + +10. **Discovered during apply — two additional native/runner bugs fixed** (exposed by the new tests, same bug families as findings 3 and 5): + - `EntryAssembly.lowerEffectRunner` lowered capture parameter types via `mirType` without the layout, so a captured scalar enum lost its `Enum` representation and every enum operation in a generated runner body silently failed to lower (the runner was then dropped and `Mir.verify` rejected the module). Fix: pass the layout through. + - `MirLinearization.opensRuntimeContinuation` omitted `RunEffectComposite` while listing every sibling `Run*` operation, so locals defined after a composite run in the same linear block leaked as raw SSA values from the synthesized `following` block into later blocks — "non-phi forward value reference" at bitcode encoding. Fix: add the missing tag. + +## Risks / Trade-offs + +- [Wasm MIN/-1 guard adds a branch to every signed `%`] → only signed integer remainder; two extra instructions; acceptable. Constant-fold cases unaffected. +- [Emitted fmod helper is the largest new code surface] → gate with parity tests against native/bootstrap on a value sweep (subnormals, extreme exponents, NaN/inf propagation, both widths). +- [New diagnostic on mismatched effect-block returns may flag existing corpus code] → it flags real unsoundness; fix corpus programs if any trip it. +- [Finding 5's lanesFor change can shift native frame layouts] → run the full native acceptance corpus; the change only affects EffectComposite types with a registered shape, where current behavior is already wrong. +- [Line numbers drift after merging main] → tasks reference symbols, not lines; re-locate by symbol. + +## Deferred to a follow-up PR + +Review findings intentionally left out of this change (tracked as a spawned follow-up task): loan-end handling for effect-bind provider references (Ownership.scanRunEnds); joining only Available returns instead of degrading the block on any Unavailable return, and never-aware offender-span selection; SEM0163 test coverage; subword/i64 remainder and rotate corpus cases; EnumValue/EffectResult loan-scan tests; the dedicated hook-release test (task 10.3); corpus program rename, parity-test tag guard, and an operation-based needsFloatRemainder gate. + +## Migration Plan + +Land as one change; each fix is an independent commit-sized edit; tests run via `node scripts/turbo.mjs run test`. No data or deployment migration (unreleased project). Rollback = revert. diff --git a/openspec/changes/compiler-review-stability-fixes/proposal.md b/openspec/changes/compiler-review-stability-fixes/proposal.md new file mode 100644 index 00000000..a818fbb3 --- /dev/null +++ b/openspec/changes/compiler-review-stability-fixes/proposal.md @@ -0,0 +1,43 @@ +# Compiler Review Stability Fixes + +## Why + +A high-effort compiler review confirmed 10 defects: cross-executor arithmetic divergence (including one LLVM undefined-behavior path), three soundness holes in effect-block result typing, a missed capture, a borrow-checker blind spot, native-only lane/frame-keying bugs, and environment-lane logic re-derived outside its authority that has already desynced once. All are reachable from legal source or from a routine future change; fixing them now converges the three executors and closes the silent-miscompile paths. + +## What Changes + +Minimal edits per finding — behavior converges on what the specs already require; no new features. + +- **Integer remainder MIN/-1** (findings 1, 6): the intended semantics is the spec's "trap on invalid division/remainder" — native already traps and the wasm backend's comment shows trap was assumed. Fix the two executors that silently return 0: wasm emits an explicit MIN/-1 guard → `unreachable` before `rem_s`; bootstrap traps in its remainder range check. `CheckedRemainder(MIN, -1)` returns `None` on all three executors (native's existing CheckedDivide guard extends to CheckedRemainder, eliminating the `srem MIN,-1` UB; wasm and bootstrap add the same overflow condition). +- **Float remainder on wasm** (finding 7): replace the inexact `left - trunc(left/right) * right` expansion (two sites) with an exact fmod helper function emitted into the module, matching native `frem` and bootstrap `%`. +- **Bootstrap rotate counts** (finding 8): mask the rotate count unsigned modulo lane width (Euclidean mod) before shifting, matching wasm `rotl`/`rotr` and native `fshl`/`fshr`. +- **Effect-block result typing** (finding 2): `collectTerminals` descends into `UnsafeStatement`; all collected returns are joined with a diagnostic on incompatible success types instead of last-return-wins; value-kind type-parameter failures survive into the failure row instead of being dropped by the nominal-only filter. +- **Effect-block captures** (finding 3): `effectCaptureFacts` visits the `EnumValue` fact's argument so `Enum.value(x)` registers `x` as a capture. +- **Ownership loan ends** (finding 4): `scanRunEnds` gains cases for `PlaceReplace`, `EffectResult`, and `EffectBindRequirement` so identifier/callable occurrences inside their operands extend loan ends and invalidate `callableEnds`. +- **Native EffectComposite lanes** (finding 5): `NativeType.lanesFor` resolves `EffectComposite` through the registered `Layout.callingShape` (overlapped MAX-payload lanes) the way `WasmLanes.laneKindsOf` does, instead of concatenating alternatives. +- **Coroutine frame lookup** (finding 9): `CoroutineFrame.stateLayout` matches frame entries by the full suspension key including `contractRow`, aligning with `pointKey`/`Instances.keyText`. +- **Environment-lane single source of truth** (finding 10): `WasmBackend.hookReleaseInstructions` and `MirVerification.effectFieldLaneCount` consume `Layout.effectFieldLanes` instead of re-deriving the field walk (one prior desync in history; the verifier's copy is count-only and blesses divergence). +- Fix the false `rem_s` trap comment at `WasmBackend.ts:134`. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `bootstrap-integer-scalars`: pin `MIN % -1` (trap) and `CheckedRemainder(MIN, -1)` (`None`) semantics, and rotate-count wrapping modulo lane width, identically across executors. +- `bootstrap-floating-point-scalars`: pin float remainder to exact IEEE fmod results on every executor. +- `bootstrap-flow-functions`: effect-block result typing accounts for terminals inside `unsafe` blocks, rejects incompatible success types across return sites with a diagnostic, keeps generic (type-parameter) failures in the failure row, and captures bindings referenced through enum-value construction. +- `bootstrap-ownership`: loan live-ranges account for uses nested in place-replace, effect-result, and requirement-binding expressions. + +### Implementation-only (no spec delta) + +Findings 5, 9, 10 make the native backend and verifier conform to already-specified parity/layout behavior; no requirement changes. + +## Impact + +- `packages/compiler/src`: `NativeScalarOperation.ts`, `NativeArith.ts`, `WasmBackend.ts`, `BootstrapArithmetic.ts`, `ExpressionAnalysis.ts`, `Ownership.ts`, `NativeType.ts` (+ `NativeEffectOperation.ts` if placement follows lanes), `CoroutineFrame.ts`, `MirVerification.ts`, `Layout.ts` (export of the lane-walk helper if not already public). +- Observable behavior: programs relying on `MIN % -1 == 0` on wasm/bootstrap now trap (they already trapped on native); effect blocks with mismatched branch returns now get a diagnostic. Silk is unreleased — no compatibility concerns. +- Tests: one focused check per fix, run via `node scripts/turbo.mjs run test`; cross-executor cases land in the existing engine-parity suites. diff --git a/openspec/changes/compiler-review-stability-fixes/specs/bootstrap-floating-point-scalars/spec.md b/openspec/changes/compiler-review-stability-fixes/specs/bootstrap-floating-point-scalars/spec.md new file mode 100644 index 00000000..53b98494 --- /dev/null +++ b/openspec/changes/compiler-review-stability-fixes/specs/bootstrap-floating-point-scalars/spec.md @@ -0,0 +1,13 @@ +## ADDED Requirements + +### Requirement: Float remainder is exact IEEE fmod on every executor + +Floating-point `%` SHALL produce the exact IEEE-754 remainder (fmod semantics: the result of `x - n*y` where `n` is `x/y` truncated toward zero, computed without intermediate rounding or overflow) for both `f32` and `f64`, identically on the interpreter, the wasm backend, and the native backend. + +#### Scenario: Extreme-magnitude operands do not overflow +- **WHEN** a program evaluates `1e308 % 1e-308` as `f64` on any executor +- **THEN** the result is the exact finite fmod value in `[0, 1e-308)` — never infinity or NaN — identically on all three executors + +#### Scenario: Ordinary operands agree bit-for-bit +- **WHEN** the same float remainder expression is evaluated on the interpreter, the wasm backend, and the native backend +- **THEN** all three produce the identical bit pattern diff --git a/openspec/changes/compiler-review-stability-fixes/specs/bootstrap-flow-functions/spec.md b/openspec/changes/compiler-review-stability-fixes/specs/bootstrap-flow-functions/spec.md new file mode 100644 index 00000000..061f6c8b --- /dev/null +++ b/openspec/changes/compiler-review-stability-fixes/specs/bootstrap-flow-functions/spec.md @@ -0,0 +1,25 @@ +## ADDED Requirements + +### Requirement: Effect-block result typing accounts for every terminal + +An effect block's success and failure types SHALL be derived from every `return` and `fail` terminal reachable in the block, including terminals nested inside `unsafe` blocks. Return sites with differing types SHALL combine through the language's canonical result join — never by silently adopting one site's type: joinable types form their union, and a join with no representable form is reported as a diagnostic at the offending return. A `fail` whose failure type is a value-kind type parameter SHALL contribute that parameter to the block's failure row exactly as a nominal failure would. + +#### Scenario: Terminals inside unsafe blocks are collected +- **WHEN** an effect block's only `fail` (or only `return`) sits inside an `unsafe { }` statement +- **THEN** the block's failure row (or success type) includes it, and running the effect requires handling the failure + +#### Scenario: Disagreeing branch returns cannot pass silently +- **WHEN** an effect block returns `bool` on one branch and `i32` on another inside a context expecting `Effect` +- **THEN** the block types as the canonical join (`Effect`) and the context rejects it with a type-mismatch diagnostic — the block is never typed from the lexically last return alone + +#### Scenario: Generic failures survive into the failure row +- **WHEN** a generic function's effect block fails with a value of type parameter `E` +- **THEN** the block types as an effect whose failure row contains `E`, and after specialization the concrete failure must be handled at `run` + +### Requirement: Effect-block captures include enum-value arguments + +Capture analysis for effect blocks SHALL register a capture for every binding referenced anywhere in the block body, including bindings referenced as the argument of an enum value construction. + +#### Scenario: Enum.value argument is captured +- **WHEN** an effect block's body evaluates `Color.value(c)` for an outer binding `c` +- **THEN** `c` appears in the effect's capture environment and the deferred runner reads the captured value diff --git a/openspec/changes/compiler-review-stability-fixes/specs/bootstrap-integer-scalars/spec.md b/openspec/changes/compiler-review-stability-fixes/specs/bootstrap-integer-scalars/spec.md new file mode 100644 index 00000000..66519fb3 --- /dev/null +++ b/openspec/changes/compiler-review-stability-fixes/specs/bootstrap-integer-scalars/spec.md @@ -0,0 +1,21 @@ +## ADDED Requirements + +### Requirement: Signed remainder overflow semantics are identical across executors + +Signed integer `%` with operands `MIN` and `-1` SHALL trap on every executor (interpreter, wasm, native), consistent with the existing rule that ordinary arithmetic traps on invalid division/remainder. The checked remainder of `MIN` and `-1` SHALL return `None` on every executor, and no executor SHALL evaluate it through an operation whose result is undefined for those operands. + +#### Scenario: Ordinary remainder of MIN by -1 traps everywhere +- **WHEN** a program evaluates `i32::MIN % -1` (or the equivalent for any signed width) on any executor +- **THEN** execution traps, and the same program traps identically on the interpreter, the wasm backend, and the native backend + +#### Scenario: Checked remainder of MIN by -1 is None everywhere +- **WHEN** a program evaluates the checked remainder of `i32::MIN` and `-1` on any executor +- **THEN** the result is `None`, identically on the interpreter, the wasm backend, and the native backend + +### Requirement: Rotate counts wrap modulo lane width on every executor + +Rotate-left and rotate-right SHALL interpret the count modulo the operand's bit width using an unsigned (Euclidean) reduction, so negative and out-of-range counts wrap instead of degenerating, identically on every executor. + +#### Scenario: Rotate by a negative count wraps +- **WHEN** a program evaluates `rotate_left(x, -1)` on an odd `i32` value on any executor +- **THEN** the result equals `rotate_left(x, 31)` — the low bit wraps into bit 31 — identically on the interpreter, the wasm backend, and the native backend diff --git a/openspec/changes/compiler-review-stability-fixes/specs/bootstrap-ownership/spec.md b/openspec/changes/compiler-review-stability-fixes/specs/bootstrap-ownership/spec.md new file mode 100644 index 00000000..505f8285 --- /dev/null +++ b/openspec/changes/compiler-review-stability-fixes/specs/bootstrap-ownership/spec.md @@ -0,0 +1,9 @@ +## ADDED Requirements + +### Requirement: Loan live-ranges account for uses nested in place and effect expressions + +Loan-end analysis SHALL treat identifier and callable occurrences nested inside place-replace, effect-result, and requirement-binding expressions as uses at that occurrence: they SHALL extend the enclosing loan's live range and SHALL invalidate any earlier record that treated the callable's last invocation as its final use. + +#### Scenario: View used inside a place replace keeps its loan live +- **WHEN** a shared view's last use sits inside a place-replace expression's value operand and the borrowed owner is mutated between the view's direct uses and that nested use +- **THEN** ownership analysis reports owner access during the loan — the view loan's live range extends through the place-replace use rather than ending at the last direct use diff --git a/openspec/changes/compiler-review-stability-fixes/tasks.md b/openspec/changes/compiler-review-stability-fixes/tasks.md new file mode 100644 index 00000000..adb10369 --- /dev/null +++ b/openspec/changes/compiler-review-stability-fixes/tasks.md @@ -0,0 +1,66 @@ +## 1. Sync + +- [x] 1.1 Fetch and merge latest main into the working branch; re-anchor all finding sites by symbol (line numbers in design.md are from commit 60c01ed) + +## 2. Integer remainder MIN/-1 (findings 1, 6) + +- [x] 2.1 NativeScalarOperation: widen the CheckedDivide MIN/-1 guard to CheckedRemainder so the result is None and no `srem MIN,-1` is emitted +- [x] 2.2 WasmBackend checked path: widen the signedOverflow guard to CheckedRemainder (result None) +- [x] 2.3 WasmBackend ordinary signed remainder: emit MIN/-1 guard → unreachable before rem_s; correct the false rem_s-traps comment +- [x] 2.4 BootstrapArithmetic: ordinary remainder traps on MIN/-1; checked remainder returns None +- [x] 2.5 Cross-executor parity test: `MIN % -1` traps and checked remainder is None on all three executors, all signed widths + +## 3. Float remainder on wasm (finding 7) + +- [x] 3.1 Implement synthetic exact-fmod helper funcs (f32/f64, musl-style bit algorithm) emitted into the wasm module +- [x] 3.2 Route both float Remainder emit sites through the helper; delete the div-trunc-mul-sub expansion +- [x] 3.3 Parity sweep test vs native/bootstrap: extreme exponents, subnormals, NaN/inf propagation, sign of zero, both widths, bit-exact + +## 4. Bootstrap rotate counts (finding 8) + +- [x] 4.1 Euclidean-mod the rotate count in BootstrapArithmetic +- [x] 4.2 Parity test: rotate by negative and >width counts matches wasm/native + +## 5. Effect-block typing (finding 2) + +- [x] 5.1 collectTerminals: descend into UnsafeStatement (mirror returnFlowOf) +- [x] 5.2 Success type: join all returns via Match.join (canonical rule); joinable types form the union, non-representable joins emit new SEM0163 at the first disagreeing return +- [x] 5.3 Failure filter: accept value-kind type parameters via the same predicate StatementAnalysis uses for fail statements +- [x] 5.4 Tests: unsafe-nested fail/return typed correctly; mismatched branch returns diagnosed; generic failure row survives specialization and must be handled at run + +## 6. Effect-block captures (finding 3) + +- [x] 6.1 effectCaptureFacts: add EnumValue arm visiting the argument; add exhaustiveness guard (`satisfies never`) on the switch +- [x] 6.2 Test: Enum.value(c) inside an effect block captures c and the runner reads it + +## 7. Ownership loan ends (finding 4) + +- [x] 7.1 scanRunEnds: add PlaceReplace, EffectResult, EffectBindRequirement arms recursing into operand facts; exhaustiveness guard if the union permits +- [x] 7.2 Test: view loan extends through a use nested in Intrinsic.replace (OWN0011). Note: the callable-capture variant and the EffectResult/EffectBindRequirement arms have no dedicated test yet — flagged in review + +## 8. Native EffectComposite lanes (finding 5) + +- [x] 8.1 NativeType.lanesFor: resolve registered Layout.callingShape for EffectComposite first (WasmLanes.laneKindsOf pattern), fallback to current computation +- [x] 8.2 Audit/align NativeEffectOperation Pack/RunEffectComposite placement and coercion with the unified payload lanes +- [x] 8.3 Native test: EffectComposite with different-arity alternative captures round-trips correctly; full native acceptance corpus passes + +## 9. Coroutine frame keying (finding 9) + +- [x] 9.1 CoroutineFrame.stateLayout: match entries by the full suspension key including contractRow via the existing key canonicalization +- [x] 9.2 Native test: added contract-row-suspension-frames corpus program (two provider-bound suspendable specializations). Note: runner naming ($provided$N suffix) already disambiguates same-name frames today, so a failing pre-fix repro is not constructible; the fix is full-key hardening + +## 10. Environment-lane single source of truth (finding 10) + +- [x] 10.1 MirVerification.effectFieldLaneCount → Layout.effectFieldLanes(...).length +- [x] 10.2 WasmBackend hookReleaseInstructions environmentOffsets → consume Layout lane/placement helpers (add minimal Layout accessor if per-field offset+hook info isn't exposed) +- [ ] 10.3 Test: environment with borrow + callableIdentity + effectIdentity fields releases exactly the layout-enumerated hooks (not written; behavior-preservation covered indirectly by DropHookExecution + StoredEffectEngineParity suites) + +## 10b. Discovered during apply + +- [x] 10b.1 EntryAssembly.lowerEffectRunner: pass layout to mirType so captured scalar enums keep their Enum representation (generated runners with enum captures previously failed to lower) +- [x] 10b.2 MirLinearization.opensRuntimeContinuation: add missing RunEffectComposite so post-composite locals get stack storage (fixes native "non-phi forward value reference" on composite runs consumed across blocks) + +## 11. Verification + +- [x] 11.1 Full suite via `node scripts/turbo.mjs run test`; fix fallout (corpus programs newly diagnosed by 5.2 are fixed, not suppressed) +- [x] 11.2 Re-report the 10 findings via ReportFindings with outcomes diff --git a/packages/compiler/src/BootstrapArithmetic.ts b/packages/compiler/src/BootstrapArithmetic.ts index fbe884aa..00b1580c 100644 --- a/packages/compiler/src/BootstrapArithmetic.ts +++ b/packages/compiler/src/BootstrapArithmetic.ts @@ -45,6 +45,13 @@ export const integralBinary = ( if ((operation === 'Divide' || operation === 'Remainder') && right === 0n) return Object.freeze({ _tag: 'Trap', reason: 'division by zero' }) const width = Scalar.bits(scalar, pointerBits) + if ( + operation === 'Remainder' && + scalar.signedness === 'Signed' && + right === -1n && + left === Scalar.range(scalar, pointerBits).minimum + ) + return Object.freeze({ _tag: 'Trap', reason: 'arithmetic overflow' }) if ( (operation === 'ShiftLeft' || operation === 'ShiftRight') && (right < 0n || right >= BigInt(width)) @@ -54,7 +61,7 @@ export const integralBinary = ( scalar.signedness === 'Signed' ? BigInt.asIntN(width, input) : BigInt.asUintN(width, input) const leftBits = BigInt.asUintN(width, left) const rightBits = BigInt.asUintN(width, right) - const rotate = Number(right % BigInt(width)) + const rotate = Number(((right % BigInt(width)) + BigInt(width)) % BigInt(width)) const rotatedLeft = rotate === 0 ? leftBits @@ -131,11 +138,15 @@ export const integralBinary = ( return Object.freeze({ _tag: 'Integer', type: scalar.spelling, value }) } -/** Computes the exact checked integer result; undefined represents trap/None. */ +/** + * Computes the exact checked integer result; undefined represents trap/None. `minimum` is the + * source scalar's lower bound, used to reject the `MIN % -1` remainder whose quotient overflows. + */ export const checked = ( operation: string, left: bigint, right: bigint | undefined, + minimum: bigint, ): bigint | undefined => { if (operation.startsWith('CheckedConvertTo')) { return left @@ -153,6 +164,7 @@ export const checked = ( return left / right } if (operation === 'CheckedRemainder' && right !== undefined && right !== 0n) { + if (left === minimum && right === -1n) return undefined return left % right } return undefined diff --git a/packages/compiler/src/BootstrapEvaluation.ts b/packages/compiler/src/BootstrapEvaluation.ts index 1422b0f7..5a7cfa8d 100644 --- a/packages/compiler/src/BootstrapEvaluation.ts +++ b/packages/compiler/src/BootstrapEvaluation.ts @@ -1145,8 +1145,14 @@ function* executeFunction( rightValue !== undefined && rightValue._tag === 'IntegerValue' ? BigInt(rightValue.value) : undefined - const exact = BootstrapArithmetic.checked(operation, left, right) - const range = Scalar.range(resultScalar, program.layout.target.pointerSize === 4 ? 32 : 64) + const pointerBits = program.layout.target.pointerSize === 4 ? 32 : 64 + const exact = BootstrapArithmetic.checked( + operation, + left, + right, + Scalar.range(source, pointerBits).minimum, + ) + const range = Scalar.range(resultScalar, pointerBits) const succeeded = exact !== undefined && exact >= range.minimum && exact <= range.maximum const semantic = Type.option(resultScalar.spelling) if (!Type.isUnion(semantic)) @@ -3854,7 +3860,12 @@ function* executeFunction( (target?.category !== 'Integer' && !characterConversion) ) throw new RangeError('MIR verifier allowed an invalid checked scalar operation') - const arithmetic = BootstrapArithmetic.checked(operation.operation, left, right) + const arithmetic = BootstrapArithmetic.checked( + operation.operation, + left, + right, + Scalar.range(source, program.layout.target.pointerSize === 4 ? 32 : 64).minimum, + ) const success = arithmetic !== undefined && (characterConversion diff --git a/packages/compiler/src/CoroutineFrame.ts b/packages/compiler/src/CoroutineFrame.ts index 3a8e4e8a..25180c9c 100644 --- a/packages/compiler/src/CoroutineFrame.ts +++ b/packages/compiler/src/CoroutineFrame.ts @@ -145,14 +145,10 @@ export const stateLayout = ( program: Mir.Module, point: Mir.SuspensionPointId, ): Mir.CoroutineFrameTargetStateLayout | undefined => + // The full instance key — contractRow included — selects the entry, matching pointKey and the + // wasm backend's Backend.suspensionPointKey; specializations can differ only in contractRow. program.coroutineFrames?.entries - .find( - (entry) => - entry.function.declaration.module === point.owner.declaration.module && - entry.function.declaration.name === point.owner.declaration.name && - entry.function.typeArguments.map(SilkType.genericArgumentKey).join(',') === - point.owner.typeArguments.map(SilkType.genericArgumentKey).join(','), - ) + .find((entry) => Instances.keyText(entry.function) === Instances.keyText(point.owner)) ?.states.find((state) => pointKey(state.point) === pointKey(point)) export type CleanupPayloadField = Mir.CoroutineFramePayloadField & { diff --git a/packages/compiler/src/Diagnostic.ts b/packages/compiler/src/Diagnostic.ts index 7c0917b3..5208856d 100644 --- a/packages/compiler/src/Diagnostic.ts +++ b/packages/compiler/src/Diagnostic.ts @@ -316,6 +316,9 @@ export const missingOpaqueRealizationCode = 'SEM0117' as const /** Stable code for an opaque result declared where no producer body can establish its identity. */ export const bodylessOpaqueResultCode = 'SEM0118' as const +/** Stable code for effect-block return sites whose success types disagree. */ +export const effectBlockReturnMismatchCode = 'SEM0163' as const + /** Stable code for a use of a binding after its consuming move. */ export const useAfterMoveCode = 'OWN0001' as const export const partialMoveCode = 'OWN0002' as const @@ -513,6 +516,7 @@ export type Code = | typeof invalidOpaqueResultBinderCode | typeof missingOpaqueRealizationCode | typeof bodylessOpaqueResultCode + | typeof effectBlockReturnMismatchCode | typeof useAfterMoveCode | typeof partialMoveCode | typeof explicitMoveRequiredCode @@ -953,6 +957,7 @@ export type Reason = readonly originalSpan: SourceSpan.SourceSpan } | { readonly _tag: 'IncompatibleMatchResults'; readonly types: ReadonlyArray } + | { readonly _tag: 'EffectBlockReturnMismatch'; readonly types: ReadonlyArray } | { readonly _tag: 'DuplicateTypeParameter' readonly spelling: string @@ -3151,6 +3156,24 @@ export const incompatibleMatchResults = ( span, }) +/** Creates the diagnostic for an effect-block return whose type disagrees with the block's. */ +export const effectBlockReturnMismatch = ( + types: ReadonlyArray, + span: SourceSpan.SourceSpan, +): Diagnostic => + Object.freeze({ + _tag: 'Diagnostic', + phase: 'semantic', + code: effectBlockReturnMismatchCode, + severity: 'error', + message: `Effect block return sites have incompatible types: ${types.join(', ')}`, + reason: Object.freeze({ + _tag: 'EffectBlockReturnMismatch', + types: Object.freeze([...types]), + }), + span, + }) + export const divergentRepresentationJoin = ( expected: string, actual: string, diff --git a/packages/compiler/src/EntryAssembly.ts b/packages/compiler/src/EntryAssembly.ts index 823cadac..cecfb821 100644 --- a/packages/compiler/src/EntryAssembly.ts +++ b/packages/compiler/src/EntryAssembly.ts @@ -311,7 +311,9 @@ export const lowerEffectRunner = ( const represented = representedValueType(layout, opaqueRealizations, field.type, new Map()) return represented === undefined ? [] : [represented] } - const lowered = mirType(field.type) + // The layout resolves scalar-enum nominals to their Enum representation; without it a + // captured enum lowers as a bare Nominal and every enum operation in the runner body fails. + const lowered = mirType(field.type, new Map(), layout) if (lowered === undefined) return [] if (field.representation === 'Value') return [lowered] if (field.access !== 'Shared' && field.access !== 'Exclusive') return [] @@ -549,7 +551,9 @@ export const lowerWitnessEffectRunner = ( opaqueRealizations: OpaqueRealization.Catalog, ): Mir.MirFunction | undefined => { const parameterTypes = spec.type.environment.fields.flatMap((field) => { - const type = mirType(field.type) + // The layout resolves scalar-enum nominals to their Enum representation, exactly as in + // lowerEffectRunner — without it a captured enum silently fails every enum operation. + const type = mirType(field.type, new Map(), layout) return type === undefined ? [] : [type] }) if (parameterTypes.length !== spec.type.environment.fields.length) return undefined diff --git a/packages/compiler/src/ExpressionAnalysis.ts b/packages/compiler/src/ExpressionAnalysis.ts index 3944c651..224e3131 100644 --- a/packages/compiler/src/ExpressionAnalysis.ts +++ b/packages/compiler/src/ExpressionAnalysis.ts @@ -4840,10 +4840,21 @@ export const effectCaptureFacts = ( for (const capture of fact.captures) recordReference(capture.reference, capture.access, capture.span, false) return + case 'EnumValue': + expression(fact.argument) + return case 'Integer': + case 'Floating': case 'Boolean': case 'Character': case 'Constant': + case 'StaticText': + case 'Unit': + case 'EnumMember': + return + default: + // Exhaustive so a new expression fact kind cannot silently skip capture registration. + fact satisfies never return } } @@ -5004,24 +5015,48 @@ export function analyzeExpression( } const statements = analyzeStatements(nested, block, scope) const returned: Array = [] - const failures: Array = [] + // The fail statement's analysis already validated the failure type, so every recorded + // failure — nominal or a value-kind type parameter — belongs in the block's failure row. + const failures: Array = [] const collectTerminals = (items: ReadonlyArray): void => { for (const statement of items) { if (statement._tag === 'ReturnStatement') returned.push(statement.expression) - else if ( - statement._tag === 'FailStatement' && - statement.failure !== undefined && - Type.isNominal(statement.failure) - ) + else if (statement._tag === 'FailStatement' && statement.failure !== undefined) failures.push(statement.failure) else if (statement._tag === 'IfStatement' || statement._tag === 'IfLetStatement') { collectTerminals(statement.taken) collectTerminals(statement.otherwise) } else if (statement._tag === 'WhileStatement') collectTerminals(statement.body) + else if (statement._tag === 'UnsafeStatement') collectTerminals(statement.statements) } } collectTerminals(statements) - const success = returned.at(-1)?.type + // Every return site contributes to the success type through the one canonical join rule; + // disagreeing sites are diagnosed instead of silently adopting the last return's type. + const returnedTypes = returned.flatMap((expression) => + expression.type._tag === 'Available' ? [expression.type.type] : [], + ) + let success: Type.Type | undefined + if (returned.length > 0 && returnedTypes.length === returned.length) { + const joined = Match.join(returnedTypes) + if (joined._tag === 'Joined') success = joined.type + else { + const first = returnedTypes.at(0) + const offender = + first === undefined + ? undefined + : returned.find( + (expression) => + expression.type._tag === 'Available' && !Type.equals(expression.type.type, first), + ) + nested.diagnostics.push( + Diagnostic.effectBlockReturnMismatch( + joined.types.map(Type.encode), + offender?.syntax.span ?? node.span, + ), + ) + } + } const captures = effectCaptureFacts( statements, firstLocalBinding, @@ -5049,8 +5084,8 @@ export function analyzeExpression( ...captures.flatMap((capture) => (capture.access === 'Copy' ? [] : [capture.access])), ) const type = - success?._tag === 'Available' - ? availableExpressionType(Type.effect(success.type, failures, access)) + success !== undefined + ? availableExpressionType(Type.effect(success, failures, access)) : unavailableExpressionType return Object.freeze({ fact: Object.freeze({ diff --git a/packages/compiler/src/MirLinearization.ts b/packages/compiler/src/MirLinearization.ts index af72791c..dccff6c2 100644 --- a/packages/compiler/src/MirLinearization.ts +++ b/packages/compiler/src/MirLinearization.ts @@ -167,6 +167,7 @@ export const opensRuntimeContinuation = (operation: LinearOperation): boolean => operation._tag === 'RawBufferFill' || operation._tag === 'RunEffect' || operation._tag === 'RunEffectValue' || + operation._tag === 'RunEffectComposite' || operation._tag === 'RunStaticEffect' || operation._tag === 'ReifyEffect' || operation._tag === 'CloseEffectEntry' || diff --git a/packages/compiler/src/MirVerification.ts b/packages/compiler/src/MirVerification.ts index 2149d076..a9eef514 100644 --- a/packages/compiler/src/MirVerification.ts +++ b/packages/compiler/src/MirVerification.ts @@ -1105,25 +1105,10 @@ const callableEnvironmentByIdentity = ( FieldRealization.matchesIdentity(identity, candidate.callable), ) -const effectFieldLaneCount = ( - layout: Layout.Plan, - field: Layout.EffectEnvironmentField, -): number | undefined => { - if (field.representation === 'Borrow') return 1 - if (field.effectIdentity !== undefined) { - const environment = effectEnvironmentByIdentity(layout, field.effectIdentity) - return environment === undefined - ? undefined - : Layout.effectEnvironmentLanes(layout, environment).length - } - if (field.callableIdentity !== undefined) { - const environment = callableEnvironmentByIdentity(layout, field.callableIdentity) - return environment === undefined - ? undefined - : Layout.callableEnvironmentLanes(layout, environment).length - } - return Layout.callingShape(layout, field.type)?.laneCount -} +// Offsets must mirror the runner ABI exactly, so the count comes from the same Layout helper +// that materializes environment lanes for cleanup emission and the backends — never re-derived. +const effectFieldLaneCount = (layout: Layout.Plan, field: Layout.EffectEnvironmentField): number => + Layout.effectFieldLanes(layout, field).length const callableEnvironmentCleanupValid = ( layout: Layout.Plan, @@ -1196,7 +1181,7 @@ const effectEnvironmentCleanupValid = ( const expected = environment.fields.flatMap((field, ordinal) => { const laneCount = effectFieldLaneCount(layout, field) const currentOffset = laneOffset - if (laneCount !== undefined) laneOffset += laneCount + laneOffset += laneCount const noCleanup: CleanupPlan.CleanupPlan = Object.freeze({ _tag: 'NoCleanup', type: field.type, @@ -1217,7 +1202,6 @@ const effectEnvironmentCleanupValid = ( const candidate = [...expected].reverse().at(ordinal) return ( candidate !== undefined && - candidate.laneCount !== undefined && slot.ordinal === candidate.ordinal && slot.laneOffset === candidate.laneOffset && slot.laneCount === candidate.laneCount && diff --git a/packages/compiler/src/NativeScalarOperation.ts b/packages/compiler/src/NativeScalarOperation.ts index fc759b76..c9a65a66 100644 --- a/packages/compiler/src/NativeScalarOperation.ts +++ b/packages/compiler/src/NativeScalarOperation.ts @@ -549,7 +549,10 @@ export const emit = Effect.fnUntraced(function* (context: Context, operation: Op throw new RangeError('LLVM checked division lost its right operand') const zero = yield* Constant.integerUnsigned(builder, targetPhysical, 0n) invalid = yield* FunctionBody.integerCompare(body, 'eq', right, zero, `${name}_zero`) - if (target.signedness === 'Signed' && operation.operation === 'CheckedDivide') { + if ( + target.signedness === 'Signed' && + (operation.operation === 'CheckedDivide' || operation.operation === 'CheckedRemainder') + ) { const range = Scalar.range(target, pointerBits) const minimum = yield* Constant.integerSigned(builder, targetPhysical, range.minimum) const negativeOne = yield* Constant.integerSigned(builder, targetPhysical, -1n) diff --git a/packages/compiler/src/NativeType.ts b/packages/compiler/src/NativeType.ts index af0a6526..9d8abf53 100644 --- a/packages/compiler/src/NativeType.ts +++ b/packages/compiler/src/NativeType.ts @@ -22,6 +22,11 @@ export const lanesFor = ( type: Mir.Type, ): ReadonlyArray => { if (type._tag === 'EffectComposite') { + // The registered shape overlaps alternatives into unified payload lanes; every consumer must + // agree with it (the wasm backend resolves it the same way in WasmLanes.laneKindsOf). The + // concatenating fallback below only covers composites the plan never registered. + const registered = Layout.callingShape(context.program.layout, type.type) + if (registered !== undefined) return registered.lanes const payloadTypes = type.alternatives.flatMap((alternative) => lanesFor(context, alternative).map((lane) => lane.type), ) diff --git a/packages/compiler/src/Ownership.ts b/packages/compiler/src/Ownership.ts index f8a159e8..a49a7cff 100644 --- a/packages/compiler/src/Ownership.ts +++ b/packages/compiler/src/Ownership.ts @@ -1515,15 +1515,34 @@ const analyzeLoans = ( case 'CallableSection': for (const capture of expression.captures) scanRunEnds(capture.expression, region) return + case 'PlaceReplace': + scanRunEnds(expression.destination, region) + scanRunEnds(expression.value, region) + return + case 'EnumValue': + scanRunEnds(expression.argument, region) + return case 'EffectCatch': scanRunEnds(expression.protected, region) scanRunEnds(expression.handler, region) return + case 'EffectResult': + scanRunEnds(expression.protected, region) + return + case 'EffectBindRequirement': + scanRunEnds(expression.protected, region) + return case 'EffectBlock': case 'FunctionItem': return case 'Integer': + case 'Floating': case 'Boolean': + case 'Character': + case 'Constant': + case 'StaticText': + case 'Unit': + case 'EnumMember': return case 'Identifier': { const site = directSite(expression)?.site @@ -1558,6 +1577,10 @@ const analyzeLoans = ( } return } + default: + // Exhaustive so a new expression fact kind cannot silently hide loan-relevant uses. + expression satisfies never + return } } const scanStatementRunEnds = (facts: ReadonlyArray): void => { diff --git a/packages/compiler/src/ToolchainIntegrity.generated.ts b/packages/compiler/src/ToolchainIntegrity.generated.ts index fda2b000..1f76b3d2 100644 --- a/packages/compiler/src/ToolchainIntegrity.generated.ts +++ b/packages/compiler/src/ToolchainIntegrity.generated.ts @@ -1,3 +1,3 @@ // Generated by scripts/generate-toolchain-integrity.mjs. Do not edit. -export const compilerDigest = 'e44ff9e9a7848d99b54e83a900b7695b35417daa4fec85d044ab0617b938eb0d' +export const compilerDigest = '4299e567c07a322cf9f5769fd51b931755f1a0227f8c525d997a3bd33c803c13' diff --git a/packages/compiler/src/WasmBackend.ts b/packages/compiler/src/WasmBackend.ts index 6ca06fd2..57e7d562 100644 --- a/packages/compiler/src/WasmBackend.ts +++ b/packages/compiler/src/WasmBackend.ts @@ -131,9 +131,9 @@ const unsignedComparisons: Readonly>> = Object.freeze( { @@ -171,6 +171,107 @@ const i64Divisions: Readonly>> = Object.freeze({ Divide: 'i64.div_u', Remainder: 'i64.rem_u' }) +/** + * Exact IEEE fmod for the wasm backend, which has no float remainder instruction. A naive + * `x - trunc(x/y) * y` expansion rounds at every step and overflows for extreme exponent + * differences, so the helper instead reduces `|x|` by the largest `|y| * 2^k` at or below it + * until it drops under `|y|`: power-of-two scaling is exact, and every subtraction satisfies + * Sterbenz's lemma (`t <= ax < 2t`), so the final value is the mathematically exact remainder — + * matching LLVM's `frem` and the interpreter's `%`. Params: 0 = dividend, 1 = divisor; locals + * 2..4 are `|x|`, `|y|`, and the scaled step `t`. + */ +const floatRemainderBody = (prefix: 'f32' | 'f64'): ReadonlyArray => { + const constant = prefix === 'f64' ? Instr.f64Const : Instr.f32Const + const [x, y, ax, ay, t] = [0, 1, 2, 3, 4] + return [ + Instr.localGet(x), + Instr.op(`${prefix}.abs`), + Instr.localSet(ax), + Instr.localGet(y), + Instr.op(`${prefix}.abs`), + Instr.localSet(ay), + // NaN operand, zero divisor, or infinite dividend: `(x*y)/(x*y)` is NaN in exactly these + // cases and propagates an operand NaN. + Instr.localGet(x), + Instr.localGet(x), + Instr.op(`${prefix}.ne`), + Instr.localGet(y), + Instr.localGet(y), + Instr.op(`${prefix}.ne`), + Instr.op('i32.or'), + Instr.localGet(ay), + constant(0), + Instr.op(`${prefix}.eq`), + Instr.op('i32.or'), + Instr.localGet(ax), + constant(Number.POSITIVE_INFINITY), + Instr.op(`${prefix}.eq`), + Instr.op('i32.or'), + Instr.ifElse( + Instr.emptyBlockType, + [ + Instr.localGet(x), + Instr.localGet(y), + Instr.op(`${prefix}.mul`), + Instr.localGet(x), + Instr.localGet(y), + Instr.op(`${prefix}.mul`), + Instr.op(`${prefix}.div`), + Instr.op('return'), + ], + [], + ), + // |x| < |y| (including an infinite divisor): x is already the remainder, sign intact. + Instr.localGet(ax), + Instr.localGet(ay), + Instr.op(`${prefix}.lt`), + Instr.ifElse(Instr.emptyBlockType, [Instr.localGet(x), Instr.op('return')], []), + // t = largest |y| * 2^k at or below |x| (a doubling that overflows to inf ends the scan). + Instr.localGet(ay), + Instr.localSet(t), + Instr.block(Instr.emptyBlockType, [ + Instr.loop(Instr.emptyBlockType, [ + Instr.localGet(t), + constant(2), + Instr.op(`${prefix}.mul`), + Instr.localGet(ax), + Instr.op(`${prefix}.gt`), + Instr.brIf(1), + Instr.localGet(t), + constant(2), + Instr.op(`${prefix}.mul`), + Instr.localSet(t), + Instr.br(0), + ]), + ]), + // Each pass subtracts t when it fits, then halves it; exits once |x| dropped under |y|. + Instr.block(Instr.emptyBlockType, [ + Instr.loop(Instr.emptyBlockType, [ + Instr.localGet(ax), + Instr.localGet(ay), + Instr.op(`${prefix}.lt`), + Instr.brIf(1), + Instr.localGet(ax), + Instr.localGet(t), + Instr.op(`${prefix}.ge`), + Instr.ifElse( + Instr.emptyBlockType, + [Instr.localGet(ax), Instr.localGet(t), Instr.op(`${prefix}.sub`), Instr.localSet(ax)], + [], + ), + Instr.localGet(t), + constant(0.5), + Instr.op(`${prefix}.mul`), + Instr.localSet(t), + Instr.br(0), + ]), + ]), + Instr.localGet(ax), + Instr.localGet(x), + Instr.op(`${prefix}.copysign`), + ] +} + /** * Wasm's `i32.add`, `i32.sub`, and `i32.mul` wrap on overflow, but MIR specifies that signed * overflow traps. Each is emitted as the wrapping operation followed by an inline overflow check @@ -989,9 +1090,28 @@ const emitIntegerBinaryValue = ( const unsignedDivisionsForWidth = bits === 64 ? unsignedI64Divisions : unsignedDivisions const division = (unsigned ? unsignedDivisionsForWidth : divisionsForWidth)[operator] if (division !== undefined) { - const result = [Instr.localGet(left), Instr.localGet(right), Instr.op(division)] - if (bits >= 32 || operator !== 'Divide') return result + const prefix = bits === 64 ? 'i64' : 'i32' const range = Scalar.range(integer, pointerBits) + const overflowGuard: ReadonlyArray = + unsigned || operator !== 'Remainder' + ? [] + : [ + Instr.localGet(left), + bits === 64 ? Instr.i64Const(range.minimum) : Instr.i32Const(Number(range.minimum)), + Instr.op(`${prefix}.eq`), + Instr.localGet(right), + bits === 64 ? Instr.i64Const(-1n) : Instr.i32Const(-1), + Instr.op(`${prefix}.eq`), + Instr.op('i32.and'), + Instr.ifElse(Instr.emptyBlockType, [Instr.op('unreachable')], []), + ] + const result = [ + ...overflowGuard, + Instr.localGet(left), + Instr.localGet(right), + Instr.op(division), + ] + if (bits >= 32 || operator !== 'Divide') return result return [ ...result, Instr.localSet(layout.scratch), @@ -1764,65 +1884,32 @@ const makeOperationContext = ( hookReleaseWalk(plan_, (nestedOffset) => frameAddress(planned.offset + byteOffset + nestedOffset), ) + // Byte offsets come from the same Layout placement walk that materializes environment lanes + // for every backend — never re-derived here, so a new field representation cannot desync + // hook release from the runner ABI (commit 7a8434b fixed exactly that drift once). + const placementOffsets = ( + placements: ReadonlyArray, + ): ReadonlyArray => + placements.map((placement) => { + const laneOffset = + placement.root === undefined + ? 0 + : LayoutVerify.laneOffset(memory.plan, placement.root, placement.lane.path) + if (laneOffset === undefined) + throw new RangeError('Wasm hook cleanup lost an environment lane offset') + return placement.byteOffset + laneOffset + }) const environmentOffsets = ( environment: Extract, - base = 0, ): ReadonlyArray => - environment.fields.flatMap((field) => { - if (field.representation === 'Borrow') return [base + field.offset] - if (field.callableIdentity !== undefined) { - const nested = - field.callableIdentity.environment === undefined - ? undefined - : LayoutPlan.callableEnvironmentByIdentity( - memory.plan, - field.callableIdentity.environment, - ) - return nested?._tag === 'CallableEnvironment' - ? callableEnvironmentOffsets(nested, base + field.offset) - : [] - } - if (field.effectIdentity !== undefined) { - const nested = LayoutPlan.effectEnvironmentByFieldIdentity( - memory.plan, - field.effectIdentity, - ) - return nested !== undefined ? environmentOffsets(nested, base + field.offset) : [] - } - const shape = LayoutPlan.callingShape(memory.plan, field.type) - return ( - shape?.lanes.flatMap((lane) => { - const offset = LayoutVerify.laneOffset(memory.plan, field.type, lane.path) - return offset === undefined ? [] : [base + field.offset + offset] - }) ?? [] - ) - }) + placementOffsets(LayoutPlan.effectEnvironmentLanePlacements(memory.plan, environment)) const callableEnvironmentOffsets = ( environment: Extract< LayoutPlan.CallableEnvironment, { readonly _tag: 'CallableEnvironment' } >, - base = 0, ): ReadonlyArray => - environment.fields.flatMap((field) => { - if (field.representation === 'Borrow') return [base + field.offset] - if (field.callableIdentity?.environment !== undefined) { - const nested = LayoutPlan.callableEnvironmentByIdentity( - memory.plan, - field.callableIdentity.environment, - ) - return nested?._tag === 'CallableEnvironment' - ? callableEnvironmentOffsets(nested, base + field.offset) - : [] - } - const shape = LayoutPlan.callingShape(memory.plan, field.type) - return ( - shape?.lanes.flatMap((lane) => { - const offset = LayoutVerify.laneOffset(memory.plan, field.type, lane.path) - return offset === undefined ? [] : [base + field.offset + offset] - }) ?? [] - ) - }) + placementOffsets(LayoutPlan.callableEnvironmentLanePlacements(memory.plan, environment)) if ( cleanup._tag === 'CallableCleanup' && localType?._tag === 'CallableValue' && @@ -6258,18 +6345,17 @@ const emitApplyCallableOperation = ( Instr.op(mnemonic), Instr.localSet(scalar(operation.destination)), ] - if (target.operation === 'Remainder') + if (target.operation === 'Remainder') { + const remainder = state.emitter.floatRemainder?.[prefix] + if (remainder === undefined) + throw new RangeError('Wasm float remainder lost its helper function') return [ Instr.localGet(left), - Instr.localGet(left), - Instr.localGet(right), - Instr.op(`${prefix}.div`), - Instr.op(`${prefix}.trunc`), Instr.localGet(right), - Instr.op(`${prefix}.mul`), - Instr.op(`${prefix}.sub`), + Instr.call(remainder), Instr.localSet(scalar(operation.destination)), ] + } throw new RangeError(`Wasm callable float ${target.operation} is unavailable`) } const integer = scalarActor @@ -6805,7 +6891,8 @@ const emitCheckedScalarOperation = ( } const minimum = Scalar.range(target, pointerBits).minimum const signedOverflow = - target.signedness === 'Signed' && operation.operation === 'CheckedDivide' + target.signedness === 'Signed' && + (operation.operation === 'CheckedDivide' || operation.operation === 'CheckedRemainder') ? [ Instr.localGet(leftSlot), targetConstant(minimum), @@ -6897,18 +6984,17 @@ const emitBinaryOperation = ( Instr.op(arithmetic), Instr.localSet(scalar(operation.destination)), ] - if (operation.operator === 'Remainder') + if (operation.operator === 'Remainder') { + const remainder = state.emitter.floatRemainder?.[prefix] + if (remainder === undefined) + throw new RangeError('Wasm float remainder lost its helper function') return [ - Instr.localGet(scalar(operation.left)), Instr.localGet(scalar(operation.left)), Instr.localGet(scalar(operation.right)), - Instr.op(`${prefix}.div`), - Instr.op(`${prefix}.trunc`), - Instr.localGet(scalar(operation.right)), - Instr.op(`${prefix}.mul`), - Instr.op(`${prefix}.sub`), + Instr.call(remainder), Instr.localSet(scalar(operation.destination)), ] + } throw new RangeError(`Wasm float operation ${operation.operator} is unavailable`) } if ( @@ -8155,6 +8241,14 @@ const emitProgramUnmapped = Effect.fnUntraced(function* ( const needsHostWrite = program.functions.some((fn) => MirVerification.operations(fn).some((operation) => operation._tag === 'HostWrite'), ) + // Over-approximates "a float remainder is reachable": both remainder emit paths operate on + // float-typed locals, so a program with no float local can never call the helper. + const needsFloatRemainder = program.functions.some((fn) => + fn.localTypes.some((type) => { + const semantic = Mir.semanticType(type) + return semantic === 'f32' || semantic === 'f64' + }), + ) const needsMemory = staticOffsets.size > 0 || needsHeap || @@ -8309,6 +8403,32 @@ const emitProgramUnmapped = Effect.fnUntraced(function* ( body: heapReleaseBody(privateMemory), }) } + let floatRemainder: { readonly f32: FuncActor.Func; readonly f64: FuncActor.Func } | undefined + if (needsFloatRemainder) { + const helperLocals = (valueType: ValType.ValType): ReadonlyArray => + ['dividend_magnitude', 'divisor_magnitude', 'step'].map((name) => + debug ? { type: valueType, name } : { type: valueType }, + ) + const remainder32 = yield* FuncActor.declare( + builder, + yield* WasmType.func(builder, [f32, f32], [f32]), + debugName('silk_f32_remainder'), + ) + yield* FuncActor.define(builder, remainder32, { + locals: helperLocals(f32), + body: floatRemainderBody('f32'), + }) + const remainder64 = yield* FuncActor.declare( + builder, + yield* WasmType.func(builder, [f64, f64], [f64]), + debugName('silk_f64_remainder'), + ) + yield* FuncActor.define(builder, remainder64, { + locals: helperLocals(f64), + body: floatRemainderBody('f64'), + }) + floatRemainder = { f32: remainder32, f64: remainder64 } + } // Declare every function first so calls resolve regardless of definition order, mirroring the // LLVM backend's declare-then-define pass structure. @@ -8563,6 +8683,7 @@ const emitProgramUnmapped = Effect.fnUntraced(function* ( memory: helperMemory, executionPackageCleanups, executionCleanup: executionCleanupHelper, + ...(floatRemainder === undefined ? {} : { floatRemainder }), ...(suspensionRuntime === undefined ? {} : { suspensionRuntime }), }), ) @@ -8649,6 +8770,7 @@ const emitProgramUnmapped = Effect.fnUntraced(function* ( ...(executionCleanupHelper === undefined ? {} : { executionCleanup: executionCleanupHelper }), + ...(floatRemainder === undefined ? {} : { floatRemainder }), ...(suspensionRuntime === undefined ? {} : { suspensionRuntime }), }), ) @@ -8725,6 +8847,7 @@ const emitProgramUnmapped = Effect.fnUntraced(function* ( ...(executionCleanupHelper === undefined ? {} : { executionCleanup: executionCleanupHelper }), + ...(floatRemainder === undefined ? {} : { floatRemainder }), ...(suspensionRuntime === undefined ? {} : { suspensionRuntime }), }), ) diff --git a/packages/compiler/src/WasmEmitContext.ts b/packages/compiler/src/WasmEmitContext.ts index 6cc41b37..494f529b 100644 --- a/packages/compiler/src/WasmEmitContext.ts +++ b/packages/compiler/src/WasmEmitContext.ts @@ -32,5 +32,7 @@ export interface WasmEmitContext { readonly executionPackageCleanups: ReadonlyMap /** Runtime-recursive owner release for opaque Execution packages. */ readonly executionCleanup?: FuncActor.Func + /** Exact-fmod helpers, present whenever the program can reach a float remainder. */ + readonly floatRemainder?: { readonly f32: FuncActor.Func; readonly f64: FuncActor.Func } readonly suspensionRuntime?: SuspensionRuntime } diff --git a/packages/compiler/test/EffectBlockTyping.test.ts b/packages/compiler/test/EffectBlockTyping.test.ts new file mode 100644 index 00000000..813bc167 --- /dev/null +++ b/packages/compiler/test/EffectBlockTyping.test.ts @@ -0,0 +1,97 @@ +import { assert, it } from '@effect/vitest' +import * as Effect from 'effect/Effect' +import * as Analysis from '../src/Analysis.js' +import * as SourceFile from '../src/SourceFile.js' +import * as SourceResolver from '../src/SourceResolver.js' + +const ascii = (value: string): Uint8Array => + Uint8Array.from(value, (character) => character.charCodeAt(0)) + +const analyze = (text: string) => + Analysis.makeRealized({ root: SourceFile.make('root', ascii(text)) }).pipe( + Effect.provide(SourceResolver.memory(new Map())), + ) + +const codes = (self: Analysis.Snapshot): ReadonlyArray => + Analysis.diagnostics(self).map((diagnostic) => diagnostic.code) + +it.effect('surfaces disagreeing effect-block return types instead of last-return-wins', () => + Effect.gen(function* () { + // Joinable-but-different return types form the canonical union join, so the block types as + // Effect and the surrounding i32 context rejects it — never a silent adoption of + // the lexically last return's type. (A join with no union form reports SEM0163 directly.) + const self = yield* analyze(`pub fn main() -> i32 { + let flag = true + let deferred = effect { + if flag { return true } + return 1 + } + return run deferred +}`) + assert.include(codes(self), 'SEM0040') + }), +) + +it.effect('joins compatible effect-block returns across branches without diagnostics', () => + Effect.gen(function* () { + const self = yield* analyze(`pub fn main() -> i32 { + let flag = false + let deferred = effect { + if flag { return 1 } + return 42 + } + return run deferred +}`) + assert.deepEqual(codes(self), []) + const evaluated = Analysis.evaluate(self) + assert.strictEqual(evaluated._tag, 'Completed') + if (evaluated._tag === 'Completed') assert.strictEqual(evaluated.result.value, 42n) + }), +) + +it.effect('keeps a failure raised inside an unsafe block in the effect failure row', () => + Effect.gen(function* () { + // Pre-fix, terminals under `unsafe { }` were invisible to effect-block typing, so this run + // site was accepted with an empty failure row and the failure escaped unchecked. + const self = yield* analyze(`struct Boom { code: i32 } +pub fn main() -> i32 { + let flag = true + let deferred = effect { + if flag { unsafe { fail Boom { code: 1 } } } + return 42 + } + return run deferred +}`) + assert.include(codes(self), 'SEM0066') + }), +) + +it.effect('collects the success type from a return nested inside an unsafe block', () => + Effect.gen(function* () { + const self = yield* analyze(`pub fn main() -> i32 { + let deferred = effect { unsafe { return 42 } } + return run deferred +}`) + assert.deepEqual(codes(self), []) + const evaluated = Analysis.evaluate(self) + assert.strictEqual(evaluated._tag, 'Completed') + if (evaluated._tag === 'Completed') assert.strictEqual(evaluated.result.value, 42n) + }), +) + +it.effect('keeps a generic value-parameter failure in the effect failure row', () => + Effect.gen(function* () { + // Pre-fix the nominal-only filter dropped the type-parameter failure, so the specialized + // failure escaped the run site's unhandled-failure check. + const self = yield* analyze(`struct Boom { code: i32 } +fn wrap(flag: bool, problem: E) -> i32 { + let deferred = effect { + if flag { fail move problem } + return 1 + } + return run deferred +} +pub fn main() -> i32 { return wrap(true, Boom { code: 7 }) }`) + assert.include(codes(self), 'SEM0066') + }), +) diff --git a/packages/compiler/test/RuntimeSliceOwnership.test.ts b/packages/compiler/test/RuntimeSliceOwnership.test.ts index f48478c1..34157b48 100644 --- a/packages/compiler/test/RuntimeSliceOwnership.test.ts +++ b/packages/compiler/test/RuntimeSliceOwnership.test.ts @@ -714,3 +714,24 @@ pub fn main() -> i32 { return 0 }`) assert.notStrictEqual(ownership?.loans.at(0)?.endRegion.ordinal, continuing?.region?.ordinal) }), ) + +it.effect('extends a view loan through a use nested in a place replace', () => + Effect.gen(function* () { + // The view's last use sits inside Place.replace's value operand; the owner write between the + // direct uses and that nested use must still count as access during the loan. + const self = yield* snapshot(`pub fn main() -> i32 { + let mut values = [1, 2] + let view = &values + let first = view[1] + values[0] = 40 + let mut sink = 0 + let old = Intrinsic.replace(sink, view[0]) + return first + old + values[0] +}`) + + assert.deepEqual( + Analysis.diagnostics(self).map((diagnostic) => diagnostic.code), + ['OWN0011'], + ) + }), +) diff --git a/packages/compiler/test/WasmBackend.test.ts b/packages/compiler/test/WasmBackend.test.ts index 7534588b..86524eb0 100644 --- a/packages/compiler/test/WasmBackend.test.ts +++ b/packages/compiler/test/WasmBackend.test.ts @@ -317,6 +317,29 @@ it.effect('keeps every valid match corpus case in three-engine agreement', () => }), ) +it.effect('keeps arithmetic convergence corpus cases in engine agreement', () => + Effect.gen(function* () { + // Remainder MIN/-1 traps, checked remainder answers None, rotate counts wrap, and float + // remainder is exact fmod — identically on wasm and the interpreter (the native acceptance + // differential covers the same programs through nativeCorpus). + const programs = corpus.filter( + (candidate) => + candidate.name.startsWith('arith-convergence-') || + candidate.name === 'finite-effect-join-capture-arity', + ) + assert.isAbove(programs.length, 0) + for (const program of programs) { + const executed = yield* run(program.source) + assert.strictEqual(executed, yield* interpret(program.source), program.name) + assert.strictEqual( + executed, + program.expected._tag === 'Completes' ? program.expected.result : 'trap', + program.name, + ) + } + }), +) + it.effect('agrees with the interpreter on partially annotated generic calls', () => Effect.gen(function* () { // A substitution seeded from an explicit prefix specializes the same way an inferred one @@ -643,7 +666,9 @@ it.effect( ['subtract', (a, b) => a - b], ['multiply', (a, b) => a * b], ['divide', (a, b) => (b === 0 ? undefined : Math.trunc(a / b))], - ['remainder', (a, b) => (b === 0 ? undefined : a % b)], + // MIN % -1 traps like the overflowing division it implies, even though wasm rem_s + // itself would answer 0. + ['remainder', (a, b) => (b === 0 || (a === minimum && b === -1) ? undefined : a % b)], ] const mismatches: Array = [] diff --git a/packages/compiler/test/support/corpus.ts b/packages/compiler/test/support/corpus.ts index 5cff0aae..037ad354 100644 --- a/packages/compiler/test/support/corpus.ts +++ b/packages/compiler/test/support/corpus.ts @@ -1052,6 +1052,25 @@ fn choose(input: First | Second) -> Effect { pub fn main() -> i32 { return run choose(First {}) }`, expected: { _tag: 'Completes', result: 41 }, }, + // Alternatives with different capture arities exercise the composite's unified payload lanes: + // every executor must place and read alternative captures through the registered calling shape. + { + name: 'finite-effect-join-capture-arity', + source: `struct First {} +struct Second {} +fn choose(input: First | Second, a: i32, b: i32, c: i32) -> Effect { + return match move input { + First {} => effect { return a + b + c } + Second {} => effect { return c } + } +} +pub fn main() -> i32 { + let wide = run choose(First {}, 11, 13, 16) + let narrow = run choose(Second {}, 11, 13, 2) + return wide + narrow +}`, + expected: { _tag: 'Completes', result: 42 }, + }, { name: 'finite-effect-join-selected-cleanup', source: `import silk.allocator { Allocator } @@ -1309,6 +1328,92 @@ return (40 + 2) * 1 'import silk.i32 as i32\npub fn main() -> i32 { return i32.add(i32.remainder(-7, 2), 43) }', expected: { _tag: 'Completes', result: 42 }, }, + // MIN % -1 traps identically everywhere: the quotient overflows even though the mathematical + // remainder is 0, matching ordinary arithmetic's invalid-remainder trap rule. + { + name: 'arith-convergence-remainder-min-trap', + source: `import silk.i32 as i32 +pub fn main() -> i32 { return i32.remainder(i32.subtract(-2147483647, 1), -1) }`, + expected: { _tag: 'Trap' }, + }, + { + name: 'arith-convergence-remainder-min-trap-i64', + source: `import silk.i64 as i64 +pub fn main() -> i32 { + let minimum = i64.subtract(-9223372036854775807, 1) + if i64.remainder(minimum, -1) != 0 { return 1 } + return 2 +}`, + expected: { _tag: 'Trap' }, + }, + // The checked variant answers None exactly where the ordinary operation traps. + { + name: 'arith-convergence-checked-remainder-min-none', + source: `import silk.i32 as i32 +import silk.option { Option } +pub fn main() -> i32 { + let minimum = i32.subtract(-2147483647, 1) + if Option.unwrapOr(i32.checkedRemainder(minimum, -1), 42) != 42 { return 1 } + if Option.unwrapOr(i32.checkedRemainder(7, -1), -1) != 0 { return 2 } + if Option.unwrapOr(i32.checkedRemainder(minimum, 2), -1) != 0 { return 3 } + if Option.unwrapOr(i32.checkedRemainder(7, 0), 42) != 42 { return 4 } + return 42 +}`, + expected: { _tag: 'Completes', result: 42 }, + }, + // Rotate counts wrap unsigned modulo the lane width, so negative counts rotate the other way + // instead of degenerating into a plain shift. + { + name: 'arith-convergence-rotate-negative-count', + source: `import silk.i32 as i32 +import silk.i64 as i64 +pub fn main() -> i32 { + if i32.rotateLeft(5, -1) != i32.rotateLeft(5, 31) { return 1 } + if i32.rotateLeft(5, -1) != -2147483646 { return 2 } + if i32.rotateRight(5, -1) != i32.rotateRight(5, 31) { return 3 } + if i32.rotateLeft(5, 33) != i32.rotateLeft(5, 1) { return 4 } + if i64.rotateLeft(5, -1) != i64.rotateLeft(5, 63) { return 5 } + return 42 +}`, + expected: { _tag: 'Completes', result: 42 }, + }, + // Float remainder is exact IEEE fmod on every executor: no intermediate overflow for extreme + // exponent differences and bit-exact results for ordinary operands. + { + name: 'arith-convergence-float-remainder-exact', + source: `import silk.f32 as f32 +import silk.f64 as f64 +fn infinity() -> f64 { return 1e308 * 10.0 } +pub fn main() -> i32 { + if f64.toBits(f64.remainder(10.5, 3.25)) != 4604930618986332160 { return 1 } + if f64.toBits(f64.remainder(1e308, 1e-308)) != 708093261633040 { return 2 } + if f64.remainder(5.0, infinity()) != 5.0 { return 3 } + if !f64.isNaN(f64.remainder(infinity(), 3.0)) { return 4 } + if !f64.isNaN(f64.remainder(5.0, 0.0)) { return 5 } + if !f64.isNaN(f64.remainder(f64.fromBits(9221120237041090560), 3.0)) { return 6 } + if !f64.isSignNegative(f64.remainder(-5.0, 1.0)) { return 7 } + if f64.remainder(-5.0, 1.0) != 0.0 { return 8 } + if f32.toBits(f32.remainder(3.4e38, 1.2e-38)) != 3993146 { return 9 } + if f32.toBits(f32.remainder(10.5, 3.25)) != 1061158912 { return 10 } + return 42 +}`, + expected: { _tag: 'Completes', result: 42 }, + }, + // A binding referenced only as an enum-value argument still becomes an effect capture. + { + name: 'arith-convergence-effect-enum-value-capture', + source: `import silk.i8 as i8 +enum(i8) Status { + Unknown = -1, + Ready = 41, +} +pub fn main() -> i32 { + let status = Status.Ready + let deferred = effect { return i8.toI32(Status.value(status)) + 1 } + return run deferred +}`, + expected: { _tag: 'Completes', result: 42 }, + }, { name: 'overflow-trap', source: 'import silk.i32 as i32\npub fn main() -> i32 { return i32.add(2147483647, 1) }', @@ -2403,6 +2508,49 @@ pub fn main() -> i32 { source: mixedServiceProviderSuspension, expected: { _tag: 'Completes', result: 42 }, }, + // Two suspendable specializations of the same wrapper differ only in contractRow (which + // provider satisfies the requirement). Each carries its own coroutine frame entry, so frame + // lookups must key on the full suspension identity including contractRow — a name-and-type- + // arguments match selects the wrong specialization's frame. + { + name: 'contract-row-suspension-frames', + source: `import silk.effect as Effect +service Value { + effect fn read() -> i32 ? &mut Value +} +struct DelayedA { value: i32 } +effect fn readA(self: &mut DelayedA) -> i32 { + let value = self.value + return run Effect.suspend(effect { return value }) +} +impl Value for DelayedA { read: DelayedA.readA } +struct DelayedB { value: i32 } +effect fn readB(self: &mut DelayedB) -> i32 { + let value = self.value + 1 + return run Effect.suspend(effect { return value }) +} +impl Value for DelayedB { read: DelayedB.readB } +effect fn use() -> i32 ? &mut Value { + let base = 1 + let got = run Value.read() + return got + base +} +effect fn first() -> i32 { + let mut provider = DelayedA { value: 19 } + return run Intrinsic.bindRequirementMut(use(), &mut provider) +} +effect fn second() -> i32 { + let mut provider = DelayedB { value: 20 } + return run Intrinsic.bindRequirementMut(use(), &mut provider) +} +effect fn program() -> i32 { + return (run first()) + (run second()) +} +pub fn main() -> i32 { + return run program() +}`, + expected: { _tag: 'Completes', result: 42 }, + }, // The float math conformance programs join the corpus so the native differential compiles and // runs each one, which is the third engine behind the evaluator and direct WebAssembly. ...floatMathPrograms.map((program) => ({ diff --git a/packages/language/docs/diagnostics.md b/packages/language/docs/diagnostics.md index 2e66cf09..f29ebdee 100644 --- a/packages/language/docs/diagnostics.md +++ b/packages/language/docs/diagnostics.md @@ -16,11 +16,11 @@ $ pnpm --filter @silk-effect/compiler documentation:generate | `LEX` | Lexical | 7 | | `PAR` | Parser | 4 | | `MOD` | Module | 3 | -| `SEM` | Semantic | 154 | +| `SEM` | Semantic | 155 | | `OWN` | Ownership | 16 | | `LAY` | Layout | 1 | -There are 185 codes in total. +There are 186 codes in total. ## Lexical (`LEX`) @@ -209,6 +209,7 @@ There are 185 codes in total. | `SEM0160` | Stable code for a scalar enum arm following an unguarded wildcard. | `` Enum match arm is unreachable after `_` `` | | `SEM0161` | Stable code for a scalar enum pattern naming a member of another enum. | `Enum pattern from cannot match ` | | `SEM0162` | Stable code for an integer literal pattern used against a scalar enum. | `Integer pattern cannot match enum ` | +| `SEM0163` | Stable code for effect-block return sites whose success types disagree. | `Effect block return sites have incompatible types: ` | ## Ownership (`OWN`) From 0503828c72af4f8cc0d39bba6ee7f3267d95326b Mon Sep 17 00:00:00 2001 From: Julia Ortiz Date: Wed, 26 Aug 2026 22:56:29 -0300 Subject: [PATCH 2/3] chore(openspec): archive compiler-review-stability-fixes Merges the 6 delta requirements into the main specs (integer/float scalar remainder and rotate convergence, effect-block terminal typing and captures, place-nested loan ends) and moves the change to the archive. Task 10.3's dedicated hook-release test is deferred to the tracked follow-up PR. --- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../bootstrap-floating-point-scalars/spec.md | 0 .../specs/bootstrap-flow-functions/spec.md | 0 .../specs/bootstrap-integer-scalars/spec.md | 0 .../specs/bootstrap-ownership/spec.md | 0 .../tasks.md | 0 .../bootstrap-floating-point-scalars/spec.md | 13 +++++++++ .../specs/bootstrap-flow-functions/spec.md | 28 +++++++++++++++++-- .../specs/bootstrap-integer-scalars/spec.md | 23 +++++++++++++-- openspec/specs/bootstrap-ownership/spec.md | 11 ++++++-- 12 files changed, 69 insertions(+), 6 deletions(-) rename openspec/changes/{compiler-review-stability-fixes => archive/2026-08-26-compiler-review-stability-fixes}/.openspec.yaml (100%) rename openspec/changes/{compiler-review-stability-fixes => archive/2026-08-26-compiler-review-stability-fixes}/design.md (100%) rename openspec/changes/{compiler-review-stability-fixes => archive/2026-08-26-compiler-review-stability-fixes}/proposal.md (100%) rename openspec/changes/{compiler-review-stability-fixes => archive/2026-08-26-compiler-review-stability-fixes}/specs/bootstrap-floating-point-scalars/spec.md (100%) rename openspec/changes/{compiler-review-stability-fixes => archive/2026-08-26-compiler-review-stability-fixes}/specs/bootstrap-flow-functions/spec.md (100%) rename openspec/changes/{compiler-review-stability-fixes => archive/2026-08-26-compiler-review-stability-fixes}/specs/bootstrap-integer-scalars/spec.md (100%) rename openspec/changes/{compiler-review-stability-fixes => archive/2026-08-26-compiler-review-stability-fixes}/specs/bootstrap-ownership/spec.md (100%) rename openspec/changes/{compiler-review-stability-fixes => archive/2026-08-26-compiler-review-stability-fixes}/tasks.md (100%) diff --git a/openspec/changes/compiler-review-stability-fixes/.openspec.yaml b/openspec/changes/archive/2026-08-26-compiler-review-stability-fixes/.openspec.yaml similarity index 100% rename from openspec/changes/compiler-review-stability-fixes/.openspec.yaml rename to openspec/changes/archive/2026-08-26-compiler-review-stability-fixes/.openspec.yaml diff --git a/openspec/changes/compiler-review-stability-fixes/design.md b/openspec/changes/archive/2026-08-26-compiler-review-stability-fixes/design.md similarity index 100% rename from openspec/changes/compiler-review-stability-fixes/design.md rename to openspec/changes/archive/2026-08-26-compiler-review-stability-fixes/design.md diff --git a/openspec/changes/compiler-review-stability-fixes/proposal.md b/openspec/changes/archive/2026-08-26-compiler-review-stability-fixes/proposal.md similarity index 100% rename from openspec/changes/compiler-review-stability-fixes/proposal.md rename to openspec/changes/archive/2026-08-26-compiler-review-stability-fixes/proposal.md diff --git a/openspec/changes/compiler-review-stability-fixes/specs/bootstrap-floating-point-scalars/spec.md b/openspec/changes/archive/2026-08-26-compiler-review-stability-fixes/specs/bootstrap-floating-point-scalars/spec.md similarity index 100% rename from openspec/changes/compiler-review-stability-fixes/specs/bootstrap-floating-point-scalars/spec.md rename to openspec/changes/archive/2026-08-26-compiler-review-stability-fixes/specs/bootstrap-floating-point-scalars/spec.md diff --git a/openspec/changes/compiler-review-stability-fixes/specs/bootstrap-flow-functions/spec.md b/openspec/changes/archive/2026-08-26-compiler-review-stability-fixes/specs/bootstrap-flow-functions/spec.md similarity index 100% rename from openspec/changes/compiler-review-stability-fixes/specs/bootstrap-flow-functions/spec.md rename to openspec/changes/archive/2026-08-26-compiler-review-stability-fixes/specs/bootstrap-flow-functions/spec.md diff --git a/openspec/changes/compiler-review-stability-fixes/specs/bootstrap-integer-scalars/spec.md b/openspec/changes/archive/2026-08-26-compiler-review-stability-fixes/specs/bootstrap-integer-scalars/spec.md similarity index 100% rename from openspec/changes/compiler-review-stability-fixes/specs/bootstrap-integer-scalars/spec.md rename to openspec/changes/archive/2026-08-26-compiler-review-stability-fixes/specs/bootstrap-integer-scalars/spec.md diff --git a/openspec/changes/compiler-review-stability-fixes/specs/bootstrap-ownership/spec.md b/openspec/changes/archive/2026-08-26-compiler-review-stability-fixes/specs/bootstrap-ownership/spec.md similarity index 100% rename from openspec/changes/compiler-review-stability-fixes/specs/bootstrap-ownership/spec.md rename to openspec/changes/archive/2026-08-26-compiler-review-stability-fixes/specs/bootstrap-ownership/spec.md diff --git a/openspec/changes/compiler-review-stability-fixes/tasks.md b/openspec/changes/archive/2026-08-26-compiler-review-stability-fixes/tasks.md similarity index 100% rename from openspec/changes/compiler-review-stability-fixes/tasks.md rename to openspec/changes/archive/2026-08-26-compiler-review-stability-fixes/tasks.md diff --git a/openspec/specs/bootstrap-floating-point-scalars/spec.md b/openspec/specs/bootstrap-floating-point-scalars/spec.md index 14908b44..2d2b8c1e 100644 --- a/openspec/specs/bootstrap-floating-point-scalars/spec.md +++ b/openspec/specs/bootstrap-floating-point-scalars/spec.md @@ -180,3 +180,16 @@ changing their deterministic semantics or engine parity. - **WHEN** a source wrapper converts an `f32` to and from its bit representation - **THEN** evaluation, native LLVM, and direct WebAssembly preserve the same bits through the concrete intrinsics + +### Requirement: Float remainder is exact IEEE fmod on every executor + +Floating-point `%` SHALL produce the exact IEEE-754 remainder (fmod semantics: the result of `x - n*y` where `n` is `x/y` truncated toward zero, computed without intermediate rounding or overflow) for both `f32` and `f64`, identically on the interpreter, the wasm backend, and the native backend. + +#### Scenario: Extreme-magnitude operands do not overflow +- **WHEN** a program evaluates `1e308 % 1e-308` as `f64` on any executor +- **THEN** the result is the exact finite fmod value in `[0, 1e-308)` — never infinity or NaN — identically on all three executors + +#### Scenario: Ordinary operands agree bit-for-bit +- **WHEN** the same float remainder expression is evaluated on the interpreter, the wasm backend, and the native backend +- **THEN** all three produce the identical bit pattern + diff --git a/openspec/specs/bootstrap-flow-functions/spec.md b/openspec/specs/bootstrap-flow-functions/spec.md index 9b7ca54c..c535898a 100644 --- a/openspec/specs/bootstrap-flow-functions/spec.md +++ b/openspec/specs/bootstrap-flow-functions/spec.md @@ -4,9 +4,7 @@ Define Silk's lazy statically shaped flow values and its exact owned typed-failure channel, including one-layer execution, propagation, recovery, and separation from unrecoverable traps. - ## Requirements - ### Requirement: Effect expressions and functions are lazy Evaluating `effect { ... }` SHALL construct `Effect` without entering its body. @@ -565,6 +563,7 @@ access, and cleanup MUST remain equivalent across those forms. - **WHEN** a provided Effect succeeds with an affine value that a mapper consumes - **THEN** the mapper receives ownership exactly once and every remaining owned component is cleaned exactly once + ### Requirement: Effect exposes three transformable channels An Effect contract SHALL treat success `A` and typed failure `E` as covariant output channels and @@ -876,3 +875,28 @@ compatible representation SHALL retain a source diagnostic. - **WHEN** equivalent joined Effects are evaluated and compiled repeatedly - **THEN** all engines produce the same typed outcome, ownership cleanup, and deterministic artifact identity + +### Requirement: Effect-block result typing accounts for every terminal + +An effect block's success and failure types SHALL be derived from every `return` and `fail` terminal reachable in the block, including terminals nested inside `unsafe` blocks. Return sites with differing types SHALL combine through the language's canonical result join — never by silently adopting one site's type: joinable types form their union, and a join with no representable form is reported as a diagnostic at the offending return. A `fail` whose failure type is a value-kind type parameter SHALL contribute that parameter to the block's failure row exactly as a nominal failure would. + +#### Scenario: Terminals inside unsafe blocks are collected +- **WHEN** an effect block's only `fail` (or only `return`) sits inside an `unsafe { }` statement +- **THEN** the block's failure row (or success type) includes it, and running the effect requires handling the failure + +#### Scenario: Disagreeing branch returns cannot pass silently +- **WHEN** an effect block returns `bool` on one branch and `i32` on another inside a context expecting `Effect` +- **THEN** the block types as the canonical join (`Effect`) and the context rejects it with a type-mismatch diagnostic — the block is never typed from the lexically last return alone + +#### Scenario: Generic failures survive into the failure row +- **WHEN** a generic function's effect block fails with a value of type parameter `E` +- **THEN** the block types as an effect whose failure row contains `E`, and after specialization the concrete failure must be handled at `run` + +### Requirement: Effect-block captures include enum-value arguments + +Capture analysis for effect blocks SHALL register a capture for every binding referenced anywhere in the block body, including bindings referenced as the argument of an enum value construction. + +#### Scenario: Enum.value argument is captured +- **WHEN** an effect block's body evaluates `Color.value(c)` for an outer binding `c` +- **THEN** `c` appears in the effect's capture environment and the deferred runner reads the captured value + diff --git a/openspec/specs/bootstrap-integer-scalars/spec.md b/openspec/specs/bootstrap-integer-scalars/spec.md index 0fbf0f11..22fcdb6b 100644 --- a/openspec/specs/bootstrap-integer-scalars/spec.md +++ b/openspec/specs/bootstrap-integer-scalars/spec.md @@ -3,9 +3,7 @@ ## Purpose Define Silk's complete bootstrap integer vocabulary, ergonomic unit and bottom forms, exact literals, conversions, operation modes, and cross-engine behavior for real width-conscious programs. - ## Requirements - ### Requirement: Integer primitive spellings are lowercase and closed Silk SHALL recognize exactly `bool`, `u8`, `u16`, `u32`, `u64`, `usize`, `i8`, `i16`, `i32`, `i64`, and `isize` as the integer and Boolean primitive spellings. Uppercase spellings such as `Bool`, `I32`, and `Usize` MUST NOT remain aliases. @@ -154,3 +152,24 @@ standard-library source backed by the smallest concrete Intrinsic primitives. - **WHEN** explicit conversion receives any valid `char` - **THEN** it returns the exact corresponding `u32` + +### Requirement: Signed remainder overflow semantics are identical across executors + +Signed integer `%` with operands `MIN` and `-1` SHALL trap on every executor (interpreter, wasm, native), consistent with the existing rule that ordinary arithmetic traps on invalid division/remainder. The checked remainder of `MIN` and `-1` SHALL return `None` on every executor, and no executor SHALL evaluate it through an operation whose result is undefined for those operands. + +#### Scenario: Ordinary remainder of MIN by -1 traps everywhere +- **WHEN** a program evaluates `i32::MIN % -1` (or the equivalent for any signed width) on any executor +- **THEN** execution traps, and the same program traps identically on the interpreter, the wasm backend, and the native backend + +#### Scenario: Checked remainder of MIN by -1 is None everywhere +- **WHEN** a program evaluates the checked remainder of `i32::MIN` and `-1` on any executor +- **THEN** the result is `None`, identically on the interpreter, the wasm backend, and the native backend + +### Requirement: Rotate counts wrap modulo lane width on every executor + +Rotate-left and rotate-right SHALL interpret the count modulo the operand's bit width using an unsigned (Euclidean) reduction, so negative and out-of-range counts wrap instead of degenerating, identically on every executor. + +#### Scenario: Rotate by a negative count wraps +- **WHEN** a program evaluates `rotate_left(x, -1)` on an odd `i32` value on any executor +- **THEN** the result equals `rotate_left(x, 31)` — the low bit wraps into bit 31 — identically on the interpreter, the wasm backend, and the native backend + diff --git a/openspec/specs/bootstrap-ownership/spec.md b/openspec/specs/bootstrap-ownership/spec.md index 7ac8a2a9..f3f7f801 100644 --- a/openspec/specs/bootstrap-ownership/spec.md +++ b/openspec/specs/bootstrap-ownership/spec.md @@ -122,8 +122,6 @@ path-sensitive analysis. - **WHEN** one arm moves a body binding and the trailing return reads it - **THEN** the later read is an `OWN0001` violation even though the move was conditional - - ### Requirement: Copy is one sealed validated property A type SHALL be Copy only through the compiler's single sealed Copy property. A user MAY declare @@ -1027,3 +1025,12 @@ rules, and cleanup plans SHALL contain no enum-specific release or drop operatio - **WHEN** endpoint invocation borrows `O` and reentrant source destroys the Execution - **THEN** ownership records deferred cleanup and does not end the endpoint borrow or clean `O` or `R` until invocation returns + +### Requirement: Loan live-ranges account for uses nested in place and effect expressions + +Loan-end analysis SHALL treat identifier and callable occurrences nested inside place-replace, effect-result, and requirement-binding expressions as uses at that occurrence: they SHALL extend the enclosing loan's live range and SHALL invalidate any earlier record that treated the callable's last invocation as its final use. + +#### Scenario: View used inside a place replace keeps its loan live +- **WHEN** a shared view's last use sits inside a place-replace expression's value operand and the borrowed owner is mutated between the view's direct uses and that nested use +- **THEN** ownership analysis reports owner access during the loan — the view loan's live range extends through the place-replace use rather than ending at the last direct use + From ea5b4d1e936f5659f4c6868d6c2968e3c1e925da Mon Sep 17 00:00:00 2001 From: Julia Ortiz Date: Wed, 26 Aug 2026 23:14:42 -0300 Subject: [PATCH 3/3] fix(compiler): link libm for native executables The native backend emits LLVM frem for float remainder, which lowers to an fmod/fmodf libcall. On Linux libm is separate from libc, so the first corpus program exercising float % broke the CI link step (undefined reference to fmod); macOS folds libm into libSystem, which is why local runs passed. Pass -lm on the one native link invocation. --- packages/compiler/src/Driver.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/compiler/src/Driver.ts b/packages/compiler/src/Driver.ts index 8d67bba4..285c96d2 100644 --- a/packages/compiler/src/Driver.ts +++ b/packages/compiler/src/Driver.ts @@ -437,7 +437,9 @@ export const compile = Effect.fn('Driver.compile')(function* ( scope, target, [object.artifact, shim.artifact], - [], + // Float remainder lowers to LLVM `frem`, which becomes an fmod/fmodf libcall on + // targets whose libm is separate from libc (Linux); macOS folds it into libSystem. + ['m'], request.destination, ), () => 1,