Skip to content
/ apa Public

APA : Arbitrary Precision Arithmetic - pure Elixir implementation.

License

Notifications You must be signed in to change notification settings

razuf/apa

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

59 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Apa

Elixir CI status codecov hex.pm version hexdocs.pm

APA : Arbitrary Precision Arithmetic - pure Elixir implementation.

Apa

For arbitrary precision mathematics - which supports numbers of any size and precision up to nearly unlimited of decimals (internal Elixir integer math), represented as strings. This is especially useful when working with floating-point numbers, as these introduce small but in some case significant rounding errors.

Intention, Pros & Cons

I started this project to learn for myself - so the focus was on learning and have fun!

You could use it if you like - there are some test coverage - but for production I would recommend the Decimal package!

The basic idea is to work with strings (inspired by BCMath/PHP):

  • parse/convert any string into internal ApaNumber - a tuple: {integer_value, exponent}
  • calculate with that tuple
  • reconvert it with to_string function

Some limits and 'bugs' in standard Erlang/Elixir:

iex> 0.30000000000000004 - 0.30000000000000003
0.0

with Apa:

"0.30000000000000004" - "0.30000000000000003"
"0.00000000000000001"

Elixir:

iex> 0.1 + 0.2
0.30000000000000004

with Apa:

"0.1" + "0.2"
"0.3"

Elixir:

iex> 9007199254740992.0 - 9007199254740991.0
1.0
iex> 9007199254740993.0 - 9007199254740992.0
0.0
iex> 9007199254740994.0 - 9007199254740993.0
2.0

iex> 87654321098765432.0 - 87654321098765431.0
16.0

iex> 0.123456789e-100 * 0.123456789e-100
1.524157875019052e-202
iex> 0.123456789e-200 * 0.123456789e-200
0.0

iex> :math.pow(2, 1500)
** (ArithmeticError) bad argument in arithmetic expression

On a short research I found the existing lib EAPA have some limits and disadvantages:

EAPA (Erlang/Elixir Arbitrary-Precision Arithmetic) a) Customized precision up to 126 decimal places (current realization) Why only 126 decimal places? Apa should not have that limit!

b) EAPA is a NIF extension written on Rust -> performance fine, but bad in case of strong dependencies. Apa is in pure Elixir with no dependency - running on any Nerves device.

Later I found Decimal which looks very nice and useful (written by Eric Meadows-Jönsson!) - so there is already a solution - nice, stable and full featured! I used it in Phoenix with Ecto without thinking about it ... but that's life.

Anyway I had fun with Apa on Eastern 2020. ;-)

A little feature I could offer compared to Decimal (but of course could be easily expanded there too)

"0.30000000000000004" - "0.30000000000000003"
"0.00000000000000001"

Or calc and compare directly with strings in case of ecto/database

with Decimal:

schema "products" do
  field :name, :string
  field :price, :decimal
  timestamps()
end

%Product{
  name: "Apple",
  price: 3,
}
cart_total = Decimal.to_string(Decimal.mult(product.price, cart_quantity))

with Apa:

schema "product" do
  field :name, :string
  field :price, :string
  timestamps()
end

%Product{
  name: "Apple",
  price: "3",
}
cart_total = product.price * cart_quantity

Could be useful together with CubDB (pure Elixir key/value database) f.e. in a Nerves environment.

Cons

Not heavy tested in production so there are probably many uncovered issues.

Slower performance compared to original Elixir integer or float calculation (see performance comparision in tests and benchee benchmarks in examples).

Installation

  1. Add apa to your list of dependencies in mix.exs:
def deps do
  [
    {:apa, "~> 0.6"}
  ]
end

Config

Default values for precision and scale - you don't need to put it in your config, only if you want to overwrite it.

config/config.exs:

use Mix.Config

# Configures the apa precision and scale defaults
# scale < 0 (default -1) - no touch on decimal point
# scale == 0 - always integer
# scale > 0 - always make a decimal point at scale
# precision <= 0 - (default -1) - no touch at the precision == arbitrary precision
# precision > 0 - the total count of significant digits in the whole number
# you can overwrite the defaults with the  following or ues explicit precision and/or scale
config :apa,
  precision_default: -1,
  scale_default: -1

Usage

