Ordery - An order preserving, safe way, to store floats in ordered datastores using math

Ordery - An order preserving, safe way, to store floats in ordered datastores using math

About:

Ordery is a simple and small module that performs simple math that encode and decode float numbers into integers while maintaing their order. It has been designed specifically for using it with OrderedDataStores, more specifically their limitation that requires all numbers stored to be integers. Using this module you will be able to convert player data to integers, store it to ordered datastores(such as in-game leaderboards), then load them back in, decode them, and since order was maintained they will be in the right order/positions relative to others and have proper float representation. This module is particularly useful for games such as simulators and incrementals, that often deal with insanely large numbers, that don’t require much precision at all.

API/Methods:

  • ordery.new() --creates a new ordery object
  • ordery:encode(x) --encodes a float into an integer representation
  • ordery:decode(x) --reverses the above
  • ordery:encodeBigNumBase2(m, e) --encodes directly from mantissa and exponent, assuming they're in base 2
  • ordery:encodeBigNumBase10(m, e) --same with above but base 10
  • ordery:encodeBigNumAnyBase(m, e, base) --same with above but you can define the base
  • ordery:decodeBigNumBase2(x): (m, e) --decodes directly to mantissa and exponent, in base 2
  • ordery:decodeBigNumBase10(x): (m, e)
  • ordery:decodeBigNumAnyBase(x, base): (m, e)
  • ordery:setMaxExponent(e) --sets max exponent to take into account when doing math, the default is 2^21, if set to smaller it can increase precision but you must be sure it wont be reached, else it may cause bugs
Mathematics

Explanation of floats:

Floats are stored internally as m*2^e, we call m the mantissa, the mantissa is basically a decimal value between -1 and 1 and it’s used for the precision and sign(positive or negative) of the float number. e is the exponent, it’s an integer that can be both positive and negative, if it’s positive it means the decimal point is pushed to the right of the mantissa, making the number bigger, if it’s negative, it means the decimal point is getting pushed to the left, making the number smaller.

Core concept:

When it comes to showing floats visually(for example on leaderboards) we don’t care much about the least significant parts of the number(for example the values way after the decimal place), but we want to maintain the order of the numbers(so the positions of each player on the leaderboard remain the same). So we need to convert m*2^e to a mathematical function that scales much slower but maintains order. A simple function that achieves that is m+2*e that basically takes each operation(multiplication and power) and converts it to a less aggressive one(addition and multiplication). In fact, we know that the mantissa can’t go above 1, so 2 isn’t needed at all(since one “rotation” of the mantissa is an increase of e). So we can simplify this even more down to m+e.

Handling edge cases:

There’re two edge cases, the mantissa being negative and the exponent being negative. If the mantissa is negative, we simply remove the sign, and at it back at the end. We can do this because ordered data stores also accept negative integers, so we don’t need to encode the sign into a positive integer as a piece of information. If the exponent is negative we reduce the mantissa down to a smaller number with m = m*2^e and then assume the exponent to be zero(we don’t take it into consideration).

Maintaining information:

The number we currently have is very small, and most of the information it contains is after the decimal point, which is an issue because we can’t store decimal points in the integer. We solve this the dumb way, by multiplying with a large number and then rounding to closest integer. The number I chose for this is 2^32-1, I’m confident that this number gives enough precision for player leaderboards and also allows for insanely large exponents. Specifically m + e is basically e since m is always really small(between 0 and 1), we know that luau allows for precise numbers for up to 2^53, so by doing 53-32 we know that we have 21 spare bits we can use for the exponent, this means that the exponent can have values up to 2 million.

The reason the module provides built-in methods for working with mantissa and exponent values directly, is because luau numbers start losing precision after a while, and when the exponent reaches 1024 they become inf. If someone wishes to bypass this, they can use this module along with a bignum module of their choice, and bind them together through the mantissa and exponent values.

Source code
local ordery = {}
ordery.__index = ordery 

type self = {multiplier: number}

export type ordery = typeof(setmetatable({} :: self, ordery))

--creates a new ordery object with default parameters
function ordery.new(): ordery
	local self = setmetatable({} :: self, ordery)
	--remaining bits: 2^53-2^32 = 2^21
	--bits to store m+e: bits to store e: 2^21
	--this means that the exponent can be as high as roughly 2M
	self.multiplier = 0xFFFFFFFF
	return self 
end

--encodes directly from mantissa and exponent, assuming they're in base 2
function ordery.encodeBigNumBase2(self: ordery, m: number, e: number): number
	local a = math.abs(m)
	local n = e >= 0 and a+e or math.ldexp(a, e)
	return math.sign(m)*math.round(n*self.multiplier)
end

--encodes directly from mantissa and exponent, assuming they're in the provided base
function ordery.encodeBigNumAnyBase(self: ordery, m: number, e: number, base: number): number
	local a = math.abs(m)
	local n = e >= 0 and a+base*e or a*math.pow(base, e)
	return math.sign(m)*math.round(n*self.multiplier)
end

--encodes directly from mantissa and exponent, assuming they're in base 10
function ordery.encodeBigNumBase10(self: ordery, m: number, e: number): number
	return self:encodeBigNumAnyBase(m, e, 10)
end

--encodes a float into an integer while maintaining order
function ordery.encode(self: ordery, x: number): number
	return self:encodeBigNumBase2(math.frexp(x))
end

--decodes directly to mantissa and exponent, assuming they're in the provided base
function ordery.decodeBigNumAnyBase(self: ordery, x: number, base: number): (number, number)
	local n = math.abs(x)/self.multiplier
	return math.sign(x)*(n%base), n//base
end

--decodes directly to mantissa and exponent, assuming they're in base 2
function ordery.decodeBigNumBase2(self: ordery, x: number): (number, number)
	return self:decodeBigNumAnyBase(x, 1)
end

--decodes directly to mantissa and exponent, assuming they're in base 10
function ordery.decodeBigNumBase10(self: ordery, x: number): (number, number)
	return self:decodeBigNumAnyBase(x, 10)
end

--decodes an encoded integer back to a float
function ordery.decode(self: ordery, x: number): number
	return math.ldexp(self:decodeBigNumBase2(x))
end

--sets the max possible exponent to be considered while doing math
--small exponents allow for higher precision, but math could break if they're reached
--the default exponent is 2^20
--this function modifies the internal multiplier and may cause game breaking behavior if it's frequently modified
function ordery.setMaxExponent(self: ordery, e: number): ()
	local requiredBits = math.ceil(math.log(e, 2))+1
	--assuming 53 to be the maximum bits that maintain precision
	self.multiplier = math.ldexp(1, 53-requiredBits)
end

return ordery

Module Link

1 Like