From dcf8260bd6395287d03e52f87f27324cc629836e Mon Sep 17 00:00:00 2001 From: Leo Conforti Date: Tue, 27 Oct 2026 16:14:15 -0600 Subject: [PATCH 1/5] fix(js/ts): give FSharp.Core's and .NET's results for seeded random functions The random collection functions and System.Random now give the same results as on .NET for the same seed: - Array, List and Seq.randomShuffle* and Array.randomShuffleInPlace* use FSharp.Core's forward Fisher-Yates shuffle. For example Random(42) shuffles [|0..4|] to [|3; 1; 2; 4; 0|] (was [|2; 1; 4; 0; 3|]), and a constant randomizer of 0.0 leaves the input in order. - Array, List and Seq.randomSample* use FSharp.Core's algorithm: a pool for small inputs, otherwise drawing indices until an unseen one comes up, so the Random can be advanced more than count times, as on .NET. Seq.randomSample* stays eager. - Seq.randomChoices* draws when enumerated, again on each enumeration, like FSharp.Core. Argument checks stay eager. - The *By functions reject a randomizer result of NaN (they picked index 0). Their errors, Array.randomChoice* on an empty array and negative counts give FSharp.Core's messages. - Random.Shared is supported. - Seeded Random.Next(minValue, maxValue) for ranges above Int32.MaxValue uses .NET's divisor, so values no longer come out 1 too high and maxValue is never returned. - Random.Next gives .NET's message, ending in (Parameter 'maxValue') or (Parameter 'minValue'), for a negative maxValue or minValue > maxValue. --- src/Fable.Transforms/Replacements.fs | 1 + src/fable-library-ts/Array.fs | 114 ++++++++++++++++----------- src/fable-library-ts/List.fs | 3 + src/fable-library-ts/Random.fs | 33 +++++--- src/fable-library-ts/Seq.fs | 22 +++++- tests/Js/Main/ArrayTests.fs | 58 ++++++++++++++ tests/Js/Main/ListTests.fs | 15 ++++ tests/Js/Main/RandomTests.fs | 21 +++++ tests/Js/Main/SeqTests.fs | 22 ++++++ 9 files changed, 226 insertions(+), 63 deletions(-) diff --git a/src/Fable.Transforms/Replacements.fs b/src/Fable.Transforms/Replacements.fs index 673aefef71..9e1693ae34 100644 --- a/src/Fable.Transforms/Replacements.fs +++ b/src/Fable.Transforms/Replacements.fs @@ -4024,6 +4024,7 @@ let random (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: Expr opti | args -> Helper.LibCall(com, "Random", "seeded", t, args, i.SignatureArgTypes, ?loc = r) |> Some + | "get_Shared", None -> Helper.LibCall(com, "Random", "shared", t, [], ?loc = r) |> Some // Not yet supported | ("NextInt64" | "NextSingle"), _ -> None | meth, Some thisArg -> diff --git a/src/fable-library-ts/Array.fs b/src/fable-library-ts/Array.fs index fd7cbe5a3a..b39f9f06a3 100644 --- a/src/fable-library-ts/Array.fs +++ b/src/fable-library-ts/Array.fs @@ -1223,22 +1223,40 @@ let insertManyAt (index: int) (ys: seq<'T>) (xs: 'T[]) ([ float) (xs: 'T[]) : unit = +// Ports FSharp.Core's Random helpers so results and draw counts match .NET for the same seed +let private randomNext (randomizer: unit -> float) (minValue: int) (maxValue: int) : int = + let r = randomizer () + + if not (r >= 0.0 && r < 1.0) then + let msg = + "The index is outside the legal range.\nrandomizer returned " + + string r + + ", should be in range [0.0, 1.0)." + + raise (System.ArgumentOutOfRangeException("randomizer", msg)) + + int (r * float (maxValue - minValue)) + minValue + +let private checkRandomCount (count: int) = + if count < 0 then + invalidArg "count" (SR.inputMustBeNonNegative + "\ncount = " + string count) + +let private shuffleInPlace (next: int -> int -> int) (xs: 'T[]) : unit = let len = xs.Length - for i = len - 1 downto 1 do - let r = randomizer () + for i = 0 to len - 2 do + let j = next i len - if r < 0.0 || r >= 1.0 then - raise (System.ArgumentOutOfRangeException("randomizer", "The index is outside the legal range.")) + if j <> i then + let tmp = xs[i] + xs[i] <- xs[j] + xs[j] <- tmp - let j = int (r * float (i + 1)) - let tmp = xs[i] - xs[i] <- xs[j] - xs[j] <- tmp +let randomShuffleInPlaceBy (randomizer: unit -> float) (xs: 'T[]) : unit = + shuffleInPlace (randomNext randomizer) xs let randomShuffleInPlaceWith (random: System.Random) (xs: 'T[]) : unit = - randomShuffleInPlaceBy (fun () -> random.NextDouble()) xs + shuffleInPlace (fun i n -> random.Next(i, n)) xs let randomShuffleInPlace (xs: 'T[]) : unit = randomShuffleInPlaceWith (System.Random()) xs @@ -1249,21 +1267,17 @@ let randomShuffleBy (randomizer: unit -> float) (xs: 'T[]) : 'T[] = arr let randomShuffleWith (random: System.Random) (xs: 'T[]) : 'T[] = - randomShuffleBy (fun () -> random.NextDouble()) xs + let arr = copy xs + randomShuffleInPlaceWith random arr + arr let randomShuffle (xs: 'T[]) : 'T[] = randomShuffleWith (System.Random()) xs let randomChoiceBy (randomizer: unit -> float) (xs: 'T[]) : 'T = if isEmpty xs then - invalidArg "source" SR.inputSequenceEmpty + invalidArg "source" "The input array was empty." - let len = xs.Length - let r = randomizer () - - if r < 0.0 || r >= 1.0 then - raise (System.ArgumentOutOfRangeException("randomizer", "The index is outside the legal range.")) - - xs[int (r * float len)] + xs[randomNext randomizer 0 xs.Length] let randomChoiceWith (random: System.Random) (xs: 'T[]) : 'T = randomChoiceBy (fun () -> random.NextDouble()) xs @@ -1277,25 +1291,13 @@ let randomChoicesBy ([] cons: Cons<'T>) : 'T[] = - if count < 0 then - invalidArg "count" SR.inputMustBeNonNegative + checkRandomCount count if count > 0 && isEmpty xs then invalidArg "source" SR.inputSequenceEmpty let len = xs.Length - - initialize - count - (fun _ -> - let r = randomizer () - - if r < 0.0 || r >= 1.0 then - raise (System.ArgumentOutOfRangeException("randomizer", "The index is outside the legal range.")) - - xs[int (r * float len)] - ) - cons + initialize count (fun _ -> xs[randomNext randomizer 0 len]) cons let randomChoicesWith (random: System.Random) @@ -1309,12 +1311,16 @@ let randomChoicesWith let randomChoices (count: int) (xs: 'T[]) ([] cons: Cons<'T>) : 'T[] = randomChoicesWith (System.Random()) count xs cons -let randomSampleBy (randomizer: unit -> float) (count: int) (xs: 'T[]) : 'T[] = - if count < 0 then - invalidArg "count" SR.inputMustBeNonNegative +let private getMaxSetSizeForSampling (count: int) = + if count > 5 then + 21 + int (4.0 ** ceil (System.Math.Log(float (count * 3), 4.0))) + else + 21 - let arr = copy xs - let len = arr.Length +// Picks from a shrinking pool for small inputs, otherwise draws indices until unseen (FSharp.Core, after CPython) +let private randomSampleWithNext (next: int -> int -> int) (count: int) (xs: 'T[]) : 'T[] = + checkRandomCount count + let len = xs.Length if len = 0 && count > 0 then invalidArg "source" SR.inputSequenceEmpty @@ -1322,21 +1328,33 @@ let randomSampleBy (randomizer: unit -> float) (count: int) (xs: 'T[]) : 'T[] = if count > len then invalidArg "count" SR.notEnoughElements - for i = 0 to count - 1 do - let r = randomizer () + let result = getSubArray xs 0 count - if r < 0.0 || r >= 1.0 then - raise (System.ArgumentOutOfRangeException("randomizer", "The index is outside the legal range.")) + if len <= getMaxSetSizeForSampling count then + let pool = copy xs - let j = i + int (r * float (len - i)) - let tmp = arr[i] - arr[i] <- arr[j] - arr[j] <- tmp + for i = 0 to count - 1 do + let j = next 0 (len - i) + result[i] <- pool[j] + pool[j] <- pool[len - i - 1] + else + let selected = HashSet() + + for i = 0 to count - 1 do + let mutable j = next 0 len + + while not (selected.Add j) do + j <- next 0 len - getSubArray arr 0 count + result[i] <- xs[j] + + result + +let randomSampleBy (randomizer: unit -> float) (count: int) (xs: 'T[]) : 'T[] = + randomSampleWithNext (randomNext randomizer) count xs let randomSampleWith (random: System.Random) (count: int) (xs: 'T[]) : 'T[] = - randomSampleBy (fun () -> random.NextDouble()) count xs + randomSampleWithNext (fun a b -> random.Next(a, b)) count xs let randomSample (count: int) (xs: 'T[]) : 'T[] = randomSampleWith (System.Random()) count xs diff --git a/src/fable-library-ts/List.fs b/src/fable-library-ts/List.fs index 3cf4d74945..50fde19075 100644 --- a/src/fable-library-ts/List.fs +++ b/src/fable-library-ts/List.fs @@ -1041,6 +1041,9 @@ let randomShuffleWith (random: System.Random) (xs: 'T list) : 'T list = let randomShuffle (xs: 'T list) : 'T list = randomShuffleWith (System.Random()) xs let randomChoiceBy (randomizer: unit -> float) (xs: 'T list) : 'T = + if isEmpty xs then + invalidArg "source" SR.inputSequenceEmpty + toArray xs |> Array.randomChoiceBy randomizer let randomChoiceWith (random: System.Random) (xs: 'T list) : 'T = diff --git a/src/fable-library-ts/Random.fs b/src/fable-library-ts/Random.fs index 8b686e3e5a..a21d4b39fa 100644 --- a/src/fable-library-ts/Random.fs +++ b/src/fable-library-ts/Random.fs @@ -2,6 +2,16 @@ module Random open System +let private checkMaxValue (maxValue: int) = + if maxValue < 0 then + let msg = "maxValue ('" + string maxValue + "') must be a non-negative value." + raise <| ArgumentOutOfRangeException("maxValue", msg) + +let private checkMinMaxValue (minValue: int) (maxValue: int) = + if minValue > maxValue then + let msg = "'minValue' cannot be greater than maxValue." + raise <| ArgumentOutOfRangeException("minValue", msg) + module private Native = open Fable.Core open Fable.Core.JsInterop @@ -9,8 +19,7 @@ module private Native = let random () = JS.Math.random () let randomNext (minValue: int, maxValue: int) : int = - if (maxValue < minValue) then - raise <| ArgumentOutOfRangeException("minValue must be less than maxValue") + checkMinMaxValue minValue maxValue emitJsStatement (box () |> nonNull) @@ -48,7 +57,10 @@ type IRandom = type NonSeeded() = interface IRandom with member _.Next0() = Native.randomNext (0, Int32.MaxValue) - member _.Next1(maxValue) = Native.randomNext (0, maxValue) + + member _.Next1(maxValue) = + checkMaxValue maxValue + Native.randomNext (0, maxValue) member _.Next2(minValue, maxValue) = Native.randomNext (minValue, maxValue) @@ -145,23 +157,19 @@ type Seeded(seed: int) = let mutable d = result d <- d + float (Int32.MaxValue - 1) - d <- d / (2.0 * float (uint (Int32.MaxValue - 1))) + d <- d / (2.0 * float Int32.MaxValue - 1.0) d interface IRandom with member this.Next0() = this.InternalSample() member this.Next1(maxValue: int) = - if maxValue < 0 then - raise <| ArgumentOutOfRangeException("maxValue must be positive") - + checkMaxValue maxValue int (this.Sample() * float maxValue) member this.Next2(minValue: int, maxValue: int) = - if minValue > maxValue then - raise <| ArgumentOutOfRangeException("minValue must be less than maxValue") - - let range = int64 (maxValue - minValue) + checkMinMaxValue minValue maxValue + let range = int64 maxValue - int64 minValue if range <= int64 Int32.MaxValue then int (int (this.Sample() * float range) + minValue) @@ -177,5 +185,8 @@ type Seeded(seed: int) = for i = 0 to buffer.Length - 1 do buffer.[i] <- byte ((int (this.InternalSample())) % (int Byte.MaxValue + 1)) +let private sharedRandom = NonSeeded() + let nonSeeded () = NonSeeded() let seeded seed = Seeded(seed) +let shared () = sharedRandom diff --git a/src/fable-library-ts/Seq.fs b/src/fable-library-ts/Seq.fs index cf14a251fd..e1b792202e 100644 --- a/src/fable-library-ts/Seq.fs +++ b/src/fable-library-ts/Seq.fs @@ -1403,16 +1403,30 @@ let randomShuffleWith (random: System.Random) (xs: 'T seq) : 'T seq = let randomShuffle (xs: 'T seq) : 'T seq = randomShuffleWith (System.Random()) xs +let private randomChoiceSource (count: int) (xs: 'T seq) : 'T[] = + if count < 0 then + invalidArg "count" (SR.inputMustBeNonNegative + "\ncount = " + string count) + + let arr = toArray xs + + if count > 0 && Array.isEmpty arr then + invalidArg "source" SR.inputSequenceEmpty + + arr + let randomChoiceBy (randomizer: unit -> float) (xs: 'T seq) : 'T = - xs |> toArray |> Array.randomChoiceBy randomizer + randomChoiceSource 1 xs |> Array.randomChoiceBy randomizer let randomChoiceWith (random: System.Random) (xs: 'T seq) : 'T = - xs |> toArray |> Array.randomChoiceWith random + randomChoiceSource 1 xs |> Array.randomChoiceWith random -let randomChoice (xs: 'T seq) : 'T = xs |> toArray |> Array.randomChoice +let randomChoice (xs: 'T seq) : 'T = + randomChoiceSource 1 xs |> Array.randomChoice +// Draws on enumeration, like FSharp.Core let randomChoicesBy (randomizer: unit -> float) (count: int) (xs: 'T seq) : 'T seq = - xs |> toArray |> Array.randomChoicesBy randomizer count |> ofArray + let arr = randomChoiceSource count xs + initialize count (fun _ -> Array.randomChoiceBy randomizer arr) let randomChoicesWith (random: System.Random) (count: int) (xs: 'T seq) : 'T seq = randomChoicesBy (fun () -> random.NextDouble()) count xs diff --git a/tests/Js/Main/ArrayTests.fs b/tests/Js/Main/ArrayTests.fs index 9f5fc8cbac..12dddc7004 100644 --- a/tests/Js/Main/ArrayTests.fs +++ b/tests/Js/Main/ArrayTests.fs @@ -1561,6 +1561,64 @@ let tests = throwsAnyError (fun () -> Array.randomSampleBy (fun () -> 1.0) 1 [|1; 2|] |> ignore) throwsAnyError (fun () -> Array.randomSampleBy (fun () -> -0.1) 1 [|1; 2|] |> ignore) + testCase "Array.randomShuffleWith gives .NET's results for the same seed" <| fun () -> + let rng = System.Random(42) + Array.randomShuffleWith rng [|0 .. 4|] |> equal [|3; 1; 2; 4; 0|] + rng.Next() |> equal 361709742 + let rng = System.Random(12345) + Array.randomShuffleWith rng [|0 .. 9|] |> equal [|0; 1; 8; 6; 2; 9; 3; 5; 4; 7|] + rng.Next() |> equal 1086637143 + let rng = System.Random(42) + let xs = [|0 .. 9|] + Array.randomShuffleInPlaceWith rng xs + xs |> equal [|6; 2; 3; 0; 5; 1; 8; 4; 7; 9|] + rng.Next() |> equal 1634773126 + + testCase "Array.randomShuffleBy gives .NET's results" <| fun () -> + Array.randomShuffleBy (fun () -> 0.0) [|0 .. 4|] |> equal [|0; 1; 2; 3; 4|] + Array.randomShuffleBy (fun () -> 0.999) [|0 .. 4|] |> equal [|4; 0; 1; 2; 3|] + let xs = [|0 .. 4|] + Array.randomShuffleInPlaceBy (fun () -> 0.999) xs + xs |> equal [|4; 0; 1; 2; 3|] + + testCase "Array.randomSampleWith gives .NET's results for the same seed" <| fun () -> + let rng = System.Random(42) + Array.randomSampleWith rng 3 [|0 .. 4|] |> equal [|3; 0; 4|] + rng.Next() |> equal 1122627734 + let rng = System.Random(12345) + Array.randomSampleWith rng 10 [|0 .. 29|] |> equal [|2; 29; 21; 13; 20; 25; 3; 16; 5; 10|] + rng.Next() |> equal 494509053 + let rng = System.Random(42) + Array.randomSampleWith rng 10 [|0 .. 99|] |> equal [|66; 14; 12; 52; 16; 26; 72; 51; 17; 76|] + rng.Next() |> equal 503774878 + // Draws again on a repeated index + let rng = System.Random(7) + Array.randomSampleWith rng 5 [|0 .. 21|] |> equal [|8; 19; 14; 1; 0|] + rng.Next() |> equal 2048632168 + + testCase "Array.randomSampleBy gives .NET's results" <| fun () -> + Array.randomSampleBy (fun () -> 0.0) 3 [|0 .. 4|] |> equal [|0; 4; 3|] + Array.randomSampleBy (fun () -> 0.999) 3 [|0 .. 4|] |> equal [|4; 3; 2|] + + testCase "Array.randomChoicesWith gives .NET's results for the same seed" <| fun () -> + let rng = System.Random(42) + Array.randomChoicesWith rng 4 [|0 .. 9|] |> equal [|6; 1; 1; 5|] + rng.Next() |> equal 361709742 + + testCase "Array random functions give FSharp.Core's error messages" <| fun () -> + let nanMessage = "The index is outside the legal range.\nrandomizer returned NaN, should be in range [0.0, 1.0). (Parameter 'randomizer')" + throwsError nanMessage (fun () -> Array.randomShuffleBy (fun () -> nan) [|1; 2|]) + throwsError nanMessage (fun () -> Array.randomShuffleInPlaceBy (fun () -> nan) [|1; 2|]) + throwsError nanMessage (fun () -> Array.randomChoiceBy (fun () -> nan) [|1; 2|]) + throwsError nanMessage (fun () -> Array.randomChoicesBy (fun () -> nan) 2 [|1; 2|]) + throwsError nanMessage (fun () -> Array.randomSampleBy (fun () -> nan) 1 [|1; 2|]) + raisedExceptionType (fun () -> Array.randomSampleBy (fun () -> nan) 1 [|1; 2|]) |> equal "ArgumentOutOfRangeException" + throwsError "The index is outside the legal range.\nrandomizer returned 1, should be in range [0.0, 1.0). (Parameter 'randomizer')" (fun () -> Array.randomChoiceBy (fun () -> 1.0) [|1; 2|]) + throwsError "The input array was empty. (Parameter 'source')" (fun () -> Array.randomChoiceWith (System.Random(1)) ([||]: int[])) + throwsError "The input sequence was empty. (Parameter 'source')" (fun () -> Array.randomSampleWith (System.Random(1)) 1 ([||]: int[])) + throwsError "The input must be non-negative.\ncount = -1 (Parameter 'count')" (fun () -> Array.randomChoicesWith (System.Random(1)) -1 [|1|]) + throwsError "The input must be non-negative.\ncount = -1 (Parameter 'count')" (fun () -> Array.randomSampleBy (fun () -> 0.0) -1 [|1|]) + testCase "Array.compareWith works" <| fun () -> // See #2961 let a = [|1;3|] let b = [|1;2;3|] diff --git a/tests/Js/Main/ListTests.fs b/tests/Js/Main/ListTests.fs index 4df3113faf..37a1a21376 100644 --- a/tests/Js/Main/ListTests.fs +++ b/tests/Js/Main/ListTests.fs @@ -1076,6 +1076,21 @@ let tests = throwsAnyError (fun () -> List.randomSampleBy (fun () -> 1.0) 1 [1; 2] |> ignore) throwsAnyError (fun () -> List.randomSampleBy (fun () -> -0.1) 1 [1; 2] |> ignore) + testCase "List random functions give .NET's results for the same seed" <| fun () -> + let rng = System.Random(42) + List.randomShuffleWith rng [0 .. 4] |> equal [3; 1; 2; 4; 0] + rng.Next() |> equal 361709742 + let rng = System.Random(42) + List.randomSampleWith rng 10 [0 .. 99] |> equal [66; 14; 12; 52; 16; 26; 72; 51; 17; 76] + rng.Next() |> equal 503774878 + List.randomShuffleBy (fun () -> 0.999) [0 .. 4] |> equal [4; 0; 1; 2; 3] + List.randomSampleBy (fun () -> 0.0) 3 [0 .. 4] |> equal [0; 4; 3] + + testCase "List random functions give FSharp.Core's error messages" <| fun () -> + throwsError "The index is outside the legal range.\nrandomizer returned NaN, should be in range [0.0, 1.0). (Parameter 'randomizer')" (fun () -> List.randomChoiceBy (fun () -> nan) [1; 2]) + throwsError "The input sequence was empty. (Parameter 'source')" (fun () -> List.randomChoiceWith (System.Random(1)) ([]: int list)) + throwsError "The input must be non-negative.\ncount = -1 (Parameter 'count')" (fun () -> List.randomChoicesWith (System.Random(1)) -1 [1]) + testCase "List functions raise ArgumentException on empty lists" <| fun () -> let xs: int list = [] raisedExceptionType (fun () -> List.head xs) |> equal "ArgumentException" diff --git a/tests/Js/Main/RandomTests.fs b/tests/Js/Main/RandomTests.fs index 5a56c1a019..97cb7bc59d 100644 --- a/tests/Js/Main/RandomTests.fs +++ b/tests/Js/Main/RandomTests.fs @@ -84,6 +84,27 @@ let tests = throwsAnyError <| fun () -> rnd.Next(14, 10) throwsAnyError <| fun () -> rnd.NextBytes(null) + testCase "System.Random seeded Next works for ranges above Int32.MaxValue" <| fun () -> + let rnd = Random(42) + rnd.Next(Int32.MinValue, Int32.MaxValue) |> equal 1434747709 + rnd.Next(Int32.MinValue, Int32.MaxValue) |> equal -269548476 + let rnd = Random(0) + [ for _ in 1 .. 3 -> rnd.Next(-1000000000, 2000000000) ] |> equal [-589364906; 1652034034; 809049730] + + testCase "System.Random gives .NET's argument errors" <| fun () -> + for rnd in [Random(42); Random()] do + throwsErrorContaining "maxValue ('-1') must be a non-negative value. (Parameter 'maxValue')" (fun () -> rnd.Next(-1)) + throwsError "'minValue' cannot be greater than maxValue. (Parameter 'minValue')" (fun () -> rnd.Next(5, 3)) + raisedExceptionType (fun () -> rnd.Next(-1)) |> equal "ArgumentOutOfRangeException" + raisedExceptionType (fun () -> rnd.Next(5, 3)) |> equal "ArgumentOutOfRangeException" + + testCase "System.Random.Shared works" <| fun () -> + let x = Random.Shared.Next(5) + (x >= 0 && x < 5) |> equal true + let x = Random.Shared.NextDouble() + (x >= 0.0 && x < 1.0) |> equal true + obj.ReferenceEquals(Random.Shared, Random.Shared) |> equal true + testCase "System.Random seeded passes chi-squared test" <| fun () -> let rnd = Random(2026) let statistic = chiSquaredStatistic 16 10000 (fun () -> rnd.Next(16)) diff --git a/tests/Js/Main/SeqTests.fs b/tests/Js/Main/SeqTests.fs index fc19f494ba..e8b4f8adb0 100644 --- a/tests/Js/Main/SeqTests.fs +++ b/tests/Js/Main/SeqTests.fs @@ -1007,6 +1007,28 @@ let tests = throwsAnyError (fun () -> Seq.randomSampleBy (fun () -> 1.0) 1 [1; 2] |> Seq.toArray |> ignore) throwsAnyError (fun () -> Seq.randomSampleBy (fun () -> -0.1) 1 [1; 2] |> Seq.toArray |> ignore) + testCase "Seq random functions give .NET's results for the same seed" <| fun () -> + let rng = System.Random(42) + Seq.randomShuffleWith rng [0 .. 4] |> Seq.toList |> equal [3; 1; 2; 4; 0] + rng.Next() |> equal 361709742 + let rng = System.Random(7) + Seq.randomSampleWith rng 5 [0 .. 21] |> Seq.toList |> equal [8; 19; 14; 1; 0] + rng.Next() |> equal 2048632168 + + testCase "Seq.randomChoicesWith draws on enumeration" <| fun () -> + let rng = System.Random(42) + let xs = Seq.randomChoicesWith rng 3 [0 .. 9] + rng.Next() |> equal 1434747710 + xs |> Seq.toList |> equal [1; 1; 5] + xs |> Seq.toList |> equal [1; 2; 7] + let mutable calls = 0 + let ys = Seq.randomChoicesBy (fun () -> calls <- calls + 1; 0.5) 3 [0 .. 9] + calls |> equal 0 + ys |> Seq.toList |> equal [5; 5; 5] + calls |> equal 3 + throwsError "The input must be non-negative.\ncount = -1 (Parameter 'count')" (fun () -> Seq.randomChoicesWith rng -1 [1]) + throwsError "The input sequence was empty. (Parameter 'source')" (fun () -> Seq.randomChoicesWith rng 1 Seq.empty) + testCase "Seq.item throws exception when index is out of range" <| fun () -> let xs = [0] (try Seq.item 1 xs |> ignore; false with | _ -> true) |> equal true From 307e2bdfa3428523059fc6387e14c31ea12c5ae0 Mon Sep 17 00:00:00 2001 From: Leo Conforti Date: Tue, 27 Oct 2026 16:20:09 -0600 Subject: [PATCH 2/5] fix(python): use FSharp.Core's algorithms for random collection functions - Array.randomShuffle*With, randomChoice*With and randomSampleWith draw from the Random they are given instead of Python's global random, so the same seed gives the same result. - Array, List and Seq.randomShuffle* and Array.randomShuffleInPlace* use FSharp.Core's forward Fisher-Yates shuffle. For example a constant randomizer of 0.0 leaves the input in order. - Array, List and Seq.randomSample* use FSharp.Core's algorithm: a pool for small inputs, otherwise drawing indices until an unseen one comes up. Seq.randomSample* stays eager. A sample of 0 items from empty input is empty (it raised IndexError). - Seq.randomChoices* draws when enumerated, again on each enumeration, like FSharp.Core. Argument checks stay eager. - The *By functions reject a randomizer result of NaN (they picked index 0). Array.randomChoice* on an empty array, List and Seq.randomChoice* on empty input and negative counts give FSharp.Core's messages. - Random.Shared is supported. - Random.Next(maxValue) and Next(minValue, maxValue) raise ArgumentOutOfRangeException with .NET's message for a negative maxValue or minValue > maxValue (was randrange's ValueError). They call new functions in random_.py, because util.py cannot import the exception types. --- src/Fable.Transforms/Python/Replacements.fs | 15 +- src/fable-library-py/fable_library/List.fs | 3 + src/fable-library-py/fable_library/Seq.fs | 22 ++- src/fable-library-py/fable_library/random_.py | 30 ++++ src/fable-library-py/fable_library/util.py | 8 + src/fable-library-py/src/array.rs | 140 ++++++++++-------- tests/Python/TestArray.fs | 53 +++++++ tests/Python/TestList.fs | 7 + tests/Python/TestRandom.fs | 17 +++ tests/Python/TestSeq.fs | 14 ++ 10 files changed, 235 insertions(+), 74 deletions(-) create mode 100644 src/fable-library-py/fable_library/random_.py diff --git a/src/Fable.Transforms/Python/Replacements.fs b/src/Fable.Transforms/Python/Replacements.fs index 3da6ef0dfd..078b106ad6 100644 --- a/src/Fable.Transforms/Python/Replacements.fs +++ b/src/Fable.Transforms/Python/Replacements.fs @@ -3473,17 +3473,18 @@ let random (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: Expr opti | ".ctor", _ -> Helper.LibCall(com, "util", "create_random", t, args, i.SignatureArgTypes, ?loc = r) |> Some + | "get_Shared", None -> Helper.LibCall(com, "util", "random_shared", t, [], ?loc = r) |> Some | "Next", Some thisArg -> - let min, max = + let moduleName, meth, args = match args with - | [] -> makeIntConst 0, makeIntConst System.Int32.MaxValue - | [ max ] -> makeIntConst 0, max - | [ min; max ] -> min, max + | [] -> "util", "random_int", [ thisArg; makeIntConst 0; makeIntConst System.Int32.MaxValue ] + // These check the arguments like .NET + | [ _ ] -> "random_", "next_max", thisArg :: args + | [ _; _ ] -> "random_", "next_range", thisArg :: args | _ -> FableError "Unexpected arg count for Random.Next" |> raise - let args = [ thisArg; min; max ] - let argTypes = [ thisArg.Type; min.Type; max.Type ] - Helper.LibCall(com, "util", "random_int", t, args, argTypes, ?loc = r) |> Some + let argTypes = args |> List.map (fun a -> a.Type) + Helper.LibCall(com, moduleName, meth, t, args, argTypes, ?loc = r) |> Some | "NextDouble", Some thisArg -> let args = [ thisArg ] let argTypes = [ thisArg.Type ] diff --git a/src/fable-library-py/fable_library/List.fs b/src/fable-library-py/fable_library/List.fs index 9859354847..16e601cb37 100644 --- a/src/fable-library-py/fable_library/List.fs +++ b/src/fable-library-py/fable_library/List.fs @@ -1021,6 +1021,9 @@ let randomShuffleWith (random: System.Random) (xs: 'T list) : 'T list = let randomShuffle (xs: 'T list) : 'T list = randomShuffleWith (System.Random()) xs let randomChoiceBy (randomizer: unit -> float) (xs: 'T list) : 'T = + if isEmpty xs then + invalidArg "source" SR.inputSequenceEmpty + toArray xs |> Array.randomChoiceBy randomizer let randomChoiceWith (random: System.Random) (xs: 'T list) : 'T = diff --git a/src/fable-library-py/fable_library/Seq.fs b/src/fable-library-py/fable_library/Seq.fs index e15b92cd11..731bb73aec 100644 --- a/src/fable-library-py/fable_library/Seq.fs +++ b/src/fable-library-py/fable_library/Seq.fs @@ -1250,16 +1250,30 @@ let randomShuffleWith (random: System.Random) (xs: 'T seq) : 'T seq = let randomShuffle (xs: 'T seq) : 'T seq = randomShuffleWith (System.Random()) xs +let private randomChoiceSource (count: int) (xs: 'T seq) : 'T[] = + if count < 0 then + invalidArg "count" (SR.inputMustBeNonNegative + "\ncount = " + string count) + + let arr = toArray xs + + if count > 0 && Array.isEmpty arr then + invalidArg "source" SR.inputSequenceEmpty + + arr + let randomChoiceBy (randomizer: unit -> float) (xs: 'T seq) : 'T = - xs |> toArray |> Array.randomChoiceBy randomizer + randomChoiceSource 1 xs |> Array.randomChoiceBy randomizer let randomChoiceWith (random: System.Random) (xs: 'T seq) : 'T = - xs |> toArray |> Array.randomChoiceWith random + randomChoiceSource 1 xs |> Array.randomChoiceWith random -let randomChoice (xs: 'T seq) : 'T = xs |> toArray |> Array.randomChoice +let randomChoice (xs: 'T seq) : 'T = + randomChoiceSource 1 xs |> Array.randomChoice +// Draws on enumeration, like FSharp.Core let randomChoicesBy (randomizer: unit -> float) (count: int) (xs: 'T seq) : 'T seq = - xs |> toArray |> Array.randomChoicesBy randomizer count |> ofArray + let arr = randomChoiceSource count xs + initialize count (fun _ -> Array.randomChoiceBy randomizer arr) let randomChoicesWith (random: System.Random) (count: int) (xs: 'T seq) : 'T seq = randomChoicesBy (fun () -> random.NextDouble()) count xs diff --git a/src/fable-library-py/fable_library/random_.py b/src/fable-library-py/fable_library/random_.py new file mode 100644 index 0000000000..b0c82a691a --- /dev/null +++ b/src/fable-library-py/fable_library/random_.py @@ -0,0 +1,30 @@ +"""System.Random methods that check their arguments like .NET (util.py cannot import the exception types).""" + +from __future__ import annotations + +import random + +from .system import ArgumentOutOfRangeException__ctor_Z384F8060 +from .util import random_int + + +def next_max(rand: random.Random, max_value: int) -> int: + """Random.Next(maxValue).""" + if max_value < 0: + raise ArgumentOutOfRangeException__ctor_Z384F8060( + "maxValue", f"maxValue ('{max_value}') must be a non-negative value." + ) + return random_int(rand, 0, max_value) + + +def next_range(rand: random.Random, min_value: int, max_value: int) -> int: + """Random.Next(minValue, maxValue).""" + if min_value > max_value: + raise ArgumentOutOfRangeException__ctor_Z384F8060("minValue", "'minValue' cannot be greater than maxValue.") + return random_int(rand, min_value, max_value) + + +__all__ = [ + "next_max", + "next_range", +] diff --git a/src/fable-library-py/fable_library/util.py b/src/fable-library-py/fable_library/util.py index e4aaf03d4c..7bb0f065eb 100644 --- a/src/fable-library-py/fable_library/util.py +++ b/src/fable-library-py/fable_library/util.py @@ -755,6 +755,13 @@ def create_random(seed: int | None = None) -> random.Random: return random.Random(None if seed is None else int(seed)) +_shared_random = random.Random() + + +def random_shared() -> random.Random: + return _shared_random + + def random_int(rand: random.Random, a: int, b: int) -> int: if a == b: return int32(a) @@ -980,6 +987,7 @@ def range(start: int, stop: int, step: int = 1) -> Iterable[int]: "random_bytes", "random_double", "random_int", + "random_shared", "range", "round", "to_iterable", diff --git a/src/fable-library-py/src/array.rs b/src/fable-library-py/src/array.rs index 8d68d631f3..5a43882be3 100644 --- a/src/fable-library-py/src/array.rs +++ b/src/fable-library-py/src/array.rs @@ -3380,16 +3380,11 @@ impl FSharpArray { randomizer: &Bound<'_, PyAny>, ) -> PyResult<()> { let len = self.storage.len(); - for i in (1..len).rev() { - let r: f64 = randomizer.call0()?.extract()?; - if r < 0.0 || r >= 1.0 { - return Err(PyErr::new::(format!( - "The index is outside the legal range.\nrandomizer returned {}, should be in range [0.0, 1.0).", - r - ))); + for i in 0..len.saturating_sub(1) { + let j = random_next(randomizer, i, len)?; + if j != i { + self.swap_items(i, j); } - let j = (r * (i + 1) as f64) as usize; - self.swap_items(i, j); } Ok(()) } @@ -3397,10 +3392,9 @@ impl FSharpArray { pub fn random_shuffle_in_place_with( &mut self, py: Python<'_>, - _random: &Bound<'_, PyAny>, + random: &Bound<'_, PyAny>, ) -> PyResult<()> { - let random_module = py.import("random")?; - let randomizer = random_module.getattr("random")?; + let randomizer = random.getattr("random")?; self.random_shuffle_in_place_by(py, &randomizer) } @@ -3423,10 +3417,9 @@ impl FSharpArray { pub fn random_shuffle_with( &self, py: Python<'_>, - _random: &Bound<'_, PyAny>, + random: &Bound<'_, PyAny>, ) -> PyResult { - let random_module = py.import("random")?; - let randomizer = random_module.getattr("random")?; + let randomizer = random.getattr("random")?; self.random_shuffle_by(py, &randomizer) } @@ -3444,26 +3437,18 @@ impl FSharpArray { let len = self.storage.len(); if len == 0 { return Err(PyErr::new::( - "The input sequence was empty.", + "The input array was empty.", )); } - let r: f64 = randomizer.call0()?.extract()?; - if r < 0.0 || r >= 1.0 { - return Err(PyErr::new::(format!( - "The index is outside the legal range.\nrandomizer returned {}, should be in range [0.0, 1.0).", - r - ))); - } - self.storage.get(py, (r * len as f64) as usize) + self.storage.get(py, random_next(randomizer, 0, len)?) } pub fn random_choice_with( &self, py: Python<'_>, - _random: &Bound<'_, PyAny>, + random: &Bound<'_, PyAny>, ) -> PyResult> { - let random_module = py.import("random")?; - let randomizer = random_module.getattr("random")?; + let randomizer = random.getattr("random")?; self.random_choice_by(py, &randomizer) } @@ -3479,11 +3464,7 @@ impl FSharpArray { randomizer: &Bound<'_, PyAny>, count: isize, ) -> PyResult { - if count < 0 { - return Err(PyErr::new::( - "The input must be non-negative.", - )); - } + check_random_count(count)?; let count = count as usize; let xs_len = self.storage.len(); if count > 0 && xs_len == 0 { @@ -3501,14 +3482,7 @@ impl FSharpArray { let first = self.storage.get(py, 0)?; let mut result = FSharpArray::create(py, count, first.bind(py))?; for i in 0..count { - let r: f64 = randomizer.call0()?.extract()?; - if r < 0.0 || r >= 1.0 { - return Err(PyErr::new::(format!( - "The index is outside the legal range.\nrandomizer returned {}, should be in range [0.0, 1.0).", - r - ))); - } - let src_idx = (r * xs_len as f64) as usize; + let src_idx = random_next(randomizer, 0, xs_len)?; let val = self.storage.get(py, src_idx)?; result.__setitem__(i as isize, val.bind(py), py)?; } @@ -3518,11 +3492,10 @@ impl FSharpArray { pub fn random_choices_with( &self, py: Python<'_>, - _random: &Bound<'_, PyAny>, + random: &Bound<'_, PyAny>, count: isize, ) -> PyResult { - let random_module = py.import("random")?; - let randomizer = random_module.getattr("random")?; + let randomizer = random.getattr("random")?; self.random_choices_by(py, &randomizer, count) } @@ -3538,14 +3511,9 @@ impl FSharpArray { randomizer: &Bound<'_, PyAny>, count: isize, ) -> PyResult { - if count < 0 { - return Err(PyErr::new::( - "The input must be non-negative.", - )); - } + check_random_count(count)?; let count = count as usize; - let mut arr = self.clone(); - let length = arr.storage.len(); + let length = self.storage.len(); if length == 0 && count > 0 { return Err(PyErr::new::( "The input sequence was empty.", @@ -3556,28 +3524,40 @@ impl FSharpArray { "The input sequence has an insufficient number of elements.", )); } - for i in 0..count { - let r: f64 = randomizer.call0()?.extract()?; - if r < 0.0 || r >= 1.0 { - return Err(PyErr::new::(format!( - "The index is outside the legal range.\nrandomizer returned {}, should be in range [0.0, 1.0).", - r - ))); + if length == 0 { + return FSharpArray::empty(py, None); + } + // FSharp.Core's algorithm: a shrinking pool for small inputs, otherwise redraw seen indices + let mut result = self.get_sub_array(py, 0, count, None)?; + if length <= max_set_size_for_sampling(count) { + let mut pool: Vec = (0..length).collect(); + for i in 0..count { + let j = random_next(randomizer, 0, length - i)?; + let item = self.storage.get(py, pool[j])?; + result.__setitem__(i as isize, item.bind(py), py)?; + pool[j] = pool[length - i - 1]; + } + } else { + let mut selected = std::collections::HashSet::new(); + for i in 0..count { + let mut j = random_next(randomizer, 0, length)?; + while !selected.insert(j) { + j = random_next(randomizer, 0, length)?; + } + let item = self.storage.get(py, j)?; + result.__setitem__(i as isize, item.bind(py), py)?; } - let j = i + (r * (length - i) as f64) as usize; - arr.swap_items(i, j); } - arr.get_sub_array(py, 0, count, None) + Ok(result) } pub fn random_sample_with( &self, py: Python<'_>, - _random: &Bound<'_, PyAny>, + random: &Bound<'_, PyAny>, count: isize, ) -> PyResult { - let random_module = py.import("random")?; - let randomizer = random_module.getattr("random")?; + let randomizer = random.getattr("random")?; self.random_sample_by(py, &randomizer, count) } @@ -3588,6 +3568,40 @@ impl FSharpArray { } } +// FSharp.Core's Random.next, which rejects a randomizer result outside [0.0, 1.0), NaN included +fn random_next( + randomizer: &Bound<'_, PyAny>, + min_value: usize, + max_value: usize, +) -> PyResult { + let r: f64 = randomizer.call0()?.extract()?; + if !(0.0..1.0).contains(&r) { + return Err(PyErr::new::(format!( + "The index is outside the legal range.\nrandomizer returned {}, should be in range [0.0, 1.0).", + r + ))); + } + Ok((r * (max_value - min_value) as f64) as usize + min_value) +} + +fn check_random_count(count: isize) -> PyResult<()> { + if count < 0 { + return Err(PyErr::new::(format!( + "The input must be non-negative.\ncount = {}", + count + ))); + } + Ok(()) +} + +fn max_set_size_for_sampling(count: usize) -> usize { + if count > 5 { + 21 + 4f64.powf((count as f64 * 3.0).log(4.0).ceil()) as usize + } else { + 21 + } +} + // Loose functions that delegate to member functions #[pyfunction] #[pyo3(signature = (array1, array2, cons=None))] diff --git a/tests/Python/TestArray.fs b/tests/Python/TestArray.fs index c1f68a3c12..7333a46076 100644 --- a/tests/Python/TestArray.fs +++ b/tests/Python/TestArray.fs @@ -1603,6 +1603,59 @@ let ``test Array.randomSampleBy works`` () = throwsAnyError (fun () -> Array.randomSampleBy (fun () -> 1.0) 1 [|1; 2|] |> ignore) throwsAnyError (fun () -> Array.randomSampleBy (fun () -> -0.1) 1 [|1; 2|] |> ignore) +[] +let ``test Array.randomShuffleBy matches FSharp.Core`` () = + Array.randomShuffleBy (fun () -> 0.0) [|0 .. 4|] |> equal [|0; 1; 2; 3; 4|] + Array.randomShuffleBy (fun () -> 0.999) [|0 .. 4|] |> equal [|4; 0; 1; 2; 3|] + let xs = [|0 .. 4|] + Array.randomShuffleInPlaceBy (fun () -> 0.999) xs + xs |> equal [|4; 0; 1; 2; 3|] + let mutable calls = 0 + let next () = + calls <- calls + 1 + float ((calls * 37) % 100) / 100.0 + Array.randomShuffleBy next [|0 .. 9|] |> equal [|3; 7; 2; 6; 9; 0; 8; 4; 5; 1|] + calls |> equal 9 + +[] +let ``test Array.randomSampleBy matches FSharp.Core`` () = + Array.randomSampleBy (fun () -> 0.0) 3 [|0 .. 4|] |> equal [|0; 4; 3|] + Array.randomSampleBy (fun () -> 0.999) 3 [|0 .. 4|] |> equal [|4; 3; 2|] + Array.randomSampleBy (fun () -> 0.0) 0 ([||]: int[]) |> equal [||] + // Above the max set size it draws again on a repeated index + let values = [|0.0; 0.0; 0.5|] + let mutable calls = 0 + let next () = + calls <- calls + 1 + values[calls - 1] + Array.randomSampleBy next 2 [|0 .. 21|] |> equal [|0; 11|] + calls |> equal 3 + +[] +let ``test Array random functions draw from the given Random`` () = + let rng = System.Random(42) + let shuffled = Array.randomShuffleWith rng [|0 .. 9|] + Array.randomShuffleWith (System.Random(42)) [|0 .. 9|] |> equal shuffled + let reference = System.Random(42) + for _ in 1 .. 9 do reference.NextDouble() |> ignore + rng.Next() |> equal (reference.Next()) + let rng = System.Random(42) + let sample = Array.randomSampleWith rng 3 [|0 .. 9|] + Array.randomSampleWith (System.Random(42)) 3 [|0 .. 9|] |> equal sample + let reference = System.Random(42) + for _ in 1 .. 3 do reference.NextDouble() |> ignore + rng.Next() |> equal (reference.Next()) + +[] +let ``test Array random functions give FSharp.Core's error messages`` () = + let message = "randomizer returned NaN, should be in range [0.0, 1.0)." + throwsErrorContaining message (fun () -> Array.randomShuffleBy (fun () -> nan) [|1; 2|]) + throwsErrorContaining message (fun () -> Array.randomChoiceBy (fun () -> nan) [|1; 2|]) + throwsErrorContaining message (fun () -> Array.randomChoicesBy (fun () -> nan) 2 [|1; 2|]) + throwsErrorContaining message (fun () -> Array.randomSampleBy (fun () -> nan) 1 [|1; 2|]) + throwsErrorContaining "The input array was empty." (fun () -> Array.randomChoiceWith (System.Random(1)) ([||]: int[])) + throwsErrorContaining "The input must be non-negative.\ncount = -1" (fun () -> Array.randomSampleBy (fun () -> 0.0) -1 [|1|]) + [] let ``test Int64 arrays roundtrip through their backing storage`` () = let xs = [| 1L; -2L; 3L |] diff --git a/tests/Python/TestList.fs b/tests/Python/TestList.fs index c7db8d1859..627f9242f2 100644 --- a/tests/Python/TestList.fs +++ b/tests/Python/TestList.fs @@ -1240,6 +1240,13 @@ let ``test List.randomSampleBy works`` () = throwsAnyError (fun () -> List.randomSampleBy (fun () -> 1.0) 1 [1; 2] |> ignore) throwsAnyError (fun () -> List.randomSampleBy (fun () -> -0.1) 1 [1; 2] |> ignore) +[] +let ``test List random functions match FSharp.Core`` () = + List.randomShuffleBy (fun () -> 0.999) [0 .. 4] |> equal [4; 0; 1; 2; 3] + List.randomSampleBy (fun () -> 0.0) 3 [0 .. 4] |> equal [0; 4; 3] + throwsErrorContaining "randomizer returned NaN, should be in range [0.0, 1.0)." (fun () -> List.randomChoiceBy (fun () -> nan) [1; 2]) + throwsErrorContaining "The input sequence was empty." (fun () -> List.randomChoiceWith (System.Random(1)) ([]: int list)) + [] let ``test List.collect works II`` () = let xs = ["a"; "fable"; "bar" ] diff --git a/tests/Python/TestRandom.fs b/tests/Python/TestRandom.fs index 06eeacfe95..0f5a3091ef 100644 --- a/tests/Python/TestRandom.fs +++ b/tests/Python/TestRandom.fs @@ -112,3 +112,20 @@ let ``test System.Random.NextBytes seeded works`` () = Random(5432).NextBytes(buffer1) Random(5432).NextBytes(buffer2) buffer1 |> equal buffer2 + +[] +let ``test System.Random gives .NET's argument errors`` () = + for rnd in [Random(42); Random()] do + throwsErrorContaining "maxValue ('-1') must be a non-negative value. (Parameter 'maxValue')" (fun () -> rnd.Next(-1)) + throwsErrorContaining "'minValue' cannot be greater than maxValue. (Parameter 'minValue')" (fun () -> rnd.Next(5, 3)) + raisedExceptionType (fun () -> rnd.Next(-1)) |> equal "ArgumentOutOfRangeException" + raisedExceptionType (fun () -> rnd.Next(5, 3)) |> equal "ArgumentOutOfRangeException" + rnd.Next(5, 5) |> equal 5 + +[] +let ``test System.Random.Shared works`` () = + let x = Random.Shared.Next(5) + (x >= 0 && x < 5) |> equal true + let x = Random.Shared.NextDouble() + (x >= 0.0 && x < 1.0) |> equal true + obj.ReferenceEquals(Random.Shared, Random.Shared) |> equal true diff --git a/tests/Python/TestSeq.fs b/tests/Python/TestSeq.fs index 78314876de..f20e7c7780 100644 --- a/tests/Python/TestSeq.fs +++ b/tests/Python/TestSeq.fs @@ -1320,6 +1320,20 @@ let ``test Seq.randomSampleBy works`` () = throwsAnyError (fun () -> Seq.randomSampleBy (fun () -> 1.0) 1 [1; 2] |> Seq.toArray |> ignore) throwsAnyError (fun () -> Seq.randomSampleBy (fun () -> -0.1) 1 [1; 2] |> Seq.toArray |> ignore) +[] +let ``test Seq.randomChoicesBy draws on enumeration`` () = + let mutable calls = 0 + let xs = Seq.randomChoicesBy (fun () -> calls <- calls + 1; 0.5) 3 [0 .. 9] + calls |> equal 0 + xs |> Seq.toList |> equal [5; 5; 5] + xs |> Seq.toList |> equal [5; 5; 5] + calls |> equal 6 + let rng = System.Random(42) + let ys = Seq.randomChoicesWith rng 3 [0 .. 9] + rng.Next() |> equal (System.Random(42).Next()) + ys |> Seq.length |> equal 3 + throwsErrorContaining "The input must be non-negative.\ncount = -1" (fun () -> Seq.randomChoicesWith rng -1 [1]) + [] let ``test Seq.reduce and Seq.reduceBack raise ArgumentException on empty input`` () = let xs = Seq.empty From 79ef4b13c47b6b9686289aa3fdbeb89757d95109 Mon Sep 17 00:00:00 2001 From: Leo Conforti Date: Tue, 27 Oct 2026 16:23:13 -0600 Subject: [PATCH 3/5] fix(dart): use FSharp.Core's algorithms for random collection functions - Array, List and Seq.randomShuffle* and Array.randomShuffleInPlace* use FSharp.Core's forward Fisher-Yates shuffle. For example a constant randomizer of 0.0 leaves the input in order. - Array, List and Seq.randomSample* use FSharp.Core's algorithm: a pool for small inputs, otherwise drawing indices until an unseen one comes up. Seq.randomSample* stays eager. - The randomShuffle*With and randomSampleWith functions draw with Random.Next(i, n) like FSharp.Core. List and Seq go through Array's, so all three give the same result for the same seed. - Seq.randomChoices* draws when enumerated, again on each enumeration, like FSharp.Core. Argument checks stay eager. - The *By functions throw ArgumentOutOfRangeException for a randomizer result of NaN (was an UnsupportedError). Their errors, Array.randomChoice* on an empty array, List and Seq.randomChoice* on empty input and negative counts give FSharp.Core's messages. - Random.Shared is supported. - Random.Next raises ArgumentOutOfRangeException with .NET's message for a negative maxValue or minValue > maxValue (was a plain Exception). --- src/Fable.Transforms/Dart/Replacements.fs | 1 + src/fable-library-dart/Array.fs | 119 +++++++++++++--------- src/fable-library-dart/List.fs | 9 +- src/fable-library-dart/Random.dart | 14 +-- src/fable-library-dart/Seq.fs | 28 +++-- tests/Dart/src/ArrayTests.fs | 50 +++++++++ tests/Dart/src/ListTests.fs | 10 ++ tests/Dart/src/RandomTests.fs | 14 +++ tests/Dart/src/SeqTests.fs | 17 ++++ 9 files changed, 201 insertions(+), 61 deletions(-) diff --git a/src/Fable.Transforms/Dart/Replacements.fs b/src/Fable.Transforms/Dart/Replacements.fs index 66b24c4b58..b637c4c3d6 100644 --- a/src/Fable.Transforms/Dart/Replacements.fs +++ b/src/Fable.Transforms/Dart/Replacements.fs @@ -3285,6 +3285,7 @@ let random (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: Expr opti ?loc = r ) |> Some + | "get_Shared", None -> Helper.LibCall(com, "Random", "shared", t, [], ?loc = r) |> Some // Not yet supported | ("NextInt64" | "NextSingle"), _ -> None | meth, Some thisArg -> diff --git a/src/fable-library-dart/Array.fs b/src/fable-library-dart/Array.fs index db4e3746e8..0cba1b4d93 100644 --- a/src/fable-library-dart/Array.fs +++ b/src/fable-library-dart/Array.fs @@ -1184,22 +1184,40 @@ let updateAt (index: int) (y: 'T) (xs: 'T[]) : 'T[] = xs[i] ) -let randomShuffleInPlaceBy (randomizer: unit -> float) (xs: 'T[]) : unit = +// Like FSharp.Core's Random.next: rejects results outside [0.0, 1.0), NaN included +let private randomNext (randomizer: unit -> float) (minValue: int) (maxValue: int) : int = + let r = randomizer () + + if not (r >= 0.0 && r < 1.0) then + let msg = + "The index is outside the legal range.\nrandomizer returned " + + string r + + ", should be in range [0.0, 1.0)." + + raise (System.ArgumentOutOfRangeException("randomizer", msg)) + + int (r * float (maxValue - minValue)) + minValue + +let private checkRandomCount (count: int) = + if count < 0 then + invalidArg "count" (SR.inputMustBeNonNegative + "\ncount = " + string count) + +let private shuffleInPlace (next: int -> int -> int) (xs: 'T[]) : unit = let len = xs.Length - for i = len - 1 downto 1 do - let r = randomizer () + for i = 0 to len - 2 do + let j = next i len - if r < 0.0 || r >= 1.0 then - raise (System.ArgumentOutOfRangeException("randomizer", "The index is outside the legal range.")) + if j <> i then + let tmp = xs[i] + xs[i] <- xs[j] + xs[j] <- tmp - let j = int (r * float (i + 1)) - let tmp = xs[i] - xs[i] <- xs[j] - xs[j] <- tmp +let randomShuffleInPlaceBy (randomizer: unit -> float) (xs: 'T[]) : unit = + shuffleInPlace (randomNext randomizer) xs let randomShuffleInPlaceWith (random: System.Random) (xs: 'T[]) : unit = - randomShuffleInPlaceBy (fun () -> random.NextDouble()) xs + shuffleInPlace (fun i n -> random.Next(i, n)) xs let randomShuffleInPlace (xs: 'T[]) : unit = randomShuffleInPlaceWith (System.Random()) xs @@ -1210,21 +1228,17 @@ let randomShuffleBy (randomizer: unit -> float) (xs: 'T[]) : 'T[] = arr let randomShuffleWith (random: System.Random) (xs: 'T[]) : 'T[] = - randomShuffleBy (fun () -> random.NextDouble()) xs + let arr = copy xs + randomShuffleInPlaceWith random arr + arr let randomShuffle (xs: 'T[]) : 'T[] = randomShuffleWith (System.Random()) xs let randomChoiceBy (randomizer: unit -> float) (xs: 'T[]) : 'T = if xs.Length = 0 then - invalidArg "source" SR.inputSequenceEmpty - - let len = xs.Length - let r = randomizer () + invalidArg "source" "The input array was empty." - if r < 0.0 || r >= 1.0 then - raise (System.ArgumentOutOfRangeException("randomizer", "The index is outside the legal range.")) - - xs[int (r * float len)] + xs[randomNext randomizer 0 xs.Length] let randomChoiceWith (random: System.Random) (xs: 'T[]) : 'T = randomChoiceBy (fun () -> random.NextDouble()) xs @@ -1232,24 +1246,13 @@ let randomChoiceWith (random: System.Random) (xs: 'T[]) : 'T = let randomChoice (xs: 'T[]) : 'T = randomChoiceWith (System.Random()) xs let randomChoicesBy (randomizer: unit -> float) (count: int) (xs: 'T[]) : 'T[] = - if count < 0 then - invalidArg "count" SR.inputMustBeNonNegative + checkRandomCount count if count > 0 && xs.Length = 0 then invalidArg "source" SR.inputSequenceEmpty let len = xs.Length - - initialize - count - (fun _ -> - let r = randomizer () - - if r < 0.0 || r >= 1.0 then - raise (System.ArgumentOutOfRangeException("randomizer", "The index is outside the legal range.")) - - xs[int (r * float len)] - ) + initialize count (fun _ -> xs[randomNext randomizer 0 len]) let randomChoicesWith (random: System.Random) (count: int) (xs: 'T[]) : 'T[] = randomChoicesBy (fun () -> random.NextDouble()) count xs @@ -1257,12 +1260,22 @@ let randomChoicesWith (random: System.Random) (count: int) (xs: 'T[]) : 'T[] = let randomChoices (count: int) (xs: 'T[]) : 'T[] = randomChoicesWith (System.Random()) count xs -let randomSampleBy (randomizer: unit -> float) (count: int) (xs: 'T[]) : 'T[] = - if count < 0 then - invalidArg "count" SR.inputMustBeNonNegative +// FSharp.Core's 21 + 4 ** ceil (log4 (3 * count)), kept to integers +let private getMaxSetSizeForSampling (count: int) = + if count > 5 then + let mutable size = 1 - let arr = copy xs - let len = arr.Length + while size < count * 3 do + size <- size * 4 + + 21 + size + else + 21 + +// Picks from a shrinking pool for small inputs, otherwise draws indices until unseen (FSharp.Core, after CPython) +let private randomSampleWithNext (next: int -> int -> int) (count: int) (xs: 'T[]) : 'T[] = + checkRandomCount count + let len = xs.Length if len = 0 && count > 0 then invalidArg "source" SR.inputSequenceEmpty @@ -1270,21 +1283,33 @@ let randomSampleBy (randomizer: unit -> float) (count: int) (xs: 'T[]) : 'T[] = if count > len then invalidArg "count" SR.notEnoughElements - for i = 0 to count - 1 do - let r = randomizer () + let result = getSubArray xs 0 count + + if len <= getMaxSetSizeForSampling count then + let pool = copy xs + + for i = 0 to count - 1 do + let j = next 0 (len - i) + result[i] <- pool[j] + pool[j] <- pool[len - i - 1] + else + let selected = HashSet() + + for i = 0 to count - 1 do + let mutable j = next 0 len - if r < 0.0 || r >= 1.0 then - raise (System.ArgumentOutOfRangeException("randomizer", "The index is outside the legal range.")) + while not (selected.Add j) do + j <- next 0 len - let j = i + int (r * float (len - i)) - let tmp = arr[i] - arr[i] <- arr[j] - arr[j] <- tmp + result[i] <- xs[j] - getSubArray arr 0 count + result + +let randomSampleBy (randomizer: unit -> float) (count: int) (xs: 'T[]) : 'T[] = + randomSampleWithNext (randomNext randomizer) count xs let randomSampleWith (random: System.Random) (count: int) (xs: 'T[]) : 'T[] = - randomSampleBy (fun () -> random.NextDouble()) count xs + randomSampleWithNext (fun a b -> random.Next(a, b)) count xs let randomSample (count: int) (xs: 'T[]) : 'T[] = randomSampleWith (System.Random()) count xs diff --git a/src/fable-library-dart/List.fs b/src/fable-library-dart/List.fs index 50c0948e56..9292b11f29 100644 --- a/src/fable-library-dart/List.fs +++ b/src/fable-library-dart/List.fs @@ -991,11 +991,16 @@ let randomShuffleBy (randomizer: unit -> float) (xs: 'T list) : 'T list = ofArray arr let randomShuffleWith (random: System.Random) (xs: 'T list) : 'T list = - randomShuffleBy (fun () -> random.NextDouble()) xs + let arr = toArray xs + Array.randomShuffleInPlaceWith random arr + ofArray arr let randomShuffle (xs: 'T list) : 'T list = randomShuffleWith (System.Random()) xs let randomChoiceBy (randomizer: unit -> float) (xs: 'T list) : 'T = + if isEmpty xs then + invalidArg "source" SR.inputSequenceEmpty + toArray xs |> Array.randomChoiceBy randomizer let randomChoiceWith (random: System.Random) (xs: 'T list) : 'T = @@ -1016,7 +1021,7 @@ let randomSampleBy (randomizer: unit -> float) (count: int) (xs: 'T list) : 'T l toArray xs |> Array.randomSampleBy randomizer count |> ofArray let randomSampleWith (random: System.Random) (count: int) (xs: 'T list) : 'T list = - randomSampleBy (fun () -> random.NextDouble()) count xs + toArray xs |> Array.randomSampleWith random count |> ofArray let randomSample (count: int) (xs: 'T list) : 'T list = randomSampleWith (System.Random()) count xs diff --git a/src/fable-library-dart/Random.dart b/src/fable-library-dart/Random.dart index d3b47305ad..af92afdef7 100644 --- a/src/fable-library-dart/Random.dart +++ b/src/fable-library-dart/Random.dart @@ -1,8 +1,6 @@ // ignore_for_file: file_names import 'dart:math' as dart_math; - -Exception _outOfRange(String paramName) => - Exception('Specified argument was out of the range of valid values. (Parameter $paramName)'); +import 'System.dart' as system; Exception _nullArg(String paramName) => Exception('Value cannot be null. (Parameter $paramName)'); @@ -16,14 +14,14 @@ class Random { int Next1(int maxValue) { if (maxValue < 0) { - throw _outOfRange('maxValue'); + throw system.ArgumentOutOfRangeException('maxValue', "maxValue ('$maxValue') must be a non-negative value."); } return Next2(0, maxValue); } int Next2(int minValue, int maxValue) { if (maxValue < minValue) { - throw _outOfRange('minValue'); + throw system.ArgumentOutOfRangeException('minValue', "'minValue' cannot be greater than maxValue."); } if (maxValue == minValue) { @@ -45,4 +43,8 @@ class Random { buffer[i] = _random.nextInt(256); } } -} \ No newline at end of file +} + +final Random _shared = Random(); + +Random shared() => _shared; diff --git a/src/fable-library-dart/Seq.fs b/src/fable-library-dart/Seq.fs index dc22feb87f..d00b0ed2d8 100644 --- a/src/fable-library-dart/Seq.fs +++ b/src/fable-library-dart/Seq.fs @@ -1328,20 +1328,36 @@ let randomShuffleBy (randomizer: unit -> float) (xs: 'T seq) : 'T seq = ofArray arr let randomShuffleWith (random: System.Random) (xs: 'T seq) : 'T seq = - randomShuffleBy (fun () -> random.NextDouble()) xs + let arr = toArray xs + Array.randomShuffleInPlaceWith random arr + ofArray arr let randomShuffle (xs: 'T seq) : 'T seq = randomShuffleWith (System.Random()) xs +let private randomChoiceSource (count: int) (xs: 'T seq) : 'T[] = + if count < 0 then + invalidArg "count" (SR.inputMustBeNonNegative + "\ncount = " + string count) + + let arr = toArray xs + + if count > 0 && Array.isEmpty arr then + invalidArg "source" SR.inputSequenceEmpty + + arr + let randomChoiceBy (randomizer: unit -> float) (xs: 'T seq) : 'T = - xs |> toArray |> Array.randomChoiceBy randomizer + randomChoiceSource 1 xs |> Array.randomChoiceBy randomizer let randomChoiceWith (random: System.Random) (xs: 'T seq) : 'T = - xs |> toArray |> Array.randomChoiceWith random + randomChoiceSource 1 xs |> Array.randomChoiceWith random -let randomChoice (xs: 'T seq) : 'T = xs |> toArray |> Array.randomChoice +let randomChoice (xs: 'T seq) : 'T = + randomChoiceSource 1 xs |> Array.randomChoice +// Draws on enumeration, like FSharp.Core let randomChoicesBy (randomizer: unit -> float) (count: int) (xs: 'T seq) : 'T seq = - xs |> toArray |> Array.randomChoicesBy randomizer count |> ofArray + let arr = randomChoiceSource count xs + initialize count (fun _ -> Array.randomChoiceBy randomizer arr) let randomChoicesWith (random: System.Random) (count: int) (xs: 'T seq) : 'T seq = randomChoicesBy (fun () -> random.NextDouble()) count xs @@ -1353,7 +1369,7 @@ let randomSampleBy (randomizer: unit -> float) (count: int) (xs: 'T seq) : 'T se xs |> toArray |> Array.randomSampleBy randomizer count |> ofArray let randomSampleWith (random: System.Random) (count: int) (xs: 'T seq) : 'T seq = - randomSampleBy (fun () -> random.NextDouble()) count xs + xs |> toArray |> Array.randomSampleWith random count |> ofArray let randomSample (count: int) (xs: 'T seq) : 'T seq = randomSampleWith (System.Random()) count xs diff --git a/tests/Dart/src/ArrayTests.fs b/tests/Dart/src/ArrayTests.fs index b0284149ab..a44eaf6c43 100644 --- a/tests/Dart/src/ArrayTests.fs +++ b/tests/Dart/src/ArrayTests.fs @@ -1438,6 +1438,56 @@ let tests () = throwsAnyError (fun () -> Array.randomSampleBy (fun () -> 1.0) 1 [|1; 2|] |> ignore) throwsAnyError (fun () -> Array.randomSampleBy (fun () -> -0.1) 1 [|1; 2|] |> ignore) + testCase "Array.randomShuffleBy matches FSharp.Core" <| fun () -> + Array.randomShuffleBy (fun () -> 0.0) [|0 .. 4|] |> equal [|0; 1; 2; 3; 4|] + Array.randomShuffleBy (fun () -> 0.999) [|0 .. 4|] |> equal [|4; 0; 1; 2; 3|] + let xs = [|0 .. 4|] + Array.randomShuffleInPlaceBy (fun () -> 0.999) xs + xs |> equal [|4; 0; 1; 2; 3|] + let mutable calls = 0 + let next () = + calls <- calls + 1 + float ((calls * 37) % 100) / 100.0 + Array.randomShuffleBy next [|0 .. 9|] |> equal [|3; 7; 2; 6; 9; 0; 8; 4; 5; 1|] + calls |> equal 9 + + testCase "Array.randomSampleBy matches FSharp.Core" <| fun () -> + Array.randomSampleBy (fun () -> 0.0) 3 [|0 .. 4|] |> equal [|0; 4; 3|] + Array.randomSampleBy (fun () -> 0.999) 3 [|0 .. 4|] |> equal [|4; 3; 2|] + Array.randomSampleBy (fun () -> 0.0) 0 ([||]: int[]) |> equal [||] + // Above the max set size it draws again on a repeated index + let values = [|0.0; 0.0; 0.5|] + let mutable calls = 0 + let next () = + calls <- calls + 1 + values[calls - 1] + Array.randomSampleBy next 2 [|0 .. 21|] |> equal [|0; 11|] + calls |> equal 3 + + testCase "Array random functions draw from the given Random like FSharp.Core" <| fun () -> + let rng = System.Random(42) + let shuffled = Array.randomShuffleWith rng [|0 .. 9|] + Array.randomShuffleWith (System.Random(42)) [|0 .. 9|] |> equal shuffled + let reference = System.Random(42) + for i in 0 .. 8 do reference.Next(i, 10) |> ignore + rng.Next() |> equal (reference.Next()) + let rng = System.Random(42) + let sample = Array.randomSampleWith rng 3 [|0 .. 9|] + Array.randomSampleWith (System.Random(42)) 3 [|0 .. 9|] |> equal sample + let reference = System.Random(42) + for i in 0 .. 2 do reference.Next(0, 10 - i) |> ignore + rng.Next() |> equal (reference.Next()) + + testCase "Array random functions give FSharp.Core's error messages" <| fun () -> + let message = "randomizer returned NaN, should be in range [0.0, 1.0)." + throwsErrorContaining message (fun () -> Array.randomShuffleBy (fun () -> System.Double.NaN) [|1; 2|]) + throwsErrorContaining message (fun () -> Array.randomChoiceBy (fun () -> System.Double.NaN) [|1; 2|]) + throwsErrorContaining message (fun () -> Array.randomChoicesBy (fun () -> System.Double.NaN) 2 [|1; 2|]) + throwsErrorContaining message (fun () -> Array.randomSampleBy (fun () -> System.Double.NaN) 1 [|1; 2|]) + raisedExceptionType (fun () -> Array.randomSampleBy (fun () -> System.Double.NaN) 1 [|1; 2|]) |> equal "ArgumentOutOfRangeException" + throwsErrorContaining "The input array was empty." (fun () -> Array.randomChoiceWith (System.Random(1)) ([||]: int[])) + throwsErrorContaining "The input must be non-negative.\ncount = -1" (fun () -> Array.randomSampleBy (fun () -> 0.0) -1 [|1|]) + testCase "Array.compareWith works" <| fun () -> // See #2961 let a = [|1;3|] let b = [|1;2;3|] diff --git a/tests/Dart/src/ListTests.fs b/tests/Dart/src/ListTests.fs index acdde1fa7d..4f5652a42b 100644 --- a/tests/Dart/src/ListTests.fs +++ b/tests/Dart/src/ListTests.fs @@ -1064,6 +1064,16 @@ let tests() = throwsAnyError (fun () -> List.randomSampleBy (fun () -> 1.0) 1 [1; 2] |> ignore) throwsAnyError (fun () -> List.randomSampleBy (fun () -> -0.1) 1 [1; 2] |> ignore) + testCase "List random functions match FSharp.Core" <| fun () -> + List.randomShuffleBy (fun () -> 0.999) [0 .. 4] |> equal [4; 0; 1; 2; 3] + List.randomSampleBy (fun () -> 0.0) 3 [0 .. 4] |> equal [0; 4; 3] + throwsErrorContaining "randomizer returned NaN, should be in range [0.0, 1.0)." (fun () -> List.randomChoiceBy (fun () -> System.Double.NaN) [1; 2]) + throwsErrorContaining "The input sequence was empty." (fun () -> List.randomChoiceWith (System.Random(1)) ([]: int list)) + + testCase "List.randomShuffleWith and randomSampleWith draw like Array's" <| fun () -> + List.randomShuffleWith (System.Random(42)) [0 .. 9] |> equal (List.ofArray (Array.randomShuffleWith (System.Random(42)) [|0 .. 9|])) + List.randomSampleWith (System.Random(42)) 3 [0 .. 9] |> equal (List.ofArray (Array.randomSampleWith (System.Random(42)) 3 [|0 .. 9|])) + testCase "List.reduce and List.min on an empty list raise ArgumentException" <| fun () -> let xs: int list = [] raisedExceptionType (fun () -> List.reduce (+) xs) |> equal "ArgumentException" diff --git a/tests/Dart/src/RandomTests.fs b/tests/Dart/src/RandomTests.fs index 1f403d1ef3..7fe0212ea0 100644 --- a/tests/Dart/src/RandomTests.fs +++ b/tests/Dart/src/RandomTests.fs @@ -106,3 +106,17 @@ let tests () = Random(5432).NextBytes(buffer1) Random(5432).NextBytes(buffer2) buffer1 |> equal buffer2 + + testCase "System.Random gives .NET's argument errors" <| fun () -> + for rnd in [Random(42); Random()] do + throwsErrorContaining "maxValue ('-1') must be a non-negative value. (Parameter 'maxValue')" (fun () -> rnd.Next(-1)) + throwsErrorContaining "'minValue' cannot be greater than maxValue. (Parameter 'minValue')" (fun () -> rnd.Next(5, 3)) + raisedExceptionType (fun () -> rnd.Next(-1)) |> equal "ArgumentOutOfRangeException" + raisedExceptionType (fun () -> rnd.Next(5, 3)) |> equal "ArgumentOutOfRangeException" + + testCase "System.Random.Shared works" <| fun () -> + let x = Random.Shared.Next(5) + (x >= 0 && x < 5) |> equal true + let x = Random.Shared.NextDouble() + (x >= 0.0 && x < 1.0) |> equal true + obj.ReferenceEquals(Random.Shared, Random.Shared) |> equal true diff --git a/tests/Dart/src/SeqTests.fs b/tests/Dart/src/SeqTests.fs index 795c9e297e..ccde179a39 100644 --- a/tests/Dart/src/SeqTests.fs +++ b/tests/Dart/src/SeqTests.fs @@ -1164,6 +1164,23 @@ let tests() = throwsAnyError (fun () -> Seq.randomSampleBy (fun () -> 1.0) 1 [1; 2] |> Seq.toArray |> ignore) throwsAnyError (fun () -> Seq.randomSampleBy (fun () -> -0.1) 1 [1; 2] |> Seq.toArray |> ignore) + testCase "Seq.randomShuffleWith and randomSampleWith draw like Array's" <| fun () -> + Seq.randomShuffleWith (System.Random(42)) [0 .. 9] |> Seq.toArray |> equal (Array.randomShuffleWith (System.Random(42)) [|0 .. 9|]) + Seq.randomSampleWith (System.Random(42)) 3 [0 .. 9] |> Seq.toArray |> equal (Array.randomSampleWith (System.Random(42)) 3 [|0 .. 9|]) + + testCase "Seq.randomChoicesBy draws on enumeration" <| fun () -> + let mutable calls = 0 + let xs = Seq.randomChoicesBy (fun () -> calls <- calls + 1; 0.5) 3 [0 .. 9] + calls |> equal 0 + xs |> Seq.toList |> equal [5; 5; 5] + xs |> Seq.toList |> equal [5; 5; 5] + calls |> equal 6 + let rng = System.Random(42) + let ys = Seq.randomChoicesWith rng 3 [0 .. 9] + rng.Next() |> equal (System.Random(42).Next()) + ys |> Seq.length |> equal 3 + throwsErrorContaining "The input must be non-negative.\ncount = -1" (fun () -> Seq.randomChoicesWith rng -1 [1]) + testCase "Seq.reduce and Seq.min on an empty sequence raise ArgumentException" <| fun () -> let xs: int seq = Seq.empty raisedExceptionType (fun () -> Seq.reduce (+) xs) |> equal "ArgumentException" From 79692e2567d7d99fa103073974947c79ae362908 Mon Sep 17 00:00:00 2001 From: Leo Conforti Date: Tue, 27 Oct 2026 16:26:01 -0600 Subject: [PATCH 4/5] fix(rust): use FSharp.Core's algorithms for random collection functions - Array, List and Seq.randomShuffle* and Array.randomShuffleInPlace* use FSharp.Core's forward Fisher-Yates shuffle. For example a constant randomizer of 0.0 leaves the input in order. - Array, List and Seq.randomSample* use FSharp.Core's algorithm: a pool for small inputs, otherwise drawing indices until an unseen one comes up. Seq.randomSample* stays eager. - The randomShuffle*With and randomSampleWith functions draw with Random.Next(i, n) like FSharp.Core. List and Seq go through Array's, so all three give the same result for the same seed. - Seq.randomChoices* draws when enumerated, again on each enumeration, like FSharp.Core. Argument checks stay eager. - The *By functions reject a randomizer result of NaN (they picked index 0). Their errors, Array.randomChoice* on an empty array, List and Seq.randomChoice* on empty input and negative counts give FSharp.Core's messages. --- src/fable-library-rust/src/Array.fs | 118 +++++++++++++++++----------- src/fable-library-rust/src/List.fs | 9 ++- src/fable-library-rust/src/Seq.fs | 28 +++++-- tests/Rust/tests/src/ArrayTests.fs | 53 +++++++++++++ tests/Rust/tests/src/ListTests.fs | 12 +++ tests/Rust/tests/src/SeqTests.fs | 19 +++++ 6 files changed, 185 insertions(+), 54 deletions(-) diff --git a/src/fable-library-rust/src/Array.fs b/src/fable-library-rust/src/Array.fs index 13c8f44d5b..b75ac417dd 100644 --- a/src/fable-library-rust/src/Array.fs +++ b/src/fable-library-rust/src/Array.fs @@ -985,22 +985,40 @@ let distinctBy<'T, 'Key when 'Key: equality and 'Key: not null> (projection: 'T let hashSet = System.Collections.Generic.HashSet<'Key>() xs |> filter (fun x -> hashSet.Add(projection x)) -let randomShuffleInPlaceBy (randomizer: unit -> float) (xs: 'T[]) : unit = +// Like FSharp.Core's Random.next: rejects results outside [0.0, 1.0), NaN included +let private randomNext (randomizer: unit -> float) (minValue: int) (maxValue: int) : int = + let r = randomizer () + + if not (r >= 0.0 && r < 1.0) then + let msg = + "The index is outside the legal range.\nrandomizer returned " + + string r + + ", should be in range [0.0, 1.0)." + + invalidArg "randomizer" msg + + int (r * float (maxValue - minValue)) + minValue + +let private checkRandomCount (count: int) = + if count < 0 then + invalidArg "count" (SR.inputMustBeNonNegative + "\ncount = " + string count) + +let private shuffleInPlace (next: int -> int -> int) (xs: 'T[]) : unit = let len = xs.Length - for i = len - 1 downto 1 do - let r = randomizer () + for i = 0 to len - 2 do + let j = next i len - if r < 0.0 || r >= 1.0 then - invalidArg "randomizer" SR.Arg_ArgumentOutOfRangeException + if j <> i then + let tmp = xs[i] + xs[i] <- xs[j] + xs[j] <- tmp - let j = int (r * float (i + 1)) - let tmp = xs[i] - xs[i] <- xs[j] - xs[j] <- tmp +let randomShuffleInPlaceBy (randomizer: unit -> float) (xs: 'T[]) : unit = + shuffleInPlace (randomNext randomizer) xs let randomShuffleInPlaceWith (random: System.Random) (xs: 'T[]) : unit = - randomShuffleInPlaceBy (fun () -> random.NextDouble()) xs + shuffleInPlace (fun i n -> random.Next(i, n)) xs let randomShuffleInPlace (xs: 'T[]) : unit = randomShuffleInPlaceWith (System.Random()) xs @@ -1011,21 +1029,18 @@ let randomShuffleBy (randomizer: unit -> float) (xs: 'T[]) : 'T[] = arr let randomShuffleWith (random: System.Random) (xs: 'T[]) : 'T[] = - randomShuffleBy (fun () -> random.NextDouble()) xs + let arr = copy xs + randomShuffleInPlaceWith random arr + arr let randomShuffle (xs: 'T[]) : 'T[] = randomShuffleWith (System.Random()) xs let randomChoiceBy (randomizer: unit -> float) (xs: 'T[]) : 'T = if isEmpty xs then - invalidArg "source" SR.inputSequenceEmpty + invalidArg "source" "The input array was empty." let len = xs.Length - let r = randomizer () - - if r < 0.0 || r >= 1.0 then - invalidArg "randomizer" SR.Arg_ArgumentOutOfRangeException - - xs[int (r * float len)] + xs[randomNext randomizer 0 len] let randomChoiceWith (random: System.Random) (xs: 'T[]) : 'T = randomChoiceBy (fun () -> random.NextDouble()) xs @@ -1033,24 +1048,13 @@ let randomChoiceWith (random: System.Random) (xs: 'T[]) : 'T = let randomChoice (xs: 'T[]) : 'T = randomChoiceWith (System.Random()) xs let randomChoicesBy (randomizer: unit -> float) (count: int) (xs: 'T[]) : 'T[] = - if count < 0 then - invalidArg "count" SR.inputMustBeNonNegative + checkRandomCount count if count > 0 && isEmpty xs then invalidArg "source" SR.inputSequenceEmpty let len = xs.Length - - initialize - count - (fun _ -> - let r = randomizer () - - if r < 0.0 || r >= 1.0 then - invalidArg "randomizer" SR.Arg_ArgumentOutOfRangeException - - xs[int (r * float len)] - ) + initialize count (fun _ -> xs[randomNext randomizer 0 len]) let randomChoicesWith (random: System.Random) (count: int) (xs: 'T[]) : 'T[] = randomChoicesBy (fun () -> random.NextDouble()) count xs @@ -1058,12 +1062,22 @@ let randomChoicesWith (random: System.Random) (count: int) (xs: 'T[]) : 'T[] = let randomChoices (count: int) (xs: 'T[]) : 'T[] = randomChoicesWith (System.Random()) count xs -let randomSampleBy (randomizer: unit -> float) (count: int) (xs: 'T[]) : 'T[] = - if count < 0 then - invalidArg "count" SR.inputMustBeNonNegative +// FSharp.Core's 21 + 4 ** ceil (log4 (3 * count)), kept to integers +let private getMaxSetSizeForSampling (count: int) = + if count > 5 then + let mutable size = 1 - let arr = copy xs - let len = arr.Length + while size < count * 3 do + size <- size * 4 + + 21 + size + else + 21 + +// Picks from a shrinking pool for small inputs, otherwise draws indices until unseen (FSharp.Core, after CPython) +let private randomSampleWithNext (next: int -> int -> int) (count: int) (xs: 'T[]) : 'T[] = + checkRandomCount count + let len = xs.Length if len = 0 && count > 0 then invalidArg "source" SR.inputSequenceEmpty @@ -1071,21 +1085,33 @@ let randomSampleBy (randomizer: unit -> float) (count: int) (xs: 'T[]) : 'T[] = if count > len then invalidArg "count" SR.notEnoughElements - for i = 0 to count - 1 do - let r = randomizer () + let result = getSubArray xs 0 count - if r < 0.0 || r >= 1.0 then - invalidArg "randomizer" SR.Arg_ArgumentOutOfRangeException + if len <= getMaxSetSizeForSampling count then + let pool = copy xs + + for i = 0 to count - 1 do + let j = next 0 (len - i) + result[i] <- pool[j] + pool[j] <- pool[len - i - 1] + else + let selected = System.Collections.Generic.HashSet() - let j = i + int (r * float (len - i)) - let tmp = arr[i] - arr[i] <- arr[j] - arr[j] <- tmp + for i = 0 to count - 1 do + let mutable j = next 0 len - getSubArray arr 0 count + while not (selected.Add j) do + j <- next 0 len + + result[i] <- xs[j] + + result + +let randomSampleBy (randomizer: unit -> float) (count: int) (xs: 'T[]) : 'T[] = + randomSampleWithNext (randomNext randomizer) count xs let randomSampleWith (random: System.Random) (count: int) (xs: 'T[]) : 'T[] = - randomSampleBy (fun () -> random.NextDouble()) count xs + randomSampleWithNext (fun a b -> random.Next(a, b)) count xs let randomSample (count: int) (xs: 'T[]) : 'T[] = randomSampleWith (System.Random()) count xs diff --git a/src/fable-library-rust/src/List.fs b/src/fable-library-rust/src/List.fs index 4ce0353ec0..16fc523498 100644 --- a/src/fable-library-rust/src/List.fs +++ b/src/fable-library-rust/src/List.fs @@ -1009,11 +1009,16 @@ let randomShuffleBy (randomizer: unit -> float) (xs: 'T list) : 'T list = ofArray arr let randomShuffleWith (random: System.Random) (xs: 'T list) : 'T list = - randomShuffleBy (fun () -> random.NextDouble()) xs + let arr = toArray xs + Array.randomShuffleInPlaceWith random arr + ofArray arr let randomShuffle (xs: 'T list) : 'T list = randomShuffleWith (System.Random()) xs let randomChoiceBy (randomizer: unit -> float) (xs: 'T list) : 'T = + if isEmpty xs then + invalidArg "source" SR.inputSequenceEmpty + toArray xs |> Array.randomChoiceBy randomizer let randomChoiceWith (random: System.Random) (xs: 'T list) : 'T = @@ -1034,7 +1039,7 @@ let randomSampleBy (randomizer: unit -> float) (count: int) (xs: 'T list) : 'T l toArray xs |> Array.randomSampleBy randomizer count |> ofArray let randomSampleWith (random: System.Random) (count: int) (xs: 'T list) : 'T list = - randomSampleBy (fun () -> random.NextDouble()) count xs + toArray xs |> Array.randomSampleWith random count |> ofArray let randomSample (count: int) (xs: 'T list) : 'T list = randomSampleWith (System.Random()) count xs diff --git a/src/fable-library-rust/src/Seq.fs b/src/fable-library-rust/src/Seq.fs index 76c9fbddce..eb818d6c63 100644 --- a/src/fable-library-rust/src/Seq.fs +++ b/src/fable-library-rust/src/Seq.fs @@ -1328,20 +1328,36 @@ let randomShuffleBy (randomizer: unit -> float) (xs: 'T seq) : 'T seq = ofArray arr let randomShuffleWith (random: System.Random) (xs: 'T seq) : 'T seq = - randomShuffleBy (fun () -> random.NextDouble()) xs + let arr = toArray xs + Array.randomShuffleInPlaceWith random arr + ofArray arr let randomShuffle (xs: 'T seq) : 'T seq = randomShuffleWith (System.Random()) xs +let private randomChoiceSource (count: int) (xs: 'T seq) : 'T[] = + if count < 0 then + invalidArg "count" (SR.inputMustBeNonNegative + "\ncount = " + string count) + + let arr = toArray xs + + if count > 0 && Array.isEmpty arr then + invalidArg "source" SR.inputSequenceEmpty + + arr + let randomChoiceBy (randomizer: unit -> float) (xs: 'T seq) : 'T = - xs |> toArray |> Array.randomChoiceBy randomizer + randomChoiceSource 1 xs |> Array.randomChoiceBy randomizer let randomChoiceWith (random: System.Random) (xs: 'T seq) : 'T = - xs |> toArray |> Array.randomChoiceWith random + randomChoiceSource 1 xs |> Array.randomChoiceWith random -let randomChoice (xs: 'T seq) : 'T = xs |> toArray |> Array.randomChoice +let randomChoice (xs: 'T seq) : 'T = + randomChoiceSource 1 xs |> Array.randomChoice +// Draws on enumeration, like FSharp.Core let randomChoicesBy (randomizer: unit -> float) (count: int) (xs: 'T seq) : 'T seq = - xs |> toArray |> Array.randomChoicesBy randomizer count |> ofArray + let arr = randomChoiceSource count xs + initialize count (fun _ -> Array.randomChoiceBy randomizer arr) let randomChoicesWith (random: System.Random) (count: int) (xs: 'T seq) : 'T seq = randomChoicesBy (fun () -> random.NextDouble()) count xs @@ -1353,7 +1369,7 @@ let randomSampleBy (randomizer: unit -> float) (count: int) (xs: 'T seq) : 'T se xs |> toArray |> Array.randomSampleBy randomizer count |> ofArray let randomSampleWith (random: System.Random) (count: int) (xs: 'T seq) : 'T seq = - randomSampleBy (fun () -> random.NextDouble()) count xs + xs |> toArray |> Array.randomSampleWith random count |> ofArray let randomSample (count: int) (xs: 'T seq) : 'T seq = randomSampleWith (System.Random()) count xs diff --git a/tests/Rust/tests/src/ArrayTests.fs b/tests/Rust/tests/src/ArrayTests.fs index b860351d83..ec2404f8c0 100644 --- a/tests/Rust/tests/src/ArrayTests.fs +++ b/tests/Rust/tests/src/ArrayTests.fs @@ -1452,6 +1452,59 @@ let ``Array.randomSampleBy works`` () = throwsAnyError (fun () -> Array.randomSampleBy (fun () -> 1.0) 1 [|1; 2|] |> ignore) throwsAnyError (fun () -> Array.randomSampleBy (fun () -> -0.1) 1 [|1; 2|] |> ignore) +[] +let ``Array.randomShuffleBy matches FSharp.Core`` () = + Array.randomShuffleBy (fun () -> 0.0) [|0 .. 4|] |> equal [|0; 1; 2; 3; 4|] + Array.randomShuffleBy (fun () -> 0.999) [|0 .. 4|] |> equal [|4; 0; 1; 2; 3|] + let xs = [|0 .. 4|] + Array.randomShuffleInPlaceBy (fun () -> 0.999) xs + xs |> equal [|4; 0; 1; 2; 3|] + let mutable calls = 0 + let next () = + calls <- calls + 1 + float ((calls * 37) % 100) / 100.0 + Array.randomShuffleBy next [|0 .. 9|] |> equal [|3; 7; 2; 6; 9; 0; 8; 4; 5; 1|] + calls |> equal 9 + +[] +let ``Array.randomSampleBy matches FSharp.Core`` () = + Array.randomSampleBy (fun () -> 0.0) 3 [|0 .. 4|] |> equal [|0; 4; 3|] + Array.randomSampleBy (fun () -> 0.999) 3 [|0 .. 4|] |> equal [|4; 3; 2|] + Array.randomSampleBy (fun () -> 0.0) 0 ([||]: int[]) |> equal [||] + // Above the max set size it draws again on a repeated index + let values = [|0.0; 0.0; 0.5|] + let mutable calls = 0 + let next () = + calls <- calls + 1 + values[calls - 1] + Array.randomSampleBy next 2 [|0 .. 21|] |> equal [|0; 11|] + calls |> equal 3 + +[] +let ``Array random functions draw from the given Random like FSharp.Core`` () = + let rng = System.Random(42) + let shuffled = Array.randomShuffleWith rng [|0 .. 9|] + Array.randomShuffleWith (System.Random(42)) [|0 .. 9|] |> equal shuffled + let reference = System.Random(42) + for i in 0 .. 8 do reference.Next(i, 10) |> ignore + rng.Next() |> equal (reference.Next()) + let rng = System.Random(42) + let sample = Array.randomSampleWith rng 3 [|0 .. 9|] + Array.randomSampleWith (System.Random(42)) 3 [|0 .. 9|] |> equal sample + let reference = System.Random(42) + for i in 0 .. 2 do reference.Next(0, 10 - i) |> ignore + rng.Next() |> equal (reference.Next()) + +[] +let ``Array random functions give FSharp.Core's error messages`` () = + let message = "randomizer returned NaN, should be in range [0.0, 1.0)." + throwsErrorContaining message (fun () -> Array.randomShuffleBy (fun () -> nan) [|1; 2|]) + throwsErrorContaining message (fun () -> Array.randomChoiceBy (fun () -> nan) [|1; 2|]) + throwsErrorContaining message (fun () -> Array.randomChoicesBy (fun () -> nan) 2 [|1; 2|]) + throwsErrorContaining message (fun () -> Array.randomSampleBy (fun () -> nan) 1 [|1; 2|]) + throwsErrorContaining "The input array was empty." (fun () -> Array.randomChoiceWith (System.Random(1)) ([||]: int[])) + throwsErrorContaining "The input must be non-negative.\ncount = -1" (fun () -> Array.randomSampleBy (fun () -> 0.0) -1 [|1|]) + [] let ``Array.transpose works`` () = // integer array diff --git a/tests/Rust/tests/src/ListTests.fs b/tests/Rust/tests/src/ListTests.fs index 456d27e883..63b3fa51ce 100644 --- a/tests/Rust/tests/src/ListTests.fs +++ b/tests/Rust/tests/src/ListTests.fs @@ -1324,3 +1324,15 @@ let ``List.randomSampleBy works`` () = // Out-of-range randomizer should throw throwsAnyError (fun () -> List.randomSampleBy (fun () -> 1.0) 1 [1; 2] |> ignore) throwsAnyError (fun () -> List.randomSampleBy (fun () -> -0.1) 1 [1; 2] |> ignore) + +[] +let ``List random functions match FSharp.Core`` () = + List.randomShuffleBy (fun () -> 0.999) [0 .. 4] |> equal [4; 0; 1; 2; 3] + List.randomSampleBy (fun () -> 0.0) 3 [0 .. 4] |> equal [0; 4; 3] + throwsErrorContaining "randomizer returned NaN, should be in range [0.0, 1.0)." (fun () -> List.randomChoiceBy (fun () -> nan) [1; 2]) + throwsErrorContaining "The input sequence was empty." (fun () -> List.randomChoiceWith (System.Random(1)) ([]: int list)) + +[] +let ``List.randomShuffleWith and randomSampleWith draw like Array's`` () = + List.randomShuffleWith (System.Random(42)) [0 .. 9] |> equal (List.ofArray (Array.randomShuffleWith (System.Random(42)) [|0 .. 9|])) + List.randomSampleWith (System.Random(42)) 3 [0 .. 9] |> equal (List.ofArray (Array.randomSampleWith (System.Random(42)) 3 [|0 .. 9|])) diff --git a/tests/Rust/tests/src/SeqTests.fs b/tests/Rust/tests/src/SeqTests.fs index 5f07d3d78e..43500ec317 100644 --- a/tests/Rust/tests/src/SeqTests.fs +++ b/tests/Rust/tests/src/SeqTests.fs @@ -1412,3 +1412,22 @@ let ``Seq.randomSampleBy works`` () = Array.distinct sample |> Array.length |> equal 3 throwsAnyError (fun () -> Seq.randomSampleBy (fun () -> 1.0) 1 [1; 2] |> Seq.toArray |> ignore) throwsAnyError (fun () -> Seq.randomSampleBy (fun () -> -0.1) 1 [1; 2] |> Seq.toArray |> ignore) + +[] +let ``Seq.randomShuffleWith and randomSampleWith draw like Array's`` () = + Seq.randomShuffleWith (System.Random(42)) [0 .. 9] |> Seq.toArray |> equal (Array.randomShuffleWith (System.Random(42)) [|0 .. 9|]) + Seq.randomSampleWith (System.Random(42)) 3 [0 .. 9] |> Seq.toArray |> equal (Array.randomSampleWith (System.Random(42)) 3 [|0 .. 9|]) + +[] +let ``Seq.randomChoicesBy draws on enumeration`` () = + let mutable calls = 0 + let xs = Seq.randomChoicesBy (fun () -> calls <- calls + 1; 0.5) 3 [0 .. 9] + calls |> equal 0 + xs |> Seq.toList |> equal [5; 5; 5] + xs |> Seq.toList |> equal [5; 5; 5] + calls |> equal 6 + let rng = System.Random(42) + let ys = Seq.randomChoicesWith rng 3 [0 .. 9] + rng.Next() |> equal (System.Random(42).Next()) + ys |> Seq.length |> equal 3 + throwsErrorContaining "The input must be non-negative.\ncount = -1" (fun () -> Seq.randomChoicesWith rng -1 [1]) From 5a3192f7610bc3a1383f89196ce1567010ee80c5 Mon Sep 17 00:00:00 2001 From: Leo Conforti Date: Tue, 27 Oct 2026 16:29:31 -0600 Subject: [PATCH 5/5] fix(beam): use FSharp.Core's algorithms for random collection functions - Array, List and Seq.randomShuffle* and Array.randomShuffleInPlace* use FSharp.Core's forward Fisher-Yates shuffle. For example a constant randomizer of 0.0 leaves the input in order. - Array, List and Seq.randomSample* use FSharp.Core's algorithm: a pool for small inputs, otherwise drawing indices until an unseen one comes up. Seq.randomSample* stays eager. - Seq.randomChoices* draws when enumerated, again on each enumeration, like FSharp.Core. Argument checks stay eager. - Negative counts give FSharp.Core's message. - Random.Shared is supported, as one instance per process. - Random.Next raises ArgumentOutOfRangeException with .NET's message for a negative maxValue or minValue > maxValue (was badarg), and Next(n, n) returns n. --- src/Fable.Transforms/Beam/Replacements.fs | 1 + src/fable-library-beam/Seq.fs | 22 +++++- src/fable-library-beam/fable_list.erl | 90 ++++++++++++----------- src/fable-library-beam/fable_random.erl | 40 ++++++++-- tests/Beam/ArrayTests.fs | 35 +++++++++ tests/Beam/ListTests.fs | 6 ++ tests/Beam/RandomTests.fs | 17 +++++ tests/Beam/SeqTests.fs | 14 ++++ 8 files changed, 172 insertions(+), 53 deletions(-) diff --git a/src/Fable.Transforms/Beam/Replacements.fs b/src/Fable.Transforms/Beam/Replacements.fs index c0f298fca7..aad449c592 100644 --- a/src/Fable.Transforms/Beam/Replacements.fs +++ b/src/Fable.Transforms/Beam/Replacements.fs @@ -4603,6 +4603,7 @@ let private randoms match args with | [ seed ] -> Helper.LibCall(com, "fable_random", "new_seeded", t, [ seed ], ?loc = r) |> Some | _ -> Helper.LibCall(com, "fable_random", "new", t, [], ?loc = r) |> Some + | "get_Shared", None -> Helper.LibCall(com, "fable_random", "shared", t, [], ?loc = r) |> Some | "Next", Some thisArg -> match args with | [] -> Helper.LibCall(com, "fable_random", "next", t, [ thisArg ], ?loc = r) |> Some diff --git a/src/fable-library-beam/Seq.fs b/src/fable-library-beam/Seq.fs index 71671d5394..2e9aa5cb16 100644 --- a/src/fable-library-beam/Seq.fs +++ b/src/fable-library-beam/Seq.fs @@ -1397,16 +1397,30 @@ let randomShuffleWith (random: System.Random) (xs: 'T seq) : 'T seq = let randomShuffle (xs: 'T seq) : 'T seq = randomShuffleWith (System.Random()) xs +let private randomChoiceSource (count: int) (xs: 'T seq) : 'T[] = + if count < 0 then + invalidArg "count" (SR.inputMustBeNonNegative + "\ncount = " + string count) + + let arr = toArray xs + + if count > 0 && Array.isEmpty arr then + invalidArg "source" SR.inputSequenceEmpty + + arr + let randomChoiceBy (randomizer: unit -> float) (xs: 'T seq) : 'T = - xs |> toArray |> Array.randomChoiceBy randomizer + randomChoiceSource 1 xs |> Array.randomChoiceBy randomizer let randomChoiceWith (random: System.Random) (xs: 'T seq) : 'T = - xs |> toArray |> Array.randomChoiceWith random + randomChoiceSource 1 xs |> Array.randomChoiceWith random -let randomChoice (xs: 'T seq) : 'T = xs |> toArray |> Array.randomChoice +let randomChoice (xs: 'T seq) : 'T = + randomChoiceSource 1 xs |> Array.randomChoice +// Draws on enumeration, like FSharp.Core let randomChoicesBy (randomizer: unit -> float) (count: int) (xs: 'T seq) : 'T seq = - xs |> toArray |> Array.randomChoicesBy randomizer count |> ofArray + let arr = randomChoiceSource count xs + initialize count (fun _ -> Array.randomChoiceBy randomizer arr) let randomChoicesWith (random: System.Random) (count: int) (xs: 'T seq) : 'T seq = randomChoicesBy (fun () -> random.NextDouble()) count xs diff --git a/src/fable-library-beam/fable_list.erl b/src/fable-library-beam/fable_list.erl index 580eb7c9e5..aabc74f97b 100644 --- a/src/fable-library-beam/fable_list.erl +++ b/src/fable-library-beam/fable_list.erl @@ -696,7 +696,7 @@ take(Count, List) -> -spec random_shuffle_by(fun((ok) -> float()), list()) -> list(). random_shuffle_by(Randomizer, Xs) -> - shuffle_loop(Randomizer, erlang:list_to_tuple(Xs), erlang:length(Xs) - 1). + shuffle_loop(Randomizer, erlang:list_to_tuple(Xs), erlang:length(Xs), 0). -spec random_shuffle_with(reference(), list()) -> list(). random_shuffle_with(Random, Xs) -> @@ -710,14 +710,7 @@ random_shuffle(Xs) -> random_choice_by(_Randomizer, []) -> invalid_arg(<<"source">>, <<"The input sequence was empty.">>); random_choice_by(Randomizer, Xs) -> - Len = erlang:length(Xs), - R = Randomizer(ok), - if - R < 0.0; R >= 1.0 -> - randomizer_out_of_range(R); - true -> - lists:nth(erlang:trunc(R * Len) + 1, Xs) - end. + lists:nth(random_next(Randomizer, 0, erlang:length(Xs)) + 1, Xs). -spec random_choice_with(reference(), list()) -> term(). random_choice_with(Random, Xs) -> @@ -731,7 +724,7 @@ random_choice(Xs) -> random_choices_by(Randomizer, Count, Xs) -> if Count < 0 -> - invalid_arg(<<"count">>, <<"The input must be non-negative.">>); + negative_count(Count); Count > 0 andalso Xs =:= [] -> invalid_arg(<<"source">>, <<"The input sequence was empty.">>); true -> @@ -744,14 +737,8 @@ random_choices_by(Randomizer, Count, Xs) -> choices_loop(_Randomizer, _Arr, _Len, 0, Acc) -> lists:reverse(Acc); choices_loop(Randomizer, Arr, Len, N, Acc) -> - R = Randomizer(ok), - if - R < 0.0; R >= 1.0 -> - randomizer_out_of_range(R); - true -> - E = erlang:element(erlang:trunc(R * Len) + 1, Arr), - choices_loop(Randomizer, Arr, Len, N - 1, [E | Acc]) - end. + E = erlang:element(random_next(Randomizer, 0, Len) + 1, Arr), + choices_loop(Randomizer, Arr, Len, N - 1, [E | Acc]). -spec random_choices_with(reference(), non_neg_integer(), list()) -> list(). random_choices_with(Random, Count, Xs) -> @@ -765,7 +752,7 @@ random_choices(Count, Xs) -> random_sample_by(Randomizer, Count, Xs) -> if Count < 0 -> - invalid_arg(<<"count">>, <<"The input must be non-negative.">>); + negative_count(Count); true -> Len = erlang:length(Xs), if @@ -774,7 +761,11 @@ random_sample_by(Randomizer, Count, Xs) -> Count > Len -> invalid_arg(<<"count">>, <<"The input sequence has an insufficient number of elements.">>); true -> - sample_loop(Randomizer, erlang:list_to_tuple(Xs), Len, Count, 0) + Arr = erlang:list_to_tuple(Xs), + case Len =< max_set_size(Count) of + true -> sample_pool(Randomizer, Arr, Len, Count, 0, []); + false -> sample_set(Randomizer, Arr, Len, Count, #{}, []) + end end end. @@ -786,38 +777,55 @@ random_sample_with(Random, Count, Xs) -> random_sample(Count, Xs) -> random_sample_with(fable_random:new(), Count, Xs). -%% Partial Fisher-Yates: after Count swaps the first Count positions hold the sample. -sample_loop(_Randomizer, Arr, _Len, Count, I) when I >= Count -> - lists:sublist(erlang:tuple_to_list(Arr), Count); -sample_loop(Randomizer, Arr, Len, Count, I) -> - R = Randomizer(ok), - if - R < 0.0; R >= 1.0 -> - randomizer_out_of_range(R); +%% FSharp.Core's sampling: a shrinking pool for small inputs, otherwise redraw seen indices. +max_set_size(Count) when Count > 5 -> + 21 + erlang:trunc(math:pow(4, math:ceil(math:log(Count * 3) / math:log(4)))); +max_set_size(_Count) -> + 21. + +sample_pool(_Randomizer, _Pool, _Len, Count, I, Acc) when I >= Count -> + lists:reverse(Acc); +sample_pool(Randomizer, Pool, Len, Count, I, Acc) -> + J = random_next(Randomizer, 0, Len - I), + Item = erlang:element(J + 1, Pool), + Pool2 = erlang:setelement(J + 1, Pool, erlang:element(Len - I, Pool)), + sample_pool(Randomizer, Pool2, Len, Count, I + 1, [Item | Acc]). + +sample_set(_Randomizer, _Arr, _Len, 0, _Selected, Acc) -> + lists:reverse(Acc); +sample_set(Randomizer, Arr, Len, N, Selected, Acc) -> + J = random_next(Randomizer, 0, Len), + case maps:is_key(J, Selected) of true -> - J = I + erlang:trunc(R * (Len - I)), - EI = erlang:element(I + 1, Arr), - EJ = erlang:element(J + 1, Arr), - Arr2 = erlang:setelement(I + 1, erlang:setelement(J + 1, Arr, EI), EJ), - sample_loop(Randomizer, Arr2, Len, Count, I + 1) + sample_set(Randomizer, Arr, Len, N, Selected, Acc); + false -> + Item = erlang:element(J + 1, Arr), + sample_set(Randomizer, Arr, Len, N - 1, Selected#{J => true}, [Item | Acc]) end. -%% Full Fisher-Yates shuffle: builds result from index Len-1 downto 1. -shuffle_loop(_Randomizer, Arr, I) when I =< 0 -> +%% FSharp.Core's forward Fisher-Yates shuffle. +shuffle_loop(_Randomizer, Arr, Len, I) when I >= Len - 1 -> erlang:tuple_to_list(Arr); -shuffle_loop(Randomizer, Arr, I) -> +shuffle_loop(Randomizer, Arr, Len, I) -> + J = random_next(Randomizer, I, Len), + EI = erlang:element(I + 1, Arr), + EJ = erlang:element(J + 1, Arr), + Arr2 = erlang:setelement(I + 1, erlang:setelement(J + 1, Arr, EI), EJ), + shuffle_loop(Randomizer, Arr2, Len, I + 1). + +%% Like FSharp.Core's Random.next: an index in [Min, Max) from a randomizer result in [0.0, 1.0). +random_next(Randomizer, Min, Max) -> R = Randomizer(ok), if R < 0.0; R >= 1.0 -> randomizer_out_of_range(R); true -> - J = erlang:trunc(R * (I + 1)), - EI = erlang:element(I + 1, Arr), - EJ = erlang:element(J + 1, Arr), - Arr2 = erlang:setelement(I + 1, erlang:setelement(J + 1, Arr, EI), EJ), - shuffle_loop(Randomizer, Arr2, I - 1) + erlang:trunc(R * (Max - Min)) + Min end. +negative_count(Count) -> + invalid_arg(<<"count">>, <<"The input must be non-negative.\ncount = ", (erlang:integer_to_binary(Count))/binary>>). + randomizer_out_of_range(R) -> erlang:error(#{ exn_type => argument_out_of_range_exception, diff --git a/src/fable-library-beam/fable_random.erl b/src/fable-library-beam/fable_random.erl index dd6e1be1e0..92390ed102 100644 --- a/src/fable-library-beam/fable_random.erl +++ b/src/fable-library-beam/fable_random.erl @@ -3,6 +3,7 @@ -export([ new/0, new_seeded/1, + shared/0, next/1, next/2, next/3, @@ -27,6 +28,18 @@ new_seeded(Seed) -> put(Ref, rand:seed_s(exsss, Seed)), Ref. +%% Random.Shared: one unseeded instance per process. +-spec shared() -> reference(). +shared() -> + case get(fable_random_shared) of + undefined -> + Ref = new(), + put(fable_random_shared, Ref), + Ref; + Ref -> + Ref + end. + %% Random.Next() — returns a non-negative integer less than Int32.MaxValue. -spec next(reference()) -> non_neg_integer(). next(Ref) -> @@ -36,24 +49,29 @@ next(Ref) -> %% Random.Next(maxValue) — returns a non-negative integer less than maxValue. -spec next(reference(), integer()) -> non_neg_integer(). +next(_Ref, MaxValue) when MaxValue < 0 -> + out_of_range( + <<"maxValue">>, + <<"maxValue ('", (erlang:integer_to_binary(MaxValue))/binary, "') must be a non-negative value.">> + ); next(_Ref, 0) -> 0; -next(Ref, MaxValue) when MaxValue > 0 -> +next(Ref, MaxValue) -> {Val, NewState} = rand:uniform_s(MaxValue, get(Ref)), put(Ref, NewState), - Val - 1; -next(_, _) -> - erlang:error(badarg). + Val - 1. %% Random.Next(minValue, maxValue) — returns an integer in [minValue, maxValue). -spec next(reference(), integer(), integer()) -> integer(). -next(Ref, MinValue, MaxValue) when MaxValue > MinValue -> +next(_Ref, MinValue, MaxValue) when MinValue > MaxValue -> + out_of_range(<<"minValue">>, <<"'minValue' cannot be greater than maxValue.">>); +next(_Ref, MinValue, MinValue) -> + MinValue; +next(Ref, MinValue, MaxValue) -> Range = MaxValue - MinValue, {Val, NewState} = rand:uniform_s(Range, get(Ref)), put(Ref, NewState), - MinValue + Val - 1; -next(_, _, _) -> - erlang:error(badarg). + MinValue + Val - 1. %% Random.NextDouble() — returns a float in [0.0, 1.0). -spec next_double(reference()) -> float(). @@ -76,3 +94,9 @@ fill_random_bytes(Ref, AtomicsRef, Remaining, Idx) -> put(Ref, NewState), atomics:put(AtomicsRef, Idx, Val - 1), fill_random_bytes(Ref, AtomicsRef, Remaining - 1, Idx + 1). + +out_of_range(ParamName, Message) -> + erlang:error(#{ + exn_type => argument_out_of_range_exception, + message => fable_utils:argument_exception_message(Message, ParamName) + }). diff --git a/tests/Beam/ArrayTests.fs b/tests/Beam/ArrayTests.fs index 36cc63eb54..4766c9198e 100644 --- a/tests/Beam/ArrayTests.fs +++ b/tests/Beam/ArrayTests.fs @@ -963,6 +963,41 @@ let ``test Array.randomSampleBy works`` () = throwsAnyError (fun () -> Array.randomSampleBy (fun () -> 1.0) 1 [|1; 2|] |> ignore) throwsAnyError (fun () -> Array.randomSampleBy (fun () -> -0.1) 1 [|1; 2|] |> ignore) +[] +let ``test Array.randomShuffleBy matches FSharp.Core`` () = + Array.randomShuffleBy (fun () -> 0.0) [|0 .. 4|] |> equal [|0; 1; 2; 3; 4|] + Array.randomShuffleBy (fun () -> 0.999) [|0 .. 4|] |> equal [|4; 0; 1; 2; 3|] + let xs = [|0 .. 4|] + Array.randomShuffleInPlaceBy (fun () -> 0.999) xs + xs |> equal [|4; 0; 1; 2; 3|] + let mutable calls = 0 + let next () = + calls <- calls + 1 + float ((calls * 37) % 100) / 100.0 + Array.randomShuffleBy next [|0 .. 9|] |> equal [|3; 7; 2; 6; 9; 0; 8; 4; 5; 1|] + calls |> equal 9 + +[] +let ``test Array.randomSampleBy matches FSharp.Core`` () = + Array.randomSampleBy (fun () -> 0.0) 3 [|0 .. 4|] |> equal [|0; 4; 3|] + Array.randomSampleBy (fun () -> 0.999) 3 [|0 .. 4|] |> equal [|4; 3; 2|] + Array.randomSampleBy (fun () -> 0.0) 0 ([||]: int[]) |> equal [||] + // Above the max set size it draws again on a repeated index + let values = [|0.0; 0.0; 0.5|] + let mutable calls = 0 + let next () = + calls <- calls + 1 + values[calls - 1] + Array.randomSampleBy next 2 [|0 .. 21|] |> equal [|0; 11|] + calls |> equal 3 + +[] +let ``test Array random functions give FSharp.Core's errors`` () = + throwsAnyError (fun () -> Array.randomShuffleBy (fun () -> nan) [|1; 2|]) + raisedExceptionType (fun () -> Array.randomChoiceBy (fun () -> 1.0) [|1; 2|]) |> equal "ArgumentOutOfRangeException" + throwsErrorContaining "The input must be non-negative.\ncount = -1" (fun () -> Array.randomSampleBy (fun () -> 0.0) -1 [|1|]) + throwsErrorContaining "The input must be non-negative.\ncount = -1" (fun () -> Array.randomChoicesBy (fun () -> 0.0) -1 [|1|]) + // --- CompareWith --- [] diff --git a/tests/Beam/ListTests.fs b/tests/Beam/ListTests.fs index 498beca6e1..a4b0aeab59 100644 --- a/tests/Beam/ListTests.fs +++ b/tests/Beam/ListTests.fs @@ -690,6 +690,12 @@ let ``test List.randomSampleBy works`` () = throwsAnyError (fun () -> List.randomSampleBy (fun () -> 1.0) 1 [1; 2] |> ignore) throwsAnyError (fun () -> List.randomSampleBy (fun () -> -0.1) 1 [1; 2] |> ignore) +[] +let ``test List random functions match FSharp.Core`` () = + List.randomShuffleBy (fun () -> 0.999) [0 .. 4] |> equal [4; 0; 1; 2; 3] + List.randomSampleBy (fun () -> 0.0) 3 [0 .. 4] |> equal [0; 4; 3] + throwsErrorContaining "The input sequence was empty." (fun () -> List.randomChoiceWith (System.Random(1)) ([]: int list)) + [] let ``test List.updateAt works`` () = equal [0; 2; 3; 4; 5] (List.updateAt 0 0 [1..5]) diff --git a/tests/Beam/RandomTests.fs b/tests/Beam/RandomTests.fs index 06eeacfe95..0f5a3091ef 100644 --- a/tests/Beam/RandomTests.fs +++ b/tests/Beam/RandomTests.fs @@ -112,3 +112,20 @@ let ``test System.Random.NextBytes seeded works`` () = Random(5432).NextBytes(buffer1) Random(5432).NextBytes(buffer2) buffer1 |> equal buffer2 + +[] +let ``test System.Random gives .NET's argument errors`` () = + for rnd in [Random(42); Random()] do + throwsErrorContaining "maxValue ('-1') must be a non-negative value. (Parameter 'maxValue')" (fun () -> rnd.Next(-1)) + throwsErrorContaining "'minValue' cannot be greater than maxValue. (Parameter 'minValue')" (fun () -> rnd.Next(5, 3)) + raisedExceptionType (fun () -> rnd.Next(-1)) |> equal "ArgumentOutOfRangeException" + raisedExceptionType (fun () -> rnd.Next(5, 3)) |> equal "ArgumentOutOfRangeException" + rnd.Next(5, 5) |> equal 5 + +[] +let ``test System.Random.Shared works`` () = + let x = Random.Shared.Next(5) + (x >= 0 && x < 5) |> equal true + let x = Random.Shared.NextDouble() + (x >= 0.0 && x < 1.0) |> equal true + obj.ReferenceEquals(Random.Shared, Random.Shared) |> equal true diff --git a/tests/Beam/SeqTests.fs b/tests/Beam/SeqTests.fs index ea637949d3..65b759bcfb 100644 --- a/tests/Beam/SeqTests.fs +++ b/tests/Beam/SeqTests.fs @@ -622,6 +622,20 @@ let ``test Seq.randomSampleBy works`` () = throwsAnyError (fun () -> Seq.randomSampleBy (fun () -> 1.0) 1 [1; 2] |> Seq.toArray |> ignore) throwsAnyError (fun () -> Seq.randomSampleBy (fun () -> -0.1) 1 [1; 2] |> Seq.toArray |> ignore) +[] +let ``test Seq.randomChoicesBy draws on enumeration`` () = + let mutable calls = 0 + let xs = Seq.randomChoicesBy (fun () -> calls <- calls + 1; 0.5) 3 [0 .. 9] + calls |> equal 0 + xs |> Seq.toList |> equal [5; 5; 5] + xs |> Seq.toList |> equal [5; 5; 5] + calls |> equal 6 + let rng = System.Random(42) + let ys = Seq.randomChoicesWith rng 3 [0 .. 9] + rng.Next() |> equal (System.Random(42).Next()) + ys |> Seq.length |> equal 3 + throwsErrorContaining "The input must be non-negative.\ncount = -1" (fun () -> Seq.randomChoicesWith rng -1 [1]) + [] let ``test Seq.average works`` () = [1.0; 2.0; 3.0] |> Seq.average |> equal 2.0