diff --git a/pkg/rules/number_coerce.go b/pkg/rules/number_coerce.go index 6d83dd2..fa5c663 100644 --- a/pkg/rules/number_coerce.go +++ b/pkg/rules/number_coerce.go @@ -221,8 +221,28 @@ func (ruleSet *IntRuleSet[T]) coerceInt(value any, ctx context.Context) (T, erro } // tryCoerceFloatToFloat attempts to coerce a float from one type to another and checks that no data was lost in the process. +// When converting float64 -> float32, exact round-trip equality is not required because float32 has less precision; +// only the value must be within float32 range (finite and |x| <= math.MaxFloat32). func tryCoerceFloatToFloat[From, To floating](value From, ctx context.Context) (To, errors.ValidationError) { floatval := To(value) + // When converting float64 -> float32, most values cannot round-trip exactly (e.g. 0.8). + // Only require that the value is within float32 range. + switch (interface{})(value).(type) { + case float64: + if _, ok := (interface{})(*new(To)).(float32); ok { + f64 := float64(value) + if math.IsNaN(f64) || math.IsInf(f64, 0) { + target := reflect.ValueOf(*new(To)).Kind().String() + return 0, errors.NewRangeError(ctx, target) + } + if math.Abs(f64) > math.MaxFloat32 { + target := reflect.ValueOf(*new(To)).Kind().String() + return 0, errors.NewRangeError(ctx, target) + } + return floatval, nil + } + } + // Same type or float32 -> float64: require exact round-trip if From(floatval) != value { target := reflect.ValueOf(*new(To)).Kind().String() return 0, errors.NewRangeError(ctx, target) diff --git a/pkg/rules/number_coerce_test.go b/pkg/rules/number_coerce_test.go index 39da08b..e33dfa8 100644 --- a/pkg/rules/number_coerce_test.go +++ b/pkg/rules/number_coerce_test.go @@ -302,6 +302,20 @@ func TestCoerceToFloat32(t *testing.T) { testhelpers.MustApplyMutation(t, ruleSet, float64(123.0), expected) } +// TestCoerceFloat64ToFloat32_CommonDecimals tests that float64 values that are not +// exactly representable in binary (e.g. 0.6, 0.8) still coerce to float32 successfully, +// since they are within range and only lose precision (no range error). +func TestCoerceFloat64ToFloat32_CommonDecimals(t *testing.T) { + ruleSet := rules.Float32().Any() + + // 0.6 and 0.8 are not exactly representable in float64; round-trip float64->float32->float64 + // would not be exact. Coercion should succeed with a range check only, not exact equality. + testhelpers.MustApplyMutation(t, ruleSet, float64(0.6), float32(0.6)) + testhelpers.MustApplyMutation(t, ruleSet, float64(0.8), float32(0.8)) + testhelpers.MustApplyMutation(t, ruleSet, float64(0.1), float32(0.1)) + testhelpers.MustApplyMutation(t, ruleSet, float64(0.3), float32(0.3)) +} + // TestFloat32EqualityCheckFailure tests: // - Returns error when float32 cannot exactly represent the integer value func TestFloat32EqualityCheckFailure(t *testing.T) {