// search
Back to feed

Limitless

These are basically my notes from working on this, but the math should still be right.

limitless numbers started from a simple thing: useful numbers don’t always fit into 32 or 64 bits, and rounding is not “good enough” when you actually need exact values.

Most of the numeric bugs I’ve run into were not really theory bugs. They were representation bugs:

  • int overflows or wraps silently
  • double cannot represent most decimal fractions exactly
  • conversions between types lose information
  • two values that look equal in source are not equal in memory

If you work on finance, symbolic transforms, exact ratios, small crypto exercises, protocol math, or generated test vectors, you bump into this fast.

So limitless had one job: exact arithmetic until you run out of memory, with a C API that is direct, portable, and small enough to embed without dragging in a lot of dependencies.

One number type, two exact forms

At runtime the type is a tagged union with two modes:

  • integer: arbitrary-precision signed bigint
  • rational: num/den, both bigints, always normalized

The mental model is three rules:

  1. If a result is an integer, keep it integer.
  2. If an operation requires a fraction, promote to rational.
  3. If a rational simplifies back to denominator 1, collapse to integer again.

So 6 / 3 stays integer 2, but 7 / 3 becomes exact 7/3. No hidden float conversion, no binary rounding drift.

This is not “big float”. It is exact integer/rational arithmetic.

The real constraint is invariants, not operators

Adding + - * / is the easy part. Keeping the invariants intact after every operation is the harder one:

  • denominator is always positive
  • zero has one canonical representation
  • rational is always reduced (gcd(num, den) = 1)
  • outputs are failure-atomic: on error, the caller’s output is unchanged

If those invariants drift, everything built on top of them starts misbehaving.

So internally the library normalizes aggressively and uses temporary compute-and-swap patterns so error handling stays predictable.

Many C libraries keep allocator state in globals. Convenient for a demo, painful in real use.

limitless uses a per-context model:

  • allocator hooks live in limitless_ctx
  • no hidden global mutable state
  • no mandatory libc dependency if you override alloc/realloc/free
  • thread safety is scoped by context ownership

There is also a beginner path:

  • limitless_ctx_init_default(&ctx) for quick start
  • limitless_ctx_init(&ctx, &alloc) for custom environments

So you can start with the default and later switch to an arena/pool/failing allocator without changing how you call the API.

Big integer magnitude is a little-endian limb array.

  • limbs are 32-bit by default
  • optional 64-bit limb mode is available when supported
  • sign is tracked separately from magnitude
  • zero is represented canonically (sign=0, used=0)

The little-endian limb layout keeps carry/borrow loops simple and cache-friendly for schoolbook ops.

Rationals are just two bigints with strict normalization rules. It sounds trivial, but keeping that stable across parse/format/ops/conversions is where most of the actual work is.

Division is the part where many libraries do something I don’t like.

In a lot of libraries, integer division truncates unless you call a separate rational function. In limitless, limitless_number_div() returns exact math by default:

  • integer result if divisible
  • rational otherwise
  • divide-by-zero is explicit LIMITLESS_EDIVZERO

So expressions stay safer because the default is mathematically correct.

Other operations are standard exact arithmetic with type promotion where needed:

  • add/sub/mul across int/rat combinations
  • unary neg/abs
  • total compare via exact cross-multiply logic

Advanced integer operations

A second layer exposes integer-only helpers:

  • gcd
  • pow_u64 (exponentiation by squaring)
  • modexp_u64 (modular exponentiation)

These are useful for small crypto things, number theory experiments, and parser/generator test fixtures.

The rules stay strict:

  • integer-only APIs reject non-integer values with LIMITLESS_ETYPE
  • modulus constraints are validated
  • range and invalid states return status codes, not undefined behavior

Most numeric libraries do okay on arithmetic and then break on conversion edges.

I tried to spend real effort there, since conversion bugs are usually the ones that fail only in CI:

  • parse from bases 2..36
  • optional base autodetect (0x, 0b, etc.)
  • rational text input as numerator/denominator
  • formatting with caller buffer and explicit required-length reporting
  • integer exports with range checks (to_i64, to_u64)

There are convenience aliases too:

  • from_str() and to_str() as base-10 shortcuts

The formatting API returns LIMITLESS_EBUF when capacity is too small and reports the required length so callers can resize.

Float import is the part that is easy to skip and important in practice.

limitless_number_from_float_exact() and ..._from_double_exact() decode the IEEE-754 object representation and construct the exact rational/int value for that bit pattern.

  • finite values become exact numbers
  • NaN / Inf are rejected (LIMITLESS_EINVAL)
  • 0.1 becomes its exact binary-rational equivalent, not a rounded decimal string

That is useful when debugging cross-language serialization and reproducible numeric tests.

C and C++ usability without splitting the core

The core stays strict C (limitless.h), with a C++ wrapper (limitless.hpp) on top for convenience.

C side:

  • explicit lifecycle
  • explicit context
  • explicit status handling
  • no operator overloading

C++ side:

  • RAII wrapper class
  • operator overloads for natural expressions
  • primitive-left operators (int + limitless_number, etc.)
  • parse/string helpers (parse, str)
  • works in no-exception builds

So you can write:

limitless_number x = 33424234;
limitless_number y = (x + 3) / 2.3f;

C users keep full control over memory and error flow; C++ users get friendlier syntax without changing what runs underneath.

Testing

For exact arithmetic, you can’t really get confidence from a few unit tests.

Test coverage includes:

  • generated deterministic vector matrices (large pairwise op sets)
  • parse/format round-trips across multiple bases
  • float/double exact import edge sets (normals, subnormals, signs)
  • property-style randomized checks
  • differential checks against Python fractions.Fraction
  • allocator fault injection to force OOM at many allocation sites
  • failure-atomic guarantees (out unchanged on failure)
  • single-TU and multi-TU include model checks
  • C++ wrapper equivalence behavior vs C core

So it is not just “worked on my machine”.

Packaging and release shape

A lot of small open-source libs stop at “here is a header”. I wanted this one to be a bit more usable, so it ships:

  • CMake package target: limitless::limitless
  • pkg-config metadata
  • Conan recipe
  • vcpkg overlay port
  • GitHub Actions matrix across OS/compiler families
  • coverage and release automation

Not the interesting part, but it is what makes a library actually usable by other people.

What this is good for and not good for

Good fit:

  • exact fraction arithmetic
  • educational math tooling
  • parser/transformation pipelines that need deterministic exactness
  • interoperability tests where numeric drift is unacceptable

Bad fit:

  • performance-critical floating-point simulation
  • vectorized numeric workloads
  • cases where approximate real arithmetic is fine

It is precision-first, not throughput-first. That trade-off is on purpose.

Open-source, you can check it out here: limitless

// comments