5
\$\begingroup\$

This is a wrapper I wrote for math.random(). I have a larger program that will create a JSON file to 'load' from when I want to resume. However my program heavily used math.random() which made it hard to debug and test the program because different results would occur depending on if the program was started fresh, or from a load file.

This wrapper lets you "resume" from a random seed.

RandomNumber.lua

--[[
 RandomNumber - Lua Class for Generating and Managing Random Numbers
 The RandomNumber class is designed to facilitate the generation of random numbers
 using Lua's built-in math.random function. It includes features for tracking the
 number of generated random numbers and the ability to 'jump' to a specific iteration.
 This functionality is particularly useful for debugging and testing. Example if
 a program is to save its state and 'resume' and we want numbers generated to not
 reset,
 Usage:
 local rng = RandomNumber:new(seed) -- Instantiate class, 'seed' is optional but suggested
 rng:jumpToIteration(10) -- Jump to the 10th iteration. Next generate() method will be the 11th
 rng:generate() -- generate a random number
--]]
---@class RandomNumber
local RandomNumber = {}
-- Wrapper for math.random() in case we want to change in the future
local function generateRandomNumber(a, b)
 if a and b then
 return math.random(a, b)
 elseif a then
 return math.random(a)
 end
 return math.random()
end
---@return RandomNumber
function RandomNumber:new(seed)
 ---@type RandomNumber
 local randomNumber = {}
 self = self or randomNumber
 self.__index = self
 setmetatable(randomNumber, self)
 randomNumber.count = 0
 if seed then
 math.randomseed(seed)
 randomNumber.seed = seed
 end
 return randomNumber
end
-- Reset the RandomNumber object with a new seed.
---@param seed number
function RandomNumber:reset(seed)
 math.randomseed(seed)
 self.count = 0
 self.seed = seed
end
function RandomNumber:generate(a, b)
 self.count = self.count + 1
 return generateRandomNumber(a, b)
end
-- Jumps to a point in the random number sequence.
-- Warning: Will produce new random seed if no seed was ever given
---@param iteration number
function RandomNumber:jumpToIteration(iteration)
 if type(iteration) ~= "number" or iteration < 0 then
 error("Invalid argument, expected a number 0 or greater: " .. iteration)
 end
 local difference = iteration - self.count
 if difference > 0 then
 for _ = 1, difference do
 generateRandomNumber()
 end
 elseif difference < 0 then
 self = RandomNumber:new(self.seed)
 self:jumpToIteration(iteration)
 end
 self.count = iteration
end
return RandomNumber

RandomNumberTest.lua:

-- Import LuaUnit module
local lu = require('luaunit')
-- Import the RandomNumber class
local RandomNumber = require('RandomNumber')
-- Test the RandomNumber class
TestRandomNumber = {}
---@class RandomNumber
local rng
local firstNumberInSeq = '0.23145237586596'
local secondNumberInSeq = '0.58485671559801'
local hundredthNumberInSeq = '0.43037202063051'
function TestRandomNumber:setUp()
 -- Set a fixed seed value for reproducibility in tests
 rng = RandomNumber:new(12345)
end
-- Function to round a number to a specified decimal place
local function round(num, numDecimalPlaces)
 local mult = 10^(numDecimalPlaces or 0)
 return math.floor(num * mult + 0.5) / mult
end
function TestRandomNumber:testGenerate()
 -- use tostring method, otherwise the comparison fails between floats
 -- Numbers are based on math.random(), given seed 12345
 lu.assertEquals(tostring(rng:generate()), firstNumberInSeq)
 lu.assertEquals(tostring(rng:generate()), secondNumberInSeq)
end
function TestRandomNumber:testJump()
 rng:jumpToIteration(99)
 lu.assertEquals(tostring(rng:generate()), hundredthNumberInSeq)
end
function TestRandomNumber:testGenerateAndJump()
 for _=1, 50 do
 rng:generate()
 end
 rng:jumpToIteration(99)
 lu.assertEquals(tostring(rng:generate()), hundredthNumberInSeq)
end
function TestRandomNumber:testJumpBackwards()
 for _=1, 50 do
 rng:generate()
 end
 rng:jumpToIteration(1)
 lu.assertEquals(tostring(rng:generate()), secondNumberInSeq)
end
function TestRandomNumber:testJumpZero()
 for _=1, 99 do
 rng:generate()
 end
 -- jump to the iteration 0
 rng:jumpToIteration(0)
 -- Should be 1st
 lu.assertEquals(tostring(rng:generate()), firstNumberInSeq)
end
function TestRandomNumber:testJumpToSameSpot()
 for _=1, 99 do
 rng:generate()
 end
 rng:jumpToIteration(99)
 lu.assertEquals(tostring(rng:generate()), hundredthNumberInSeq)
end
function TestRandomNumber:testReset()
 rng:generate()
 rng:jumpToIteration(105)
 rng:reset(12345)
 lu.assertEquals(tostring(rng:generate()), firstNumberInSeq)
 lu.assertEquals(tostring(rng:generate()), secondNumberInSeq)
end
function TestRandomNumber:testJumpToIterationInvalidArgumentNegative()
 -- Use pcall to catch the error
 local success, errorMessage = pcall(function()
 rng:jumpToIteration(-1)
 end)
 -- Check that pcall was not successful (error was thrown)
 lu.assertFalse(success)
 -- Check that the error message is as expected
 lu.assertStrContains(errorMessage, "Invalid argument")
end
function TestRandomNumber:testJumpToIterationInvalidArgumentNaN()
 -- Use pcall to catch the error
 local success, errorMessage = pcall(function()
 rng:jumpToIteration('11')
 end)
 -- Check that pcall was not successful (error was thrown)
 lu.assertFalse(success)
 -- Check that the error message is as expected
 lu.assertStrContains(errorMessage, "Invalid argument")
