--- anarchy.lua -- @module anarchy -- Reversible chaos library (version for Lua 5.3+). -- @author Peter Mawhorter -- @copyright 2025 Peter Mawhorter -- @release 1.0-1 -- -- This version uses the native integers available in Lua versions 5.3+, -- which are 64-bit integers by default or 32-bit integers if your Lua -- was compiled with only 32-bit integer support. -- -- If you want to use Lua 5.1, 5.2 or LuaJIT, use the 'anarchy32' -- library instead, although performance and probably quality will be -- worse. -- -- See more details [in the README -- file](https://cs.wellesley.edu/~pmwh/anarchy/lua/). -- Local refs for math functions local min = math.min local log = math.log local floor = math.floor local ceil = math.ceil -- Determine number of ID bytes by packing a single integer and measuring -- the number of bytes in the result. local ID_BYTES = #string.pack("J", 0) local ID_BITS = ID_BYTES * 8 local LEFTMOST_BIT = 1 << (ID_BITS - 1) -- Set up module table local anarchy = { version = 1.0, ID_BITS = ID_BITS, ID_BYTES = ID_BYTES, LEFTMOST_BIT = LEFTMOST_BIT, util = {}, rng = {}, cohort = {}, distribution = {}, } --- Utility Functions. -- Helper functions that other categories make use of. -- @section util --- Modulus that's always positive -- Use % operator instead in Lua since it has the same behavior. -- Included for compatibility with anarchy implementations in other -- languages. -- @tparam number num The number to compute the modulus of. -- @tparam number base The base of the modulus. -- @treturn number The number modulo the base. anarchy.util.posmod = function (num, base) return num % base end local posmod = anarchy.util.posmod --- A string hashing function. -- See: https://stackoverflow.com/questions/7616461/generate-a-hash-from-string-in-javascript-jquery -- @tparam string s The string to hash -- @treturn number The hash for that string anarchy.util.hash_string = function(s) local hash = 0 local i local chr if #s == 0 then return hash end for i = 1,#s do chr = s:byte(i) hash = (hash * 31) + chr end return hash end local hash_string = anarchy.util.hash_string --- Creates a mask with the given number of 1 bits, up to -- `anarchy.ID_BITS` maximum (any larger value gives all-1s, same as if -- you had given `anarchy.ID_BITS`). -- @tparam number bits The number of 1 bits to put in the mask. -- @treturn number A value with that many 1 bits starting from the -- least significant place. anarchy.util.mask = function(bits) return (1 << bits) - 1 end local mask = anarchy.util.mask --- Returns a mask that covers just the nth byte (zero-indexed), starting -- from the least-significant digits. -- Returns 0 if n is >= `anarchy.ID_BYTES`. -- @tparam number n The byte index (starting from 0) -- @treturn number The mask with 1s in that byte and 0s elsewhere anarchy.util.byte_mask = function(n) return (1 << (n*8)) * 255 end local byte_mask = anarchy.util.byte_mask --- Random Number Generator Functions. -- These functions help support the `anarchy.rng.prng` pseudo-random -- number generator function, or otherwise deal with generating -- sequences of pseudo-random individual numbers, or with generating -- pseudo-random numbers from specific integer or floating-point -- distributions based on a pseudo-random integer seed. -- @section rng --- Circular bit shift to the right -- Distance is capped at 3/4 of ID_BITS -- @tparam number x The number to swirl. -- @tparam number distance How far to swirl it. -- @treturn number The swirled number. anarchy.rng.swirl = function(x, distance) distance = distance % floor(3 * ID_BITS / 4) local m = mask(distance) local fall_off = x & m local shift_by = (ID_BITS - distance) return ( (x >> distance) | (fall_off << shift_by) ) end local swirl = anarchy.rng.swirl --- Circular bit shift to the left inverse of swirl -- Distance is capped at 3/4 of ID_BITS -- @tparam number x The number to unswirl. -- @tparam number distance How far to unswirl it. -- @treturn number The unswirled number. anarchy.rng.rev_swirl = function(x, distance) distance = distance % floor(3 * ID_BITS / 4) local rest = ID_BITS - distance local edge = mask(distance) << rest local around = (x & edge) >> rest return x << distance | around end local rev_swirl = anarchy.rng.rev_swirl --- Folds lower bits into upper bits using xor. -- fold is its own inverse. -- 'where' is restricted to fall between 1/4 and 1/2 of ID_BITS. -- @tparam number x the number to fold. -- @tparam number where which tit to fold at anarchy.rng.fold = function(x, where) local quarter = floor(ID_BITS / 4); where = where % quarter + quarter local m = mask(where) local lower = x & m local shift_by = ID_BITS - where return x ~ (lower << shift_by) end local fold = anarchy.rng.fold -- Left half of each byte; used for flopping by swapping these bits with -- the right half of each byte. local FLOP_MASK = 0xf0 -- Extend byte-by-byte until we hit max # of bytes (detected when a 1 -- shows up in the leftmost bit). while (FLOP_MASK & LEFTMOST_BIT) == 0 do FLOP_MASK = (FLOP_MASK << 8) | FLOP_MASK end anarchy.rng.FLOP_MASK = FLOP_MASK --- Flops each 1/2 byte with the adjacent 1/2 byte. -- flop is its own inverse. -- @tparam number x the number to flop -- @treturn number The flopped number anarchy.rng.flop = function(x) local left = x & FLOP_MASK local right = x & ~FLOP_MASK return (left >> 4) | (right << 4) end local flop = anarchy.rng.flop -- Note that none of the bits in the SCRAMBLE_TRIGGER is adjacent to a -- bit in the SCRAMBLE_SET. This allows us to check even after -- scrambling whether the trigger would have activated beforehand. local SCRAMBLE_TRIGGER = 0x80200003 local SCRAMBLE_SET = 0x03040610 -- Extend one byte at a time to fill available bit width by copying 4th -- byte into 1st position. So for 8 bytes we'll get 2 copies of this -- pattern. This may fail i if ID_BITS % 8 == 0 then while SCRAMBLE_TRIGGER & LEFTMOST_BIT == 0 do SCRAMBLE_TRIGGER = ( ((SCRAMBLE_TRIGGER >> 24) & 0xff) | (SCRAMBLE_TRIGGER << 8) ) SCRAMBLE_SET = ( ((SCRAMBLE_SET >> 24) & 0xff) | (SCRAMBLE_SET << 8) ) end end anarchy.rng.SCRAMBLE_TRIGGER = SCRAMBLE_TRIGGER anarchy.rng.SCRAMBLE_SET = SCRAMBLE_SET --- Implements a reversible linear-feedback-shift-register-like operation. -- @tparam number x the number to scramble. -- @treturn number the scrambled number anarchy.rng.scramble = function(x) -- trigger if we have a 1 in one of several specific bits local trigger = ((x & SCRAMBLE_TRIGGER) ~= 0) -- circular shift right local fall = x & 1 local r = (x >> 1) | (fall << (ID_BITS - 1)) -- XOR with constant if we hit a trigger bit, adding/changing bits if trigger then -- Note: This constant has no bits set next to bits of our -- trigger mask, so that even after this operation, if we -- left-shift by 1, the trigger boolean will get the same value. -- This is key to reversibility. r = r ~ SCRAMBLE_SET end return r end local scramble = anarchy.rng.scramble --- Inverse of scramble (see above). -- @tparam number x the number to unscramble -- @treturn number the unscrambled number anarchy.rng.rev_scramble = function(x) local around = (x & LEFTMOST_BIT) >> (ID_BITS - 1) local pr = x << 1 | around -- Same trigger mask as in scramble, because we know that scramble -- didn't mess with any of the bits the trigger relies on. local trigger = ((pr & SCRAMBLE_TRIGGER) ~= 0) if trigger then pr = pr ~ (SCRAMBLE_SET << 1) end return pr end local rev_scramble = anarchy.rng.rev_scramble --- Scrambles a seed value to help separate RNG sequences generated from -- sequential seeds. -- @tparam number s the seed to scramble -- @treturn number the scrambled seed anarchy.rng.scramble_seed = function(s) s = (s + 1) * (3 + s % 23) s = fold(s, 11) -- prime s = scramble(s) s = swirl(s, s + 23) -- prime s = scramble(s) s = s ~ ((s % 153) * scramble(s)) return s; end local scramble_seed = anarchy.rng.scramble_seed --- A simple reversible pseudo-random number generator -- You can call `anarchy.rng.rev_prng` with the result of this -- function and the same seed to get back the number you put in here, -- so that results form a chain you can traverse forwards or backwards. -- @tparam number x the current/previous number -- @tparam number seed the generation sequence seed (this can be -- constant for a bunch of calls) -- @treturn number the next number in the generation sequence anarchy.rng.prng = function(x, seed) -- seed scrambling: seed = scramble_seed(seed) -- value scrambling: x = x ~ seed x = scramble(x) x = fold(x, seed + 17) -- prime x = flop(x) x = swirl(x, seed + 37) -- prime x = fold(x, seed + 89) -- prime x = swirl(x, seed + 107) -- prime x = scramble(x) return x end local prng = anarchy.rng.prng --- Inverse of `anarchy.rng.prng` (see above). -- @tparam number x the current/next number -- @tparam number seed the generation seed that determines the sequence -- @treturn number the previous number in the generation sequence anarchy.rng.rev_prng = function(x, seed) -- seed scrambling: seed = scramble_seed(seed) -- value unscrambling: x = rev_scramble(x) x = rev_swirl(x, seed + 107) -- prime x = fold(x, seed + 89) -- prime x = rev_swirl(x, seed + 37) -- prime x = flop(x) x = fold(x, seed + 17) -- prime -- TODO: This extra scramble helps pass flip tests but makes cycle -- tests worse... x = rev_scramble(x) x = x ~ seed return x end local rev_prng = anarchy.rng.rev_prng -- For 32-bit version, same as the scramble trigger we use bits 32, 22, -- 2, and 1. See this PDF table of max-length LFSR parameters: -- http://courses.cse.tamu.edu/walker/csce680/lfsr_table.pdf local LFSR_BITS = 0x80200003 if ID_BYTES == 8 then -- For 64-bit version, we use bits 64, 63, 61, and 60. LFSR_BITS = 0xe800000000000000 end -- We won't worry here about bit widths other than 32 or 64, which should -- be the only two ones we'll see. Other bit widths might not be -- maximum-length cycles given these values. anarchy.rng.LFSR_BITS = LFSR_BITS --- Implements a max-cycle-length 32-bit linear feedback shift register. -- See: https://en.wikipedia.org/wiki/Linear-feedback_shift_register -- Note that this is NOT reversible! -- Note: Do not use this as an irreversible PRNG; it's a terrible one! -- Note: Zero is a fixed point of this function: do not use it as -- your seed! -- @tparam number x the current number -- @treturn number the next number in the LFSR sequence anarchy.rng.lfsr = function(x) -- Note: the SCRAMBLE_TRIGGER has bits 32, 22, 2, 1 set, or if we're -- using 64-bit integers, bits 64, 54, 34, 33, 32, 22, 2, and 1. return (x >> 1) ~ (LFSR_BITS * (x & 1)) end local lfsr = anarchy.rng.lfsr -- Note: We set the sign bit for U_DIVISOR in both cases to 0 to avoid -- any unexpected surprises about signed vs. unsigned values. local U_DIVISOR = 0x7fffffff -- largest prime below 2^31 is 2^31 - 1 local U_SHIFT = 16 -- half the bits if ID_BYTES == 8 then U_DIVISOR = 0x7fffffffffffffe7 -- largest prime below 2^63 U_SHIFT = 32 end anarchy.rng.U_DIVISOR = U_DIVISOR --- Generates a random number between 0 and 1 given a seed value. -- @tparam number seed The seed that determines the result. -- @treturn number A number between 0 (inclusive) and 1 (exclusive) anarchy.rng.uniform = function(seed) local sc = seed ~ (seed << U_SHIFT) local ux = prng(prng(prng(sc, 53), sc), seed) return (ux % U_DIVISOR) / U_DIVISOR end local uniform = anarchy.rng.uniform --- Generates and averages three random numbers between 0 and 1 to give a -- pseudo-gaussian-distributed random number (still strictly on [0, 1) ) -- @tparam number seed The seed that determines the result. -- @treturn number A number between 0 (inclusive) and 1 (exclusive) -- which is more likely to be near 0.5 than near either 0 or 1. anarchy.rng.normalish = function(seed) local t = 0 t = t + uniform(seed + 9182183) t = t + uniform(seed + 7438742) t = t + uniform(seed + 4683129) return t/3 end local normalish = anarchy.rng.normalish --- Flips a coin with probability p of being True. Using the same seed -- always gives the same result. -- @tparam number p The probability that the result should be True when -- results are averaged across many different seeds. -- @tparam number seed The seed that determines the result. -- @treturn boolean Either true (with probability p when averaged -- across seeds) or false (with probability 1-p when averaged -- across seeds). anarchy.rng.flip = function(p, seed) return uniform(seed) < p end local flip = anarchy.rng.flip --- Even distribution over the given integer range, including the start -- but excluding the cap (even if the cap is lower than the start). -- The last number before the cap is about 1/`anarchy.rng.U_DIVISOR` less -- likely to be generated than the other numbers. -- -- Note: Why route integer stuff through uniform instead of just using a -- modulus which would be faster? Because we have little idea how -- selective biased lfsr and/or prng values might be for an arbitrary -- modulus. Our prng is almost certainly NOT capable of producing every -- 64-bit number, for example, and might even have some short cycles out -- there somewhere. -- -- @tparam number seed The seed that determines the result. -- @tparam number start The minimum allowed result value. -- @tparam number cap The result limit (will NOT ever be generated itself). -- @treturn number An integer between start (inclusive) and cap (exclusive). anarchy.rng.integer = function(seed, start, cap) return floor(uniform(seed) * (cap - start)) + start; end local integer = anarchy.rng.integer --- Generates a number from an exponential distribution on [0,∞) with mean -- 1/lambda given a seed. Higher values of lambda make the distribution more -- sharply exponential; values between 0.5 and 1.5 exhibit reasonable -- variation. See: -- https://math.stackexchange.com/questions/28004/random-exponential-like-distribution -- and -- https://en.wikipedia.org/wiki/Exponential_distribution -- @tparam number seed The seed that determines the result. -- @tparam number lambda The shape parameter for the distribution. -- @treturn number An arbitrarily large number (subject to the limits of the -- number type) that's much more likely to be small than large. anarchy.rng.exponential = function(seed, lambda) local u = uniform(seed) return -log(u)/lambda end local exponential = anarchy.rng.exponential --- Generates a number from a truncated exponential distribution on [0, -- 1), given a particular seed. As with expdist, the lambda parameter -- controls the shape of the distribution, and a 0.5–1.5 range is usually -- reasonable. For why this method works, see: -- https://math.stackexchange.com/questions/28004/random-exponential-like-distribution -- @tparam number seed The seed that determines the result. -- @tparam number lambda The shape parameter for the distribution. -- @treturn number A number between 0 (inclusive) and 1 (exclusive) that -- is much more likely to be small than large. anarchy.rng.truncated_exponential = function(seed, lambda) local e = exponential(seed, lambda); return e - floor(e); end local truncated_exponential = anarchy.rng.truncated_exponential --- Cohort Functions. -- These functions all deal with 'cohorts', which are arbitrary -- groupings of integers along the number line into same-size groups. -- For example, every 100 numbers might be grouped together. A function -- like `anarchy.cohort.cohort_shuffle` will randomize an input -- number's position within its cohort, such that each number's random -- result is in the same cohort it started in, but the ordering of -- numbers within each cohort is different (up to the resolution limits -- of the RNG). -- @section cohort --- Computes cohort number for the given outer index and cohort size. -- @tparam number outer An index into a sequence to be chopped into -- cohorts, starting with the beginning of the first cohort at index 1. -- @tparam number cohort_size The size of each cohort to divide the -- sequence into. -- @treturn number The index of the cohort that the outer index falls -- into. The first cohort (starting at index 1 of the outer sequence) -- has index 1. -- -- For example, if outer index is 54 and cohort_size is 10, the return -- value will be 6, since 54 falls into the 6th group of 10. anarchy.cohort.cohort = function(outer, cohort_size) return floor((outer - 1) / cohort_size) + 1 end local cohort = anarchy.cohort.cohort --- Computes within-cohort index for the given outer index and cohorts of -- the given size. -- @tparam number outer An index into a sequence to be chopped into -- cohorts, starting with the beginning of the first cohort at index 1. -- @tparam number cohort_size The size of each cohort to divide the -- sequence into. -- @treturn number The index of the given outer index within its cohort, -- starting from 1 in the first position. -- -- For example, if the outer index is 54 and the cohort_size is 10, the -- result will be 4, since 54 falls at index 4 within its group of 10 -- (starting at index 1 at 51). anarchy.cohort.cohort_inner = function(outer, cohort_size) return 1 + ((outer - 1) % cohort_size) end local cohort_inner = anarchy.cohort.cohort_inner --- Returns two results: the cohort number and inner index for the given -- outer index and cohort size. -- @tparam number outer An index into a sequence to be chopped into cohorts. -- @tparam number cohort_size The size of each cohort to divide the -- sequence into. -- @treturn multiple Multiple values: The cohort index (an integer) -- followed by the inner index within that cohort (also an integer). -- -- For example, if the outer index is 29 and the cohort_size is 10, this will -- return 3, 9 anarchy.cohort.cohort_and_inner = function(outer, cohort_size) return cohort(outer, cohort_size), cohort_inner(outer, cohort_size) end local cohort_and_inner = anarchy.cohort.cohort_and_inner --- Inverse of cohort_and_inner; computes the outer index from a cohort -- number and inner index. -- @tparam number cohort The cohort index determining which group a position -- belongs to. -- @tparam number inner The index of the position within that group. -- Should be between 1 and the cohort size, inclusive. -- @tparam number cohort_size The size of each group to divide the total -- sequence of positions into. -- @treturn number The index of that position within the total sequence of -- positions, starting from index 1. -- -- For example, if cohort is 3, inner is 1, and cohort_size is 5, then -- we're at index 1 (1st item) within the 3th group of 5 things, putting -- us at total index 5*2 + 1 = 11 outside of the groups. -- -- Note: Negative cohort and/or inner indices may result in negative -- outer indices. The inner index value is not constrained to be smaller -- than the cohort size. anarchy.cohort.cohort_outer = function(cohort, inner, cohort_size) return cohort_size * (cohort - 1) + inner end local cohort_outer = anarchy.cohort.cohort_outer --- Interleaves cohort members by folding the top half into the bottom -- half. The bottom half get even indices while the top half are assigned -- backwards to the odd indices. -- @tparam number inner The index of an item within a cohort. -- @tparam number cohort_size The size of each cohort. -- @treturn number The new within-cohort index for the same item after -- interleaving the top and bottom parts of the cohort. anarchy.cohort.cohort_interleave = function(inner, cohort_size) if inner <= floor(cohort_size/2) then return inner * 2 -- even and never > cohort_size else return ((cohort_size - inner) * 2) + 1 end end local cohort_interleave = anarchy.cohort.cohort_interleave --- Inverse interleave (see above). -- @tparam number inner The index of an item within a cohort after -- interleaving items. -- @tparam number cohort_size The size of each cohort. -- @treturn number The original index within that cohort that the item -- would have been at prior to interleaving things. anarchy.cohort.rev_cohort_interleave = function(inner, cohort_size) if inner % 2 ~= 0 then return cohort_size - floor(inner/2) else return floor(inner / 2) end end local rev_cohort_interleave = anarchy.cohort.rev_cohort_interleave --- Folds items past an arbitrary split point (in the second half of the -- cohort) into the middle of the cohort. The split will always leave an -- odd number at the end. -- @tparam number inner The index of an item within a cohort. -- @tparam number cohort_size The size of each cohort. -- @tparam number seed The seed that determines where the fold happens. -- @treturn number The new index of the item within the cohort after folding. anarchy.cohort.cohort_fold = function(inner, cohort_size, seed) local half = ceil(cohort_size / 2) local quarter = ceil(cohort_size / 4) local split = half if quarter > 0 then split = split + seed % quarter end local after = cohort_size - split split = split + ((after + 1) % 2) -- force odd leftovers after = cohort_size - split local fold_to = half - floor(after / 2) if inner < fold_to then -- first region return inner elseif inner <= split then -- second region return inner + after -- push out past fold region else -- fold region return (inner - split) + fold_to - 1 end end local cohort_fold = anarchy.cohort.cohort_fold --- Inverse fold (see above). -- @tparam number inner The index of an item within a cohort after folding. -- @tparam number cohort_size The size of each cohort. -- @tparam number seed The seed that determines where the fold happened. -- @treturn number The original index that would have been folded into -- the given position if cohort_fold were used with the same seed.. anarchy.cohort.rev_cohort_fold = function(inner, cohort_size, seed) local half = ceil(cohort_size / 2) local quarter = ceil(cohort_size / 4) local split = half if quarter > 0 then split = split + seed % quarter end local after = cohort_size - split split = split + ((after + 1) % 2) -- force an odd split point after = cohort_size - split local fold_to = half - floor(after / 2) if inner < fold_to then -- first region return inner elseif (inner < fold_to + after) then -- second region return inner - (fold_to - 1) + split else return inner - after end end local rev_cohort_fold = anarchy.cohort.rev_cohort_fold --- Applies a circular offset -- @tparam number inner The index of an item within a cohort. -- @tparam number cohort_size The size of each cohort. -- @tparam number seed The seed that determines how far to spin. -- @treturn number The index of the item after spinning. anarchy.cohort.cohort_spin = function(inner, cohort_size, seed) return ((inner - 1) + (seed % cohort_size)) % cohort_size + 1 end local cohort_spin = anarchy.cohort.cohort_spin --- Inverse spin (see above). -- @tparam number inner The index of an item within a cohort after being spun. -- @tparam number cohort_size The size of each cohort. -- @tparam number seed The seed that determined how far to spin. -- @treturn number The original index of the item before spinning. anarchy.cohort.rev_cohort_spin = function(inner, cohort_size, seed) return ( (inner - 1) + (cohort_size - (seed % cohort_size)) ) % cohort_size + 1 end local rev_cohort_spin = anarchy.cohort.rev_cohort_spin --- Flops sections (with seeded sizes) with their neighbors. -- @tparam number inner The index of an item within a cohort. -- @tparam number cohort_size The size of each cohort. -- @tparam number seed The seed that determines how big each section to -- flop is. -- @treturn number The index of the item after flopping with a neighboring -- sesction. anarchy.cohort.cohort_flop = function(inner, cohort_size, seed) local limit = floor(cohort_size / 8) if limit < 4 then limit = limit + 4 end local size = (seed % limit) + 2 local which = floor((inner - 1) / size) local in_part = (inner - 1) % size local result = 0 if which % 2 ~= 0 then result = (which - 1) * size + in_part + 1 else result = (which + 1) * size + in_part + 1 end if result > cohort_size then -- don't flop out of the cohort return inner else return result end end local cohort_flop = anarchy.cohort.cohort_flop -- flop is its own inverse --- Applies a spin to even and odd items separately with different seeds. -- @tparam number inner The index of an item within a cohort. -- @tparam number cohort_size The size of each cohort. -- @tparam number seed The seed that determines how far to spin evens & odds. -- @treturn number The index of the item after spinning the even & odd elements -- among themselves with different (derived) seeds. anarchy.cohort.cohort_mix = function(inner, cohort_size, seed) local even = inner - inner % 2 local target if inner % 2 ~= 0 then target = cohort_spin( 1 + floor(even / 2), cohort_size - floor(cohort_size / 2), seed + 464185 ) return 2 * target - 1 else target = cohort_spin( floor(even / 2), floor(cohort_size / 2), seed + 1048239 ) return 2 * target end end local cohort_mix = anarchy.cohort.cohort_mix --- Inverse mix (see above). -- @tparam number inner The index of an item within a cohort after mixing. -- @tparam number cohort_size The size of each cohort. -- @tparam number seed The seed that determined how far to spin evens & odds. -- @treturn number The index that this index would have been before mixing -- with the given seed. anarchy.cohort.rev_cohort_mix = function(inner, cohort_size, seed) local even = inner - inner % 2 local target = 0 if inner % 2 ~= 0 then target = rev_cohort_spin( 1 + floor(even / 2), cohort_size - floor(cohort_size / 2), seed + 464185 ) return 2 * target - 1 else target = rev_cohort_spin( floor(even / 2), floor(cohort_size / 2), seed + 1048239 ); return 2 * target end end local rev_cohort_mix = anarchy.cohort.rev_cohort_mix -- Constants that limit how regions can be sized in cohort_spread. local MIN_REGION_SIZE = 2; local MAX_REGION_COUNT = 16; anarchy.cohort.MIN_REGION_SIZE = MIN_REGION_SIZE; anarchy.cohort.MAX_REGION_COUNT = MAX_REGION_COUNT; --- Spreads items out between a random number of different regions within -- the cohort. -- @tparam number inner The index of an item within a cohort. -- @tparam number cohort_size The size of each cohort. -- @tparam number seed The seed that determines how many regions we use. -- @treturn number The index after spreading out indices between random -- regions. anarchy.cohort.cohort_spread = function(inner, cohort_size, seed) local min_regions = 2 if cohort_size < 2 * MIN_REGION_SIZE then min_regions = 1 end local max_regions = 1 + floor(cohort_size / MIN_REGION_SIZE) local regions = ( min_regions + ( (seed % (1 + (max_regions - min_regions))) % MAX_REGION_COUNT ) ) local region_size = floor(cohort_size / regions) local leftovers = cohort_size - (regions * region_size) local region = (inner - 1) % regions local index = floor((inner - 1) / regions) + 1 if index <= region_size then -- non-leftovers return region * region_size + index + leftovers else -- leftovers go at the front: return (inner - 1) - regions * region_size + 1 end end local cohort_spread = anarchy.cohort.cohort_spread --- Inverse spread (see above). -- @tparam number inner The index of an item within a cohort after spreading. -- @tparam number cohort_size The size of each cohort. -- @tparam number seed The seed that determined how many regions we use. -- @treturn number The index that would have been used to generate the -- provided index with a spread operation and the given seed. anarchy.cohort.rev_cohort_spread = function(inner, cohort_size, seed) local min_regions = 2 if cohort_size < 2 * MIN_REGION_SIZE then min_regions = 1 end local max_regions = 1 + floor(cohort_size / MIN_REGION_SIZE) local regions = ( min_regions + ( (seed % (1 + (max_regions - min_regions))) % MAX_REGION_COUNT ) ); local region_size = floor(cohort_size / regions); local leftovers = cohort_size - (regions * region_size); local index = floor(((inner - 1) - leftovers) / region_size) local region = ((inner - 1) - leftovers) % region_size if inner <= leftovers then -- leftovers back to the end: return regions * region_size + inner else return region * regions + 1 + index end end local rev_cohort_spread = anarchy.cohort.rev_cohort_spread --- Reverses ordering within each of several fragments. -- @tparam number inner The index of an item within a cohort. -- @tparam number cohort_size The size of each cohort. -- @tparam number seed The seed that determined how many regions we use. -- @treturn number The index after upending the order of each region. anarchy.cohort.cohort_upend = function(inner, cohort_size, seed) local min_regions = 2 if (cohort_size < 2 * MIN_REGION_SIZE) then min_regions = 1 end local max_regions = 1 + floor(cohort_size / MIN_REGION_SIZE) local regions = ( min_regions + ( (seed % (1 + (max_regions - min_regions))) % MAX_REGION_COUNT ) ) local region_size = floor(cohort_size / regions) local region = floor((inner - 1) / region_size) local index = ((inner - 1) % region_size) + 1 local result = (region * region_size) + (region_size + 1 - index) if result <= cohort_size then return result else return inner end end local cohort_upend = anarchy.cohort.cohort_upend -- Upend is its own inverse. --- Compose a bunch of the above functions to perform a nice thorough -- shuffle within a cohort. -- @tparam number inner The index of an item within a cohort. -- @tparam number cohort_size The size of each cohort. -- @tparam number seed The seed that determines where to shuffle things. -- @treturn number The position of the index after shuffling the cohort. When -- shuffling two different indices with the same cohort size and -- seed, you'll get two different results. Usually not the same as -- the original index. anarchy.cohort.cohort_shuffle = function(inner, cohort_size, seed) local r = inner seed = seed ~ cohort_size -- print("\ncs", inner, cohort_size) r = cohort_spread(r, cohort_size, seed + 457) -- prime -- io.write(" spread " .. r) r = cohort_mix(r, cohort_size, seed + 2897) -- prime -- io.write(" mix " .. r) r = cohort_interleave(r, cohort_size) -- io.write(" interleave " .. r) -- io.write("\n") r = cohort_spin(r, cohort_size, seed + 1987) -- prime -- io.write(" spin " .. r) r = cohort_upend(r, cohort_size, seed + 47) -- prime -- io.write(" upend " .. r) r = cohort_fold(r, cohort_size, seed + 839) -- prime -- io.write(" fold " .. r) r = cohort_interleave(r, cohort_size) -- io.write(" interleave " .. r) -- io.write("\n") r = cohort_flop(r, cohort_size, seed + 53) -- prime -- io.write(" flop " .. r) r = cohort_fold(r, cohort_size, seed + 211) -- prime -- io.write(" fold " .. r) r = cohort_mix(r, cohort_size, seed + 733) -- prime -- io.write(" mix " .. r) r = cohort_spread(r, cohort_size, seed + 881) -- prime -- io.write(" spread " .. r) r = cohort_interleave(r, cohort_size) -- io.write(" interleave " .. r) -- io.write("\n") r = cohort_flop(r, cohort_size, seed + 193) -- prime -- io.write(" flop " .. r) r = cohort_upend(r, cohort_size, seed + 794641) -- prime -- io.write(" upend " .. r) r = cohort_spin(r, cohort_size, seed + 19) -- prime -- io.write(" spin " .. r) -- io.write("\n") return r end local cohort_shuffle = anarchy.cohort.cohort_shuffle --- Inverse shuffle (see above). -- @tparam number inner The index of an item within a cohort after shuffling. -- @tparam number cohort_size The size of each cohort. -- @tparam number seed The seed that determined where to shuffle things. -- @treturn number The original index within the cohort that would have been -- shuffled into the specified position. anarchy.cohort.rev_cohort_shuffle = function(inner, cohort_size, seed) local r = inner seed = seed ~ cohort_size -- print("\nrcs", inner, cohort_size) r = rev_cohort_spin(r, cohort_size, seed + 19) -- prime -- io.write(" spin " .. r) r = cohort_upend(r, cohort_size, seed + 794641) -- prime -- io.write(" upend " .. r) r = cohort_flop(r, cohort_size, seed + 193) -- prime -- io.write(" flop " .. r) r = rev_cohort_interleave(r, cohort_size) -- io.write("\n") -- io.write(" interleave " .. r) r = rev_cohort_spread(r, cohort_size, seed + 881) -- prime -- io.write(" spread " .. r) r = rev_cohort_mix(r, cohort_size, seed + 733) -- prime -- io.write(" mix " .. r) r = rev_cohort_fold(r, cohort_size, seed + 211) -- prime -- io.write(" fold " .. r) r = cohort_flop(r, cohort_size, seed + 53) -- prime -- io.write(" flop " .. r) r = rev_cohort_interleave(r, cohort_size) -- io.write("\n") -- io.write(" interleave " .. r) r = rev_cohort_fold(r, cohort_size, seed + 839) -- prime -- io.write(" fold " .. r) r = cohort_upend(r, cohort_size, seed + 47) -- prime -- io.write(" upend " .. r) r = rev_cohort_spin(r, cohort_size, seed + 1987) -- prime -- io.write(" spin " .. r) r = rev_cohort_interleave(r, cohort_size); -- io.write("\n") -- io.write(" interleave " .. r) r = rev_cohort_mix(r, cohort_size, seed + 2897) -- prime -- io.write(" mix " .. r) r = rev_cohort_spread(r, cohort_size, seed + 457) -- prime -- io.write(" spread " .. r) -- io.write("\n") return r end local rev_cohort_shuffle = anarchy.cohort.rev_cohort_shuffle --- Distribution Functions. -- These functions all deal with distributing items unevenly between -- 'segments', which are a finite number of finite-sized bins. For -- example, we might want to distribute 1000 rare items among 1000000 -- rooms, each of which can hold at most 2 rare items. This section -- also includes functions for dealing with 'sumtables' which are -- tables that encode a distribution as the cumulative sum of items -- assigned to each segment (and all segments before it). -- @section distribution -- Forward declaration for recursive function local distribution_split_point --- Implements common distribution functionality: given a total item -- count, a segment count and per-segment capacity, and a roughness value -- and seed, computes the split point for the items as well as the -- halfway index for the segments. -- -- Note that per the normal Lua convention, indices for both total items -- and segments start from 1! -- -- @tparam number total The total number of items that will be -- distributed among segments. -- @tparam number n_segments The number of segments to distribute the -- items into. -- @tparam number segment_capacity The capacity of each segment. -- n_segments times segment_capacity must be >= total. -- @tparam number roughness A number between 0 and 1. When roughness is -- 0, items will be split across segments as evenly as possible. When -- roughness is 1, up to 100% of the items may be placed into the -- first or second half of the segments while recursively -- apportioning items. At roughness 0.5 the first or second half of -- the segments might get as little as 50% of what it would get when -- roughness is 0. -- @tparam number seed The seed that determines how the total gets -- distributed among the segments when roughness allows different -- distributions. -- @treturn multiple Multiple results: two numbers. The first is the -- split point for the distribution: the number out of the total -- items which end up in the first half of the segments. The second -- is the number of segments in that first half of the segments, -- which is half of n_segments, rounded down. anarchy.distribution.distribution_split_point = function( total, n_segments, segment_capacity, roughness, seed ) -- how the segments are divided: local first_half = floor(n_segments / 2) -- compute min/max split points according to roughness: local nat = floor(total * (first_half / n_segments)) local split_min = floor(nat - nat * roughness) local split_max = floor(nat + (total - nat) * roughness) -- adjust for capacity limits: if (total - split_min) > segment_capacity * (n_segments - first_half) then split_min = total - (segment_capacity * (n_segments - first_half)) end if split_max > segment_capacity * first_half then split_max = segment_capacity * first_half end -- compute a random split point: local split = nat if split_min >= split_max then split = split_min else split = split_min + ( prng(total, seed) % (split_max - split_min) ) end return split, first_half end distribution_split_point = anarchy.distribution.distribution_split_point -- Forward declaration for recursive function local distribution_items --- Given that 'total' items are to be distributed evenly among -- 'n_segment' segments each with at most 'segment_capacity' items and -- we're in segment 'segment' of those (indexed from 1), computes the -- start index (from 1) among the total of the first item in this -- segment, and how many items are in this segment. The 'roughness' -- argument should be a number between 0 and 1 indicating how even the -- distribution is: 0 indicates a perfectly even distribution, while 1 -- indicates a perfectly random distribution. Does work proportional to -- the log of the number of segments. -- -- Note that segment_capacity * n_segments should be > total. -- -- @tparam number segment Which segment we want to know the portion for, -- as an index starting from 1. -- @tparam number total The total number of items that will be -- distributed among segments. -- @tparam number n_segments The number of segments to distribute the -- items into. -- @tparam number segment_capacity The capacity of each segment. -- n_segments times segment_capacity must be >= total. -- @tparam number roughness A number between 0 and 1. When roughness is -- 0, items will be split across segments as evenly as possible. When -- roughness is 1, up to 100% of the items may be placed into the -- first or second half of the segments while recursively -- apportioning items. At roughness 0.5 the first or second half of -- the segments might get as little as 50% of what it would get when -- roughness is 0. -- @tparam number seed The seed that determines how the total gets -- distributed among the segments when roughness allows different -- distributions. -- @treturn multiple Multiple values: the start index (from 1) of the -- first item in this segment and the number of the total items that -- get distributed into the specified segment, for the given seed. -- The number of items May be as low as 0 when roughness is 1; it -- will be one of the integers adjacent to (total / n_segments) when -- roughness is 0. If the number of items is 0, then the 'start -- index' will be the same as the 'start index' for the subsequent -- segment (and if multiple segments in a row get 0 items they'll all -- share the same start index with the first segment after them that -- gets at least 1 item). anarchy.distribution.distribution_items = function( segment, total, n_segments, segment_capacity, roughness, seed ) -- base case if n_segments == 1 then return 1, total end -- compute split point: local split, half = distribution_split_point( total, n_segments, segment_capacity, roughness, seed ); -- call ourselves recursively: if segment <= half then return distribution_items( segment, split, half, segment_capacity, roughness, seed ) else local start, count = distribution_items( segment - half, total - split, n_segments - half, segment_capacity, roughness, seed ) return split + start, count end end distribution_items = anarchy.distribution.distribution_items -- Forward declaration for recursive function local distribution_segment --- Computes the segment number in which a certain item appears (one of -- the 'total' items distributed between segments; see distribution_items -- above). Requires work proportional to the log of the number of -- segments. -- -- @tparam number index Which item among the total we want to know the -- fate of, starting from 1. Must be less than or equal to the total. -- @tparam number total The total number of items that will be -- distributed among segments. -- @tparam number n_segments The number of segments to distribute the -- items into. -- @tparam number segment_capacity The capacity of each segment. -- n_segments times segment_capacity must be >= total. -- @tparam number roughness A number between 0 and 1. When roughness is -- 0, items will be split across segments as evenly as possible. When -- roughness is 1, up to 100% of the items may be placed into the -- first or second half of the segments while recursively -- apportioning items. At roughness 0.5 the first or second half of -- the segments might get as little as 50% of what it would get when -- roughness is 0. -- @tparam number seed The seed that determines how the total gets -- distributed among the segments when roughness allows different -- distributions. -- @treturn multiple Multiple values: the segment index (starting from -- 1) within which the indicated item appears, and then the index -- within that segment (starting from 1) at which it is placed. anarchy.distribution.distribution_segment = function( index, total, n_segments, segment_capacity, roughness, seed ) -- base case if n_segments == 1 then return 1, index -- we are in the only segment there is end -- compute split point: local split, half = distribution_split_point( total, n_segments, segment_capacity, roughness, seed ) -- call ourselves recursively: if index <= split then return distribution_segment( index, split, half, segment_capacity, roughness, seed ) else local segment, within = distribution_segment( index - split, total - split, n_segments - half, segment_capacity, roughness, seed ) return half + segment, within end end distribution_segment = anarchy.distribution.distribution_segment -- Forward declaration as local (see function definition below) local fill_sumtable_for_distribution --- Takes distribution parameters and returns a table containing the -- cumulative sum of items in each segment (The table indices will start -- at 1, just like the segment indices). The list contains `n_segments` -- entries, and each entry holds the sum of the items from the `total` -- assigned to that segment plus all earlier segments. This means that -- each entry is greater than or equal to the entry before it (and -- they're only equal if zero items are assigned to that segment). The -- last entry will be equal to the total. -- -- @tparam number total The total number of items being distributed. -- @tparam number n_segments The number of segments to distribute items -- among. -- @tparam number segment_capacity The capacity of each segment. -- @tparam number roughness How rough the distribution should be (0-1). -- @tparam number seed The seed that determines a specific distribution. -- -- @treturn table An array table holding cumulative sums, starting at -- index 1 with the number of items in the first segment. -- -- `n_segments` times `segment_capacity` must be >= `total` otherwise an -- error will be raised. anarchy.distribution.sumtable_for_distribution = function( total, n_segments, segment_capacity, roughness, seed ) if n_segments * segment_capacity < total then error( "Cannot distribute " .. total .. " item(s) across " .. n_segments .. " segments of size " .. segment_capacity .. ": There's not enough space for them all to fit." ) end -- No need to pre-populate table result = {} fill_sumtable_for_distribution( result, 1, 0, total, n_segments, segment_capacity, roughness, seed ) return result end local sumtable_for_distribution = ( anarchy.distribution.sumtable_for_distribution ) --- Recursively fills in the sumtable based on the distribution -- parameters given (see `sumtable_for_distribution`). Total work done -- is `n_segments` times log(`n_segments`). -- -- Note that using a sumtable instead of using the distribution -- functions with the same parameters over and over again represents a -- space/time tradeoff. Most operations on a sumtable are O(1), where -- equivalent distribution functions need O(log(n)) time. The sumtable -- needs to store one integer per segment. The use of distribution -- functions is recommended primarily in situations where the algorithm -- structure makes it inconvenient to store the sumtable in an -- accessible way, so sharing parameters is easier. -- Parameters are the same as for `sumtable_for_distribution` but with: -- -- @tparam table ledger The table to fill in. Values will be -- added/overwritten between `start` and `start` + `n_segments` - 1 -- (inclusive). -- @tparam number start The index at which to start overwriting values -- (counting from index 1). -- @tparam number prior The number of items distributed prior to the start -- index. -- -- @treturn nil No return (modifies the given ledger). anarchy.distribution.fill_sumtable_for_distribution = function( table, start, prior, total, n_segments, segment_capacity, roughness, seed ) if n_segments == 0 then -- base case: nothing to modify assert(total == 0) return elseif n_segments == 1 then -- base case: fill in one entry table[start] = prior + total else -- recurse into each half of the table local sub_prior, first_half = distribution_split_point( total, n_segments, segment_capacity, roughness, seed ) -- Fill in the first half fill_sumtable_for_distribution( table, start, prior, sub_prior, first_half, segment_capacity, roughness, seed ) -- Fill in the second half fill_sumtable_for_distribution( table, start + first_half, prior + sub_prior, total - sub_prior, n_segments - first_half, segment_capacity, roughness, seed ) end end fill_sumtable_for_distribution = ( anarchy.distribution.fill_sumtable_for_distribution ) --- Returns a sumtable for the same distribution, where entries are the -- cumulative sum of all portions up to an index, rather than just the -- portion at that index. Use functions like `sumtable_total`, -- `sumtable_segments`, `sumtable_portion`, and `sumtable_segment` to get -- information out of the sumtable efficiently. -- -- @tparam table portions The array of per-segment item counts, indexed -- starting at 1. -- -- @treturn table The sumtable that encodes the same distribution (also -- indexed from 1, like all normal Lua tables. Hahaha why would we -- even mention this?). anarchy.distribution.sumtable_from_portions = function(portions) sofar = 0 result = {} for i, p in ipairs(portions) do sofar = sofar + p result[#result + 1] = sofar end return result end local sumtable_from_portions = anarchy.distribution.sumtable_from_portions --- Returns the total number of items across all segments in a sumtable, -- which is just the last entry in that table. -- -- @tparam table sumtable The table of sums describing the distribution -- in question. -- -- @treturn number The total number of items distributed. anarchy.distribution.sumtable_total = function(sumtable) return sumtable[#sumtable] end local sumtable_total = anarchy.distribution.sumtable_total --- Returns the number of segments in the given sumtable, which is just -- its length. -- -- @tparam table sumtable The table of sums describing the distribution -- in question. -- -- @treturn number The number of segments in the distribution. anarchy.distribution.sumtable_segments = function(sumtable) return #sumtable end local sumtable_segments = anarchy.distribution.sumtable_segments --- Returns the portion of the total items which is distributed into the -- segment with the given index (starting from 1). Just needs to subtract -- the entry to the left to figure this out. Raises an error if the index -- is invalid. -- -- @tparam table sumtable The table of sums describing the distribution -- in question. -- @tparam number i The segment to compute the portion for (from 1). -- -- @treturn number The number of items distributed into the specified -- segment. anarchy.distribution.sumtable_portion = function(sumtable, i) -- First entry has nothing to subtract local here = sumtable[i] if here == nil then -- Out-of-bounds error( "Invalid index " .. i .. " for table with " .. #sumtable .. " entries." ) end if i == 1 then return here else -- In-bounds entries just subtract sums to get value return here - sumtable[i - 1] end end local sumtable_portion = anarchy.distribution.sumtable_portion --- Returns a pair containing the index of the first item in the segment -- and then the number of items in that segment, like -- `distribution_items`, but for a sumtable. Parameters: -- -- @tparam table sumtable The table of sums describing the distribution -- in question. -- @tparam number i The segment to compute the portion for (from 1). -- -- @treturn multiple Two numbers: The index of the first item in the -- segment (from index 1), then the number o items in the indicated -- segment. anarchy.distribution.sumtable_items = function(sumtable, i) local portion = sumtable_portion(sumtable, i) -- error here if bad i -- Read prior sum directly from adjacent table entry if i == 1 then prior = 0 else prior = sumtable[i - 1] end return prior + 1, portion end local sumtable_items = anarchy.distribution.sumtable_items --- Uses binary search to find the index of the smallest sum in the given -- sumtable that's greater than or equal to the given value breaking ties -- towards earlier entries. Works in time proportional to the logarithm -- of the sumtable size. This will be the segment which the given item -- (out of the total) falls into. -- -- Raises an error if the sumtable is empty, or if the item index is too -- large for the sumtable or is zero or negative. -- -- @tparam table sumtable The table of sums describing the distribution -- in question. -- @tparam number item The item (out of the total distributed, starting -- from 0) that we want to find the segment for. -- -- @treturn number The segment index (starting from 1) for the specified -- item. anarchy.distribution.sumtable_segment = function(sumtable, item) if #sumtable == 0 then error("Empty sumtable in sumtable_segment.") elseif item < 1 then error( "Zero or negative item index " .. item .. " not permitted in" .. " sumtable_segment." ) elseif item > sumtable[#sumtable] then error( "Item index " .. item .. " is too large in sumtable_segment." .. " The sumtable only contains " .. sumtable[#sumtable] .. " items." ) end local fr = 1 -- inclusive local to = #sumtable -- inclusive local where = nil -- Keep splitting until we narrow it down to a single position (or none) while to - fr > 0 do where = fr + floor((to - fr) / 2) -- halfway between if sumtable[where] < item then -- definitely not here; it's farther fr = where + 1 else -- could be here to = where end end if to - fr < 0 then -- shouldn't be possible in a valid sumtable given the checks -- above... local frspec = "" .. fr .. " (value " .. sumtable[fr] .. ")" local tospec = "" .. to .. " (value " .. sumtable[to] .. ")" error( "Item index " .. item .. " not found in sumtable. Must be at" .. " least at index " .. frspec .. " but may not be past index " .. tospec .. "." ) end assert(to == fr) -- Check the single remaining entry & return if sumtable[fr] < item then -- Shouldn't be possible with a valid sumtable given checks above -- that the index is in range of the total error( "Item index " .. item .. " not found in sumtable. Must be at" .. " index " .. fr .. " (value " .. sumtable[fr] .. ") but that" .. " value is too small." ) else return fr end end local sumtable_segment = anarchy.distribution.sumtable_segment -- Return module table return anarchy