diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e27e990..f9728847 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/TODO.md b/TODO.md index e0c415bf..bf196442 100644 --- a/TODO.md +++ b/TODO.md @@ -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) diff --git a/VERSION b/VERSION index c11b74ad..b85b1049 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v1.15.133 +v1.15.140 diff --git a/engine.go b/engine.go index 0262626a..cdd00c6f 100644 --- a/engine.go +++ b/engine.go @@ -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 @@ -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 } diff --git a/extensions/math/prim_transcendental.go b/extensions/math/prim_transcendental.go index 6acda005..28ad18c0 100644 --- a/extensions/math/prim_transcendental.go +++ b/extensions/math/prim_transcendental.go @@ -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) } @@ -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) } @@ -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, diff --git a/extensions/math/prim_transcendental_test.go b/extensions/math/prim_transcendental_test.go index d1a9e3ad..4705e35c 100644 --- a/extensions/math/prim_transcendental_test.go +++ b/extensions/math/prim_transcendental_test.go @@ -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}, diff --git a/ffi.go b/ffi.go index 574571e4..a5e33a38 100644 --- a/ffi.go +++ b/ffi.go @@ -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 } @@ -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) @@ -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 } diff --git a/ffi_arg_converters.go b/ffi_arg_converters.go index 99134493..bf18048d 100644 --- a/ffi_arg_converters.go +++ b/ffi_arg_converters.go @@ -28,7 +28,12 @@ import ( // makeArgConverter creates a converter for a single Go parameter type. // Converters are recursive: composite types (slices, maps, structs) build // inner converters for their element/field types at registration time. -func makeArgConverter(name string, pos int, t reflect.Type) (argConverter, error) { +// +// The lossyAllowed flag travels with the converter chain. The Float64 and +// Complex128 leaves consult it to choose between values.ToFloat64Lossless +// (strict — errors on precision loss) and values.ToFloat64WithAccuracy +// (lossy-allowed — silent truncation). +func makeArgConverter(name string, pos int, t reflect.Type, lossyAllowed bool) (argConverter, error) { // Only accept the exact wile.Value interface type. Concrete Value // implementers (e.g., *values.Integer) would cause reflect.Call to panic // since the converter produces a *wrappedValue, not the concrete type. @@ -76,23 +81,54 @@ func makeArgConverter(name string, pos int, t reflect.Type) (argConverter, error case reflect.Float64: targetType := t return func(_ *MachineContext, v values.Value) (reflect.Value, error) { - switch n := v.(type) { - case *values.Float: - return reflect.ValueOf(n.Value).Convert(targetType), nil - case *values.Integer: - return reflect.ValueOf(float64(n.Value)).Convert(targetType), nil - case *values.BigInteger: - if n.BigInt().IsInt64() { - return reflect.ValueOf(float64(n.Int64())).Convert(targetType), nil + n, ok := v.(values.Number) + if !ok { + return reflect.Value{}, fmtArgError(name, pos, "number", v) + } + var f float64 + if lossyAllowed { + // Type-asserted to Number above; the error path is + // only reachable for nil-Number, which cannot occur + // here. Discard accuracy + isReal + err deliberately. + f, _, _, _ = values.ToFloat64WithAccuracy(n) + } else { + lossless, err := values.ToFloat64Lossless(n) + if err != nil { + return reflect.Value{}, werr.WrapForeignErrorf( + err, + "%s: argument %d: %s", name, pos, v.SchemeString(), + ) } - f, _ := n.BigInt().Float64() - return reflect.ValueOf(f).Convert(targetType), nil - case *values.Rational: - f, _ := n.Rat().Float64() - return reflect.ValueOf(f).Convert(targetType), nil - default: + f = lossless + } + return reflect.ValueOf(f).Convert(targetType), nil + }, nil + + case reflect.Complex128: + targetType := t + return func(_ *MachineContext, v values.Value) (reflect.Value, error) { + n, ok := v.(values.Number) + if !ok { return reflect.Value{}, fmtArgError(name, pos, "number", v) } + var c complex128 + if lossyAllowed { + // Type-asserted above; the error path is unreachable. + // Project the value slot; discard per-component + // accuracies deliberately. + res, _ := values.ToComplex128WithAccuracy(n) + c = res.Value + } else { + lossless, err := values.ToComplex128Lossless(n) + if err != nil { + return reflect.Value{}, werr.WrapForeignErrorf( + err, + "%s: argument %d: %s", name, pos, v.SchemeString(), + ) + } + c = lossless + } + return reflect.ValueOf(c).Convert(targetType), nil }, nil case reflect.String: @@ -116,16 +152,16 @@ func makeArgConverter(name string, pos int, t reflect.Type) (argConverter, error }, nil case reflect.Slice: - return makeSliceArgConverter(name, pos, t) + return makeSliceArgConverter(name, pos, t, lossyAllowed) case reflect.Map: - return makeMapArgConverter(name, pos, t) + return makeMapArgConverter(name, pos, t, lossyAllowed) case reflect.Struct: - return makeStructArgConverter(name, pos, t) + return makeStructArgConverter(name, pos, t, lossyAllowed) case reflect.Func: - return makeCallbackArgConverter(name, pos, t) + return makeCallbackArgConverter(name, pos, t, lossyAllowed) default: return nil, werr.WrapForeignErrorf( @@ -138,7 +174,7 @@ func makeArgConverter(name string, pos int, t reflect.Type) (argConverter, error // makeSliceArgConverter creates a converter for Go slice types. // []byte is special-cased to ByteVector; all other element types use // recursive inner converters that walk Scheme proper lists. -func makeSliceArgConverter(name string, pos int, t reflect.Type) (argConverter, error) { +func makeSliceArgConverter(name string, pos int, t reflect.Type, lossyAllowed bool) (argConverter, error) { elemType := t.Elem() // []byte special case: ByteVector. @@ -153,7 +189,7 @@ func makeSliceArgConverter(name string, pos int, t reflect.Type) (argConverter, } // Typed slice: build inner converter for element type. - elemConv, err := makeArgConverter(name, pos, elemType) + elemConv, err := makeArgConverter(name, pos, elemType, lossyAllowed) if err != nil { return nil, err } @@ -185,7 +221,7 @@ func makeSliceArgConverter(name string, pos int, t reflect.Type) (argConverter, // makeMapArgConverter creates a converter for Go map types. // Key types are restricted to Go types that produce Hashable Scheme values. -func makeMapArgConverter(name string, pos int, t reflect.Type) (argConverter, error) { +func makeMapArgConverter(name string, pos int, t reflect.Type, lossyAllowed bool) (argConverter, error) { keyType := t.Key() valType := t.Elem() @@ -197,11 +233,11 @@ func makeMapArgConverter(name string, pos int, t reflect.Type) (argConverter, er ) } - keyConv, err := makeArgConverter(name, pos, keyType) + keyConv, err := makeArgConverter(name, pos, keyType, lossyAllowed) if err != nil { return nil, err } - valConv, err := makeArgConverter(name, pos, valType) + valConv, err := makeArgConverter(name, pos, valType, lossyAllowed) if err != nil { return nil, err } @@ -235,7 +271,7 @@ func makeMapArgConverter(name string, pos int, t reflect.Type) (argConverter, er // makeStructArgConverter creates a converter for Go struct types. // Scheme alists ((FieldName . value) ...) are mapped to struct fields by // matching the car symbol against exported field names. -func makeStructArgConverter(name string, pos int, t reflect.Type) (argConverter, error) { +func makeStructArgConverter(name string, pos int, t reflect.Type, lossyAllowed bool) (argConverter, error) { type fieldInfo struct { index int conv argConverter @@ -247,7 +283,7 @@ func makeStructArgConverter(name string, pos int, t reflect.Type) (argConverter, if !f.IsExported() { continue } - conv, err := makeArgConverter(name, pos, f.Type) + conv, err := makeArgConverter(name, pos, f.Type, lossyAllowed) if err != nil { return nil, err } @@ -311,7 +347,7 @@ func makeStructArgConverter(name string, pos int, t reflect.Type) (argConverter, // The direction of inner converters is inverted relative to the outer function: // callback parameters use retConverters (Go→Scheme) and callback returns use // argConverters (Scheme→Go), since data flows in the opposite direction. -func makeCallbackArgConverter(name string, pos int, t reflect.Type) (argConverter, error) { +func makeCallbackArgConverter(name string, pos int, t reflect.Type, lossyAllowed bool) (argConverter, error) { // Build Go→Scheme converters for callback parameters. numIn := t.NumIn() paramConvs := make([]retConverter, numIn) @@ -336,7 +372,7 @@ func makeCallbackArgConverter(name string, pos int, t reflect.Type) (argConverte hasErrorReturn := shape.hasError var resultConv argConverter if shape.valueType != nil { - conv, err := makeArgConverter(name, pos, shape.valueType) + conv, err := makeArgConverter(name, pos, shape.valueType, lossyAllowed) if err != nil { return nil, err } diff --git a/ffi_loss_signals_test.go b/ffi_loss_signals_test.go new file mode 100644 index 00000000..7cb92718 --- /dev/null +++ b/ffi_loss_signals_test.go @@ -0,0 +1,331 @@ +// Copyright 2026 Aaron Alpar +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package wile_test + +import ( + "context" + "errors" + "testing" + + "github.com/aalpar/wile" + extmath "github.com/aalpar/wile/extensions/math" + "github.com/aalpar/wile/werr" + + qt "github.com/frankban/quicktest" +) + +// --- PR 2 — numeric loss signals: FFI Float64 + Complex128 tests --- + +// newEngineWithMath constructs a default engine with the math extension +// enabled; the loss-signal tests need expt and make-rectangular to +// construct BigInteger / BigFloat / BigComplex test inputs from Scheme. +func newEngineWithMath(t *testing.T) *wile.Engine { + t.Helper() + engine, err := wile.NewEngine(context.Background(), + wile.WithExtension(extmath.Extension)) + if err != nil { + t.Fatal(err) + } + return engine +} + +// newEngineLossy constructs an engine with WithLossyConversionsAllowed +// and the math extension enabled. +func newEngineLossy(t *testing.T) *wile.Engine { + t.Helper() + engine, err := wile.NewEngine(context.Background(), + wile.WithLossyConversionsAllowed(), + wile.WithExtension(extmath.Extension)) + if err != nil { + t.Fatal(err) + } + return engine +} + +// TestRegisterFuncFloat64StrictModeLossless verifies that Float64 FFI +// parameters accept inputs that fit float64 exactly under the default +// strict mode. All inputs here are exactly representable; none should error. +func TestRegisterFuncFloat64StrictModeLossless(t *testing.T) { + c := qt.New(t) + engine := newEngineWithMath(t) + + err := engine.RegisterFunc("take-float", func(f float64) float64 { + return f + }) + c.Assert(err, qt.IsNil) + + tcs := []struct { + name string + code string + want string + }{ + {"integer 42", `(take-float 42)`, "42.0"}, + {"integer negative", `(take-float -7)`, "-7.0"}, + {"float literal 0.5", `(take-float 0.5)`, "0.5"}, + {"float 3.0", `(take-float 3.0)`, "3.0"}, + {"rational 1/2 (power-of-2 denom)", `(take-float 1/2)`, "0.5"}, + // (expt 2 53) = 2^53, the largest exact integer in float64. + {"power-of-2 boundary 2^53", `(take-float (expt 2 53))`, "9007199254740992.0"}, + // MinInt64 = -2^63, an exact power of 2 (one sign bit + leading + // mantissa bit). float64 renders it via printer rounding to + // nearest representable decimal. + {"math.MinInt64 (exact power of 2)", + `(take-float -9223372036854775808)`, "-9223372036854776000.0"}, + } + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + result := eval(t, engine, tc.code) + c.Assert(result.SchemeString(), qt.Equals, tc.want) + }) + } +} + +// TestRegisterFuncFloat64StrictModeLossy verifies that Float64 FFI +// parameters reject inputs that cannot fit float64 exactly under strict +// mode (the default). Pre-PR-2 these all succeeded silently; now they +// return ErrLossyConversion. +func TestRegisterFuncFloat64StrictModeLossy(t *testing.T) { + c := qt.New(t) + engine := newEngineWithMath(t) + + err := engine.RegisterFunc("take-float", func(f float64) float64 { + return f + }) + c.Assert(err, qt.IsNil) + + tcs := []struct { + name string + code string + }{ + // 1/3 is rational but not exactly representable in binary + // float64 (denominator is not a power of 2). + {"rational 1/3 (non-power-of-2 denom)", `(take-float 1/3)`}, + // (+ 1.0 (expt 10 60)) constructs a *BigFloat (mixing an + // inexact unit with a 60-digit exact integer forces + // arbitrary-precision arithmetic). Float64 can't hold all + // 60 decimal digits; conversion rounds with non-Exact accuracy. + {"big float lossy mantissa", `(take-float (+ 1.0 (expt 10 60)))`}, + // 2^100 + 1: BigInteger whose mantissa requires more than + // 53 bits, so float64 can't preserve every digit. + {"big integer precision loss", + `(take-float (+ (expt 2 100) 1))`}, + // math.MaxInt64 = 2^63 - 1 is a BigInteger-fits-int64 value + // that float64 rounds up to 2^63 (accuracy Above). + {"int64 max value (rounds Above)", + `(take-float 9223372036854775807)`}, + } + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + err := evalExpectError(t, engine, tc.code) + c.Assert(errors.Is(err, werr.ErrLossyConversion), qt.IsTrue, + qt.Commentf("expected ErrLossyConversion, got: %v", err)) + }) + } +} + +// TestRegisterFuncFloat64LossyAllowedMode verifies that the same lossy +// inputs succeed (with silently-rounded values) when +// WithLossyConversionsAllowed is set. Assertions check the exact +// IEEE-754-rounded result (rounding is deterministic). +func TestRegisterFuncFloat64LossyAllowedMode(t *testing.T) { + c := qt.New(t) + engine := newEngineLossy(t) + + err := engine.RegisterFunc("take-float", func(f float64) float64 { + return f + }) + c.Assert(err, qt.IsNil) + + tcs := []struct { + name string + code string + want string // exact float64 SchemeString + }{ + {"rational 1/3", `(take-float 1/3)`, "0.3333333333333333"}, + // (+ 1.0 (expt 10 60)) → ~1e60 rounded to nearest float64. + {"big float lossy", `(take-float (+ 1.0 (expt 10 60)))`, + "1000000000000000000000000000000000000000000000000000000000000.0"}, + // 2^100 + 1 → 2^100 (the +1 rounds away). + {"big integer", `(take-float (+ (expt 2 100) 1))`, + "1267650600228229400000000000000.0"}, + // MaxInt64 → 2^63. + {"int64 max", `(take-float 9223372036854775807)`, "9223372036854776000.0"}, + } + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + result := eval(t, engine, tc.code) + c.Assert(result.SchemeString(), qt.Equals, tc.want) + }) + } +} + +// TestRegisterFuncFloat64EngineIsolation verifies the flag is per-engine: +// registering the same Go function on a strict engine and a lossy-allowed +// engine yields independent behaviors. The flag is captured at RegisterFunc +// time, so behavior is frozen per engine. +func TestRegisterFuncFloat64EngineIsolation(t *testing.T) { + c := qt.New(t) + strict := newEngineWithMath(t) + lossy := newEngineLossy(t) + + fn := func(f float64) float64 { return f } + c.Assert(strict.RegisterFunc("take-float", fn), qt.IsNil) + c.Assert(lossy.RegisterFunc("take-float", fn), qt.IsNil) + + // Strict: 1/3 errors with ErrLossyConversion. + errStrict := evalExpectError(t, strict, `(take-float 1/3)`) + c.Assert(errors.Is(errStrict, werr.ErrLossyConversion), qt.IsTrue, + qt.Commentf("strict engine: expected ErrLossyConversion, got: %v", errStrict)) + + // Lossy-allowed: 1/3 succeeds with the IEEE-754-rounded result. + result := eval(t, lossy, `(take-float 1/3)`) + c.Assert(result.SchemeString(), qt.Equals, "0.3333333333333333") +} + +// TestRegisterFuncFloat64FreezeAtRegistration verifies that the lossy +// flag is captured at RegisterFunc time, not consulted dynamically. +// After registration, additional RegisterFunc calls on the same engine +// continue to share the engine's flag state — each newly-registered +// function gets the same captured value. +func TestRegisterFuncFloat64FreezeAtRegistration(t *testing.T) { + c := qt.New(t) + + strictEng := newEngineWithMath(t) + fn := func(f float64) float64 { return f } + c.Assert(strictEng.RegisterFunc("take-float", fn), qt.IsNil) + c.Assert(strictEng.RegisterFunc("take-float-2", fn), qt.IsNil) + c.Assert(strictEng.RegisterFunc("take-float-3", fn), qt.IsNil) + + // All three closures share the captured strict flag. + for _, code := range []string{ + `(take-float 1/3)`, + `(take-float-2 1/3)`, + `(take-float-3 1/3)`, + } { + err := evalExpectError(t, strictEng, code) + c.Assert(errors.Is(err, werr.ErrLossyConversion), qt.IsTrue, + qt.Commentf("for %s, expected ErrLossyConversion, got: %v", code, err)) + } + + // Sanity: a separate lossy engine permits the same input. + lossyEng := newEngineLossy(t) + c.Assert(lossyEng.RegisterFunc("take-float", fn), qt.IsNil) + result := eval(t, lossyEng, `(take-float 1/3)`) + c.Assert(result.SchemeString(), qt.Equals, "0.3333333333333333") +} + +// TestRegisterFuncFloat64SliceLossyAllowedPropagation verifies the +// lossyAllowed flag threads through composite-type builders. A slice +// of float64 with a lossy element succeeds under lossy-allowed mode +// and errors under strict — proving makeSliceArgConverter forwards +// the flag to its element converter. +func TestRegisterFuncFloat64SliceLossyAllowedPropagation(t *testing.T) { + c := qt.New(t) + + fn := func(xs []float64) int64 { return int64(len(xs)) } + + strictEng := newEngineWithMath(t) + c.Assert(strictEng.RegisterFunc("count-floats", fn), qt.IsNil) + errStrict := evalExpectError(t, strictEng, `(count-floats '(0.5 1/3 1.0))`) + c.Assert(errors.Is(errStrict, werr.ErrLossyConversion), qt.IsTrue, + qt.Commentf("strict slice: expected ErrLossyConversion for lossy element, got: %v", errStrict)) + + lossyEng := newEngineLossy(t) + c.Assert(lossyEng.RegisterFunc("count-floats", fn), qt.IsNil) + result := eval(t, lossyEng, `(count-floats '(0.5 1/3 1.0))`) + c.Assert(result.SchemeString(), qt.Equals, "3") +} + +// TestRegisterFuncComplex128StrictMode verifies the new Complex128 path: +// lossless inputs succeed, lossy ones error with ErrLossyConversion. +// The Go callback returns bool to sidestep the not-yet-supported +// complex128 *return* path. +func TestRegisterFuncComplex128StrictMode(t *testing.T) { + c := qt.New(t) + engine := newEngineWithMath(t) + + err := engine.RegisterFunc("complex-finite?", func(z complex128) bool { + _ = z + return true + }) + c.Assert(err, qt.IsNil) + + losslessCases := []struct { + name string + code string + }{ + {"integer 3", `(complex-finite? 3)`}, + {"float 0.5", `(complex-finite? 0.5)`}, + {"complex 3+4i", `(complex-finite? (make-rectangular 3 4))`}, + } + for _, tc := range losslessCases { + t.Run("lossless/"+tc.name, func(t *testing.T) { + result := eval(t, engine, tc.code) + c.Assert(result.SchemeString(), qt.Equals, "#t") + }) + } + + lossyCases := []struct { + name string + code string + }{ + {"rational 1/3", `(complex-finite? 1/3)`}, + {"complex 1/3+0i", `(complex-finite? (make-rectangular 1/3 0))`}, + // BigComplex with a real-part magnitude that float64 cannot + // preserve. (+ 1.0 (expt 10 60)) is a *BigFloat (~10^60), + // representable in big.Float but lossy in float64. + {"big complex (lossy real)", + `(complex-finite? (make-rectangular (+ 1.0 (expt 10 60)) 0))`}, + // BigComplex with a lossy imaginary part (real-part exact). + {"big complex (lossy imag)", + `(complex-finite? (make-rectangular 0 (+ 1.0 (expt 10 60))))`}, + } + for _, tc := range lossyCases { + t.Run("lossy/"+tc.name, func(t *testing.T) { + err := evalExpectError(t, engine, tc.code) + c.Assert(errors.Is(err, werr.ErrLossyConversion), qt.IsTrue, + qt.Commentf("expected ErrLossyConversion, got: %v", err)) + }) + } +} + +// TestRegisterFuncComplex128LossyAllowed verifies the new Complex128 +// path silently truncates under WithLossyConversionsAllowed. +func TestRegisterFuncComplex128LossyAllowed(t *testing.T) { + c := qt.New(t) + engine := newEngineLossy(t) + + err := engine.RegisterFunc("complex-finite?", func(z complex128) bool { + _ = z + return true + }) + c.Assert(err, qt.IsNil) + + tcs := []struct { + name string + code string + }{ + {"rational 1/3", `(complex-finite? 1/3)`}, + {"complex 1/3+0i", `(complex-finite? (make-rectangular 1/3 0))`}, + {"big complex (lossy real)", + `(complex-finite? (make-rectangular (+ 1.0 (expt 10 60)) 0))`}, + } + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + result := eval(t, engine, tc.code) + c.Assert(result.SchemeString(), qt.Equals, "#t") + }) + } +} diff --git a/ffi_test.go b/ffi_test.go index 93f03983..916330dc 100644 --- a/ffi_test.go +++ b/ffi_test.go @@ -46,13 +46,18 @@ func eval(t *testing.T, engine *wile.Engine, code string) wile.Value { return result } -func evalExpectError(t *testing.T, engine *wile.Engine, code string) { +// evalExpectError parses and runs the given Scheme expression, asserts that +// an error occurred, and returns the error. Callers that need to match the +// error against a sentinel (errors.Is) consume the return value; callers that +// only need "did it error" can ignore it. +func evalExpectError(t *testing.T, engine *wile.Engine, code string) error { t.Helper() ctx := context.Background() _, err := engine.Eval(ctx, engine.MustParse(ctx, code)) if err == nil { t.Fatalf("eval %q: expected error, got nil", code) } + return err } // --- Registration validation errors --- @@ -89,7 +94,9 @@ func TestRegisterFuncUnsupportedTypes(t *testing.T) { name string fn any }{ - {"complex128 param", func(c complex128) float64 { return real(c) }}, + // complex128 *parameters* are supported as of PR 2 of the numeric + // loss signals plan; complex128 *returns* and complex128 *callback + // parameters* still go through makeRetConverter and remain unsupported. {"unsupported map key", func(m map[float64]int) int { return len(m) }}, {"unsupported return", func() complex128 { return 0 }}, {"three returns", func() (int64, int64, error) { return 0, 0, nil }}, diff --git a/options.go b/options.go index aa254aef..4a230f27 100644 --- a/options.go +++ b/options.go @@ -64,6 +64,12 @@ type engineConfig struct { // whose specs declare ParamTypes. Enabled via WithContractEnforcement. contractEnforcement bool + // lossyConversionsAllowed permits FFI converters to silently truncate + // Scheme numerics into fixed-precision Go types (float64, complex128). + // Enabled via WithLossyConversionsAllowed. Captured into each FFI + // closure at RegisterFunc time. + lossyConversionsAllowed bool + // coverageCollector, when non-nil, receives every NativeTemplate produced // by the compiler so per-s-expression execution can be tracked. coverageCollector *coverage.Collector @@ -113,6 +119,29 @@ func WithContractEnforcement() EngineOption { } } +// WithLossyConversionsAllowed permits FFI converters to silently +// truncate when converting Scheme numerics to fixed-precision Go +// types (float64, complex128). When set, *BigFloat with magnitude +// exceeding float64 range converts to ±math.Inf(0) without error; +// *Rational with non-representable denominators rounds via +// (*big.Rat).Float64 with the loss bit discarded; *BigComplex +// imaginary/real components each may truncate independently. +// +// Default (option not set): the FFI converter returns +// werr.ErrLossyConversion (wrapped, with direction info) when any +// precision loss would occur. This is the "fail loud" discipline — +// opt-in is required to suppress. +// +// The option is per-engine; the flag is captured into each FFI +// closure at RegisterFunc time, so calling +// WithLossyConversionsAllowed after some functions have already +// registered does NOT change their behavior. +func WithLossyConversionsAllowed() EngineOption { + return func(cfg *engineConfig) { + cfg.lossyConversionsAllowed = true + } +} + // WithMaxCallDepth sets the maximum recursion depth for the VM. // When the continuation stack exceeds this depth, ErrCallDepthExceeded is returned. // A value of 0 means unlimited (no depth check). Negative values are clamped diff --git a/plans/axis-b-manifest.scm b/plans/axis-b-manifest.scm index a9f69bf3..f0eaee8b 100644 --- a/plans/axis-b-manifest.scm +++ b/plans/axis-b-manifest.scm @@ -207,7 +207,7 @@ ("exp" "number" ("number") "github.com/aalpar/wile/extensions/math.init.makeComplexPrimitive.func9" "extensions/math/prim_transcendental.go:33") ("expand" "any" ("any") "github.com/aalpar/wile/extensions/eval.PrimExpand" "extensions/eval/prim_eval.go:424") ("expand-once" "any" ("any") "github.com/aalpar/wile/extensions/eval.PrimExpandOnce" "extensions/eval/prim_eval.go:463") - ("expt" "number" ("number" "number") "github.com/aalpar/wile/extensions/math.PrimExpt" "extensions/math/prim_transcendental.go:251") + ("expt" "number" ("number" "number") "github.com/aalpar/wile/extensions/math.PrimExpt" "extensions/math/prim_transcendental.go:275") ("features" "list" () "github.com/aalpar/wile/extensions/introspection.PrimFeatures" "extensions/introspection/prim_introspection.go:120") ("file-error?" "boolean" ("any") "github.com/aalpar/wile/registry/core.PrimFileErrorQ" "registry/core/prim_exceptions.go:348") ("file-exists?" "boolean" ("string") "github.com/aalpar/wile/extensions/files.PrimFileExistsQ" "extensions/files/prim_files.go:88") @@ -404,7 +404,7 @@ ("set-current-directory!" "void" ("string") "github.com/aalpar/wile/extensions/files.PrimSetCurrentDirectory" "extensions/files/prim_directory.go:134") ("sin" "number" ("number") "github.com/aalpar/wile/extensions/math.init.makeComplexPrimitive.func10" "extensions/math/prim_transcendental.go:33") ("sort" "list" ("procedure -- a two-argument comparison predicate" "list") "" "") - ("sqrt" "number" ("number") "github.com/aalpar/wile/extensions/math.PrimSqrt" "extensions/math/prim_transcendental.go:117") + ("sqrt" "number" ("number") "github.com/aalpar/wile/extensions/math.PrimSqrt" "extensions/math/prim_transcendental.go:141") ("square" "number" ("number") "" "") ("string" "string" ("...character") "github.com/aalpar/wile/registry/core.PrimString" "registry/core/prim_strings.go:30") ("string->char-set" "" () "github.com/aalpar/wile/extensions/charsets.primStringToCharSet" "extensions/charsets/charsets.go:104") diff --git a/registry/helpers/value_conv.go b/registry/helpers/value_conv.go index 89ab85bf..7e6ca955 100644 --- a/registry/helpers/value_conv.go +++ b/registry/helpers/value_conv.go @@ -65,28 +65,31 @@ func ComplexOrFloat(c complex128) values.Value { return values.NewComplex(c) } -// ToFloat64 converts a Scheme real number to a Go float64, covering the full -// real numeric tower: Integer, BigInteger, Float, BigFloat, and Rational. -// Complex types are excluded — they cannot be reduced to a single float64 -// without information loss. Use ToComplex128 for complex values. +// ToFloat64 converts a Scheme real number to a Go float64 with a lossless +// guarantee. Real inputs (*Integer, *BigInteger, *Float, *BigFloat, *Rational) +// that fit float64 exactly succeed; ones that don't fit return +// werr.ErrLossyConversion (wrapped, message names direction Below/Above). +// Complex inputs (*Complex, *BigComplex) return werr.ErrNotAReal — the helper +// is real-domain only. +// +// Tightened in PR 2 of the numeric loss signals plan: previously this function +// silently truncated *BigFloat overflow, *BigInteger overflow, and *Rational +// with non-representable denominators (e.g., 1/3). Callers that need the +// silent-truncation behavior should call values.ToFloat64WithAccuracy directly +// and discard the accuracy slot. See CHANGELOG for migration guidance. func ToFloat64(v values.Value) (float64, error) { - switch n := v.(type) { - case *values.Integer: - return float64(n.Value), nil - case *values.BigInteger: - f, _ := new(big.Float).SetInt(n.BigInt()).Float64() - return f, nil - case *values.Float: - return n.Value, nil - case *values.BigFloat: - f, _ := n.BigFloatValue().Float64() - return f, nil - case *values.Rational: - f, _ := n.Rat().Float64() - return f, nil - default: + n, ok := v.(values.Number) + if !ok { + return 0, werr.WrapForeignErrorf(werr.ErrNotAReal, "expected a real number but got %T", v) + } + // Domain dispatch via ComplexNumber interface — matches Hashable/Tuple/ + // Indexable precedent in values/. Avoids enumerating *Complex and + // *BigComplex by name (would need updating for any new complex kind). + _, isComplex := n.(values.ComplexNumber) + if isComplex { return 0, werr.WrapForeignErrorf(werr.ErrNotAReal, "expected a real number but got %T", v) } + return values.ToFloat64Lossless(n) } // ExtractReal extracts a float64 from a real number, tracking exactness. diff --git a/registry/helpers/value_conv_test.go b/registry/helpers/value_conv_test.go index b5b32c61..ee17ad7e 100644 --- a/registry/helpers/value_conv_test.go +++ b/registry/helpers/value_conv_test.go @@ -17,6 +17,7 @@ package helpers import ( "errors" "math" + "math/big" "testing" qt "github.com/frankban/quicktest" @@ -253,11 +254,12 @@ func TestToFloat64(t *testing.T) { want float64 checkFn func(float64) bool // optional: overrides want comparison }{ - // Integer + // Integer — values that fit float64 exactly (≤ 2^53 magnitude + // or exact powers of 2 above that). {"integer zero", values.NewInteger(0), 0, nil}, {"integer positive", values.NewInteger(42), 42, nil}, {"integer negative", values.NewInteger(-7), -7, nil}, - {"integer max", values.NewInteger(math.MaxInt64), float64(math.MaxInt64), nil}, + // math.MinInt64 = -2^63 is an exact power of 2 → representable. {"integer min", values.NewInteger(math.MinInt64), float64(math.MinInt64), nil}, // Float @@ -271,8 +273,8 @@ func TestToFloat64(t *testing.T) { math.IsNaN, }, - // Rational - {"rational 1/3", values.NewRational(1, 3), 1.0 / 3.0, nil}, + // Rational — only those losslessly representable as float64 + // (powers-of-2 denominator, numerator ≤ 2^53 magnitude). {"rational 1/1", values.NewRational(1, 1), 1, nil}, {"rational -1/2", values.NewRational(-1, 2), -0.5, nil}, {"rational zero", values.NewRational(0, 1), 0, nil}, @@ -282,7 +284,7 @@ func TestToFloat64(t *testing.T) { {"big integer negative", values.NewBigIntegerFromInt64(-7), -7, nil}, {"big integer zero", values.NewBigIntegerFromInt64(0), 0, nil}, - // BigFloat + // BigFloat — values constructed from float64 round-trip exactly. {"big float positive", values.NewBigFloatFromFloat64(3.14), 3.14, nil}, {"big float negative", values.NewBigFloatFromFloat64(-2.718), -2.718, nil}, {"big float zero", values.NewBigFloatFromFloat64(0.0), 0, nil}, @@ -328,6 +330,45 @@ func TestToFloat64_Errors(t *testing.T) { } } +// TestToFloat64_LossyConversion verifies the PR 2 tightening: inputs that +// previously truncated silently now error with werr.ErrLossyConversion. +// Pre-PR-2, all of these succeeded with a silently-rounded float64. +func TestToFloat64_LossyConversion(t *testing.T) { + c := qt.New(t) + + bigOverflow, _, err := big.ParseFloat("1e500", 10, 256, big.ToNearestEven) + c.Assert(err, qt.IsNil) + + tcs := []struct { + name string + input values.Value + }{ + // math.MaxInt64 = 2^63 - 1; float64 rounds to 2^63 (Above). + {"integer max int64", values.NewInteger(math.MaxInt64)}, + // 1/3 is rational but not exactly representable in binary + // float64 (denominator is not a power of 2) — rounds Below. + {"rational 1/3", values.NewRational(1, 3)}, + // BigInteger that requires more than 53 mantissa bits — float64 + // can't preserve every digit. (2^100 + 1 cannot, because 2^100 + // alone uses the implicit leading-1 mantissa bit.) + {"big integer precision loss", + values.NewBigInteger(new(big.Int).Add( + new(big.Int).Lsh(big.NewInt(1), 100), + big.NewInt(1)))}, + // BigFloat overflowing float64 magnitude (saturates to +Inf, Above). + {"big float overflow", values.NewBigFloat(bigOverflow)}, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + _, err := ToFloat64(tc.input) + c.Assert(err, qt.IsNotNil) + c.Assert(errors.Is(err, werr.ErrLossyConversion), qt.IsTrue, + qt.Commentf("expected ErrLossyConversion, got: %v", err)) + }) + } +} + func TestExtractReal(t *testing.T) { c := qt.New(t)