Skip to content

feat: replace numeric subtyping with explicit modes 🔢 - #210

Open
timfennis wants to merge 29 commits into
masterfrom
feat/explicit-numeric-modes
Open

feat: replace numeric subtyping with explicit modes 🔢#210
timfennis wants to merge 29 commits into
masterfrom
feat/explicit-numeric-modes

Conversation

@timfennis

Copy link
Copy Markdown
Owner

Summary

  • make Int, Float, and Number sibling public types
  • keep advanced values permanently wrapped as Number, backed by internal bigint, rational, float, or complex representations
  • remove the public Rational and Complex types without compatibility shims
  • add decimal and base-prefixed n literals, plus migration diagnostics for oversized primitive integers
  • define the complete mixed-mode arithmetic and standard-library overload matrices
  • preserve exact cross-mode equality and hashing while retaining lexicographic complex ordering
  • migrate the language tests and rewrite the numeric reference documentation

Closes #191

Semantics to review

  • Int uses checked i64 arithmetic; / truncates, \ floors, % uses truncating remainder, and %% uses Euclidean remainder.
  • Float keeps IEEE 754 behavior.
  • Number keeps exact arithmetic where possible and permits internal bigint, rational, float, and complex results.
  • Equal numeric values hash identically across modes; negative zero is normalized and NaN values compare equal for map/set consistency.
  • Scalar order is negative infinity, finite values, positive infinity, then NaN.
  • Complex values are ordered lexicographically by real component and then imaginary component.
  • Bitwise operations and shifts remain Int-only.
  • Transcendental operations on Number support complex continuation.

Out of scope

Specialized numeric bytecode from #110 remains separate from this semantic cutover.

Verification

  • cargo fmt --all
  • cargo test --workspace
  • cargo build --no-default-features
  • cargo clippy -p ndc_core -p ndc_lexer -p ndc_vm -p ndc_stdlib --all-targets
  • git diff --check

AI disclosure

OpenAI Codex using GPT-5 assisted across the complete patch, including the runtime and type-system refactor, lexer and standard-library changes, tests, and documentation. The resulting implementation was validated with the commands listed above.

Comment thread manual/src/reference/types/number.md Outdated
Comment thread manual/src/reference/variables-and-scopes.md Outdated
Comment thread manual/src/reference/variables-and-scopes.md Outdated
Comment thread ndc_vm/src/value/mod.rs
@timfennis
timfennis force-pushed the feat/explicit-numeric-modes branch 2 times, most recently from f84329c to b8214eb Compare September 2, 2026 13:51
@timfennis
timfennis force-pushed the feat/explicit-numeric-modes branch 2 times, most recently from 94652c8 to 1ed6ebc Compare September 2, 2026 14:41
timfennis added a commit that referenced this pull request Sep 3, 2026
## Context

Noticed while rebasing `feat/explicit-numeric-modes` (#210) onto the
as-cast work. That branch dropped a commit which, among other things,
had fixed this comment — so the inaccuracy stays on master until someone
corrects it on its own.

## Change

`Object::static_type`'s doc comment claimed container element types are
reported as `Any`, with `Tuple` as the sole exception. The body says
otherwise: lists, deques, maps, and `Some` all derive element types from
their contents via `lub`. Iterators and heaps are the variants that
report `Any`.

The `# Performance` note on `Value::static_type`, immediately above,
already documents the O(n) element scan for `List`, `Map`, and `Deque` —
so the two comments contradicted each other. Comment only, no behaviour
change.

🤖

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
timfennis and others added 20 commits September 3, 2026 15:42
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dispatch keeps master's no-scan policy, so a value the analyser only knows as
`List<Any>` no longer reaches the typed `sum` overloads. An `as` cast is the
supported spelling: it scans once, at a site the author wrote.

`016_casts/024` pins that a cast selects a *typed* container overload —
`002_list_downcast_from_any` only reaches `sorted(Sequence<Any>)`, so nothing
covered that. It includes the empty case, since `[].sum()` cannot be written
without a cast.

`007_numeric_hierarchy` was written to change here — with Int, Float, and
Number siblings, `1 as Number` can never hold, so it moves to an error test
and the file keeps only the casts that recover a numeric type from Any.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The troubleshooting page claimed every standard library container overload
uses `<Any>` elements, so the element-type limitation was invisible. The
typed `sum` overloads make it visible, and a cast is the way through it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Introduce a borrowed NumericRef and operational NumericMode without changing the public sibling types or Value representation. Reuse them for promotion, comparison, hashing, constructors, and conversions, while keeping primitive fast paths and exact overload validation.

Borrow Number payloads in generated native adapters so reference parameters no longer clone AdvancedNumber values.
Pick the evaluator per overload at registration instead of rediscovering the
output mode on every call, and give each integer operation its own
precondition so add, subtract and multiply test nothing but overflow.

Collapse the fixed-signature natives onto one declare_typed! macro so a type
is named once instead of three times, and report operand mismatches from a
single cold path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@timfennis
timfennis force-pushed the feat/explicit-numeric-modes branch from 1f4ceb5 to 888b9a0 Compare September 3, 2026 13:42
@timfennis
timfennis marked this pull request as ready for review September 3, 2026 13:42

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 888b9a0cb1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread ndc_stdlib/src/math.rs Outdated
Comment thread ndc_vm/src/value/mod.rs Outdated
timfennis and others added 4 commits September 5, 2026 13:01
`checked_rem` and `checked_rem_euclid` report the overflow of the quotient
they imply, so `i64::MIN % -1` failed even though the remainder is 0. The
divisor is already screened for zero, which leaves no way for a remainder
to overflow, so wrap instead. Division keeps reporting the real overflow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`cmp_to_zero` enumerated the exact Number variants and let the rest fall
through to a rejection, so a comparator returning a float-backed Number
failed with "must return a number, got Number". Handle the float with the
same NaN check the primitive branch uses. The match is now exhaustive, so
a new variant is a compile error rather than a silent rejection, and the
complex arm says which half of "number" is missing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Widening the declared type of `&BigRational` and `Complex64` parameters to
`StaticType::Number` left their runtime extraction matching one variant, so
such a native advertised every Number and then rejected most of them. Route
both through the existing conversions instead. Nothing declares these
parameters today, so this is a trap disarmed rather than a bug fixed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Number overloads converted through an i64 conversion that accepted only
exact BigInt values, so `randi(0, 10.0n)` and `randi(0, 20n/2n)` failed at
runtime with "cannot convert Number to int". Dropping them moves the
rejection to resolve time and matches randi already having no Float
overloads. That conversion now has no callers, so it goes too.

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

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1d29e7f665

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread ndc_vm/src/value/number.rs Outdated
/// negative base is raised to a fractional power: `(-8.0) ^ 2.0` is `64.0`,
/// but `(-8.0) ^ 0.5` has no real value.
fn float_pow(base: f64, exponent: f64) -> AdvancedNumber {
if base < 0.0 && exponent.fract() != 0.0 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep non-finite exponents out of complex continuation

When a Number exponent is infinite or NaN, exponent.fract() is NaN and therefore compares unequal to zero, so this condition incorrectly classifies it as fractional. For example, (-2n) ^ Number(Inf) takes the complex path and produces a complex NaN instead of the real f64::powf result Inf; a negative-infinite exponent similarly should produce zero. Restrict complex continuation to finite fractional exponents, leaving non-finite exponents on the real floating-point path.

Useful? React with 👍 / 👎.

timfennis and others added 5 commits September 5, 2026 13:42
Four changes to AdvancedNumber, all reachable from ordinary programs:

`0 ^ negative` only guarded the integer base, so a zero rational reached
num-rational directly and panicked the process. One check before the match
covers every exact operand pair and lets int_pow drop its own copy.

A non-finite exponent has a NaN `fract()`, which compares unequal to zero and
sent a negative base into the complex plane: `(-2n) ^ Inf` answered a complex
NaN where the float path answers Inf. Found by the Codex review.

Division and the remainders build their result as a fraction, and a
denominator of one never collapsed, so `4n / 2n` stayed a Rational that
printed as `2` but no longer matched the Int arm serde switches on. The
constructor now normalizes, which fixes every caller at once.

Hashing a value no i64 could hold built an exact BigRational, so float map
keys allocated twice per insert. Values an f64 represents exactly now hash
from their bits; only the rest reach the canonical form. 200k float keys go
from 176ms to 48ms. Equal values still share a bucket in every mode, which
the new test pins down across floats, dyadic rationals and big integers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`checked_shl` only rejects a shift amount of 64 or more, never a result that
does not fit, so `1 << 63` wrapped to a negative Int in silence while every
other Int operation reported its overflow — and the manual already claimed
shifts were checked. Verify the shift is reversible instead.

Three other repairs in the same file:

The error helpers formatted operands with the unbounded `static_type()`, so
comparing a self-referential list aborted the process with a stack overflow
while building the message. They use the bounded `diagnostic_type()` the
same PR introduced for this hazard.

`convert_to_int` described its argument before the numeric fast path that
discards the description, walking whole containers to do it. It now only
describes on the error paths.

Restores the per-function documentation on ceil, floor, round, abs, signum,
real, imag, numerator, denominator, gcd and lcm, which the rewrite had
replaced with one shared placeholder per group; the same strings feed LSP
hover. Registration of the unary helpers is reversed to match
register_binary_arithmetic, so a dynamically dispatched abs(1) tests the Int
candidate first rather than last.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Publishes `diagnostic_type()` so ndc_stdlib can reach the bounded description
its error helpers now use; it was pub(crate), which left the unbounded
`static_type()` as the only option outside this crate.

Every non-Int range bound claimed the integer was too large. Now that `n`
literals are the only way to write a big integer, a small Number bound is the
common case, and the message sent you looking for an overflow that was not
there. Only an integer that genuinely does not fit reports a size problem.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`float_to_i64` allocated a BigInt to range-test a finite f64. Truncating
first makes it exactly the conversion `exact_f64_to_i64` already performs
with two comparisons, so it delegates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`5n % 0n` is NaN where `5 % 0` is an error. The page covered division but
left the remainders implicit.

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

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Something went wrong. Try again later by commenting “@codex review”.

Unknown error
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2a6a1afbc5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +487 to +490
Ok(Self::Rational(Box::new(BigRational::new(
BigInt::from(1),
denominator,
))))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Normalize integral power results

When a negative power has an integral reciprocal, this direct Rational construction bypasses AdvancedNumber::rational and leaves a denominator-one value in the rational variant. For example, json_encode(1n ^ -1n) fails with “cannot convert a rational number” even though the result equals the integer 1n; the rational-base power branches below have the same problem for cases such as (1n / 2n) ^ -1n. Route these results through the normalizing constructor so representation-sensitive consumers see them as integers.

Useful? React with 👍 / 👎.

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.

RFC: Replace numeric subtyping with explicit numeric modes

1 participant