#305

Question: precise decimal arithmetic in stdlib (std.decimal) on the roadmap?

OpenPeterXMRopened 5mo ago

Question: precise decimal arithmetic in stdlib (std.decimal) on the roadmap?

The stdlib in v0.1.4 covers a lot of what a small native CLI / library needs — std.json, std.http.fetch (thanks for landing #64), std.parse, std.time — and the source-backed stdlib pattern got three back-to-back additions on 2026-05-25 (std.path, std.str, std.math), so this seems like the right moment to ask about the next adjacent piece. I can't find a precise-decimal arithmetic story anywhere in the published surface. Asking because that's the gap that decides whether the math + parsing pieces of the small Bitcoin price viewer I mentioned in #47 and #83 (PeterXMR/spotprice) are portable to Zero today, or whether the port has to wait. Same four-option framing pattern as those issues; happy to be told it's option (4).

What's documented today (v0.1.4)

  • The full numeric primitive set in docs/articles/primitives.md is i8 i16 i32 i64 / u8 u16 u32 u64 / usize isize / f32 f64. Eleven fixed-width scalars, two of which are IEEE 754 binary floats. No decimal, no fixed-point, no arbitrary-precision.
  • docs/articles/modules/parse.md exposes integer parsers only — parseU8 / parseU16 / parseU32 returning Maybe<T>. No parseDecimal, no parseF64.
  • docs/articles/modules/json.md covers parse, validate, streamTokens with no documented number-precision contract. The conformance fixture conformance/native/pass/std-data-formats.0 exercises JSON parsing on a generic document tree without numeric-precision assertions.
  • Float literals are parsed at compile time via the C standard library's strtod() in parse_float_literal() in native/zero-c/src/checker.c, with f64 as the default for untyped float literals. The literal 0.1 becomes its IEEE 754 binary approximation at lex time — no decimal-preservation pass.
  • The fresh std.math doc (docs/articles/modules/math.md) declares its current scope explicitly: "Helpers are pure, target-neutral fixed-width integer operations… The module does not provide floating-point math, big integers, or arbitrary-precision number theory." The same doc's design notes say "Large-number algorithms should be added as explicit APIs with documented bounds instead of hidden heap allocation or implicit widening." I read that as the policy that any future numeric module — including a hypothetical std.decimal — would be expected to honor.
  • The stdlib design statement in docs/articles/standard-library.md is "Zero's standard library is pay-as-used and capability-aware. Importing memory helpers does not pull in hosted filesystem helpers." A std.decimal that's a pure source-backed module (no hosted capability, no allocation, no FFI) fits that posture cleanly.
  • No open or closed PR proposes a decimal type. I checked across decimal, BigDecimal, fixed-point, money, currency, precision, rounding, BigInt, and a handful of adjacent terms in both --state all issues and PRs.
  • Related on the float side: PR #124 (SysV AMD64 f32/f64 return-type lowering, open) and PR #184 (f64 literals in the test runner, open) confirm that even IEEE 754 binary float arithmetic across the direct backends is still in progress, so today's "use f64 instead" path is itself not fully solid.

Provisional four-option framing (same pattern as #47 / #83)

  1. Already exists, I missed it — ruled out by the absence of any decimal module under docs/articles/modules/ and the integer-only parser surface in std.parse.
  2. Planned but not yet shipped — most likely. std.math landed in #273 only a few days ago, which suggests numeric-stdlib work is actively in scope; a decimal module would be the next adjacent piece.
  3. Build it on top of u64 / i64 yourself, or FFI to a C decimal library — workable for spotprice (the workspace pins panic = "abort" and the existing Rust code uses 128-bit fixed-precision rust_decimal anyway), but loses the "agent-readable typed core surface" point of using Zero in the first place. Doable via examples/c-interop if that's the recommendation.
  4. Out of scope by design — Zero is targeting host CLIs / Vercel-edge servers / wasm where decimal isn't load-bearing, and financial-precision math is intentionally a library-author concern rather than a stdlib concern. Totally reasonable scoping; just want to know explicitly.

Provisional answer: I suspect (2) for a small future addition, (3) for today's workaround.

Three concrete questions

  1. Is a std.decimal (or equivalently-named) module tracked as a target version or milestone? Even at "weeks vs. months vs. no concrete plan" resolution. The closest precedent in shape is PR #273 (std.math, source-backed u32 helpers — 99 LOC in std/math.0, plus a 52-LOC conformance breadth test, a 34-LOC example, the std_sig.c / std_source.c metadata triple, the doc page, and the CHANGELOG entry — no new C-level runtime). A 128-bit fixed-precision value can be expressed as a shape Decimal { hi: u64, lo: u64, scale: u8 } (multi-field shapes are confirmed working in examples/point.0, examples/config-shape.0, and conformance fixtures), so the type surface is bounded. The implementation honesty caveat: addition and subtraction are doable in pure Zero today via manual carry detection on u64, but multiplication of two 128-bit values needs schoolbook 32×32 splitting (sixteen partial products) because Zero today has no u128, no addcarry, and no wide-mul primitive — that's a real cost that the current std.math/u32 surface doesn't yet have to pay. So a fair follow-up question is: is the preferred path (a) pure-source schoolbook splitting (works today, slower per op), (b) waiting on a wide_mul_u64 / addcarry_u64 primitive to land first, or (c) a different representation entirely?
  2. If a decimal type lands, which representation model is the intended one? Modern stdlib decimals split three ways: IEEE 754-2008 decimal128 (C23's _Decimal128, the BDE C++ implementation, and Java's MathContext.DECIMAL128 all anchor here — 34 decimal digits, cohort-based encoding, standards-traceable); arbitrary-precision BigDecimal-style (Java, Python, Go's shopspring/decimal, the DeciMojo community lib — flexible but requires allocation or some implicit-widening mechanism, which would conflict with the std.math design note above); and fixed-precision 128-bit (Rust's rust_decimal: 96-bit mantissa + 8-bit scale + sign = 128-bit Copy-able value, ~28 digits; Swift's Decimal: 160-bit, ~38 digits). The two contemporary systems-language designs that started from a clean sheet (Rust 2014, Swift 2014) independently chose fixed-precision; that path is also the one that composes best with Zero's existing fixed-width primitive set and avoids the "hidden heap allocation or implicit widening" footgun std.math explicitly disclaims. Sufficient for spotprice's worst case (USD price × 21M BTC × 1e8 sats < 22 digits). Provisional answer: fixed-precision 128-bit, with IEEE 754 decimal128 as the "if we want standards-traceable" alternative; arbitrary-precision as the "if we want a general numerics stdlib including non-financial" choice. Want to defer to whichever you've already been thinking about.
  3. Which rounding-control model fits the language's stance, and what's the fallibility shape? Three known rounding shapes in comparable stdlib decimals: per-operation argument (Rust rust_decimal::round_dp(n), Go shopspring/decimal.Round(places), Swift NSDecimalNumber.RoundingMode passed to method calls); per-operation MathContext object (Java BigDecimal); thread-local global context (Python decimal.localcontext()). The clean-sheet systems-language designs (Rust, Swift) both went per-operation, but no preference assumed here. On fallibility: the established idiom in current stdlib is Maybe<T> — std.parse returns Maybe<u8> / Maybe<u16> / Maybe<u32> for fallible numeric parsing and explicitly says "Integer parsers return Maybe<T> instead of allocating diagnostics. Callers can layer richer errors on top." That seems like the natural shape for decimal.parse, decimal.div, and friends. Adjacent design question: Zero today has explicit +% (wrapping) and +| (saturating) integer operators per conformance/native/pass/wrapping-saturating-arithmetic.0 — does decimal arithmetic want an analogous explicit-overflow-handling family, or is fallible-Maybe-returning sufficient at this layer? Real-world grounding: spotprice's core/convert/src/lib.rs:56-59 had to guard Decimal::Div with checked_div because the default Div panics on scale overflow and the workspace ships panic = "abort". That's the exact "infallible-by-default sharp edge" a fallible-by-default std.decimal would let users avoid.

Why I'm asking

The spotprice port-targets are core/convert/src/lib.rs (sats ↔ fiat conversion: every public function takes/returns Decimal) and core/aggregator/src/median.rs (median + σ-rejection across N exchange feeds, all Decimal). Both modules currently depend on rust_decimal = { workspace = true, features = ["serde-with-str"] } (core/Cargo.toml:52-53) and never touch f64 for price quantities.

For sizing, when I walked the spotprice call sites against the reference decimal libs surveyed in question 2, the consumer-side surface that actually gets called is small: roughly parse(string), from_int(int), add / sub / mul / div, cmp, abs, round(places, mode), floor, to_int, to_string, plus a RoundingMode with the four common variants (HalfEven, HalfUp, Down, Up). Same order of magnitude as std.math's ten u32 helpers in #273. Not proposing this as the surface — the design questions above are exactly the ones that would determine what the surface actually looks like — just sharing the shape I evaluated against to confirm the gap is real and the consumer-side surface is bounded.

Concrete value if this lands: spotprice's two pure-math modules become portable to Zero in their current shape (no design changes), which is exactly the "core-as-library, UI-in-platform-native" pattern that #83's library-artifact question is asking about. The two issues are independent — this one is the correctness gap, #83 is the delivery gap — but they're the same evaluation arc.

Related issues / PRs

  • #47 (HTTP client — closed by PR #64; same four-option framing precedent)
  • #83 (library artifact emit + Kotlin/Swift bindings — still open; the delivery-side counterpart to this issue)
  • #273 (Add source-backed std.math — merged; closest stylistic precedent for a source-backed numeric stdlib module)
  • #124 (ABI classification + f32/f64 return-type lowering, SysV AMD64 — open; context that even IEEE 754 float arithmetic is still partial across the direct backends)
  • #184 (f64 literals in the test runner — open; same area)
  • #20 (structured concurrency — adjacent but kept on its own issue; spotprice's fan-out across exchange feeds wants both eventually)

Happy to leave this open as a tracker for "current state of decimal arithmetic in Zero" the same way #47 functioned for HTTP. No rush; just want the answer on the public record so external evaluators can plan around it.

Activity

0

No activity yet.