defmodule ApaExample do
  import Apa
  import Kernel, except: [+: 2, -: 2, *: 2, /: 2, to_string: 1, abs: 1]

  def the_answer() do
    apa1 = Apa.add("1", "2")
    apa2 = Apa.sub("3", "2")

    price = "3.50 Euro"
    quantity = "12"
    total_string = price * quantity

    IO.puts("The Answer to the Ultimate Question of Life, the Universe, and Everything is: ")

    "1"
    |> Apa.add("2")
    |> Apa.add("3")
    |> Apa.sub("4")
    |> Apa.add("5")
    |> Apa.mul("6")
  end
end

Examples (see examples folder too)

iex> Apa.add("0.1", "0.2")
"0.3"
iex> Apa.sub("3.0", "0.000000000000000000000000000000000000000000000001")
"2.999999999999999999999999999999999999999999999999"
iex> "333.33" |> Apa.add("666.66") |> Apa.sub("111.11")
"888.88"

iex> "1" |> Apa.add("2") |> Apa.add("3") |> Apa.sub("4") |> Apa.add("5") |> Apa.mul("6")
"42"

😆

Performance comparison with Decimal - fortunately it's 'a little' faster and lower memory consumption

More benchmark results (f.e.linux ), other tests and infos..

Benchee script in examples folder - bench_apa_short.exs:

inputs = %{
  "606 Digits Integer as String" =>
    {123_456_789_012_345_678_901_234_567_890_123_456_789_012_345_678_901_234_567_890_123_456_789_012_345_678_901_234_567_890_123_456_789_011_234_567_890_123_456_789_012_345_678_901_234_567_890_123_456_789_012_345_678_901_234_567_890_123_456_789_012_345_678_901_234_567_890_112_345_678_901_234_567_890_123_456_789_012_345_678_901_234_567_890_123_456_789_012_345_678_901_234_567_890_123_456_789_012_345_678_901_123_456_789_012_345_678_901_234_567_890_123_456_789_012_345_678_901_234_567_890_123_456_789_012_345_678_901_234_567_890_123_456_789_011_234_567_890_123_456_789_012_345_678_901_234_567_890_123_456_789_012_345_678_901_234_567_890_123_456_789_012_345_678_901_234_567_890_112_345_678_901_234_567_890_123_456_789_012_345_678_901_234_567_890_123_456_789_012_345_678_901_234_567_890_123_456_789_012_345_678_901,
     893_456_789_012_345_678_901_234_567_890_123_456_789_012_345_678_901_234_567_890_123_456_789_012_345_678_901_234_567_890_123_456_789_011_234_567_890_123_456_789_012_345_678_901_234_567_890_123_456_789_012_345_678_901_234_567_890_123_456_789_012_345_678_901_234_567_890_112_345_678_901_234_567_890_123_456_789_012_345_678_901_234_567_890_123_456_789_012_345_678_901_234_567_890_123_456_789_012_345_678_901_123_456_789_012_345_678_901_234_567_890_123_456_789_012_345_678_901_234_567_890_123_456_789_012_345_678_901_234_567_890_123_456_789_011_234_567_890_123_456_789_012_345_678_901_234_567_890_123_456_789_012_345_678_901_234_567_890_123_456_789_012_345_678_901_234_567_890_112_345_678_901_234_567_890_123_456_789_012_345_678_901_234_567_890_123_456_789_012_345_678_901_234_567_890_123_456_789_012_345_678_999}
}

bench = %{
  "Decimal.add() Int" => fn {l, r} ->
    Decimal.add(%Decimal{sign: 1, coef: l, exp: 0}, %Decimal{sign: 1, coef: r, exp: 0})
  end,
  "Apa.add() Int" => fn {l, r} ->
    Apa.add({l, 0}, {r, 0})
  end,
  "Decimal.add() Dec" => fn {l, r} ->
    Decimal.add(%Decimal{sign: 1, coef: l, exp: -12}, %Decimal{sign: 1, coef: r, exp: 12})
  end,
  "Apa.add() Dec" => fn {l, r} ->
    Apa.add({l, -12}, {r, 12})
  end
}

Benchee.run(bench,
  inputs: inputs,
  time: 6,
  warmup: 1,
  memory_time: 1,
  print: [fast_warning: false]
)
##### With input 606 Digits Integer as String #####
Name                        ips        average  deviation         median         99th %
Apa.add() Int         2987.35 K        0.33 μs  ±7486.92%           0 μs           1 μs
Apa.add() Dec          628.08 K        1.59 μs   ±131.47%           2 μs           2 μs
Decimal.add() Int       46.63 K       21.44 μs    ±26.49%          21 μs          37 μs
Decimal.add() Dec       43.02 K       23.24 μs    ±25.11%          22 μs          44 μs

