From f64f91f4b9b59ca26829638d69d589e4d806140b Mon Sep 17 00:00:00 2001 From: Vedant Madane <6527493+VedantMadane@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:58:15 +0530 Subject: [PATCH] feat: add mod, a floored remainder that never follows a negative dividend % truncates toward zero, so wrapping arithmetic needs ((n % d) + d) % d. mod(n, d) is the floored form: sign follows the divisor, zero divisor refuses with the same words as %, and floats promote like every other binary numeric builtin. Fixes #44 Signed-off-by: Vedant Madane <6527493+VedantMadane@users.noreply.github.com> --- docs/specification.md | 7 +++ src/builtins.rs | 116 ++++++++++++++++++++++++++++++++++++++---- 2 files changed, 114 insertions(+), 9 deletions(-) diff --git a/docs/specification.md b/docs/specification.md index 7ce98e0..d6d5828 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -1341,6 +1341,7 @@ of them as a value. | Builtin | Arguments | Result | | ------- | --------- | ------ | | `abs(n)` | 1 | The magnitude. | +| `mod(n, d)` | 2 | The floored remainder of `n` by `d`. The sign follows `d`. A zero divisor is an error, with the same words `%` uses. | | `min(...)` | 1 or more | The smallest. | | `max(...)` | 1 or more | The largest. | | `floor(n)` | 1 | The largest integer that is not larger than `n`. Gives an `int`. | @@ -1353,6 +1354,12 @@ of them as a value. | `sum(a)` | 1 array | The numbers in the array, added. An empty array gives `0`. | | `product(a)` | 1 array | The numbers in the array, multiplied. An empty array gives `1`. | + +`mod` is the floored remainder. `%` truncates toward zero, so `-7 % 3` is `-1`. +`mod(-7, 3)` is `2`. The sign of `mod` follows the divisor: `mod(1, -10)` is +`-9`. Use `mod` for wrapping (ring buffers, clocks, hash buckets); keep `%` +when the truncated form is what you want. + `sum` and `product` give an `int` for an array of integers, and a `float` for an array that has one or more floats. Section 5.2 gives the promotion rule. Overflow of the integer form is an error, as it is for each other integer diff --git a/src/builtins.rs b/src/builtins.rs index 5419cd2..7d68d5d 100644 --- a/src/builtins.rs +++ b/src/builtins.rs @@ -50,6 +50,7 @@ pub fn register(globals: &mut Globals) { define(globals, "slice", slice); define(globals, "reverse", reverse); define(globals, "abs", abs); + define(globals, "mod", modulo); define(globals, "min", min); define(globals, "max", max); define(globals, "floor", floor); @@ -1138,6 +1139,73 @@ fn abs(_out: &mut dyn Output, _input: &mut dyn Input, args: Vec) -> Resul } } + +/// Floored remainder of two integers: sign follows the divisor, never the +/// dividend. Differs from `%`, which truncates toward zero. +/// +/// `a.checked_rem(b)` is the truncated form. When the truncated remainder is +/// non-zero and the operands have different signs, adding `b` lands on the +/// floored answer. That is the adjustment every wrapping use wants: +/// `mod(-1, 10)` is `9`, not `-1`. +fn floored_rem_i64(a: i64, b: i64) -> Option { + let r = a.checked_rem(b)?; + if r != 0 && (a < 0) != (b < 0) { + r.checked_add(b) + } else { + Some(r) + } +} + +/// Floored remainder of two floats. Same definition as the integer form: +/// `a - b * floor(a / b)`. A zero divisor is refused by the caller. +fn floored_rem_f64(a: f64, b: f64) -> f64 { + a - b * (a / b).floor() +} + +/// `mod(n, d)` is the floored remainder of `n` by `d`. The sign follows `d`, +/// so a negative dividend never produces a negative result when `d` is positive. +/// +/// `%` keeps the truncating form required by section 5 (and by every language +/// that maps remainder onto machine division). Wrapping arithmetic needs the +/// other definition, and writing `((n % d) + d) % d` by hand is three operations +/// and two mentions of `d` for one idea. `mod` is that idea under one name. +/// +/// A zero divisor refuses with the same words `%` uses. Two integers stay +/// integers; any float promotes, matching every other binary numeric builtin. +fn modulo( + _out: &mut dyn Output, + _input: &mut dyn Input, + args: Vec, +) -> Result { + check_arity("mod", &args, 2)?; + match (&args[0], &args[1]) { + (Value::Int(n), Value::Int(d)) => { + if *d == 0 { + return Err("modulo by zero".to_string()); + } + floored_rem_i64(*n, *d) + .map(Value::Int) + .ok_or_else(|| "integer overflow in mod".to_string()) + } + (Value::Int(_) | Value::Float(_), Value::Int(_) | Value::Float(_)) => { + let n = number_arg("mod", &args[0])?; + let d = number_arg("mod", &args[1])?; + if d == 0.0 { + return Err("modulo by zero".to_string()); + } + Ok(Value::Float(floored_rem_f64(n, d))) + } + (other, _) if !matches!(other, Value::Int(_) | Value::Float(_)) => Err(format!( + "mod expects numbers but got a {}", + other.type_name() + )), + (_, other) => Err(format!( + "mod expects numbers but got a {}", + other.type_name() + )), + } +} + /// Shared implementation of `min` and `max`: pick the argument that compares as /// `want` against the running best. Preserves the winning value's own type. /// @@ -1768,14 +1836,14 @@ impl Args { /// /// This is the last per-element heap allocation left in the engine, and it /// only happens when a *builtin* is the callback, as in `map(xs, abs)`. - /// Removing it means changing [`BuiltinFn`] for the forty builtins + /// Removing it means changing [`BuiltinFn`] for the forty-eight builtins /// registered with `define`, several of which move values out of the vector /// they are handed, which is a larger change than it looks and is left for /// later. /// - /// Forty-one is the count of `define` calls, not the count of builtins. The - /// two were the same figure until 1.1 and are not any more: there are - /// sixty-one builtins, of which four take a `SystemFn`, twelve take an + /// Forty-eight is the count of `define` calls, not the count of builtins. + /// The two were the same figure until 1.1 and are not any more: there are + /// sixty-eight builtins, of which four take a `SystemFn`, twelve take an /// `AmbientFn`, and four take a `HostFn`, and none of those twenty would /// be touched by such a change. pub fn into_vec(self) -> Vec { @@ -2619,6 +2687,35 @@ mod tests { assert_eq!(out("print(abs(-3), abs(-2.5), abs(4))"), "3 2.5 4\n"); } + #[test] + fn mod_follows_the_divisor_and_wraps_a_negative_dividend() { + // Floored remainder: positive divisor, negative dividend lands in + // `[0, d)` rather than the negative truncated remainder `%` gives. + assert_eq!(out("print(mod(-1, 10), mod(-7, 3), mod(7, 3))"), "9 2 1\n"); + // Negative divisor: sign follows the divisor under the floored rule. + assert_eq!(out("print(mod(1, -10), mod(7, -3), mod(-7, -3))"), "-9 -2 -1\n"); + // Floats match, including mixed int/float promotion. + assert_eq!( + out("print(mod(-7.0, 3.0), mod(-1, 10.0), mod(7.5, 2.0))"), + "2.0 9.0 1.5\n" + ); + // Same answer as `%` when both operands are non-negative. + assert_eq!(out("print(mod(7, 3), 7 % 3)"), "1 1\n"); + } + + #[test] + fn mod_refuses_a_zero_divisor() { + assert_eq!(err("mod(1, 0)"), "modulo by zero"); + assert_eq!(err("mod(1.0, 0.0)"), "modulo by zero"); + } + + #[test] + fn mod_refuses_non_numbers() { + assert!(err("mod(\"a\", 2)").contains("expects numbers")); + assert!(err("mod(2, \"a\")").contains("expects numbers")); + assert!(err("mod(1)").contains("2 arguments")); + } + #[test] fn min_and_max_over_numbers() { assert_eq!(out("print(min(3, 1, 2), max(3, 1, 2))"), "1 3\n"); @@ -2927,8 +3024,8 @@ mod count { } assert_eq!( - plain, 47, - "{plain} builtins take a BuiltinFn, not 47. Quoted by `Args::into_vec` \ + plain, 48, + "{plain} builtins take a BuiltinFn, not 48. Quoted by `Args::into_vec` \ above and by the trampoline section of docs/architecture.md." ); assert_eq!( @@ -2938,8 +3035,8 @@ mod count { ); assert_eq!( plain + system + ambient, - 63, - "{} builtins reach `call_native`, not 63. Quoted by the caught-error \ + 64, + "{} builtins reach `call_native`, not 64. Quoted by the caught-error \ guard in `Vm::call_native`.", plain + system + ambient ); @@ -2959,7 +3056,7 @@ mod count { /// Every builtin, in registration order. Pinned so the count cannot drift and /// so the specification has one list to be generated from rather than a second /// hand-written one that can disagree. -pub const BUILTIN_NAMES: [&str; 67] = [ +pub const BUILTIN_NAMES: [&str; 68] = [ "print", "eprint", "exit", @@ -2995,6 +3092,7 @@ pub const BUILTIN_NAMES: [&str; 67] = [ "slice", "reverse", "abs", + "mod", "min", "max", "floor",