From f7cad899b163ed068750bebb9e95a871e9d52b77 Mon Sep 17 00:00:00 2001 From: Alisander Qoshqosh <37006439+qalisander@users.noreply.github.com> Date: Tue, 2 Jun 2026 19:32:20 +0400 Subject: [PATCH 1/5] feat: add median implementation (#135) * add separate quick_sort implementations for each type * remove duplicated qsort implementations * add in-place qsort implementation * add partition function * rewrite qsort without recursion * convert quick_sort to macro * add comprehensive test cases for qsort * update qsort documentation * add review fixes * remove --trace flag from testing pipeline * Revert "remove --trace flag from testing pipeline" This reverts commit 9bf706592aba5cae2efb78d9d5c92dd353243c33. * split macro module * inline partition macro * add median implementation * add more edge case test cases * fix fmt * remove quick_sort duplicate * feat: add median * docs: update PR * Route median through u256 workhorse fun (#325) * fix: fmt * fix: apply CR comments * test: add missing test * fix: macro * feat: use quickselect * feat: improve CHANGELOG * feat: apply review updates * feat: update comment --------- Co-authored-by: Daniel Bigos Co-authored-by: Daniel Bigos Co-authored-by: immrsd <103599616+immrsd@users.noreply.github.com> Co-authored-by: Eric Nordelo --- CHANGELOG.md | 4 + math/core/README.md | 20 +- math/core/sources/vector.move | 204 +++++++++++++ math/core/tests/macros/median.move | 464 +++++++++++++++++++++++++++++ math/fixed_point/Move.lock | 10 +- 5 files changed, 698 insertions(+), 4 deletions(-) create mode 100644 math/core/tests/macros/median.move diff --git a/CHANGELOG.md b/CHANGELOG.md index c5c473bf..c77715de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) ### `openzeppelin_math` +#### Added + +- Added `vector::median` macro for unsigned integer vectors with rounding for even lengths. Includes `vector::median_u256`. (#135) + #### Changed - `u128::is_power_of_ten` and `u256::is_power_of_ten` now compute the result via `log10_floor` and `pow` instead of a hardcoded lookup table. (#323) diff --git a/math/core/README.md b/math/core/README.md index e248bf00..10fb8ca3 100644 --- a/math/core/README.md +++ b/math/core/README.md @@ -19,6 +19,14 @@ Operations for `u8`, `u16`, `u32`, `u64`, `u128`, and `u256`, including: - `is_power_of_ten`: Power-of-ten check - Decimal scaling helpers +### Vector operations + +Generic over `u8`..`u256`: + +- `vector::quick_sort` / `vector::quick_sort_by`: In-place iterative quicksort with three-way partitioning +- `vector::median`: Median of a borrowed unsigned integer vector with configurable rounding for even-length input; uses quickselect instead of sorting the full vector and aborts on empty input +- `vector::median_u256`: Concrete `vector` median function using the same quickselect implementation + ## Rounding modes - **Down**: Round toward zero (truncate) @@ -27,16 +35,24 @@ Operations for `u8`, `u16`, `u32`, `u64`, `u128`, and `u256`, including: ## Usage examples -```rust +```move use openzeppelin_math::{u128, rounding}; let result = u128::mul_div(100, 200, 3, rounding::up()); // result = Some(6667) (rounded up from 6666.66...) ``` -```rust +```move use openzeppelin_math::{u64, rounding}; let mean = u64::average(5, 6, rounding::down()); // mean = 5 ``` + +```move +use openzeppelin_math::rounding; +use openzeppelin_math::vector; + +let med = vector::median!(&vector[5u64, 1, 9, 3, 7], rounding::down()); +// med = 5 +``` diff --git a/math/core/sources/vector.move b/math/core/sources/vector.move index 34c55db3..33dfb98f 100644 --- a/math/core/sources/vector.move +++ b/math/core/sources/vector.move @@ -1,5 +1,13 @@ module openzeppelin_math::vector; +use openzeppelin_math::macros; +use openzeppelin_math::rounding::RoundingMode; + +// === Errors === + +#[error(code = 0)] +const EMedianOfEmptyVector: vector = "Median of empty vector is undefined"; + // === Public Functions === /// Sort an unsigned integer vector in-place using the quicksort algorithm. @@ -194,3 +202,199 @@ public macro fun quick_sort_by<$T>($vec: &mut vector<$T>, $le: |&$T, &$T| -> boo }; }; } + +/// Compute the median of an unsigned integer vector. +/// +/// For odd-length vectors, the median is the central order statistic and +/// `$rounding_mode` has no effect on the result. +/// +/// For even-length vectors, the median is the arithmetic mean of the two central +/// values, rounded according to `$rounding_mode`. +/// +/// The input vector is not mutated. +/// +/// Median selection uses quickselect over a `u256` working vector instead of +/// sorting the full input. Benchmarks show similar cost for tiny vectors and +/// substantially lower cost than sorting as input size grows. +/// +/// #### Generics +/// - `$Int`: Any unsigned integer type (`u8`, `u16`, `u32`, `u64`, `u128`, or `u256`). +/// +/// #### Parameters +/// - `$vec`: Reference to the vector whose median is desired. +/// - `$rounding_mode`: Rounding strategy, applied only when the length is even. +/// +/// #### Returns +/// - The median of `$vec`. +/// +/// #### Aborts +/// - `EMedianOfEmptyVector` if `$vec` is empty. +/// +/// #### Example +/// ```move +/// use openzeppelin_math::rounding +/// use openzeppelin_math::vector; +/// +/// let v = vector[3u64, 1, 4, 1, 5, 9, 2, 6]; +/// let m = vector::median!(&v, rounding::down()); +/// // m == 3 +/// ``` +public macro fun median<$Int>($vec: &vector<$Int>, $rounding_mode: RoundingMode): $Int { + let vec = $vec; + let vec_u256 = vec.map_ref!(|x| *x as u256); + median_u256(vec_u256, $rounding_mode) as $Int +} + +// === Concrete Public Functions === + +/// Compute the median of a `u256` vector with configurable rounding. +/// +/// This function consumes and partially reorders `vec`. +/// +/// Median selection uses quickselect instead of sorting the full input. +/// Benchmarks show similar cost for tiny vectors and substantially lower cost +/// than sorting as input size grows. +/// +/// #### Parameters +/// - `vec`: Vector whose median is desired (consumed). +/// - `rounding_mode`: Rounding strategy, applied only when the length is even. +/// +/// #### Returns +/// - The median of `vec`. +/// +/// #### Aborts +/// - `EMedianOfEmptyVector` if `vec` is empty. +/// +/// #### Example +/// ```move +/// use openzeppelin_math::rounding +/// use openzeppelin_math::vector; +/// +/// let m = vector::median_u256(vector[10u256, 2, 8, 4], rounding::down()); +/// // m == 6 +/// ``` +public fun median_u256(mut vec: vector, rounding_mode: RoundingMode): u256 { + let len = vec.length(); + assert!(len != 0, EMedianOfEmptyVector); + + let mid = len / 2; + if (len % 2 == 1) { + select_k_u256(&mut vec, mid) + } else { + let upper = select_k_u256(&mut vec, mid); + // Selecting index `mid` leaves all preceding elements less than or equal + // to `upper`, so the lower central statistic is the prefix maximum. + let lower = max_prefix_u256(&vec, mid); + macros::average!(lower, upper, rounding_mode) + } +} + +// === Internal Functions === + +// Return the kth order statistic, partially partitioning `vec` in place. +// +// Quickselect repeatedly partitions the active range around a pivot and keeps +// only the side that can contain index `k`. This avoids sorting the whole vector +// while still placing the selected value at the same index it would occupy in +// sorted order. +fun select_k_u256(vec: &mut vector, k: u64): u256 { + let mut start = 0; + let mut end = vec.length(); + + loop { + if (start + 1 >= end) return vec[k]; + + // Use insertion sort as the quickselect base case for small active ranges. + if (end - start <= 10) { + insertion_sort_u256_range(vec, start, end); + return vec[k] + }; + + let pivot_index = end - 1; + let mid = start + (end - start) / 2; + + // Median-of-three pivot selection using start, middle, and end - 1. + // The median of those three values is moved into `pivot_index`. + if (vec[mid] <= vec[start]) { + vec.swap(start, mid); + }; + if (vec[pivot_index] <= vec[start]) { + vec.swap(start, pivot_index) + }; + if (vec[mid] <= vec[pivot_index]) { + vec.swap(mid, pivot_index); + }; + + let mut lt = start; + let mut i = start; + let mut gt = pivot_index; + + // Three-way partition around the pivot at `pivot_index`. + // + // During the scan: + // - [start, lt) contains values less than the pivot. + // - [lt, i) contains values equal to the pivot. + // - [i, gt) is unprocessed. + // - [gt, pivot_index) contains values greater than the pivot. + while (i < gt) { + if (vec[i] <= vec[pivot_index]) { + if (vec[pivot_index] <= vec[i]) { + i = i + 1; + } else { + vec.swap(lt, i); + lt = lt + 1; + i = i + 1; + } + } else { + gt = gt - 1; + vec.swap(i, gt); + }; + }; + + // Move the pivot next to the equal region. After this, [lt, eq_end) + // contains exactly the pivot-equivalent values. + vec.swap(gt, pivot_index); + let eq_end = gt + 1; + + // Discard the partitions that cannot contain the kth order statistic. + if (k < lt) { + end = lt; + } else if (k >= eq_end) { + start = eq_end; + } else { + return vec[k] + }; + } +} + +// Sort a half-open sub-range [start, end). Used as the quickselect base case +// for small active ranges. +fun insertion_sort_u256_range(vec: &mut vector, start: u64, end: u64) { + let mut i = start + 1; + while (i < end) { + let mut j = i; + while (j != start && vec[j] < vec[j - 1]) { + vec.swap(j, j - 1); + j = j - 1; + }; + i = i + 1; + }; +} + +// Return the maximum value in vec[0..end). +// +// For even-length medians, selecting the upper middle index partitions the +// vector enough to guarantee every earlier element is less than or equal to it, +// but that prefix is not sorted. The lower middle value is therefore the maximum +// of the prefix. +fun max_prefix_u256(vec: &vector, end: u64): u256 { + let mut max = vec[0]; + let mut i = 1; + while (i < end) { + if (max < vec[i]) { + max = vec[i]; + }; + i = i + 1; + }; + max +} diff --git a/math/core/tests/macros/median.move b/math/core/tests/macros/median.move new file mode 100644 index 00000000..77d3cf64 --- /dev/null +++ b/math/core/tests/macros/median.move @@ -0,0 +1,464 @@ +#[test_only] +module openzeppelin_math::median; + +use openzeppelin_math::macros; +use openzeppelin_math::rounding::{Self, RoundingMode}; +use openzeppelin_math::vector; +use std::unit_test::assert_eq; + +fun sorted_median_u64(sorted: &vector, rounding_mode: RoundingMode): u64 { + let len = sorted.length(); + let mid = len / 2; + if (len % 2 == 1) { + sorted[mid] + } else { + macros::average!(sorted[mid - 1], sorted[mid], rounding_mode) + } +} + +// === Empty input === + +#[test, expected_failure(abort_code = vector::EMedianOfEmptyVector)] +fun median_empty_u8_aborts() { + vector::median!(&vector[], rounding::down()); +} + +#[test, expected_failure(abort_code = vector::EMedianOfEmptyVector)] +fun median_empty_u16_aborts() { + vector::median!(&vector[], rounding::down()); +} + +#[test, expected_failure(abort_code = vector::EMedianOfEmptyVector)] +fun median_empty_u32_aborts() { + vector::median!(&vector[], rounding::down()); +} + +#[test, expected_failure(abort_code = vector::EMedianOfEmptyVector)] +fun median_empty_u64_aborts() { + vector::median!(&vector[], rounding::down()); +} + +#[test, expected_failure(abort_code = vector::EMedianOfEmptyVector)] +fun median_empty_u128_aborts() { + vector::median!(&vector[], rounding::down()); +} + +#[test, expected_failure(abort_code = vector::EMedianOfEmptyVector)] +fun median_empty_u256_aborts() { + vector::median!(&vector[], rounding::nearest()); +} + +#[test, expected_failure(abort_code = vector::EMedianOfEmptyVector)] +fun median_u256_empty_aborts() { + vector::median_u256(vector[], rounding::down()); +} + +// === Single-element and small inputs === + +#[test] +fun median_single_element() { + assert_eq!(vector::median!(&vector[42u64], rounding::down()), 42u64); +} + +#[test] +fun median_two_elements_unsorted() { + assert_eq!(vector::median!(&vector[9u64, 1], rounding::down()), 5u64); +} + +#[test] +fun median_two_elements_same_value() { + assert_eq!(vector::median!(&vector[7u64, 7], rounding::down()), 7u64); +} + +#[test] +fun median_does_not_mutate_input() { + let vec = vector[3u64, 1, 2]; + assert_eq!(vector::median!(&vec, rounding::down()), 2u64); + assert_eq!(vec, vector[3u64, 1, 2]); +} + +#[test] +fun median_u256_computes_directly() { + assert_eq!(vector::median_u256(vector[10u256, 2, 8, 4], rounding::down()), 6u256); +} + +#[test] +fun median_u256_computes_odd_directly() { + assert_eq!(vector::median_u256(vector[9u256, 1, 7, 3, 5], rounding::down()), 5u256); +} + +#[test] +fun median_u256_respects_even_rounding_modes() { + assert_eq!(vector::median_u256(vector[6u256, 5, 1, 2], rounding::down()), 3u256); + assert_eq!(vector::median_u256(vector[6u256, 5, 1, 2], rounding::nearest()), 4u256); + assert_eq!(vector::median_u256(vector[6u256, 5, 1, 2], rounding::up()), 4u256); +} + +#[test] +fun median_u256_handles_many_duplicates() { + assert_eq!(vector::median_u256(vector[4u256, 1, 4, 4, 2, 4], rounding::down()), 4u256); +} + +#[test] +fun median_u256_handles_extreme_values() { + let max = std::u256::max_value!(); + assert_eq!(vector::median_u256(vector[0, max], rounding::nearest()), max / 2 + 1); +} + +// === Odd-length correctness === + +#[test] +fun median_odd_length_unsorted() { + assert_eq!(vector::median!(&vector[5u64, 1, 9, 3, 7], rounding::down()), 5u64); +} + +#[test] +fun median_reverse_sorted_odd_length() { + assert_eq!(vector::median!(&vector[9u64, 7, 5, 3, 1], rounding::down()), 5u64); +} + +#[test] +fun median_already_sorted_odd() { + assert_eq!(vector::median!(&vector[1u64, 2, 3, 4, 5], rounding::down()), 3u64); +} + +#[random_test] +fun median_odd_length_rounding_modes_agree_u64(mut v: vector, a: u64, _b: u64) { + // ensure `v` has odd length + if (v.is_empty()) { + v.push_back(a); + } else if (v.length() % 2 == 0) { + // pop avoids overflow risk of push if v.length == u64::max + v.pop_back(); + }; + + let down = vector::median!(&v, rounding::down()); + let nearest = vector::median!(&v, rounding::nearest()); + let up = vector::median!(&v, rounding::up()); + + assert_eq!(down, nearest); + assert_eq!(nearest, up); +} + +#[random_test] +fun median_matches_sorted_reference_u64(mut v: vector, a: u64) { + if (v.is_empty()) { + v.push_back(a); + }; + + let down = vector::median!(&v, rounding::down()); + let nearest = vector::median!(&v, rounding::nearest()); + let up = vector::median!(&v, rounding::up()); + + let mut sorted = v; + vector::quick_sort!(&mut sorted); + + assert_eq!(down, sorted_median_u64(&sorted, rounding::down())); + assert_eq!(nearest, sorted_median_u64(&sorted, rounding::nearest())); + assert_eq!(up, sorted_median_u64(&sorted, rounding::up())); +} + +// === Even-length correctness and rounding contract === + +#[test] +fun median_even_length_unsorted() { + assert_eq!(vector::median!(&vector[10u64, 2, 8, 4], rounding::down()), 6u64); +} + +#[test] +fun median_even_length_rounds_down() { + // (2 + 5) / 2 = 3.5 -> rounds down to 3 + assert_eq!(vector::median!(&vector[6u64, 5, 1, 2], rounding::down()), 3u64); +} + +#[test] +fun median_even_length_rounds_up() { + // middles 2 and 3 -> ceil((2+3)/2) = 3 + assert_eq!(vector::median!(&vector[1u64, 2, 3, 4], rounding::up()), 3u64); +} + +#[test] +fun median_even_length_rounds_nearest_half_rounds_up() { + // (1 + 2) / 2 = 1.5 — rounding::nearest() rounds half-up + assert_eq!(vector::median!(&vector[1u64, 2], rounding::nearest()), 2u64); +} + +#[test] +fun median_even_length_nearest_exact_midpoint_down() { + // sorted: [2, 4, 8, 10]; middles 4, 8 -> (4+8)/2 = 6 exactly. Every rounding + // mode returns the exact value. + assert_eq!(vector::median!(&vector[10u64, 2, 8, 4], rounding::down()), 6u64); +} + +#[test] +fun median_even_length_nearest_exact_midpoint_up() { + assert_eq!(vector::median!(&vector[10u64, 2, 8, 4], rounding::up()), 6u64); +} + +#[test] +fun median_even_length_nearest_exact_midpoint_nearest() { + assert_eq!(vector::median!(&vector[10u64, 2, 8, 4], rounding::nearest()), 6u64); +} + +#[test] +fun median_sorted_even_length() { + assert_eq!(vector::median!(&vector[1u64, 3, 5, 7], rounding::down()), 4u64); +} + +#[random_test] +fun median_even_length_rounding_modes_are_monotonic_u64(mut v: vector, a: u64, b: u64) { + // ensure `v` has even length (pop avoids overflow risk of push if v.length == u64::max) + if (v.length() % 2 == 1) { v.pop_back(); }; + // ensure `v` is non-empty + if (v.is_empty()) { + v.push_back(a); + v.push_back(b); + }; + + let down = vector::median!(&v, rounding::down()); + let nearest = vector::median!(&v, rounding::nearest()); + let up = vector::median!(&v, rounding::up()); + + assert!(down <= nearest); + assert!(nearest <= up); +} + +// === Duplicates === + +#[test] +fun median_with_duplicates() { + assert_eq!(vector::median!(&vector[2u64, 2, 2, 3, 4], rounding::down()), 2u64); +} + +#[test] +fun median_with_many_duplicates_even() { + assert_eq!(vector::median!(&vector[4u64, 1, 4, 4, 2, 4], rounding::down()), 4u64); +} + +// === Constant-vector idempotence === + +#[test] +fun median_constant_vector_down_n3() { + assert_eq!(vector::median!(&vector[7u64, 7, 7], rounding::down()), 7u64); +} + +#[test] +fun median_constant_vector_down_n4() { + assert_eq!(vector::median!(&vector[7u64, 7, 7, 7], rounding::down()), 7u64); +} + +#[test] +fun median_constant_vector_down_zeros() { + assert_eq!(vector::median!(&vector[0u64, 0], rounding::down()), 0u64); +} + +#[test] +fun median_constant_vector_down_u64_max() { + let max = std::u64::max_value!(); + assert_eq!(vector::median!(&vector[max, max], rounding::down()), max); +} + +#[test] +fun median_constant_vector_up() { + assert_eq!(vector::median!(&vector[42u64, 42], rounding::up()), 42u64); +} + +#[test] +fun median_constant_vector_nearest() { + assert_eq!(vector::median!(&vector[42u64, 42], rounding::nearest()), 42u64); +} + +// === Permutation invariance === + +#[test] +fun median_permutation_canonical_odd() { + assert_eq!(vector::median!(&vector[1u64, 2, 3, 4, 5], rounding::down()), 3u64); +} + +#[test] +fun median_permutation_shuffled_odd() { + assert_eq!(vector::median!(&vector[3u64, 1, 5, 2, 4], rounding::down()), 3u64); +} + +#[test] +fun median_permutation_reversed_odd() { + assert_eq!(vector::median!(&vector[5u64, 4, 3, 2, 1], rounding::down()), 3u64); +} + +#[test] +fun median_permutation_canonical_even() { + assert_eq!(vector::median!(&vector[1u64, 2, 3, 4], rounding::down()), 2u64); +} + +#[test] +fun median_permutation_shuffled_even() { + assert_eq!(vector::median!(&vector[4u64, 1, 3, 2], rounding::down()), 2u64); +} + +#[test] +fun median_permutation_other_shuffle_even() { + assert_eq!(vector::median!(&vector[3u64, 4, 2, 1], rounding::down()), 2u64); +} + +// === Overflow safety on even-length resolution === + +#[test] +fun median_even_u64_zero_and_max_down() { + // floor(u64::MAX / 2) = 2^63 - 1 + assert_eq!( + vector::median!(&vector[0, std::u64::max_value!()], rounding::down()), + std::u64::max_value!() / 2, + ); +} + +#[test] +fun median_even_u64_zero_and_max_up() { + // ceil(u64::MAX / 2) = 2^63 + assert_eq!( + vector::median!(&vector[0, std::u64::max_value!()], rounding::up()), + std::u64::max_value!() / 2 + 1, + ); +} + +#[test] +fun median_even_u64_zero_and_max_nearest() { + // 0.5 tie -> nearest rounds up -> 2^63 + assert_eq!( + vector::median!(&vector[0, std::u64::max_value!()], rounding::nearest()), + std::u64::max_value!() / 2 + 1, + ); +} + +#[test] +fun median_even_u256_max_max_down() { + let max = std::u256::max_value!(); + assert_eq!(vector::median!(&vector[max, max], rounding::down()), max); +} + +#[test] +fun median_even_u256_max_max_up() { + let max = std::u256::max_value!(); + assert_eq!(vector::median!(&vector[max, max], rounding::up()), max); +} + +#[test] +fun median_even_u256_max_max_nearest() { + let max = std::u256::max_value!(); + assert_eq!(vector::median!(&vector[max, max], rounding::nearest()), max); +} + +#[test] +fun median_even_u256_zero_and_max_down() { + // floor(MAX/2) = 2^255 - 1 + let max = std::u256::max_value!(); + let half_floor = std::u256::max_value!() / 2; + assert_eq!(vector::median!(&vector[0, max], rounding::down()), half_floor); +} + +#[test] +fun median_even_u256_zero_and_max_up() { + // ceil(MAX/2) = 2^255 + let max = std::u256::max_value!(); + let half_ceil = std::u256::max_value!() / 2 + 1; + assert_eq!(vector::median!(&vector[0, max], rounding::up()), half_ceil); +} + +#[test] +fun median_even_u256_zero_and_max_nearest() { + // 0.5 tie -> nearest rounds up -> 2^255 + let max = std::u256::max_value!(); + let half_ceil = std::u256::max_value!() / 2 + 1; + assert_eq!(vector::median!(&vector[0, max], rounding::nearest()), half_ceil); +} + +// === Per-width sanity checks === + +#[test] +fun median_supports_u8() { + assert_eq!(vector::median!(&vector[5u8, 1, 9], rounding::down()), 5u8); +} + +#[test] +fun median_u8_even_length_rounds_after_average() { + assert_eq!(vector::median!(&vector[1u8, 2, 5, 9], rounding::down()), 3u8); + assert_eq!(vector::median!(&vector[1u8, 2, 5, 9], rounding::nearest()), 4u8); + assert_eq!(vector::median!(&vector[1u8, 2, 5, 9], rounding::up()), 4u8); +} + +#[test] +fun median_u16_even_length() { + assert_eq!(vector::median!(&vector[100u16, 200, 1, 3], rounding::down()), 51u16); +} + +#[test] +fun median_u32_odd_length() { + assert_eq!(vector::median!(&vector[1000u32, 1, 500, 3, 7], rounding::down()), 7u32); +} + +#[test] +fun median_u128_even_length_large_values() { + assert_eq!( + vector::median!(&vector[std::u128::max_value!(), 0], rounding::down()), + std::u128::max_value!() / 2, + ); +} + +#[test] +fun median_u256_odd_length_large_values() { + let u128_max_as_u256 = std::u128::max_value!() as u256; + assert_eq!( + vector::median!(&vector[0, std::u256::max_value!(), u128_max_as_u256], rounding::down()), + u128_max_as_u256, + ); +} + +// === Scale === + +#[test] +fun median_large_odd_length() { + // Build a 1001-element vector [0, 1, ..., 1000] and check median = 500. + let mut vec = vector[]; + let mut i = 0u64; + while (i <= 1000) { + vec.push_back(i); + i = i + 1; + }; + assert_eq!(vector::median!(&vec, rounding::down()), 500u64); +} + +#[test] +fun median_large_even_reverse_sorted_length() { + // Build a 1000-element vector [1000, 999, ..., 1]. + let mut vec = vector[]; + let mut i = 1000u64; + while (i > 0) { + vec.push_back(i); + i = i - 1; + }; + + assert_eq!(vector::median!(&vec, rounding::down()), 500u64); + assert_eq!(vector::median!(&vec, rounding::up()), 501u64); + assert_eq!(vector::median!(&vec, rounding::nearest()), 501u64); +} + +#[test] +fun median_large_duplicate_heavy_vector() { + // Build 1000 interleaved values with counts: 400 ones, 300 twos, 300 threes. + let mut vec = vector[]; + let mut i = 0u64; + while (i < 1000) { + let bucket = i % 10; + if (bucket < 4) { + vec.push_back(1); + } else if (bucket < 7) { + vec.push_back(2); + } else { + vec.push_back(3); + }; + i = i + 1; + }; + + assert_eq!(vector::median!(&vec, rounding::down()), 2u64); + assert_eq!(vector::median!(&vec, rounding::nearest()), 2u64); + assert_eq!(vector::median!(&vec, rounding::up()), 2u64); +} diff --git a/math/fixed_point/Move.lock b/math/fixed_point/Move.lock index 2fba1bdd..2125bf37 100644 --- a/math/fixed_point/Move.lock +++ b/math/fixed_point/Move.lock @@ -23,13 +23,13 @@ manifest_digest = "E41BBD67BE8940D26C79D78B028477EF5B33BA217A1282C78ACB344CF8A5E deps = { std = "MoveStdlib", sui = "Sui" } [pinned.testnet.MoveStdlib] -source = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/move-stdlib", rev = "868c226359ef914f1f3b080518f27eb13d8967f5" } +source = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/move-stdlib", rev = "c2428b3aaf9c24270b609001e56d96cb10c76d28" } use_environment = "testnet" manifest_digest = "C4FE4C91DE74CBF223B2E380AE40F592177D21870DC2D7EB6227D2D694E05363" deps = {} [pinned.testnet.Sui] -source = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/sui-framework", rev = "868c226359ef914f1f3b080518f27eb13d8967f5" } +source = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/sui-framework", rev = "c2428b3aaf9c24270b609001e56d96cb10c76d28" } use_environment = "testnet" manifest_digest = "7AFB66695545775FBFBB2D3078ADFD084244D5002392E837FDE21D9EA1C6D01C" deps = { MoveStdlib = "MoveStdlib" } @@ -37,5 +37,11 @@ deps = { MoveStdlib = "MoveStdlib" } [pinned.testnet.openzeppelin_fp_math] source = { root = true } use_environment = "testnet" +manifest_digest = "EBBF3891F1226FE83CE09DB6310A89A7037E942865CB38AB3F4997C5B7E3FB95" +deps = { openzeppelin_math = "openzeppelin_math", std = "MoveStdlib", sui = "Sui" } + +[pinned.testnet.openzeppelin_math] +source = { local = "../core" } +use_environment = "testnet" manifest_digest = "5745706258F61D6CE210904B3E6AE87A73CE9D31A6F93BE4718C442529332A87" deps = { std = "MoveStdlib", sui = "Sui" } From bb34ec4e5974970b8471a36275279c8d250ecbf6 Mon Sep 17 00:00:00 2001 From: Nenad Date: Wed, 10 Jun 2026 12:05:05 +0200 Subject: [PATCH 2/5] docs: add ! to macros and update import format Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- math/core/README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/math/core/README.md b/math/core/README.md index 10fb8ca3..87c4504b 100644 --- a/math/core/README.md +++ b/math/core/README.md @@ -23,8 +23,8 @@ Operations for `u8`, `u16`, `u32`, `u64`, `u128`, and `u256`, including: Generic over `u8`..`u256`: -- `vector::quick_sort` / `vector::quick_sort_by`: In-place iterative quicksort with three-way partitioning -- `vector::median`: Median of a borrowed unsigned integer vector with configurable rounding for even-length input; uses quickselect instead of sorting the full vector and aborts on empty input +- `vector::quick_sort!` / `vector::quick_sort_by!`: In-place iterative quicksort with three-way partitioning +- `vector::median!`: Median of a borrowed unsigned integer vector with configurable rounding for even-length input; uses quickselect instead of sorting the full vector and aborts on empty input - `vector::median_u256`: Concrete `vector` median function using the same quickselect implementation ## Rounding modes @@ -36,14 +36,16 @@ Generic over `u8`..`u256`: ## Usage examples ```move -use openzeppelin_math::{u128, rounding}; +use openzeppelin_math::rounding; +use openzeppelin_math::u128; let result = u128::mul_div(100, 200, 3, rounding::up()); // result = Some(6667) (rounded up from 6666.66...) ``` ```move -use openzeppelin_math::{u64, rounding}; +use openzeppelin_math::rounding; +use openzeppelin_math::u64; let mean = u64::average(5, 6, rounding::down()); // mean = 5 From 1040961eba9ed4ed4e4c251c1b54ffd61b925827 Mon Sep 17 00:00:00 2001 From: Nenad Date: Wed, 10 Jun 2026 12:14:16 +0200 Subject: [PATCH 3/5] chore: update median PR in changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c77715de..9ac28b1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) #### Added -- Added `vector::median` macro for unsigned integer vectors with rounding for even lengths. Includes `vector::median_u256`. (#135) +- Added `vector::median` macro for unsigned integer vectors with rounding for even lengths. Includes `vector::median_u256`. (#362) #### Changed From fb5c19694878d14d01fc9fb1aafb99e59ceee3a8 Mon Sep 17 00:00:00 2001 From: 0xNeshi Date: Wed, 10 Jun 2026 12:22:29 +0200 Subject: [PATCH 4/5] chore: add ! to median macro mention --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ac28b1a..74c16847 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) #### Added -- Added `vector::median` macro for unsigned integer vectors with rounding for even lengths. Includes `vector::median_u256`. (#362) +- Added `vector::median!` macro for unsigned integer vectors with rounding for even lengths. Includes `vector::median_u256`. (#362) #### Changed From da13b772bfabc6523c828ad73232a4022e0804ea Mon Sep 17 00:00:00 2001 From: 0xNeshi Date: Wed, 10 Jun 2026 12:23:08 +0200 Subject: [PATCH 5/5] docs: add missing ; after rounding imports in examples --- math/core/sources/vector.move | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/math/core/sources/vector.move b/math/core/sources/vector.move index 33dfb98f..3fd0990d 100644 --- a/math/core/sources/vector.move +++ b/math/core/sources/vector.move @@ -232,7 +232,7 @@ public macro fun quick_sort_by<$T>($vec: &mut vector<$T>, $le: |&$T, &$T| -> boo /// /// #### Example /// ```move -/// use openzeppelin_math::rounding +/// use openzeppelin_math::rounding; /// use openzeppelin_math::vector; /// /// let v = vector[3u64, 1, 4, 1, 5, 9, 2, 6]; @@ -267,7 +267,7 @@ public macro fun median<$Int>($vec: &vector<$Int>, $rounding_mode: RoundingMode) /// /// #### Example /// ```move -/// use openzeppelin_math::rounding +/// use openzeppelin_math::rounding; /// use openzeppelin_math::vector; /// /// let m = vector::median_u256(vector[10u256, 2, 8, 4], rounding::down());