Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,74 @@ and this project adheres to [Semantic Versioning](https://semver.org/).

## [Unreleased]

### Changed

- **FFI `float64` parameter conversion is now precision-aware.** Numeric arguments
passed across the FFI to Go `float64` parameters are checked for lossless
representability under the default ("strict") mode:
- `*BigFloat` is now accepted when the value fits `float64` losslessly;
previously the FFI rejected it via `werr.ErrTypeConversion`. Lossy
`*BigFloat` (mantissa beyond 53 bits, or magnitude beyond `float64` range)
now errors with `werr.ErrLossyConversion`.
- `*BigInteger` overflow newly errors. Previously the FFI silently truncated
to `±Inf` via `(*big.Int).Float64()`'s discarded accuracy bit; now returns
`ErrLossyConversion` with the direction (`Above` / `Below`) named in the
message.
- `*Rational` non-representable newly errors. Previously `(/ 1 3)` passed to
a `float64` parameter silently rounded to `0.333…`; now errors with
`ErrLossyConversion`.
- Passing `*Complex` or `*BigComplex` to a Go `float64` parameter now returns
`ErrLossyConversion` (via the new `!isReal` branch of
`values.ToFloat64Lossless`) instead of the previous `ErrTypeConversion`.
Embedders matching on `errors.Is(err, ErrTypeConversion)` to catch
"complex passed where real expected" should add `errors.Is(err,
ErrLossyConversion)`.

Embedders relying on the previous silent-truncation path can recover it via
the new `WithLossyConversionsAllowed()` engine option.

- **FFI `complex128` parameter conversion is now supported.** Go functions
taking `complex128` parameters can now be registered. Previously, registration
failed with `ErrFFIRegistration`. `*Complex` and `*BigComplex` arguments
convert with per-component precision tracking; under strict mode, any
component that rounds returns `ErrLossyConversion`. Complex *return* values
and complex callback parameters remain unsupported (`makeRetConverter` has no
`complex128` arm).

- **`registry/helpers/value_conv.ToFloat64` tightened.** Previously silently
truncated `*BigFloat` overflow, `*BigInteger` overflow, and `*Rational` with
non-representable denominators (e.g., `1/3`). Now errors with
`werr.ErrLossyConversion` on loss. Same-precision inputs (`*Integer`,
`*Float`, exact-power-of-2 `*Rational`, etc.) continue to succeed
unchanged. Migration: callers needing the silent-truncation behavior should
call `values.ToFloat64WithAccuracy` and discard the accuracy slot. The only
in-tree caller affected was `(atan y x)`, which now uses the lossy-allowed
path directly (R7RS §6.2.6 inherently returns inexact, so silent loss is
load-bearing there).

### Added

- **`wile.WithLossyConversionsAllowed()` engine option** — opt-in flag
suppressing `ErrLossyConversion` returns from FFI converters. When set, the
Float64 converter calls `values.ToFloat64WithAccuracy` and discards the
accuracy / `isReal` flags; the Complex128 converter projects the value slot
and discards per-component accuracies. Per-engine; the flag is captured at
`RegisterFunc` time so changes after registration do not affect already-built
FFI closures.

- **`werr.ErrLossyConversion` sentinel** — new static error distinct from
`ErrNotAReal` (real-vs-complex domain mismatch) and `ErrTypeConversion`
(`reflect.Kind` mismatch). Callers can `errors.Is` against it to detect
precision-loss specifically.

- **`values.ToFloat64WithAccuracy`, `values.ToFloat64Lossless`,
`values.ToComplex128WithAccuracy`, `values.ToComplex128Lossless`** — public
helpers surfacing Go's `big.Accuracy` three-valued enum (`big.Below` /
`big.Exact` / `big.Above`) at the cross-package boundary. `WithAccuracy`
forms return the raw value plus accuracy slots; `Lossless` forms return
`ErrLossyConversion` when any component would round. See
`values/conversion.go`.

### Fixed

- `syntax-case` now propagates non-`ErrNotAMatch` matcher errors instead of silently translating them to "no matching clause". Context cancellations and malformed-input errors during pattern matching surface with the actual diagnostic instead of the misleading no-match message. (#732)
Expand Down
1 change: 1 addition & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ Directions documents — identify prioritized capability extensions. Priority se
- [x] **Environment package structural reduction** [Medium-High, mixed XS/S/M, Done — Phase 10 deferred] — **Phases 1–9 shipped (PR #730, 2026-05-10).** Closed Tier A.2 of the roadmap. 10 findings + 4 opportunities from `/structural-reduction ./environment` (2026-05-09). Findings 1, 2, 3, 4, 5, 6, 7, 8, 9 implemented (dead-code drops + `Namespace.root()` extraction + `bestOf[T]` reducer + `Binding` accessor collapse + 5 Namespace constructors → `NewChildNamespace` + options + `BindingTypeUnknown` documented + `EnvironmentFrame` delegation surface documented). Phase 10 (Finding 10 — `*LocalIndex` allocation audit across 40 sites; unboxed `slot, depth int` fast path already exists) **deferred — benchmark-gated** per the recommended phasing; re-open if a measured allocation win surfaces. `plans/2026-05-09-environment-structural-reduction.md`
- [ ] **Bidirectional opcode conversion test** [Medium, S]: Verify `operationToInstruction` and `instructionToOperation` cover the same opcode set.
- [ ] **LocalEnvironmentFrame pointer ambiguity** [Low, S]: Doc comment on `NewLocalEnvironment` explaining lifecycle (value-vs-pointer ownership).
- [ ] **Unify `atan2Operand` with `helpers.ToFloat64`** [Low, S]: PR #754 surfaced 3-lens convergence on a duplication. `extensions/math/prim_transcendental.go::atan2Operand` re-implements the Number-assertion → ComplexNumber-rejection → float64-extraction sequence that `helpers.ToFloat64` performs, just to swap the loss-policy knob from "strict" to "silent truncate." Two call sites today (both inside the same function). Right shape: add `helpers.ToFloat64Lossy` (or extend `ToFloat64` with a `Lossy` variant via the existing `values.ToFloat64WithAccuracy` discard) so the math extension does not re-derive real-vs-complex screening locally. Deferred from PR 2 to keep the loss-signals scope tight.

### Tech Debt Plan (remaining)

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v1.15.133
v1.15.140
44 changes: 23 additions & 21 deletions engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,18 +55,19 @@ const DefaultMaxCallDepth int = 10000
// SRFI-18 threads within a single Engine are safe — the VM handles
// thread coordination internally.
type Engine struct {
namespace *environment.Namespace
env *environment.EnvironmentFrame
registry *registry.Registry
debugger *Debugger
lastCounters machine.VMCounters
closers []registry.Closeable
closed bool
maxCallDepth int
maxStackSize uint64
inlineThreshold int
contractEnforcement bool // propagated to RegisterPrimitive via cfg
coverageCollector *coverage.Collector
namespace *environment.Namespace
env *environment.EnvironmentFrame
registry *registry.Registry
debugger *Debugger
lastCounters machine.VMCounters
closers []registry.Closeable
closed bool
maxCallDepth int
maxStackSize uint64
inlineThreshold int
contractEnforcement bool // propagated to RegisterPrimitive via cfg
lossyConversionsAllowed bool // captured into FFI closures at RegisterFunc time
coverageCollector *coverage.Collector

exportIndexMu sync.Mutex
exportIndexBuilt bool
Expand Down Expand Up @@ -246,15 +247,16 @@ func NewEngine(ctx context.Context, opts ...EngineOption) (*Engine, error) {
}

q := &Engine{
namespace: ns,
env: env,
registry: reg,
closers: closers,
maxCallDepth: cfg.maxCallDepth,
maxStackSize: cfg.maxStackSize,
inlineThreshold: cfg.inlineThreshold,
contractEnforcement: cfg.contractEnforcement,
coverageCollector: cfg.coverageCollector,
namespace: ns,
env: env,
registry: reg,
closers: closers,
maxCallDepth: cfg.maxCallDepth,
maxStackSize: cfg.maxStackSize,
inlineThreshold: cfg.inlineThreshold,
contractEnforcement: cfg.contractEnforcement,
lossyConversionsAllowed: cfg.lossyConversionsAllowed,
coverageCollector: cfg.coverageCollector,
}
return q, nil
}
Expand Down
28 changes: 26 additions & 2 deletions extensions/math/prim_transcendental.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,14 @@ func PrimAtan(mc machine.CallContext) error {
}
mc.SetValue(helpers.ComplexOrFloat(cmplx.Atan(z)))
} else {
y, err := helpers.ToFloat64(o)
// atan2 inherently returns an inexact result, so silent
// lossy conversion of *Rational / *BigFloat operands is
// load-bearing for R7RS §6.2.6 semantics. Bypass the
// PR-2 tightening on helpers.ToFloat64 by going through
// the WithAccuracy variant directly and discarding the
// per-component accuracy slot. See plans/2026-05-14-
// numeric-loss-signals-design.md §R8.
y, err := atan2Operand(o)
if err != nil {
return werr.WrapForeignErrorf(err, "atan: %v", err)
}
Expand All @@ -100,7 +107,7 @@ func PrimAtan(mc machine.CallContext) error {
if !values.IsEmptyList(xArg.Cdr()) {
return werr.WrapForeignErrorf(werr.ErrWrongNumberOfArguments, "atan: expected 1 or 2 arguments")
}
x, err := helpers.ToFloat64(xArg.Car())
x, err := atan2Operand(xArg.Car())
if err != nil {
return werr.WrapForeignErrorf(err, "atan: %v", err)
}
Expand All @@ -109,6 +116,23 @@ func PrimAtan(mc machine.CallContext) error {
return nil
}

// atan2Operand extracts a float64 from a real Scheme number, preserving the
// pre-PR-2 silent-truncation behavior that helpers.ToFloat64 used to provide.
// Used by (atan y x), where lossy conversion is semantically correct because
// the result is inherently inexact.
func atan2Operand(v values.Value) (float64, error) {
n, ok := v.(values.Number)
if !ok {
return 0, werr.WrapForeignErrorf(werr.ErrNotAReal, "expected a real number but got %T", v)
}
_, isComplex := n.(values.ComplexNumber)
if isComplex {
return 0, werr.WrapForeignErrorf(werr.ErrNotAReal, "expected a real number but got %T", v)
}
f, _, _, _ := values.ToFloat64WithAccuracy(n)
return f, nil
}

// PrimSqrt implements the (sqrt) primitive.
//
// R7RS §6.2.6: The branch cut for sqrt lies along the negative real axis,
Expand Down
12 changes: 12 additions & 0 deletions extensions/math/prim_transcendental_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,18 @@ func TestTranscendental(t *testing.T) {
// atan (two args — atan2)
{"atan2 diagonal", `(< (abs (- (atan 1 1) 0.7853981633974483)) 1e-10)`, values.TrueValue},
{"atan2 y-axis", `(< (abs (- (atan 1 0) 1.5707963267948966)) 1e-10)`, values.TrueValue},
// PR-2 migration regression: atan2 must accept lossy real
// operands (1/3, BigFloat overflow, etc.) per R7RS §6.2.6
// since the result is inherently inexact. atan2Operand
// goes through ToFloat64WithAccuracy and discards the
// accuracy slot instead of helpers.ToFloat64 (which now
// errors on lossy inputs). A future reversion would surface
// here as a failing test.
{"atan2 rational y", `(< (abs (- (atan 1/3 1) 0.3217505543966422)) 1e-10)`, values.TrueValue},
{"atan2 rational x", `(< (abs (- (atan 1 1/3) 1.2490457723982544)) 1e-10)`, values.TrueValue},
{"atan2 rational both", `(< (abs (- (atan 1/3 2/7) 0.8621700546672261)) 1e-10)`, values.TrueValue},
{"atan2 big float operand",
`(< (abs (- (atan 1 (+ 1.0 (expt 10 60))) 1e-60)) 1e-50)`, values.TrueValue},

// sqrt
{"sqrt perfect square", `(< (abs (- (sqrt 4) 2.0)) 1e-10)`, values.TrueValue},
Expand Down
8 changes: 5 additions & 3 deletions ffi.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ type ffiSpec struct {
//
// Returns an error wrapping [werr.ErrFFIRegistration] if fn is not a function or uses unsupported types.
func (p *Engine) RegisterFunc(name string, fn any) error {
spec, err := buildFFISpec(name, fn)
spec, err := buildFFISpec(name, fn, p.lossyConversionsAllowed)
if err != nil {
return err
}
Expand Down Expand Up @@ -171,7 +171,9 @@ func (p *Engine) RegisterFuncs(funcs map[string]any) error {
}

// buildFFISpec reflects on fn to produce an ffiSpec with pre-computed converters.
func buildFFISpec(name string, fn any) (*ffiSpec, error) {
// lossyAllowed is captured into the Float64/Complex128 leaf converters so each
// FFI registration freezes its loss policy at RegisterFunc time.
func buildFFISpec(name string, fn any, lossyAllowed bool) (*ffiSpec, error) {
fnType := reflect.TypeOf(fn)
if fnType == nil || fnType.Kind() != reflect.Func {
return nil, werr.WrapForeignErrorf(werr.ErrFFIRegistration, "RegisterFunc %q: not a function", name)
Expand Down Expand Up @@ -217,7 +219,7 @@ func buildFFISpec(name string, fn any) (*ffiSpec, error) {
if spec.isVariadic && i == fnType.NumIn()-1 {
convType = paramType.Elem()
}
conv, err := makeArgConverter(name, idx+1, convType)
conv, err := makeArgConverter(name, idx+1, convType, lossyAllowed)
if err != nil {
return nil, err
}
Expand Down
Loading
Loading