--------------------------------------------------------------------------------
-- 0140-random.lua: tests for utilities for random generation
-- This file is a part of lua-nucleo library
-- Copyright (c) lua-nucleo authors (see file `COPYRIGHT` for the license)
--------------------------------------------------------------------------------

local make_suite = assert(loadfile('test/test-lib/init/strict.lua'))(...)

local ensure_fails_with_substring
      = import 'lua-nucleo/ensure.lua'
      {
        'ensure_fails_with_substring',
      }

local tgenerate_n
      = import 'lua-nucleo/table-utils.lua'
      {
        'tgenerate_n',
      }

local taccumulate,
      tnormalize,
      tclone
      = import 'lua-nucleo/table.lua'
      {
        'taccumulate',
        'tnormalize',
        'tclone'
      }

local validate_probability_rough,
      validate_probability_precise,
      random_exports
      = import 'lua-nucleo/random.lua'
      {
        'validate_probability_rough',
        'validate_probability_precise'
      }

local invariant
      = import 'lua-nucleo/functional.lua'
      {
        'invariant'
      }

--------------------------------------------------------------------------------

local test = make_suite("random", random_exports)

--------------------------------------------------------------------------------

local pairs, print, error = pairs, print, error
local math_random = math.random

-- test global constants, define number of elements (cases) in tested tables
local START_POINT = 2 -- can't be less than 2, or more then END_POINT
local MIDDLE_POINT = 10
local END_POINT = 100 -- cant be more than 100, or less then START_POINT
local STEP_SMALL = 2 -- before MIDDLE_POINT
local STEP_LARGE = 30 -- after MIDDLE_POINT
-- if we need full test use further values:
-- START_POINT = 2
-- MIDDLE_POINT = 10
-- END_POINT = 100
-- STEP_SMALL = 1
-- STEP_LARGE = 1
assert(2 <= START_POINT, "2 > START_POINT")
assert(START_POINT <= MIDDLE_POINT, "START_POINT > MIDDLE_POINT")
assert(MIDDLE_POINT <= END_POINT, "MIDDLE_POINT > END_POINT")
assert(END_POINT <= 100, "END_POINT > 100")

-- step of tests
local get_next_iteration = function(i)
  if i < MIDDLE_POINT then
    i = i + STEP_SMALL
  else
    i = i + STEP_LARGE
  end
  return i
end

local table_sort = table.sort

-- generates distribution table, where keys are cases and
-- values - number of experiments fallen in this case
local generate_experiments = function(
    num_experiments, -- number of experiments to generate
    weights -- table, contains weights of each case
  )
  local weights_normalized = tnormalize(weights)
  local experiments = {}
  -- table, that contains formalized information on each case
  local formalized = {}
  local probability = 0

  -- filling formalized table
  for k, v in pairs(weights) do
    experiments[k] = 0
    local cached = probability + weights_normalized[k]
    formalized[#formalized + 1] =
    {
      cached - probability;
      name = k;
      lowBound = probability;
      upBound = cached;
    }
    probability = cached
  end

  -- sorting formalized by chance of hitting case
  table_sort(formalized, function(a, b) return a[1] > b[1] end)

  -- carrying out experiments
  for i = 1, num_experiments do
    local experiment = math_random()
    for i = 1, #formalized do
      if
        experiment >= formalized[i].lowBound
        and experiment < formalized[i].upBound
      then
        local name = formalized[i].name
        experiments[name] = experiments[name] + 1
        break
      end
    end
  end

  return experiments
end

-- generates indexed table with length n, and random numbers in values
local generate_weights = function(n)
  return tgenerate_n(n, math_random)
end

local os_clock = os.clock

--------------------------------------------------------------------------------

test:group "validate_probability_rough"

--------------------------------------------------------------------------------

-- wrong input test
test 'validate_probability_rough-input' (function()
  print("Wrong inputs:")
  ensure_fails_with_substring(
      "wrong arguments",
      function() validate_probability_rough("a") end,
      "expected `table', got `string'"
    )
  ensure_fails_with_substring(
      "wrong arguments",
      function() validate_probability_rough({ 1 }, "b") end,
      "expected `table', got `string'"
    )
  local res, err = validate_probability_rough({ 1, 3 }, { 3, 5, 6 })
  if err ~= nil then print(err) else error("wrong input") end
  res, err = validate_probability_rough({ 1 }, { 1 })
  if err ~= nil then print(err) else error("wrong input") end
  res, err = validate_probability_rough({ 1, 2, 3 }, { 1, 2, 3 })
  if err ~= nil then print(err) else error("wrong input") end
end)

--------------------------------------------------------------------------------

test 'validate_probability_rough-simple1' (function()
  local weights = { 0,5; 0,5 }
  if
    validate_probability_rough(
        weights,
        generate_experiments(1000, weights)
      )
  then
    error("Simple test failed.")
  end
end)

test 'validate_probability_rough-simple2' (function()
  local weights = { 0,5; 0,25; 0,25 }
  if
    validate_probability_rough(
        weights,
        generate_experiments(1000, weights)
      )
  then
    error("Simple test failed.")
  end
end)

test 'validate_probability_rough-simple3' (function()
  local weights = { 0,6; 0,3; 0,05; 0,05 }
  if
    validate_probability_rough(
        weights,
        generate_experiments(1000, weights)
      )
  then
    error("Simple test failed.")
  end
end)

-- simple wrong test
test 'validate_probability_rough-simple4' (function()
  if
    validate_probability_rough(
        { 0.6, 0.4 },
        generate_experiments(1000, { 0.4, 0.6 })
      ) ~= false
  then
    error("Simple test failed.")
  end
end)

--------------------------------------------------------------------------------

test 'validate_probability_rough-complex' (function()
  -- generates data, tests function validate_probability_rough on this data
  -- and returns number of true checks
  local check = function(
      num_cycles, -- number of cycles of testing
      length, -- number of cases (keys) in weights and experiments
      weights, -- table, contains weights of each case
      experiments, -- table, contains experiments
      should_generate_weights, -- if we generate other weights for each test
      should_generate_experiments, -- if we generate other experiments for test
      num_experiments, -- generated, if previous true
      weights_to_use -- used to generate experiments if needed
    )

    -- default values
    should_generate_weights = should_generate_weights or false
    should_generate_experiments = should_generate_experiments or false
    num_experiments = num_experiments or 10^4
    if weights_to_use == nil then weights_to_use = weights end

    -- testing cycle
    local true_checks = 0
    local weights_current = weights
    local experiments_current = experiments
    for i = 1, num_cycles do
      if should_generate_weights then
        weights_current = generate_weights(length)
      end
      if should_generate_experiments then
        experiments_current = generate_experiments(
            num_experiments,
            weights_to_use
          )
      end
      if validate_probability_rough(weights_current, experiments_current) then
        true_checks = true_checks + 1
      end
    end

    return true_checks
  end

  -- test step constants
  local NUM_SET = 1000
  local NUM_CYCLES = 100

  local start = os_clock()

  -- all testing inside
  print("Random test")
  print("Experiments number in set: " .. NUM_SET)
  local table_size = START_POINT
  while table_size <= END_POINT do

    -- false positive check
    local num_experiments = check(
        NUM_CYCLES,
        table_size,
        { },
        generate_experiments(NUM_SET, generate_weights(table_size)),
        true
      )
    print("Table size: " .. table_size .. " keys, false data")
    print(num_experiments .. " of " .. NUM_CYCLES .. " false positive.")

    -- function broken, function cant randomly fail 100 of 100 times
    -- can be 10 of 100 or even 40 of 100
    if num_experiments == 100 then
      error("Test failed!")
    end
    print("OK")

    -- false negative check
    num_experiments = NUM_CYCLES - check(
        NUM_CYCLES,
        table_size,
        generate_weights(table_size),
        { },
        false,
        true,
        NUM_SET
      )
    print("Table size: " .. table_size .. " keys, correct data")
    print(num_experiments .. " of " .. NUM_CYCLES .. " false negative.")
    if num_experiments ~= 0 then
      error("Test failed!")
    end
    print("OK")

    -- next iteration counter
    table_size = get_next_iteration(table_size)
  end

  print(("Time: %.3f s (fast test)"):format(os_clock() - start))
end)

--------------------------------------------------------------------------------

test:group "validate_probability_precise"

--------------------------------------------------------------------------------

test 'validate_probability_precise-input' (function()
  print("Wrong inputs:")
  ensure_fails_with_substring(
      "wrong arguments",
      function() validate_probability_precise("a") end,
      "expected `table', got `string'"
    )
  ensure_fails_with_substring(
      "wrong arguments",
      function() validate_probability_precise({1, 2, "c"}, function() end) end,
      "`number' expected, got `string'"
    )
  ensure_fails_with_substring(
      "wrong arguments",
      function() validate_probability_precise({1, 2, 3}, function() end) end,
      "`table' expected, got `nil'"
    )
  local res, err = validate_probability_precise({1, 2, 0.9e-5}, function() end)
  if err ~= nil then print(err) else error("wrong input") end
end)

--------------------------------------------------------------------------------

do
  -- generates table, containing set of contrats weights (not normalized)
  local generate_contrast_weights = function(
      length, -- length of generated table
      power, -- 10^power high weight value
      are_low_rare -- if true table has single value = 1 and other = 10^power
    )
    local weights = { }
    local powered = 10 ^ power

    -- filling table
    if are_low_rare then
      weights = tgenerate_n(length, invariant(powered))
    else
      weights = tgenerate_n(length, invariant(1))
    end

    -- adding random rare value
    if are_low_rare then
      weights[math_random(length)] = 1
    else
      weights[math_random(length)] = powered
    end

    return weights
  end

  local weights_closure = { }
  -- is passed to validate_probability_precise
  local generate_experiments_defined = function(num_experiments)
    return generate_experiments(num_experiments, weights_closure)
  end

  -- checks validate_probability_precise work
  local check = function(weights, expermiments_fn, is_data_true, do_not_output)
    local res, err = validate_probability_precise(weights, expermiments_fn)
    if is_data_true ~= res or err ~= nil then
      if err ~= nil then print(err) end
      error("Failed!")
    else
      if not do_not_output then
        if is_data_true then
          print("OK - (no false negative)")
        else
          print("OK - (no false positive)")
        end
      end
    end
  end

  test 'validate_probability_precise-simple1' (function()
    weights_closure = { 0.45, 0.55 }
    check(weights_closure, generate_experiments_defined, true, true)
    check({ 0.5, 0.5 }, generate_experiments_defined, false, true)
  end)

  test 'validate_probability_precise-simple2' (function()
    weights_closure = { 0.3, 0.6, 0.1 }
    check(weights_closure, generate_experiments_defined, true, true)
    check({ 0.275, 0.625, 0.1 }, generate_experiments_defined, false, true)
  end)

  test 'validate_probability_precise-simple3' (function()
    weights_closure = { 0.2, 0.2, 0.2, 0.2 }
    check(weights_closure, generate_experiments_defined, true, true)
    check({ 0.22, 0.19, 0.19, 0.2 }, generate_experiments_defined, false, true)
  end)

  test 'validate_probability_precise-simple4' (function()
    weights_closure = { 0.1, 0.15, 0.2, 0.25, 0.3 }
    check(weights_closure, generate_experiments_defined, true, true)
    check(
        { 0.09, 0.15, 0.2, 0.25, 0.301 },
        generate_experiments_defined,
        false,
        false
      )
  end)

  test 'validate_probability_precise-complex' (function()
    if test:in_strict_mode() then
      local start = os_clock()
      local i = START_POINT
      while i <= END_POINT do
        print("\nTable size: " .. i)
        print("random:")
        -- random correct input check
        weights_closure = generate_weights(i)
        check(weights_closure, generate_experiments_defined, true)

        -- random false input check
        local weights_closure_false = generate_weights(i)
        check(weights_closure_false, generate_experiments_defined, false)

        -- contrast correct input
        print("contrast:")
        -- for full test use 1, 5
        for j = 1, 3 do
          print("1 and " .. i - 1 .. " of 10^" .. j)
          weights_closure = generate_contrast_weights(i, j, true)
          check(weights_closure, generate_experiments_defined, true)

          if i > 2 then
            print(i - 1 .. " of 1 and 10^" .. j)
            weights_closure = generate_contrast_weights(i, j, false)
            check(weights_closure, generate_experiments_defined, true)
          end
        end

        -- contrast false input
        -- for full test use 1, 4
        for j = 1, 2 do
          print("1 and " .. i - 1 .. " of 10^" .. j .. ", added +" .. i)
          weights_closure = generate_contrast_weights(i, j, true)

          -- create wrong weights (by adding small value) and check
          local weights_closure_false = tclone(weights_closure)
          local ran_key = math_random(i)
          weights_closure_false[ran_key] =
            weights_closure_false[ran_key] + 3 * i
          check(weights_closure_false, generate_experiments_defined, false)

          if i > 2 then
            print(i - 1 .. " of 1 and 10^" .. j .. ", +" .. i)
            weights_closure = generate_contrast_weights(i, j, false)

            -- create wrong weights (by adding small value) and check
            weights_closure_false = tclone(weights_closure)
            ran_key = math_random(i)
            weights_closure_false[ran_key] =
              weights_closure_false[ran_key] + 3 * i
            check(weights_closure_false, generate_experiments_defined, false)
          end
        end

        -- next iteration counter
        i = get_next_iteration(i)
      end
      print(("Time: %.3f s (slow test)"):format(os_clock() - start))
    else
      print("Test skipped because strict mode is disabled.")
    end
  end)
end
