Skip to content

Add decimal package#3495

Open
bobzhang wants to merge 4 commits into
mainfrom
codex/decimal-package
Open

Add decimal package#3495
bobzhang wants to merge 4 commits into
mainfrom
codex/decimal-package

Conversation

@bobzhang

Copy link
Copy Markdown
Contributor

Summary

  • Add a new moonbitlang/core/decimal package backed by @bigint.BigInt.
  • Store decimals canonically as coefficient * 10^exponent, including canonical zero and value-consistent Eq/Hash.
  • Add parsing/formatting, arithmetic, explicit rounding, exact/rounded division, exact BigInt conversion, and JSON string encoding/decoding.
  • Omit / intentionally; division requires exactness or an explicit rounding scale/mode.

Claude CLI feedback incorporated

  • Replaced the original linear private power loop with BigInt::pow and made negative powers fail loudly.
  • Added checked Int64 exponent arithmetic for scale conversion, arithmetic exponent math, formatting, and division shifts.
  • Documented that rounded results remain canonical, so requested scale does not preserve display trailing zeros.
  • Simplified JSON decode failures for invalid decimal strings to avoid leaking lossy Show output.
  • Removed the warning suppression around suberror usage.

Validation

  • moon fmt
  • moon info
  • moon check decimal (passes; existing unrelated warnings only)
  • moon test decimal (15 passed)
  • moon check --target all (passes; existing unrelated warnings only)
  • moon test (6403 passed)

@bobzhang bobzhang marked this pull request as ready for review April 28, 2026 06:56
devin-ai-integration[bot]

This comment was marked as resolved.

bobzhang and others added 3 commits April 28, 2026 15:25
No behavior change. Enriches the docstrings on every public function,
trait impl, error variant, and the `Decimal` / `RoundingMode` /
`DecimalError` types with worked examples, edge cases, and rationale:

- Errors document each variant's trigger.
- `RoundingMode` separates directional modes from half modes and
  explains why directional naming (`TowardZero`, `TowardNegativeInfinity`)
  is preferred over the ambiguous "up"/"down".
- `Decimal`'s canonical model is called out, including its implications
  for trailing zeros and JSON-as-string.
- Each constructor / accessor / arithmetic / division / rounding entry
  has examples and lists the failure modes (raise vs abort).
- The deliberate absence of `/` is documented alongside `div_exact` /
  `div_round`.

mbti is byte-identical with origin (verified via `diff` against HEAD).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three bug fixes from a code-review pass:

1. `Compare` no longer aborts on extreme exponents.

   `dec("1e2147483647").compare(dec("1e-2147483648"))` previously
   aborted because `align_coefficients` tried to materialize a
   coefficient scaling factor whose 10^diff exponent did not fit in
   `Int`. Compare now uses sign + adjusted-exponent (digit count from
   `BigInt::bit_length` plus exponent) to decide cleanly when
   magnitudes are clearly apart, and only falls through to scaled
   comparison when the exponent gap is bounded.

2. `div_round` no longer aborts on extreme shifts.

   The shift expression was a nested `sub_exponents` that could abort
   before the existing pathological-shift handler at line ~140 had a
   chance to run. Refactored to compute the shift in `Int64`
   throughout, only narrowing to `Int` once we know the value is
   within range. Out-of-range scale or shift now raises
   `ExponentOutOfRange` (new variant) rather than aborting.

3. Parser rejects `_` adjacent to `.`.

   `"1._2"` and `"._5"` were silently accepted because the underscore
   rule only checked `has_digit && !after_underscore` and missed the
   "previous token was a dot" case. Tracking `after_dot` plugs that
   gap. The complementary `"1_.2"` case was already rejected.

Also:

- `DecimalError::ExponentOutOfRange` added so `round` and `div_round`
  can report scale-driven exponent overflow as a typed error rather
  than aborting (the prior `panic round scale out of range` test now
  asserts the error instead).
- `round` signature now `raise DecimalError`; tests adjusted.
- Removed unused private helper `scale_to_exponent`.

mbti delta:
- adds `DecimalError::ExponentOutOfRange`;
- changes `Decimal::round` to `... raise DecimalError`.

Tests added for each fix; suite is 18/18 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coveralls

Copy link
Copy Markdown
Collaborator

Coverage Report for CI Build 4066

Warning

Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes.
Quick fix: rebase this PR. Learn more →

Coverage decreased (-0.1%) to 94.741%

Details

  • Coverage decreased (-0.1%) from the base build.
  • Patch coverage: 31 uncovered changes across 7 files (216 of 247 lines covered, 87.45%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
decimal/types.mbt 53 42 79.25%
decimal/rounding.mbt 43 37 86.05%
decimal/traits.mbt 28 23 82.14%
decimal/parse.mbt 47 43 91.49%
decimal/division.mbt 26 23 88.46%
decimal/arithmetic.mbt 19 18 94.74%
decimal/constructors.mbt 12 11 91.67%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 15877
Covered Lines: 15042
Line Coverage: 94.74%
Coverage Strength: 216131.96 hits per line

💛 - Coveralls

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 9 additional findings in Devin Review.

Open in Devin Review

Comment thread decimal/division.mbt
Comment on lines +152 to +161
if neg_shift > max_exponent64 {
// The exact value is more than `max_exponent64` decimal places
// below the target scale, so the magnitude is strictly below
// half of one unit. Rounding therefore depends only on the
// sign of the ratio and the rounding mode; we settle the result
// without materializing the (infeasibly large) divisor.
return make_decimal(
rounded_ratio_below_half(self.coefficient, other.coefficient, mode),
target_exponent,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 rounded_ratio_below_half makes incorrect assumption when coefficients are very large

In div_round, when neg_shift > max_exponent64, the code assumes |self.coefficient / other.coefficient * 10^(-neg_shift)| < 0.5 and delegates to rounded_ratio_below_half. This assumption is only based on the exponent gap being very large, but ignores that self.coefficient could have far more decimal digits than 10^neg_shift. For example, if self has a canonical coefficient with 3 billion digits (exponent = -2147483648) and other has coefficient 1 (exponent = 0), with scale=0: neg_shift = 2147483648, triggering the below-half path. But the actual value is self.coefficient * 10^(-2147483648), which is approximately 10^(3e9 - 2.1e9) = 10^(8.5e8) — an astronomically large number, not below 0.5. The function would incorrectly return 0 (for HalfEven) instead of the correct huge integer.

Triggering this requires self.coefficient to have at least ~2.1 billion decimal digits (~2 GB of RAM for the coefficient alone), so it is practically infeasible on most systems, but the mathematical claim in the code is provably wrong for valid BigInt inputs.

Prompt for agents
In div_round (decimal/division.mbt), the branch at line 152 where neg_shift > max_exponent64 assumes |numerator/denominator| < 0.5 based solely on the exponent gap, but this ignores the magnitude of the coefficients. When self.coefficient has significantly more digits than 10^neg_shift, the actual ratio can be >= 0.5 or even very large.

To fix this properly, instead of calling rounded_ratio_below_half, the code should compare the digit counts (e.g., via bit_length) of self.coefficient versus other.coefficient plus neg_shift decimal digits. If self.coefficient is large enough that the ratio could be >= 0.5, the code needs to either raise ExponentOutOfRange (since materializing the result would be infeasible) or find a way to compute the ratio without materializing 10^neg_shift (e.g., by scaling both sides). The simplest correct fix is likely to check whether log10(|self.coefficient|) - log10(|other.coefficient|) > neg_shift (using bit_length bounds) and raise ExponentOutOfRange if so, since the result coefficient would itself be infeasibly large.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants