Ensure that Decimal32/64/128 ToString roundtrips and preserves the cohort - #131422
Ensure that Decimal32/64/128 ToString roundtrips and preserves the cohort#131422tannergooding wants to merge 7 commits into
Conversation
…hort The general and roundtrip specifiers unconditionally suppressed scientific notation, so a positive quantum exponent had no correct spelling and reparsed as a different cohort member. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
|
Azure Pipelines: Successfully started running 3 pipeline(s). 13 pipeline(s) were filtered out due to trigger conditions. There may be pipelines that require an authorized user to comment /azp run to run. |
|
Tagging subscribers to this area: @dotnet/area-system-numerics |
There was a problem hiding this comment.
Pull request overview
This PR updates the formatting logic for IEEE-754 decimal floating-point types (Decimal32/Decimal64/Decimal128) so that ToString on the general/round-trip formats preserves the quantum exponent (cohort member) and reliably round-trips through Parse, including for zeros and positive quantum exponents.
Changes:
- Adjust
G/Rformatting for decimal IEEE-754 values to emit scientific notation when required (and when more compact), and preserve quantum for zero coefficients. - Update
DecimalIeee754ToNumberso zero coefficients retain the unbiased exponent inNumberBuffer.Scale. - Update
Decimal32/Decimal64/Decimal128tests to reflect the new output and add roundtrip/cohort-preservation coverage.
Show a summary per file
| File | Description |
|---|---|
| src/libraries/System.Private.CoreLib/src/System/Number.Formatting.cs | Implements new general/round-trip formatting logic for IEEE-754 decimal types and preserves quantum for zero coefficients. |
| src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal32Tests.cs | Updates expected ToString/parse-preservation outputs and adds roundtrip/cohort-preservation theory. |
| src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal64Tests.cs | Updates expected ToString/parse-preservation outputs and adds roundtrip/cohort-preservation theory. |
| src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal128Tests.cs | Updates expected ToString/parse-preservation outputs and adds roundtrip/cohort-preservation theory. |
Copilot's findings
- Files reviewed: 4/4 changed files
- Comments generated: 1
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
RoundNumber strips trailing coefficient digits without adjusting Scale, so Scale - DigitsCount no longer reports the quantum exponent once a precision specifier has rounded the value. Compare against the requested precision in that case and recover the dropped digits as trailing zeros. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The IEEE decimal types shared NumberBufferKind.Decimal with System.Decimal, which has no concept of negative zero, so RoundNumber and NumberToStringFormat cleared the sign for every specifier other than G and R. A signed zero is a distinct IEEE value, so give these types their own kind and keep the sign the way the binary floating-point types do. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
IEEE 754 requires conversions to a decimal character sequence to be correctly rounded under the applicable rounding-direction attribute, which defaults to roundTiesToEven. These types were inheriting the half-away-from-zero rounding that System.Decimal uses for back-compat, which also made ToString disagree with Round, quantize, and every arithmetic operation on the same type. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Dismissing because a new commit came in right before I approved it.
|
@vcsjones I think the last commit conflicted with your approval and so it instant dismissed. No more changes should be coming, should all be handled now |
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
|
CC. @jeffhandley for sign-off to take into RC1. Correctness fix to ensure that |
ToStringforDecimal32,Decimal64, andDecimal128was not producing roundtrippable results and did not preserve the cohort.The
G/Rpath went throughFormatGeneral(..., suppressScientific: true), which unconditionally emits fixed-point. A positive quantum exponent has no fixed-point spelling, so the result reparsed as a different member of the cohort:1e7100000001000000e1(doesn't fit in 7 digits)1E+0710e6100000001000000e11.0E+070e200e00E+02Decimal32.MaxValue9999999+ 90 zeros9.999999E+96Zeros were worse --
DecimalIeee754ToNumberclampednumber.Scaleto0for a zero coefficient, so a positive quantum was discarded outright and0e2was indistinguishable from0.FormatGeneralAndRoundTripDecimalIeee754now emits scientific notation when it is required (quantum exponent> 0) or when it is more compact (adjusted exponent< -4). The latter is exactly the existingGheuristic for the binary floating-point types (digPos > nMaxDigits || digPos < -3), and the exponent goes through the sharedFormatExponentwithminDigits: 2and an explicit sign, so the output is stylistically identical todouble/float/Half. IEEE 754 §5.12.2 constrains the syntax and requires the roundtrip to recover the quantum, but says nothing about padding -- that part is purely a .NET consistency call.Two smaller fixes ride along, both needed for the above to be correct:
DecimalIeee754ToNumberno longer clampsScalefor a zero coefficient. Every other consumer resets it --NumberToString'sF/N/E/P/Ccases all callRoundNumberfirst (which setsScale = 0wheni == 0), andNumberToStringFormatsets it explicitly in itsdig[0] == 0branch.R/rnow ignores a precision specifier andG{n}honors it viaRoundNumber, matchingdouble.RoundNumberstrips trailing coefficient digits without adjustingScale, so once a precision specifier has rounded the value,Scale - DigitsCountno longer reports the quantum exponent.G2on10.00000left the buffer as digits1/Scale 2, which the required-scientific test misread as a positive quantum:double10.00000G21E+0110101000.400G41E+0310001000100.0G21E+021E+021E+02The test now compares against the requested precision when rounding occurred, and the fixed-point path recovers the dropped digits as trailing zeros. Thanks @vcsjones for catching this.
The sign of zero was being dropped for every specifier other than
G/R. These types sharedNumberBufferKind.DecimalwithSystem.Decimal, which has no concept of negative zero, soRoundNumberandNumberToStringFormatclearedIsNegativewhenever the rounded result was zero. A signed zero is a distinct IEEE value --Decimal64.IsNegative(Decimal64.NegativeZero)istrue-- so the sign has to survive, exactly as it does fordouble:double-0E20.00E+000-0.00E+000-0.00E+000-0F20.00-0.00-0.00-0C2¤0.00(¤0.00)(¤0.00)-00.000.00-0.00-0.00-0.001F20.00-0.00-0.00G/Rwere only correct by accident, because the rewrite above bypassesRoundNumberfor a zero coefficient.This needs a new
NumberBufferKind.DecimalIeee754; neither existing kind works, and I confirmed both by building them:FloatingPointfor the format buffer flipsisCorrectlyRoundedtotrue, andShouldRoundUpthen returnsfalseunconditionally, soE/F/N/C/Pstop rounding altogether -- 438 divergences fromSystem.Decimal, e.g.0.999F0gives0instead of1.double's buffer arrives pre-rounded from Dragon4, where this is a no-op; the IEEE decimal buffer holds the raw coefficient and genuinely needs to round.FloatingPointfor the parse buffer discards the quantum of a zero, since onlyDecimalpreservesScalethere --0e-101parses back as0.The requirement is a mix of the two, so the new kind is added and the three existing kinds keep byte-identical behavior; every changed condition in shared code only narrows.
Ties were rounding half away from zero rather than to even. IEEE 754 §5.12.1 requires a conversion to a character sequence to be correctly rounded under the applicable rounding-direction attribute, which defaults to
roundTiesToEven. This was inherited fromSystem.Decimal, whose away-from-zero behavior is itself a back-compat concession -- the comment inShouldRoundUphas said since .NET Core 3.0 that the spec dictates ties-to-even.double0.5F01002.5F03222.500F0322-2.5F0-3-2-21.25E11.3E+0001.2E+0001.2E+00012.5G2131212It also made
ToStringdisagree with the rest of the same type:Round,quantize, parse, and every arithmetic operation were already ties-to-even, soDecimal64.Round(0.25, 1)gave0.2while(0.25).ToString("F1")gave0.3.The
doublecustom-format path rounds away from zero because it double-rounds the Dragon4 shortest digits, which is the hazard the existing comment describes. TheDecimalIeee754buffer holds the exact coefficient, so a5followed only by zeros is a genuine tie and there is a single correct rounding -- these types are therefore ties-to-even for custom format strings too. That is a deliberate divergence fromdouble, taken because these types are new in .NET 11 and carry no back-compat constraint.Validation beyond the added tests:
Decimal32encodings, 9,216 explicit (coefficient, quantum) pairs across the full exponent range, and 4,368Decimal64/Decimal128values, assertingEncodeDecimal(Parse(ToString(x))) == EncodeDecimal(x)fornull/G/R. Zero failures.Decimal64againstSystem.Decimalacross 30 specifiers (E/F/N/P/Cand custom formats) and againstG{n}rounding: 7,980 plus 749 comparisons, zero divergences other than the intended tie cases above, whichSystem.Decimalrounds away from zero.doubleover values that are exact in both radices, across 18 specifiers. The only remaining differences are the deliberate custom-format divergence and cohort preservation ((2.500).ToString("G4")is2.500, not2.5).TryFormatfor char and UTF-8 at exact size, and correctfalseon every truncated destination.2.00 + 1.000is3.000,1.20 * 1.00is1.2000,Sqrt(1.00)is1.0);1e3 * 1e3giving1E+06is only observable because of this fix.Parse/TryParseoverloads route throughValidateParseStyleDecimal, and IEEE 754 §5.12.3 defines that form for binary formats only.Every regression test was confirmed to fail against the unfixed code. Full
System.Runtime.Testspasses (76,591 tests) andSystem.Runtime.Numerics.Testspasses (8,422 tests) -- the latter becauseBigIntegercompiles the same shared parsing and formatting sources.CC. @dotnet/area-system-numerics
Note
This PR description was drafted by GitHub Copilot.