end
function TestRandomNumber:testJumpToIterationNilSeed()
 local rngTarget = RandomNumber:new()
 lu.assertNil(rngTarget.seed)
 rngTarget:generate()
 rngTarget:generate()
 rngTarget:jumpToIteration(0)
 lu.assertNil(rngTarget.seed)
 rngTarget:generate()
 rngTarget:generate()
end
function TestRandomNumber:testJumpToIterationWithSeed()
 local rngTarget = RandomNumber:new(1550)
 lu.assertEquals(1550, rngTarget.seed)
 local firstValue = rngTarget:generate()
 local secondValue = rngTarget:generate()
 rngTarget:jumpToIteration(0)
 lu.assertEquals(1550, rngTarget.seed)
 local newValue1 = rngTarget:generate()
 local newValue2 = rngTarget:generate()
 lu.assertEquals(firstValue, newValue1)
 lu.assertEquals(secondValue, newValue2)
end
-- Run the tests
os.exit(lu.LuaUnit.run())

random-number-1.0.0-2.rockspec (Included in case suggestions can be made to it)

package = "random-number"
version = "1.0.0-2"
source = {
 url = "git://github.com/LionelBergen/RandomNumber",
 tag = "master",
}
description = {
 summary = "RandomNumber generator and manager.",
 detailed = [[
 This is a wrapper for Lua's builtin math.random. Purpose is for debugging & testing applications
 that use random numbers. Having numbers be reproduced the same way makes for easier testing & debugging
 ]],
 homepage = "https://github.com/LionelBergen/RandomNumber",
}
dependencies = {
 "lua >= 5.1",
}
build = {
 ["type"] = "builtin",
 modules = {
 randomnumber = "RandomNumber.lua"
 }
}
asked Jan 23, 2024 at 17:36
\$\endgroup\$
0

1 Answer 1

4
\$\begingroup\$

The main issue: side effects

The main issue is that you are using a random which there is only a single seed, shared between any code running on the platform. This is a questionable design by Lua (and many other runtimes) to start with, but it does mean that the state of your program may be influenced by calls to math.random() or math.randomseed(x) that simply skip your wrapper.

Moreover, the calls to randomseed(x) may influence other parts that rely on the random number generator, leading to weird interactions between code that does use the wrapper and code that doesn't. In other words, your code has significant side effects on the state of the runtime.

One way would be to implement a RNG yourself, preferably the same one that Lua uses at the moment. In that case the results from your RandomNumber and math.random() are decoupled.

Implementation code

RandomNumber - Lua Class for Generating and Managing Random Numbers

Just the name RandomNumber nor the first line clearly shows what the class can be used for. A more clear name should be considered, such as RepeatableRandomNumber.


function RandomNumber:jumpToIteration(iteration)

It is unclear how the user would be able to know the iteration, as calls to any code may increase the iteration. Furthermore, this code may skip your interesting wrapper entirely.

It would be a good idea to at least to be able to retrieve the count that is kept so that the user can retrieve the iteration before or after retrieving a random at a specific point.


function RandomNumber:generate(a, b)
local function generateRandomNumber(a, b)

I'm wondering why the parameter names are called a and b while they are called m and n in the Lua documentation of math.random. That just makes things harder to understand.

Similarly, I'm not sure why there would be a count which counts the iterations. It might as well be called iteration in my opinion, so that there is just one term to deal with.


Missing documentation of:

function RandomNumber:generate(a, b)

The documentation of this method is missing. It should indicate that the iteration count is increased, and it should both summarize and reference the documentation for math.random. This is the main function of the library and although it may be clear to you what the function does, it should at least greet any other user with easy to understand documentation.

Test code

Beware that the Lua documentation doesn't seem to explicitly indicate a specific RNG implementation, just saying that "This function is an interface to the underling pseudo-random generator function provided by C." which is extremely loose. That also means that storing expected values within floating point in the test code is dangerous, as any change in the underlying implementation will result in different values being generated.

In general these kind of pseudo number generators will produce specific ranges of random numbers as long as the runtime implementation implements the same algorithms.


local function round(num, numDecimalPlaces)

This is an unused function. I'm not sure why it would be in the test code. There is no need to test the correctness of the random numbers if this is just a wrapper; I'd just test if the minimum and maximum values can indeed be returned (obviously from a small range).


lu.assertEquals(firstValue, newValue1)

Yes, that trap was setup by yourself :). If you'd just named it this way firstvalue -> a1 and secondValue -> b1 then you could have named the second run a2 and b2.

General remarks

Naming and code style seems OK, other than the remarks that have already been made.

Tests seem to take boundary values into account and cover most if not all of the code, which is generally a good idea.

answered Feb 17, 2024 at 5:34
\$\endgroup\$
3
  • \$\begingroup\$ THANK YOU! There is not one word in your review that I didn't find useful :-) \$\endgroup\$ Commented Feb 18, 2024 at 0:03
  • 1
    \$\begingroup\$ You're welcome. Made a slight change about the runtime, it's of course not the case that every change of the runtime will change the implementation of the system RNG :) \$\endgroup\$ Commented Feb 18, 2024 at 1:24
  • \$\begingroup\$ Fair enough, I've left the implementation as math.random for now, but I plan to change it in the future. Tbh I'm not sure how without straight up copying someone else's random number implementation. Here is the updated code in case you're interested: github.com/LionelBergen/RepeatableRandomNumber \$\endgroup\$ Commented Feb 18, 2024 at 8:35

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.