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
20 changes: 20 additions & 0 deletions pkg/rules/number_coerce.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
14 changes: 14 additions & 0 deletions pkg/rules/number_coerce_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down