From 9b30418f763e459a881b21b0275834da40e0430f Mon Sep 17 00:00:00 2001 From: sdcoffey Date: Fri, 29 May 2026 09:08:13 -0700 Subject: [PATCH 01/13] Preserve precision in decimal constructors --- decimal.go | 61 ++++++++++++++++++++++++++++++++++++++----------- decimal_test.go | 18 +++++++++++++++ 2 files changed, 66 insertions(+), 13 deletions(-) diff --git a/decimal.go b/decimal.go index 81afa64..8737667 100644 --- a/decimal.go +++ b/decimal.go @@ -7,8 +7,12 @@ import ( "fmt" "math" "math/big" + "math/bits" + "unicode" ) +const minPrecision uint = 256 + var ( flZero = *big.NewFloat(0) @@ -35,18 +39,12 @@ type Decimal struct { // NewDecimal creates a new Decimal type from a float value. func NewDecimal(val float64) Decimal { - var fl *big.Float - - defer func() { - if r := recover(); r != nil { - switch r.(type) { - case big.ErrNaN: - fl = nil - } - } - }() + if math.IsNaN(val) { + return NaN + } - fl = big.NewFloat(val) + fl := newFloat(53) + fl.SetFloat64(val) return Decimal{ fl: fl, @@ -55,7 +53,7 @@ func NewDecimal(val float64) Decimal { // NewFromString creates a new Decimal type from a string value. func NewFromString(str string) Decimal { - bfl := big.NewFloat(0) + bfl := newFloat(decimalPrecision(str)) if _, _, err := bfl.Parse(str, 10); err != nil { return NaN @@ -66,7 +64,44 @@ func NewFromString(str string) Decimal { // NewFromInt creates a new Decimal type from an int value func NewFromInt(dec int) Decimal { - return Decimal{big.NewFloat(float64(dec))} + fl := newFloat(intPrecision(dec)) + fl.SetInt64(int64(dec)) + return Decimal{fl: fl} +} + +func newFloat(precision uint) *big.Float { + if precision < minPrecision { + precision = minPrecision + } + + return new(big.Float).SetPrec(precision).SetMode(big.ToNearestEven) +} + +func decimalPrecision(str string) uint { + digits := 0 + for _, char := range str { + if unicode.IsDigit(char) { + digits++ + } + } + + if digits == 0 { + return minPrecision + } + + return uint(digits)*4 + 16 +} + +func intPrecision(dec int) uint { + if dec == 0 { + return minPrecision + } + + if dec < 0 { + return uint(bits.Len64(uint64(-(dec + 1))+1)) + 1 + } + + return uint(bits.Len64(uint64(dec))) + 1 } // MaxSlice returns the max of a slice of decimals diff --git a/decimal_test.go b/decimal_test.go index df9a549..4e381cc 100644 --- a/decimal_test.go +++ b/decimal_test.go @@ -3,6 +3,8 @@ package big import ( "encoding/json" "math" + mathbig "math/big" + "strconv" "testing" "github.com/stretchr/testify/assert" @@ -59,12 +61,28 @@ func TestNewFromString(t *testing.T) { expected: "NaN", }, ) + + t.Run("preserves large integer precision", func(t *testing.T) { + d := NewFromString("9007199254740993") + expected := new(mathbig.Float).SetPrec(minPrecision) + expected.SetInt64(9007199254740993) + + assert.Equal(t, 0, d.fl.Cmp(expected)) + }) } func TestNewFromInt(t *testing.T) { d := NewFromInt(1) assert.EqualValues(t, "1", d.String()) + + if strconv.IntSize == 64 { + large := int(9007199254740993) + expected := new(mathbig.Float).SetPrec(minPrecision) + expected.SetInt64(int64(large)) + + assert.Equal(t, 0, NewFromInt(large).fl.Cmp(expected)) + } } func TestMaxSlice(t *testing.T) { From bd89af9b81b75d0d6e7809993268b2cb6a9f1d5a Mon Sep 17 00:00:00 2001 From: sdcoffey Date: Fri, 29 May 2026 09:08:47 -0700 Subject: [PATCH 02/13] Handle invalid decimal math cases --- decimal.go | 26 +++++++++++++++++++++++--- decimal_test.go | 12 ++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/decimal.go b/decimal.go index 8737667..55dd1b5 100644 --- a/decimal.go +++ b/decimal.go @@ -202,10 +202,26 @@ func (d Decimal) Pow(exp int) Decimal { return ONE } - x := Decimal{d.cpy()} + if exp < 0 { + if d.IsZero() { + return NaN + } - for i := 1; i < exp; i++ { - x = x.Mul(d) + return ONE.Div(d.Pow(-exp)) + } + + x := ONE + base := Decimal{d.cpy()} + + for exp > 0 { + if exp%2 == 1 { + x = x.Mul(base) + } + + exp /= 2 + if exp > 0 { + base = base.Mul(base) + } } return x @@ -215,6 +231,10 @@ func (d Decimal) Pow(exp int) Decimal { // Sqrt returns the decimal's square root func (d Decimal) Sqrt() Decimal { return nanGuard(func() Decimal { + if d.LT(ZERO) { + return NaN + } + return Decimal{d.cpy().Sqrt(d.cpy())} }, d) } diff --git a/decimal_test.go b/decimal_test.go index 4e381cc..a37e2a6 100644 --- a/decimal_test.go +++ b/decimal_test.go @@ -357,6 +357,14 @@ func TestDecimal_Pow(t *testing.T) { value: TEN.Pow(0), expected: "1", }, + equalExample{ + value: TEN.Pow(-2), + expected: "0.01", + }, + equalExample{ + value: ZERO.Pow(-1), + expected: "NaN", + }, equalExample{ value: NaN.Pow(2), expected: "NaN", @@ -370,6 +378,10 @@ func TestDecimal_Sqrt(t *testing.T) { value: NewDecimal(64).Sqrt(), expected: "8", }, + equalExample{ + value: NewDecimal(-1).Sqrt(), + expected: "NaN", + }, equalExample{ value: NaN.Sqrt(), expected: "NaN", From 74d8e379135f35f15fb627ec58e69f2ec3887256 Mon Sep 17 00:00:00 2001 From: sdcoffey Date: Fri, 29 May 2026 09:10:08 -0700 Subject: [PATCH 03/13] Normalize zero value and NaN serialization --- decimal.go | 78 +++++++++++++++++++++++++++++++------------------ decimal_test.go | 56 ++++++++++++++++++++++++++++++++++- 2 files changed, 105 insertions(+), 29 deletions(-) diff --git a/decimal.go b/decimal.go index 55dd1b5..29a0b47 100644 --- a/decimal.go +++ b/decimal.go @@ -1,8 +1,8 @@ package big import ( + "bytes" "database/sql/driver" - "encoding/json" "errors" "fmt" "math" @@ -17,7 +17,7 @@ var ( flZero = *big.NewFloat(0) // NaN == Not a Number - NaN = NewDecimal(math.NaN()) + NaN = Decimal{nan: true} // ZERO == 0 ZERO = NewFromString("0") @@ -34,7 +34,8 @@ var ( // Decimal is the main exported type. It is a simple, immutable wrapper around a *big.Float type Decimal struct { - fl *big.Float + fl *big.Float + nan bool } // NewDecimal creates a new Decimal type from a float value. @@ -59,7 +60,7 @@ func NewFromString(str string) Decimal { return NaN } - return Decimal{bfl} + return Decimal{fl: bfl} } // NewFromInt creates a new Decimal type from an int value @@ -144,28 +145,28 @@ func MinSlice(decimals ...Decimal) Decimal { // Add adds a decimal instance to another Decimal instance. func (d Decimal) Add(addend Decimal) Decimal { return nanGuard(func() Decimal { - return Decimal{d.cpy().Add(d.fl, addend.fl)} + return Decimal{fl: d.cpy().Add(d.value(), addend.value())} }, d, addend) } // Sub subtracts another decimal instance from this Decimal instance. func (d Decimal) Sub(subtrahend Decimal) Decimal { return nanGuard(func() Decimal { - return Decimal{d.cpy().Sub(d.fl, subtrahend.fl)} + return Decimal{fl: d.cpy().Sub(d.value(), subtrahend.value())} }, d, subtrahend) } // Mul multiplies another decimal instance with this Decimal instance. func (d Decimal) Mul(factor Decimal) Decimal { return nanGuard(func() Decimal { - return Decimal{d.cpy().Mul(d.fl, factor.fl)} + return Decimal{fl: d.cpy().Mul(d.value(), factor.value())} }, d, factor) } // Div divides this Decimal by the denominator passed. func (d Decimal) Div(denominator Decimal) Decimal { return nanGuard(func() Decimal { - return Decimal{d.cpy().Quo(d.fl, denominator.fl)} + return Decimal{fl: d.cpy().Quo(d.value(), denominator.value())} }, d, denominator) } @@ -211,7 +212,7 @@ func (d Decimal) Pow(exp int) Decimal { } x := ONE - base := Decimal{d.cpy()} + base := Decimal{fl: d.cpy()} for exp > 0 { if exp%2 == 1 { @@ -235,7 +236,7 @@ func (d Decimal) Sqrt() Decimal { return NaN } - return Decimal{d.cpy().Sqrt(d.cpy())} + return Decimal{fl: d.cpy().Sqrt(d.cpy())} }, d) } @@ -290,7 +291,7 @@ func (d Decimal) Cmp(other Decimal) int { return 0 } - return d.fl.Cmp(other.fl) + return d.value().Cmp(other.value()) } // Float will return this Decimal as a float value. @@ -300,7 +301,7 @@ func (d Decimal) Float() float64 { return math.NaN() } - f, _ := d.fl.Float64() + f, _ := d.value().Float64() return f } @@ -312,7 +313,7 @@ func (d Decimal) Zero() bool { // NaN returns true if the underlying is not a valid number func (d Decimal) NaN() bool { - return d.fl == nil + return d.nan } // IsZero will return true if this Decimal is equal to 0. @@ -321,7 +322,7 @@ func (d Decimal) IsZero() bool { return false } - return d.fl == nil || d.fl.Cmp(&flZero) == 0 + return d.value().Cmp(&flZero) == 0 } func (d Decimal) String() string { @@ -329,11 +330,7 @@ func (d Decimal) String() string { return "NaN" } - if d.fl == nil { - d.fl = new(big.Float) - } - - return d.fl.String() + return d.value().String() } // FormattedString returns the string value of the number to the requested precision @@ -353,20 +350,33 @@ func (d Decimal) MarshalJSON() ([]byte, error) { return []byte("\"" + d.String() + "\""), nil } - return d.fl.MarshalText() + if d.NaN() { + return []byte("null"), nil + } + + return d.value().MarshalText() } // UnmarshalJSON implements the json.Unmarshaler interface func (d *Decimal) UnmarshalJSON(b []byte) error { - if d.fl == nil { - d.fl = big.NewFloat(0) - } + b = bytes.TrimSpace(b) if isQuoted(b) { b = b[1 : len(b)-1] } - return d.fl.UnmarshalText(b) + if bytes.Equal(b, []byte("null")) || bytes.Equal(b, []byte("NaN")) { + *d = NaN + return nil + } + + fl := newFloat(decimalPrecision(string(b))) + if _, _, err := fl.Parse(string(b), 10); err != nil { + return err + } + + *d = Decimal{fl: fl} + return nil } func isQuoted(b []byte) bool { @@ -383,17 +393,29 @@ func (d Decimal) Value() (driver.Value, error) { func (d *Decimal) Scan(src interface{}) error { switch src := src.(type) { case string: - return json.Unmarshal([]byte(src), d) + return d.UnmarshalJSON([]byte(src)) case []byte: - return json.Unmarshal(src, d) + return d.UnmarshalJSON(src) + case nil: + *d = NaN + return nil default: return errors.New(fmt.Sprint("Passed value ", src, " should be a string")) } } func (d Decimal) cpy() *big.Float { - cpy := new(big.Float) - return cpy.Copy(d.fl) + val := d.value() + cpy := newFloat(val.Prec()) + return cpy.Copy(val) +} + +func (d Decimal) value() *big.Float { + if d.fl != nil { + return d.fl + } + + return newFloat(minPrecision) } func anyNan(decimals ...Decimal) bool { diff --git a/decimal_test.go b/decimal_test.go index a37e2a6..4db2726 100644 --- a/decimal_test.go +++ b/decimal_test.go @@ -46,7 +46,7 @@ func TestNewDecimal(t *testing.T) { t.Run("NaN", func(t *testing.T) { d := NewDecimal(math.NaN()) - assert.Nil(t, d.fl) + assert.True(t, d.NaN()) }) } @@ -399,6 +399,10 @@ func TestDecimal_String(t *testing.T) { value: NaN, expected: "NaN", }, + equalExample{ + value: Decimal{}, + expected: "0", + }, ) } @@ -421,6 +425,10 @@ func TestDecimal_IsZero(t *testing.T) { value: NaN.IsZero(), expected: false, }, + booleanExample{ + value: Decimal{}.IsZero(), + expected: true, + }, ) } func TestDecimal_Json(t *testing.T) { @@ -450,6 +458,24 @@ func TestDecimal_Json(t *testing.T) { assert.Equal(t, `{"decimal":3.1419}`, string(marshaled)) }) + t.Run("MarshalJSON - zero value", func(t *testing.T) { + tmpStruct := jsonType{} + marshaled, err := json.Marshal(tmpStruct) + + assert.NoError(t, err) + assert.Equal(t, `{"decimal":0}`, string(marshaled)) + }) + + t.Run("MarshalJSON - NaN", func(t *testing.T) { + tmpStruct := jsonType{ + Decimal: NaN, + } + marshaled, err := json.Marshal(tmpStruct) + + assert.NoError(t, err) + assert.Equal(t, `{"decimal":null}`, string(marshaled)) + }) + t.Run("UnmarshalJSON - unquoted", func(t *testing.T) { var ts jsonType @@ -469,6 +495,16 @@ func TestDecimal_Json(t *testing.T) { assert.NoError(t, err) assert.Equal(t, "3.1419", ts.Decimal.String()) }) + + t.Run("UnmarshalJSON - null", func(t *testing.T) { + var ts jsonType + + d := `{"decimal":null}` + err := json.Unmarshal([]byte(d), &ts) + + assert.NoError(t, err) + assert.True(t, ts.Decimal.NaN()) + }) } func TestDecimal_Sql(t *testing.T) { @@ -500,6 +536,24 @@ func TestDecimal_Sql(t *testing.T) { assert.Equal(t, "1.23", d.String()) }) + t.Run("Scan NaN", func(t *testing.T) { + var d Decimal + + err := d.Scan("NaN") + + assert.NoError(t, err) + assert.True(t, d.NaN()) + }) + + t.Run("Scan nil", func(t *testing.T) { + var d Decimal + + err := d.Scan(nil) + + assert.NoError(t, err) + assert.True(t, d.NaN()) + }) + t.Run("Scan returns error when src is not string", func(t *testing.T) { var d Decimal From fa353c973450bb1047661d26c705a5acfb32ccd5 Mon Sep 17 00:00:00 2001 From: sdcoffey Date: Fri, 29 May 2026 09:10:58 -0700 Subject: [PATCH 04/13] Avoid mutable sentinel dependencies --- decimal.go | 24 ++++++++++++++++-------- decimal_test.go | 41 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 9 deletions(-) diff --git a/decimal.go b/decimal.go index 29a0b47..12e39f6 100644 --- a/decimal.go +++ b/decimal.go @@ -78,6 +78,14 @@ func newFloat(precision uint) *big.Float { return new(big.Float).SetPrec(precision).SetMode(big.ToNearestEven) } +func zeroDecimal() Decimal { + return NewFromInt(0) +} + +func oneDecimal() Decimal { + return NewFromInt(1) +} + func decimalPrecision(str string) uint { digits := 0 for _, char := range str { @@ -110,7 +118,7 @@ func MaxSlice(decimals ...Decimal) Decimal { if anyNan(decimals...) { return NaN } else if len(decimals) == 0 { - return ZERO + return zeroDecimal() } initial := NewFromString("-Inf") @@ -129,7 +137,7 @@ func MinSlice(decimals ...Decimal) Decimal { if anyNan(decimals...) { return NaN } else if len(decimals) == 0 { - return ZERO + return zeroDecimal() } initial := NewFromString("Inf") @@ -189,8 +197,8 @@ func (d Decimal) Neg() Decimal { // Abs returns the absolute value of this Decimal func (d Decimal) Abs() Decimal { - if d.LT(ZERO) { - return d.Mul(ONE.Neg()) + if d.LT(zeroDecimal()) { + return d.Neg() } return d @@ -200,7 +208,7 @@ func (d Decimal) Abs() Decimal { func (d Decimal) Pow(exp int) Decimal { return nanGuard(func() Decimal { if exp == 0 { - return ONE + return oneDecimal() } if exp < 0 { @@ -208,10 +216,10 @@ func (d Decimal) Pow(exp int) Decimal { return NaN } - return ONE.Div(d.Pow(-exp)) + return oneDecimal().Div(d.Pow(-exp)) } - x := ONE + x := oneDecimal() base := Decimal{fl: d.cpy()} for exp > 0 { @@ -232,7 +240,7 @@ func (d Decimal) Pow(exp int) Decimal { // Sqrt returns the decimal's square root func (d Decimal) Sqrt() Decimal { return nanGuard(func() Decimal { - if d.LT(ZERO) { + if d.LT(zeroDecimal()) { return NaN } diff --git a/decimal_test.go b/decimal_test.go index 4db2726..7a64cab 100644 --- a/decimal_test.go +++ b/decimal_test.go @@ -123,6 +123,41 @@ func TestMinSlice(t *testing.T) { ) } +func TestExportedSentinelsDoNotAffectInternalMath(t *testing.T) { + oldZero := ZERO + oldOne := ONE + t.Cleanup(func() { + ZERO = oldZero + ONE = oldOne + }) + + ZERO = TEN + ONE = TEN + + validateEqExamples(t, + equalExample{ + value: MaxSlice(), + expected: "0", + }, + equalExample{ + value: MinSlice(), + expected: "0", + }, + equalExample{ + value: NewFromString("-10").Abs(), + expected: "10", + }, + equalExample{ + value: TEN.Pow(0), + expected: "1", + }, + equalExample{ + value: NewFromString("-1").Sqrt(), + expected: "NaN", + }, + ) +} + func TestDecimal_Add(t *testing.T) { validateEqExamples(t, equalExample{ @@ -437,6 +472,11 @@ func TestDecimal_Json(t *testing.T) { } t.Run("MarshalJSON - quoted", func(t *testing.T) { + oldMarshalQuoted := MarshalQuoted + t.Cleanup(func() { + MarshalQuoted = oldMarshalQuoted + }) + MarshalQuoted = true tmpStruct := jsonType{ Decimal: NewFromString("3.1419"), @@ -445,7 +485,6 @@ func TestDecimal_Json(t *testing.T) { assert.NoError(t, err) assert.Equal(t, `{"decimal":"3.1419"}`, string(marshaled)) - MarshalQuoted = false }) t.Run("MarshalJSON - unquoted", func(t *testing.T) { From d3278e622233114578cb38de021ce3d387deefaa Mon Sep 17 00:00:00 2001 From: sdcoffey Date: Fri, 29 May 2026 09:11:19 -0700 Subject: [PATCH 05/13] Order NaN values in Cmp --- decimal.go | 10 +++++++++- decimal_test.go | 2 ++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/decimal.go b/decimal.go index 12e39f6..af30751 100644 --- a/decimal.go +++ b/decimal.go @@ -295,10 +295,18 @@ func (d Decimal) GTE(other Decimal) bool { // Cmp will return 1 if this decimal is greater than the provided, 0 if they are the same, and -1 if it is less. func (d Decimal) Cmp(other Decimal) int { - if anyNan(d, other) { + if d.NaN() && other.NaN() { return 0 } + if d.NaN() { + return -1 + } + + if other.NaN() { + return 1 + } + return d.value().Cmp(other.value()) } diff --git a/decimal_test.go b/decimal_test.go index 7a64cab..f251683 100644 --- a/decimal_test.go +++ b/decimal_test.go @@ -375,6 +375,8 @@ func TestDecimal_Cmp(t *testing.T) { assert.EqualValues(t, 1, TEN.Cmp(ONE)) assert.EqualValues(t, -1, ONE.Cmp(TEN)) assert.EqualValues(t, 0, NaN.Cmp(NaN)) + assert.EqualValues(t, -1, NaN.Cmp(ONE)) + assert.EqualValues(t, 1, ONE.Cmp(NaN)) } func TestDecimal_Float(t *testing.T) { From 4d6161a2ac0b59fb1118702be8a749065d654387 Mon Sep 17 00:00:00 2001 From: sdcoffey Date: Fri, 29 May 2026 09:12:28 -0700 Subject: [PATCH 06/13] Preserve precision in arithmetic formatting --- decimal.go | 44 +++++++++++++++++++++++++++++++++++++------- decimal_test.go | 9 +++++++++ 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/decimal.go b/decimal.go index af30751..7754699 100644 --- a/decimal.go +++ b/decimal.go @@ -153,28 +153,28 @@ func MinSlice(decimals ...Decimal) Decimal { // Add adds a decimal instance to another Decimal instance. func (d Decimal) Add(addend Decimal) Decimal { return nanGuard(func() Decimal { - return Decimal{fl: d.cpy().Add(d.value(), addend.value())} + return Decimal{fl: resultFloat(1, d, addend).Add(d.value(), addend.value())} }, d, addend) } // Sub subtracts another decimal instance from this Decimal instance. func (d Decimal) Sub(subtrahend Decimal) Decimal { return nanGuard(func() Decimal { - return Decimal{fl: d.cpy().Sub(d.value(), subtrahend.value())} + return Decimal{fl: resultFloat(1, d, subtrahend).Sub(d.value(), subtrahend.value())} }, d, subtrahend) } // Mul multiplies another decimal instance with this Decimal instance. func (d Decimal) Mul(factor Decimal) Decimal { return nanGuard(func() Decimal { - return Decimal{fl: d.cpy().Mul(d.value(), factor.value())} + return Decimal{fl: newFloat(sumPrecision(d, factor)).Mul(d.value(), factor.value())} }, d, factor) } // Div divides this Decimal by the denominator passed. func (d Decimal) Div(denominator Decimal) Decimal { return nanGuard(func() Decimal { - return Decimal{fl: d.cpy().Quo(d.value(), denominator.value())} + return Decimal{fl: resultFloat(0, d, denominator).Quo(d.value(), denominator.value())} }, d, denominator) } @@ -355,9 +355,7 @@ func (d Decimal) FormattedString(places int) string { return d.String() } - format := "%." + fmt.Sprint(places) + "f" - fl := d.Float() - return fmt.Sprintf(format, fl) + return d.value().Text('f', places) } // MarshalJSON implements the json.Marshaler interface @@ -426,6 +424,38 @@ func (d Decimal) cpy() *big.Float { return cpy.Copy(val) } +func resultFloat(extra uint, decimals ...Decimal) *big.Float { + return newFloat(maxPrecision(decimals...) + extra) +} + +func maxPrecision(decimals ...Decimal) uint { + precision := minPrecision + for _, decimal := range decimals { + if decimal.NaN() { + continue + } + + if valuePrecision := decimal.value().Prec(); valuePrecision > precision { + precision = valuePrecision + } + } + + return precision +} + +func sumPrecision(decimals ...Decimal) uint { + precision := uint(0) + for _, decimal := range decimals { + if decimal.NaN() { + continue + } + + precision += decimal.value().Prec() + } + + return precision +} + func (d Decimal) value() *big.Float { if d.fl != nil { return d.fl diff --git a/decimal_test.go b/decimal_test.go index f251683..49fa0a8 100644 --- a/decimal_test.go +++ b/decimal_test.go @@ -207,6 +207,14 @@ func TestDecimal_Mul(t *testing.T) { expected: "NaN", }, ) + + t.Run("preserves operand precision", func(t *testing.T) { + long := NewFromString("9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999") + result := ONE.Mul(long) + + assert.Equal(t, 0, result.Cmp(long)) + assert.GreaterOrEqual(t, result.fl.Prec(), long.fl.Prec()) + }) } func TestDecimal_Div(t *testing.T) { @@ -445,6 +453,7 @@ func TestDecimal_String(t *testing.T) { func TestDecimal_FormattedString(t *testing.T) { assert.EqualValues(t, "3.1416", NewDecimal(math.Pi).FormattedString(4)) + assert.EqualValues(t, "9007199254740993", NewFromString("9007199254740993").FormattedString(0)) assert.EqualValues(t, "NaN", NaN.FormattedString(4)) } From 12e4b5dd5786a405b5f8cd0ab6e2b33cf55e8317 Mon Sep 17 00:00:00 2001 From: sdcoffey Date: Fri, 29 May 2026 09:13:43 -0700 Subject: [PATCH 07/13] Pin Go tooling in Makefile --- Makefile | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/Makefile b/Makefile index 591506a..b733717 100644 --- a/Makefile +++ b/Makefile @@ -1,19 +1,23 @@ files := $(shell find . -name "*.go" | grep -v vendor) +GOLINT_VERSION := v0.0.0-20241112194109-818c5a804067 +GOIMPORTS_VERSION := v0.24.0 +STATICCHECK_VERSION := 2024.1.1 + bootstrap: - go install -v golang.org/x/lint/golint@latest - go install -v golang.org/x/tools/...@latest - go install -v honnef.co/go/tools/cmd/staticcheck@latest + go install -v golang.org/x/lint/golint@$(GOLINT_VERSION) + go install -v golang.org/x/tools/cmd/goimports@$(GOIMPORTS_VERSION) + go install -v honnef.co/go/tools/cmd/staticcheck@$(STATICCHECK_VERSION) lint: golint -set_exit_status - staticcheck github.com/sdcoffey/big + staticcheck ./... -clean: +fmt: goimports -w $(files) -test: clean - go test -v +test: + go test -v ./... -release: clean test - ./scripts/release.sh \ No newline at end of file +release: fmt test + ./scripts/release.sh From 7ccfddc607f554c20003304221f36aad37e383b3 Mon Sep 17 00:00:00 2001 From: sdcoffey Date: Fri, 29 May 2026 09:14:13 -0700 Subject: [PATCH 08/13] Harden release tag detection --- scripts/release.sh | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/scripts/release.sh b/scripts/release.sh index 5c25324..b174775 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -3,28 +3,28 @@ set -euf -o pipefail echo -n "Version: " -read newversion +read -r newversion -tag_count=`git tag --list | grep "$newversion" | wc -l | tr -d '[:space:]' || true` +tag_name="v$newversion" -if [[ $tag_count -gt 0 ]]; then - echo "Tag: $newversion already exists" +if git rev-parse -q --verify "refs/tags/$tag_name" >/dev/null; then + echo "Tag: $tag_name already exists" exit 1 else echo "Releasing $newversion" fi echo "Update CHANGELOG.md and press enter" -read +read -r git add CHANGELOG.md -added_count=`git status --porcelain | grep "CHANGELOG.md" | wc -l | tr -d '[:space:]' || true` +added_count=$(git status --porcelain | grep "CHANGELOG.md" | wc -l | tr -d '[:space:]' || true) if [[ $added_count -gt 0 ]]; then git commit -m"Release version $newversion" fi -git tag "v$newversion" -m "v$newversion" +git tag "$tag_name" -m "$tag_name" git push origin main git push --tags From d83a1dbd597634f2901a140083da2623459cd7a5 Mon Sep 17 00:00:00 2001 From: sdcoffey Date: Fri, 29 May 2026 09:14:32 -0700 Subject: [PATCH 09/13] Clarify decimal semantics in README --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d244e33..d4d67c5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # BIG -Big is a simple, immuatable wrapper around golang's built-in `*big.Float` type desinged to offer a more user-friendly API and immutability guarantees at the cost of some runtime performance. +Big is a simple, immutable wrapper around Go's built-in `*big.Float` type designed to offer a more user-friendly API and immutability guarantees at the cost of some runtime performance. + +Because Big wraps `*big.Float`, it uses arbitrary-precision binary floating-point arithmetic. It is not a fixed-point decimal or money library. If you need decimal-exact financial arithmetic, use a decimal package with explicit scale and rounding semantics. ### Example From 2de25572d397876dea9ef5cc139c0134577d0ef6 Mon Sep 17 00:00:00 2001 From: sdcoffey Date: Fri, 29 May 2026 09:15:05 -0700 Subject: [PATCH 10/13] Handle minimum int precision --- decimal.go | 3 ++- decimal_test.go | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/decimal.go b/decimal.go index 7754699..6dce734 100644 --- a/decimal.go +++ b/decimal.go @@ -107,7 +107,8 @@ func intPrecision(dec int) uint { } if dec < 0 { - return uint(bits.Len64(uint64(-(dec + 1))+1)) + 1 + magnitude := uint64(-(dec + 1)) + 1 + return uint(bits.Len64(magnitude)) + 1 } return uint(bits.Len64(uint64(dec))) + 1 diff --git a/decimal_test.go b/decimal_test.go index 49fa0a8..278449f 100644 --- a/decimal_test.go +++ b/decimal_test.go @@ -82,6 +82,11 @@ func TestNewFromInt(t *testing.T) { expected.SetInt64(int64(large)) assert.Equal(t, 0, NewFromInt(large).fl.Cmp(expected)) + + min := -int(^uint(0)>>1) - 1 + expected.SetInt64(int64(min)) + + assert.Equal(t, 0, NewFromInt(min).fl.Cmp(expected)) } } From 84550ec737f87a128f93b0f9f823d65b95c6023b Mon Sep 17 00:00:00 2001 From: sdcoffey Date: Fri, 29 May 2026 09:18:14 -0700 Subject: [PATCH 11/13] Update testify dependency --- go.mod | 4 +++- go.sum | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index a20d6dc..cf5ba1d 100644 --- a/go.mod +++ b/go.mod @@ -2,8 +2,10 @@ module github.com/sdcoffey/big go 1.21 +require github.com/stretchr/testify v1.11.1 + require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/stretchr/testify v1.1.4 + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index eb3efa6..fac6575 100644 --- a/go.sum +++ b/go.sum @@ -4,3 +4,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.1.4 h1:ToftOQTytwshuOSj6bDSolVUa3GINfJP/fg3OkkOzQQ= github.com/stretchr/testify v1.1.4/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From 34a242d2a37fe118692ba9d1f5a0699ca5eb35de Mon Sep 17 00:00:00 2001 From: sdcoffey Date: Fri, 29 May 2026 09:20:10 -0700 Subject: [PATCH 12/13] Avoid duplicate PR workflow runs --- .github/workflows/test.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 80ef17d..55cdac7 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -2,6 +2,7 @@ name: Tests on: push: + branches: [main] pull_request: permissions: From 1cdc0f7317bc4c4d20e5a7cfff70319e3d59e185 Mon Sep 17 00:00:00 2001 From: sdcoffey Date: Fri, 29 May 2026 09:23:32 -0700 Subject: [PATCH 13/13] Address Codex decimal review comments --- decimal.go | 18 ++++++++++++++++-- decimal_test.go | 15 +++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/decimal.go b/decimal.go index 6dce734..4b6b107 100644 --- a/decimal.go +++ b/decimal.go @@ -41,7 +41,7 @@ type Decimal struct { // NewDecimal creates a new Decimal type from a float value. func NewDecimal(val float64) Decimal { if math.IsNaN(val) { - return NaN + return Decimal{nan: true} } fl := newFloat(53) @@ -212,14 +212,24 @@ func (d Decimal) Pow(exp int) Decimal { return oneDecimal() } + if d.EQ(oneDecimal()) { + return oneDecimal() + } + if exp < 0 { if d.IsZero() { return NaN } - return oneDecimal().Div(d.Pow(-exp)) + return oneDecimal().Div(d.pow(negativeExponentMagnitude(exp))) } + return d.pow(uint(exp)) + }, d) +} + +func (d Decimal) pow(exp uint) Decimal { + return nanGuard(func() Decimal { x := oneDecimal() base := Decimal{fl: d.cpy()} @@ -238,6 +248,10 @@ func (d Decimal) Pow(exp int) Decimal { }, d) } +func negativeExponentMagnitude(exp int) uint { + return uint(-(exp + 1)) + 1 +} + // Sqrt returns the decimal's square root func (d Decimal) Sqrt() Decimal { return nanGuard(func() Decimal { diff --git a/decimal_test.go b/decimal_test.go index 278449f..1780d65 100644 --- a/decimal_test.go +++ b/decimal_test.go @@ -48,6 +48,17 @@ func TestNewDecimal(t *testing.T) { assert.True(t, d.NaN()) }) + + t.Run("NaN ignores exported sentinel mutation", func(t *testing.T) { + oldNaN := NaN + t.Cleanup(func() { + NaN = oldNaN + }) + + NaN = TEN + + assert.True(t, NewDecimal(math.NaN()).NaN()) + }) } func TestNewFromString(t *testing.T) { @@ -415,6 +426,10 @@ func TestDecimal_Pow(t *testing.T) { value: ZERO.Pow(-1), expected: "NaN", }, + equalExample{ + value: ONE.Pow(-int(^uint(0)>>1) - 1), + expected: "1", + }, equalExample{ value: NaN.Pow(2), expected: "NaN",