1
0
pretty/number.lua

243 lines
9.9 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

-- pretty.number
-- The number formatting module for pretty.
--[=[ Thoughts on displaying numbers in an informative way.
Numbers are such an intrinsic part of computer science, mathematics, science in
general and even finance industry, that it boggles they mind how different they
each treat numbers. This is one of the reasons that many programming languages
possess a huge number of number types: Integers both signed and unsigned,
rational number, floating point numbers, fixed point numbers, not to mention the
various Computer Algebra Systems (CAS), that exist.
Lua has historically used floating point numbers, which can represent numbers in
a huge range, at the cost of non-uniform precision across that range. How can we
represent numbers in a intuitive and useful way?
1. Native representation: We could use Lua's native way of representing
numbers. This is easy, as we just use the `tostring` function.
This is unsatifactory, if we're aiming for Lua-compatible output, as
`tostring` will produce `inf` (`1/0`) and `nan` (`0/0`), which are valid
identifiers, and does not guarentee that these variables are equal to their
respective values.
It's also unsatifactory when aiming for precision, as `tostring` truncate
the output if "close enough".
2. Unnecessary precision: We can use `string.format` with the format code
`%.99f`, to get a "precise" representation of the number. Unfortunantly, for
many "larger" (> 10⁻¹³?) numbers, some of the right digits will be 0, due
to floating point precision issues. We also run against the fact that π is
rounded and is only accurate 15 decimal digits. Do we really care about the
remaining digits, most of which are 0?
3. As required representation: We use as many digits as necessary for the Lua
parser to read the exact same number. Involves invoking `string.format` and
`tonumber` a lot.
This approach improves on 2. as we not only discard all those 0s and the
confusing unaccurate digits. Indeed, many "nice" numbers won't even possess
any decimal digits. Numbers feed directly into the interpreter would look
identical to the input, barring removal of trailing 0s.
And with that we achieved precision and a reasonable amount of consiseness.
4. Fractions: One issue with floating points have always been that the
precision is sorely limited. Some languages possess fraction types, but
such systems suffer from pathological cases, where memory usage spikes.
We can still exploit fractions despite using floating point numbers. When we
type `13/7` into Lua, we get a number back. This is the "canonical"
floating point representation of `13/7`, and every time we encounter this
number, we can use `13/7` to represent it.
This allows more consise representation of certain numbers, for example
numbers with repeating digits. It will also highlight patterns that a
decimal representation might not.
Unfortunantly it might also hide patterns, and it reduces the usefulness of
pretty as a calculator.
5. Reverse engineered number: Remember the concept of "canonical"
representation from 4.? We can expand that concept to also talk about more
general expressions like `math.sqrt(2)` or `2^-5`. Discovering those
expressions from a single number is pretty complex.
Again, this allows for very consise representations of numbers, and can find
hidden patterns all over the place. It's much easier to see the relation to
radians when we see `0.5*math.pi`, rather than `0.78539816339745`.
But again we lose the calculator aspect.
6. TODO: Write about `inf` and `nan`.
7. TODO: Write about rounding.
8. TODO: Write about unicode.
We a bit of every one of the above methods. They each have their upsides and
their drawbacks. Which approach we use, depend on the number, and what is the
shortest representation.
If the chosen representation is different from the "as required approach", and
we're only pretty printing a single number value, then we also append the
approximate value given by the "as required approach" approach as a comment, in
case somebody was looking for that.
--]=]
--------------------------------------------------------------------------------
-- Constants
local MAXIMUM_INT = 2^53 -- The maximum double for where all integers can be represented exactly.
local MAXIMUM_ZERO = 10^-7 -- Used when attempting to determine fraction. Anything below is counted as 0.
--------------------------------------------------------------------------------
-- Util
local function calculate_fraction (n)
-- Returns x and y such that x/y = n. If none could be found, returns nil.
local a, b = 1, n % 1
while MAXIMUM_ZERO < b and a <= MAXIMUM_INT do
local r = math.pow(b, -1)
a, b = a * r, r % 1
-- Invariant: n * a / a = n
end
-- Check the values make sense.
local numerator, denumberator = math.floor(n * a), math.floor(a)
if numerator / denumberator == n then
return numerator, denumberator
end
end
--------------------------------------------------------------------------------
local SPECIAL_NUMBER = {
-- x = ∞
{ est = function (a) return math.huge end,
real = function (a) return math.huge end,
repr = function (a) return '1/0' end,
},
-- x = a/b
{ est = calculate_fraction,
real = function (a, b) return b ~= 1 and (a/b) end,
repr = function (a, b) return a..'/'..b end,
},
-- x = 2^a
{ est = function (n) return math.log(n)/math.log(2) end,
real = function (a) return 2^a end,
repr = function (a) return '2^'..a end,
},
-- x = 10^a
{ est = function (n) return math.log(n)/math.log(10) end,
real = function (a) return 10^a end,
repr = function (a) return '10^'..a end,
},
-- x = 1/√a
{ est = function (n) return 1/(n^2) end,
real = function (a) return a >= 0 and 1/math.sqrt(a) end,
repr = function (a) return ('1/math.sqrt(%.0f)'):format(a) end,
},
-- x = lg a
{ est = function (n) return math.exp(n) end,
real = function (a) return a >= 0 and math.log(a) end,
repr = function (a) return ('math.log(%.0f)'):format(a) end,
},
-- x = ^a
{ est = function (n) return math.log(n) end,
real = function (a) return math.exp(a) end,
repr = function (a) return ('math.exp(%.0f)'):format(a) end,
},
-- x = aπ
-- TODO: Add support for factions of π.
{ est = function (n) return n/math.pi end,
real = function (a) return a*math.pi end,
repr = function (a) return a == 1 and 'math.pi' or a..'*math.pi' end,
},
-- x = sqrt(a)
{ est = function (n) return n^2 end,
real = function (a) return a >= 0 and math.sqrt(a) end,
repr = function (a) return ('math.sqrt(%.0f)'):format(a) end,
},
}
--------------------------------------------------------------------------------
local function format_soft_num (n)
assert(type(n) == 'number')
if n ~= n then return '0/0'
elseif n == 0 then return '0'
elseif n < 0 then return '-' .. format_soft_num(-n)
end
-- Finding the shortest
local shortest, length = nil, math.huge
local function alternative_repr (repr)
if #repr < length then shortest, length = repr, #repr end
end
-- Maybe it's a "special" number?
for _, special_number_tests in pairs(SPECIAL_NUMBER) do
local a = { special_number_tests.est(n) }
if a[1] then
for i = 1, #a do a[i] = math.floor(a[i] + 0.5) end
local num = special_number_tests.real(unpack(a))
if num == n then
alternative_repr( special_number_tests.repr(unpack(a)) )
elseif num then
local repr = special_number_tests.repr(unpack(a))
local native_precise = tonumber(('%'..#repr..'f'):format(n))
if math.abs(num - n) <= math.abs( native_precise - n ) then
alternative_repr( repr )
end
end
end
end
-- Maybe it's a decimal number?
alternative_repr( tostring(n):gsub('([^e]+)e%+?(%-?[^e]*)', function(a, b) return (a == '1' and '' or a..'*')..'10^'..b end))
-- Well, this is not a pretty number!
return shortest
end
local function format_hard_num (n)
assert(type(n) == 'number')
-- All the fun special cases
if n ~= n then return '0/0'
elseif n == 0 then return '0'
elseif n == math.huge then return '1/0'
elseif n == -math.huge then return '-1/0'
end
-- Now for the serious part.
for i = 0, 99 do
local repr = ('%.'..i..'f'):format(n)
local num = tonumber(repr)
if num == n then return repr end
end
assert(false)
end
local DISPLAY = { -- TODO: Move To common file
HIDE = 1,
SMALL = 2,
INLINE = 3,
EXPAND = 4,
}
return function (value, display, l)
-- Formats the number nicely. If display is DISPLAY.EXPAND, we have some
-- space for extra info, we give some tidbits, to help investigation.
assert(type(value) == 'number')
assert(type(display) == 'number' and type(l) == 'table')
-- First format a "soft" version. This number is not guarenteed to accurate.
-- It's purpose is to give a general idea of the value.
l[#l+1] = format_soft_num(value)
-- If we have space for it, format a "hard" value, also. This number is as
-- short as possible, while evaluating precisely to the value of the number.
if display == DISPLAY.EXPAND then
local hard_repr = format_hard_num(value)
if l[#l] ~= hard_repr then
l[#l+1] = ' -- Approx: '
l[#l+1] = hard_repr
end
-- TODO: Add extra information. I don't really know what is useful?
-- Prime factorization is fun, but useless unless people are doing
-- cryptography or general number theory.
-- Bit pattern is maybe too lowlevel.
end
end