Comparison: 
Apa.add() Int         2987.35 K
Apa.add() Dec          628.08 K - 4.76x slower +1.26 μs
Decimal.add() Int       46.63 K - 64.06x slower +21.11 μs
Decimal.add() Dec       43.02 K - 69.44x slower +22.91 μs

Memory usage statistics:

Name                 Memory usage
Apa.add() Int           0.0703 KB
Apa.add() Dec           0.0938 KB - 1.33x memory usage +0.0234 KB
Decimal.add() Int         2.25 KB - 32.00x memory usage +2.18 KB
Decimal.add() Dec         1.38 KB - 19.56x memory usage +1.30 KB

Precision and Scale

Some ideas come from Postgres and I extend that to be useful in Elixir:

The 'precision' of an ApaNumber is the total count of significant digits in the whole number, that is, the number of digits to both sides of the decimal point. The 'scale' of an ApaNumber is the count of decimal digits in the fractional part, to the right of the decimal point. So the number 123.456 has a precision of 6 and a scale of 3. A scale of 0 will effect as Integer.

scale < 0 (default -1) - no touch on decimal point, when it is there or not - with a limit of 321 if its unlimited/periodic flow of numbers like 10/3 = 0.333333... - if you want more digits after the decimal point you can overwrite it with an explicit value for scale > 0 see below scale == 0 - always integer -> "1.1" with a scale of 0 will be "1" scale > 0 - always make a decimal point with the amount of scale -> "1" with scale of 3 will be "1.000"

precision <= 0 - means no touch at the precision - arbitrary precision as possible maybe limited by scale precision > 0 - the total count of significant digits in the whole number - if the precision is less then the real significant digits it will be replaced by 0 without rounding: 123.456 with a precision of 5 will be returned as 123.450

No explicit precision and no explicit scale

All operations (except the division - see below) without any explicit precision or scale works up to the implementation limit on elixir integer. An ApaNumber of this kind will not coerce input values to any particular scale. Implemented with default value of precision -1 and default value of scale -1. These defaults can be overwritten via config.

The division is limited in this case by the default scale value (see config), otherwise there will be very often huge nearly endless strings (f.e. 10/3 = 0.3333...). If you need any higher precision/scale you could adjust the default value (via config) or use the precision and/or scale parameter for each operation.

iex> Apa.add("0.12", "0.34")
"0.46"

iex> Apa.div("10", "3")
"3.333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333"

otherwise:
iex> Apa.div("10", "3", -1, 12)
"3.333333333333"

Explicit precision and/or explicit scale

Both the precision and the scale of an ApaNumber can be configured as maximum values. That means ApaNumbers with a declared precision and/or scale will coerce input values to that precision/scale. The precision must be positive, the scale zero or positive.

iex> Apa.add("0.1", "0.2", -1, 3)
"0.300"

iex> Apa.add("1001", "2002", 3, -1)
"3000"

iex> Apa.add("12.34", "43.21", 4, 2)
"55.55"

iex> Apa.add("12.34", "43.21", 3, 2)
"55.50"

iex> Apa.add("12.34", "43.21", 3, 0)
"55"

iex> Apa.mul("3.50 Euro", "12 Stück", -1, 2)
"42.00"

Features

A list of supported and planned features (maybe incomplete)

  • basic operations (add)
  • basic operations (sub)
  • basic operations (mul)
  • basic operations (div)
  • comparison (comp)
  • absolute value (abs)
  • precision (total count of significant digits)
  • scale (number of digits after the decimal place)
  • config for precision and scale defaults
  • NaN and Infinity - (my decision is: Don't use NaN and Infinity - see below)
  • performance - f.e. benchee check - this pure Elixir implementation looks like fast enough for normal applications (normal means not for number crunching)
  • string format for result - it's possible for some cases with precision and scale (may be later expansion)
  • parse int, float and benchee testing
  • rounding

NaN and Infinity

I don't use NaN and Infinity because I think its more clear and strait forward to handle division by zero with an error/exception, because it makes no sense at all to continue with any operation after a division by zero - see Wikipedia Division_by_zero. And no other operation in Apa generate a NaN nor an Infinity so I don't use them.

About

APA : Arbitrary Precision Arithmetic - pure Elixir implementation.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages