#!/usr/bin/env python
# -*- coding: utf-8 -*-

from collections import Counter
import math
import sys
# Convenience
STDOUT, STDERR = sys.stdout, sys.stderr

from scipy.stats import binom
# Stupid black magic to suppress rare `divide by zero encountered in _binom_cdf` warning when calling binom for first
# time with very particular values; scipy version 1.7.1. Python suppresses warnings past the first so we purposely
# trigger it here. Messing with warnings management didn't help since reverting them to normal resets the count.
import os
sys.stderr = open(os.devnull, 'w')
binom.cdf(39, 43097, 0.5)  # No, it doesn't occur for 38 or 40, or for any n lower than 43097
sys.stderr = STDERR

from .solution import Solution
from .components import RandomInput

NON_PRECOG_MIN_PASS_RATE = 0.5

# We will keep two confidence levels (CLs) for statistical operations; a more strict ideal CL, which we will attempt
# to achieve if given enough time, and a fallback minimum acceptable CL, which if time-constrained, we will consider
# acceptable for returning an answer anyway even if the ideal is not achieved. If neither of these confidence levels
# is obtained, an error will be raised

# The preferred rate of precognitive solutions being marked as non-precognitive
# Lowering this increases the number of runs non-precog solutions require
# We could probably be more lax on this one since precogs are submitted much less often, but it only saves about
# 10 runs when checking a typical non-precog production solution
PREFERRED_FALSE_NEG_RATE = 0.001
# The maximum acceptable rate of non-precognitive solutions being marked as precognitive
# Lowering this increases the number of runs precog solutions require
# This is the expensive one, but non-precogs are more common so we need a low false positive rate for them
PREFERRED_FALSE_POS_RATE = 0.001

# Fallback confidence levels used for very slow solutions if we can't run enough to reach the higher confidence levels
MAX_FALSE_POS_RATE = 0.1
MAX_FALSE_NEG_RATE = 0.1
# A constant factor that determines how quickly we decide a molecule variant has been assumed, if we see it fail X times
# without ever succeeding. We declare precog if the variant's success rate is provably (to within our above
# false positive confidence level) less than this ratio of the solution's observed success rate. Check the comments near
# its usage for a fuller explanation, but I don't believe it actually has to be near 0, and putting it near 0 scales the
# time precog solutions take to evaluate. For example, at a factor of 0.75 and false positive rate 0.001, for a solution
# that was originally observed to have 100% success rate before searching for missing variants, it will only be
# declared precog if a variant of the Nth molecule appears in 8 failing runs without ever appearing in a succeeding run.
# We do want it to be less than 1 however since that saves us from edge case handling if the success rate was originally
# measured to be 100%
MOLECULE_SUCCESS_RATE_DEVIATION_LIMIT = 0.75

# Since long cycle counts go hand-in-hand with demanding many runs for sufficient certainty, practical applications
# don't have time to properly check precog for long solutions. By default, cut off the max total cycles runtime and
# raise an error if this will be exceeded (rather than returning an insufficiently-confident answer)
DEFAULT_MAX_PRECOG_CHECK_CYCLES = 2_000_000  # Large enough to ensure it doesn't constrain typical required run counts


def is_precognitive(solution: Solution, max_cycles=None, just_run_cycle_count=0, max_total_cycles=None,
                    verbose=False, stderr_on_precog=False):
    """Given a Solution, run/validate it then check if fits the current community definition of a precognitive solution.

    If time constraints do not allow enough runs for even 90% certainty either way, raise a TimeoutError.

    Currently, a solution is considered precognitive if:
    * It assumes the value of the Nth molecule of a random input, for some N >= 2.
      Stated conversely, a solution (with acceptable success rate) is non-precognitive if, for each random input I,
      each N >= 2, and each type of molecule M that I may create, there exists a random seed where the Nth input of I is
      M, and the solution succeeds.
    * OR it fails for >= 50% of random seeds.
         Accordingly with the first rule excepting the first input molecule, this check only uses seeds that match
         the first molecule (or all first molecules if there are multiple random inputs), if that is more favourable.

    In practice we check this with the following process:
    1. Run the solution on the original level, verifying it succeeds (validate the solution's expected score here too if
       possible). Track how many molecules were generated from each random input (call this N), and what the nth
       molecule's variant was for every n up to N.
    2. Increment the seeds of all random inputs at once until a set of seeds is found that has the same first molecule
       produced for each input (since assumptions on the first input are allowed). Incrementing the seeds in lockstep
       also ensures assumptions about inputs that share a seed are not violated (such assumptions are not forbidden in
       non-precognitive solutions).
    3. Repeat step 1 with the new random seed(s) (but without requiring that it succeed). Update N for each random input
       to be whichever run used less molecules (since the solution might always terminate on a certain molecule, using
       the max would risk the last n never being able to 'find' all variants).
       If the solution succeeds, aggregrate the nth-input-variant data with that of the first run, in order to track
       which variants for a given n have had at least one run succeed.
       If the solution fails, put the same nth-input-variant data in a separate 'failure variants' dataset.
    4. Repeat steps 2-3 until any of the following conditions is met (again ignoring seeds that had a differing first
       input if that is more forgiving):
       * The failure rate is measured to be >= 50%, with sufficient confidence (precog).
       * The success rate is measured to be > 50% with sufficient confidence, and the dataset of succeeding runs covers
         every possible variant of every possible n (2 <= n <= N), for all random input components (non-precog).
       * The maximum allowed runs based on max_total_cycles is reached (TimeoutError).
         With default settings this should only occur for very long (100k+ cycles) solutions or solutions with a
         failure rate extremely close to 50%.

    Args:
        solution: The loaded solution to check.
        max_cycles: The maximum cycle count allowed for a SINGLE run of the solution (passed to Solution.run).
            Note that this is not the total number of cycles allowed across all runs; any solution within this limit
            is allowed to run at least twice, with the maximum runs taken being limited for extremely slow solutions.
            This is bounded to 300 runs, since at a certain point it can be assumed that the missing variants are not
            being found because the first molecule was unique in its bucket.
        max_total_cycles: The maximum total cycle count that may be used by all runs; if this value is exceeded before
            sufficient confidence in an answer is obtained, a TimeoutError is raised.
        just_run_cycle_count: In order to save on excess runs, if the solution has just been successfully run on the
            loaded level (and not been modified or reset() since), pass its cycle count here to skip the first run (but
            still pull the first run's data from the Solution object).
        verbose: If True, print more detailed info on the number of runs performed and how many passed before returning.
        stderr_on_precog: If True, when a solution is precognitive, print an explanation of why to STDERR.
                          Can be enabled independently of `verbose`. Default False.
    """
    # Hang onto references to each random input in the solution
    random_inputs = [input_component for input_component in solution.inputs
                     if isinstance(input_component, RandomInput)]

    if not random_inputs:
        return False  # duh

    if max_cycles is None:
        if solution.expected_score is not None:
            # Set a larger default than in Solution.run since the seed might change the cycle count by a lot
            max_cycles = 2 * solution.expected_score.cycles
        else:
            max_cycles = Solution.DEFAULT_MAX_CYCLES

    if max_total_cycles is None:
        # TODO: Might also want to limit this by reactor count
        max_total_cycles = DEFAULT_MAX_PRECOG_CHECK_CYCLES

    total_cycles = 0
    # Track the min cycles a passing run takes so we can exit early if we know we can't prove anything before timeout
    min_passing_run_cycles = math.inf

    # For each input zone, let N be the minimum molecules the solution must use from that input
    # Before we do any checks that require resetting the input objects, initialize Ns to the data from the last run if
    # just_run_cycle_count was provided
    # TODO: re-jigger this with the below check so we pick up the ignore-pipe refinement but preferably
    #       without having the same code in two places
    Ns = [random_input.num_inputs if just_run_cycle_count else math.inf for random_input in random_inputs]

    # Collect a bunch of information about each random input which we'll use for calculating how many runs are needed
    num_variants = [len(random_input.molecules) for random_input in random_inputs]
    first_input_variants = [random_input.reset().get_next_molecule_idx() for random_input in random_inputs]
    # When accounting for the allowable first input assumption, we need to know whether that variant will be impossible
    # to find for the rest of its bucket
    first_input_is_unique = [random_input.input_counts[first_variant] == 1
                                for random_input, first_variant in zip(random_inputs, first_input_variants)]
    bucket_sizes = [sum(random_input.input_counts) for random_input in random_inputs]

    # For each random input, track which variants of the Nth molecule have been seen, and how many runs it passed vs
    # failed. We could get away with Sets instead of Counters for the success data but I prefer to keep things symmetric
    # Since we allow first input assumptions, we don't store the 1st input's variants, but store a dummy
    # value at the front to keep our indices sane
    # Success data could get away with just set() but the symmetry keeps the code cleaner for negligible extra memory
    # TODO: This variable is unused now; rip it out without hurting the code symmetry too much
    #       Probably switching success data back to set() at the same time will make the two asymmetries mostly cancel out
    success_run_variants = [[Counter()] for _ in range(len(random_inputs))]
    fail_run_variants = [[Counter()] for _ in range(len(random_inputs))]
    num_runs = 0
    num_passing_runs = 0

    # Keep additional datasets that track only data from runs that had the same first molecule(s) as the base seed, so
    # our checks are unbiased for solutions that use the allowable assumption on the first input
    # Note that we don't need a separate measure of Ns since it is a minimum of any successful run, regardless of seed
    success_run_variants_first_match = [[Counter()] for _ in range(len(random_inputs))]
    fail_run_variants_first_match = [[Counter()] for _ in range(len(random_inputs))]
    num_runs_first_match = 0
    num_passing_runs_first_match = 0

    # Since in the case of a timeout we'll need to redo the precog checks with a different confidence level,
    # define some helpers for the precog checks
    def success_rate_okay(false_neg_rate):
        """Check if, with sufficient confidence, the success rate is high enough."""
        # Using the binomial cumulative distribution function of the failure count (= P(failures <= X)), and assuming
        # the highest disallowed success rate, check if the probability of seeing this few failures is below our false
        # negative threshold.
        # First run is ignored since it's biased (and must always pass).
        # Since assuming the first input is allowed, the check can pass either including or not including seeds that
        # match the base seed's first molecule(s)
        return (binom.cdf(num_runs_first_match - num_passing_runs_first_match, num_runs_first_match - 1,
                          1 - NON_PRECOG_MIN_PASS_RATE) < false_neg_rate
                or binom.cdf(num_runs - num_passing_runs, num_runs - 1, 1 - NON_PRECOG_MIN_PASS_RATE) < false_neg_rate)

    def success_rate_too_low(false_pos_rate):
        """Check if, with sufficient confidence, the success rate is too low."""
        # Using the binomial cumulative distribution function of the success count (= P(successes <= X)), and assuming
        # the lowest allowed success rate, check if the probability of seeing this few successes is below our false
        # positive threshold.
        # First run is ignored since it's biased (and must always pass).
        # Since assuming the first input is allowed, the rate is only considered too low if it's too low both including
        # and not including seeds that match the base seed's first molecule(s)
        if (binom.cdf(num_passing_runs_first_match - 1, num_runs_first_match - 1, NON_PRECOG_MIN_PASS_RATE)
                < false_pos_rate
            and binom.cdf(num_passing_runs - 1, num_runs - 1, NON_PRECOG_MIN_PASS_RATE)
                < false_pos_rate):
            if verbose or stderr_on_precog:
                print(f"Solution is precognitive; <= {100 * NON_PRECOG_MIN_PASS_RATE}% success rate for a random seed"
                      f" (with {100 * (1 - false_pos_rate)}% confidence);"
                      f" {num_passing_runs} / {num_runs} runs passed"
                      f" (or {num_passing_runs_first_match} / {num_runs_first_match} for seeds with same first molecule).",
                      file=STDERR if stderr_on_precog else STDOUT)

            return True

        return False

    def check_molecule_assumptions(false_pos_rate, skip_non_precog_checks=False):
        """Return True if we can safely declare the solution assumes a particular molecule (other than the first),
        return False if we can safely declare it does not, and return None if we aren't confident either way yet.

        Also accept a flag to skip non-precog checks (saving a little computation) in the case that the success rate
        check hasn't passed yet. Checks that would determine the solution to be precog are still performed.
        """
        # TODO: skip_non_precog_checks flag is ugly, split this into two functions now that it's two independent blocks

        # If for every random input, we've succeeded at least once on all molecule variants (ignoring the first
        # molecule) up to the minimum number of molecules the solution needs from that input to complete, there are
        # guaranteed no assumed molecules.
        # Note that we don't need to ignore successes from seeds with a differing first molecule from the base seed,
        # since if there's a difference then the solution, by definition, didn't assume the first molecule.
        if (not skip_non_precog_checks
            and all(len(success_run_variants[i][n]) == num_variants[i]
                    # To account for the allowed first molecule assumption, ignore the first molecule's variant in its
                    # bucket if it was unique, since it can be impossible for it to show up again.
                    or (first_input_is_unique[i]
                        and n < bucket_sizes[i]
                        and len(success_run_variants[i][n]) == num_variants[i] - 1
                        and first_input_variants[i] not in success_run_variants[i][n])
                    for i, N in enumerate(Ns)
                    for n in range(1, N))):  # Ignore first molecule
            if verbose:
                print("Solution is not precognitive; successful variants found for all input molecules"
                      f" ({num_passing_runs} / {num_runs} runs passed, or"
                      f" {num_passing_runs_first_match} / {num_runs_first_match} for seeds with same first molecule).")

            return False

        # Otherwise, check if any of the variants has failed X times without ever succeeding.
        # Before calculating X, we need to know what confidence level to use for its calculation.
        # To account for the increased chance of a false positive caused by individually testing every variant (e.g. for
        # a 50/50 production level, we'd have 80 individual variants to check, hence 80 chances for a variant to only
        # show up during failing runs by pure bad luck), we do a little rejiggering with some basic math:
        # total_false_positive_rate = P(any variant false positives)
        # = 1 - P(no variant false positives)
        # = 1 - P(single variant doesnt false positive)^total_variants
        # = 1 - (1 - P(single variant false positives))^total_variants
        # Rearranging, we get the stricter confidence level we must use for each individual variant check:
        # P(single variant false positives) = 1 - (1 - total_false_positive_rate)^(1 / total_variants)
        # Note that we don't care about the relative probabilities of the variants, since the solution has no control
        # over which variants it is tested on; more common molecule variants will be seen in successful runs sooner, but
        # the total probability that a variant eventually hits X failures before 1 success is the same as that for a
        # rarer variant, all things being equal.
        total_variants = sum(Ns[i] * num_variants[i] for i in range(len(random_inputs)))
        individual_false_pos_rate = 1 - (1 - false_pos_rate)**(1 / total_variants)
        # Now, to calculate X, consider that the solution has some unknown probability of succeeding for each given
        # variant of the Nth molecule (e.g. P(success | 3rd molecule is Nitrogen)). In order to declare the solution
        # precognitive, we must find one of these variants for which we can prove, with sufficient confidence, that
        # its probability of success equals 0.
        # However since proving an event is impossible is a hard problem (?), we'll settle for proving that a particular
        # molecule variant's success rate is statistically significantly far below some constant factor of the
        # solution's current success rate.
        # E.g. if the solution succeeds 90% of the time, we will be much more suspicious of always-failing variants than
        # if it succeeds 50% of the time. This isn't perfect since assumptions on sequences (allowed) might cause
        # certain molecules' variants to have a lower success rate, but my expectation is that this bias will be
        # somewhat counteracted by the stricter individual check confidence level (see above), since for
        # any single variant to be significantly below the average success rate, other variants must be above it, and
        # will thus have a reduced chance to false positive their own checks; making the effective confidence level
        # stricter in the worst case of a single 'biased but not assumed' variant.
        # In any case, this means we want (for some constant factor c < 1 that we'll pick to our liking):
        # P(false positive) <= P(X failures in X tries) = (1 - c * success_rate)^X
        # => X = log(P(false positive), base=(1 - c * success_rate))
        # Note that this becomes prohibitively large for success rates close to 0, but we restrict success rate anyway
        # so this is not a problem.
        # TODO: Probably need to do two sets of checks, both with and without the off-seed runs
        # TODO 2: This includes the first run, which is biased. If we want to exclude it, need to handle the
        #         success rate = 0 case too. I don't think the bias matters much though especially with the factor.
        max_variant_failures = math.ceil(math.log(individual_false_pos_rate,
                                                  1 - (MOLECULE_SUCCESS_RATE_DEVIATION_LIMIT
                                                       * success_rate_first_match)))
        # TODO: This check is doing much more work than needed since only newly-failing variants need to be re-checked
        #       It's insignificant compared to the cost of schem.run, but still.
        for i, N in enumerate(Ns):
            for n in range(1, N):
                if n >= len(fail_run_variants_first_match[i]):
                    continue

                # TODO: Is there any point also analyzing failures caused by differing first molecule runs?
                #       No, but we CAN account for successes from off-seed runs. Of course, if a success appears in an
                #       off-seed run then we clearly haven't assumed the first input...
                for variant_idx in range(num_variants[i]):
                    if ((n > len(success_run_variants[i])
                         or variant_idx not in success_run_variants[i][n])
                            and fail_run_variants_first_match[i][n][variant_idx] >= max_variant_failures):
                        if verbose:
                            # TODO: Use the molecule's name for clarity, or its formula if the formula is unique
                            print(f"Solution is precognitive; variant {variant_idx} of molecule {n + 1} / {N}"
                                  f" failed every time in {max_variant_failures} appearances (whereas solution success"
                                  f" rate was otherwise {round(100 * success_rate_first_match)}%).",
                                  file=STDERR if stderr_on_precog else STDOUT)

                        return True

        return None

    # Track when we can start skipping seeds without introducing bias in the success check
    # Once we start skipping seeds we will also stop updating the success rate vars so they remain unbiased
    success_check_passed = False
    success_rate = None
    success_rate_first_match = None
    while total_cycles < max_total_cycles:
        if num_runs != 0:  # Smelly if, but this way `continue` is safe to use anywhere below
            for random_input in random_inputs:
                random_input.seed += 1

        # If we have already achieved sufficient confidence on the success rate, we are just waiting for all input
        # variants to show up and no longer need to worry about biasing the success rate; skip seeds which consist
        # entirely of input variants we've already seen in a previous successful run
        # TODO: This is very unfocused seed skipping, simply ensuring bounded run time
        #       Assuming the search for a relevant seed doesn't start to surpass schem.run in terms of compute cost,
        #       it should be possible to achieve much faster convergence by strategically searching for seeds with
        #       multiple variants that have yet to succeed/be seen, or to speed up precog solutions, by specifically
        #       targeting the variants with the highest failure count, so we can quickly hit the cutoff point.
        #       Doing the latter should be okay in terms of not introducing bias, but I'll have to have a think
        #       about whether the former could increase variant failure rates in case say, the last two variants
        #       always fail if they appear together - we could get caught purposely feeding the solution its bad
        #       case when it would eventually succeed if given either bad variant individually.
        #       For now we'll do the bare minimum seed skipping to ensure an unbiased proof-of-concept.
        # TODO 2: It'd be nice to not have to wait for the success rate check to be done to start skipping seeds...
        #         Possibly some skipping can be done that just fixes 'bad luck', and in theory only reduces
        #         volatility in the success rate measurement rather than really biasing it - for example, skipping
        #         an all-successful-variants seed if there exist variants that should have appeared by now but
        #         haven't due to bad luck.
        #         However this has to be implemented in a way that we're not forcing low-probability variants to appear
        #         at a rate higher than they should (or biasing their neighbor molecules' variants, etc...).
        # TODO 3: If the solution has never succeeded with a differing first molecule, it might be worth skipping those
        #         seeds too. However would have to be careful of bad luck causing us to never give it a chance to pass
        #         the off-brand seeds again.
        if success_check_passed:
            for i, random_input in enumerate(random_inputs):
                random_input.reset().get_next_molecule_idx()  # Reset and skip past the first molecule

                # Make sure at least one molecule variant in the seed is one we haven't seen succeed yet
                if any(random_input.get_next_molecule_idx() not in success_run_variants[i][n]
                       for n in range(1, Ns[i])):
                    break
            else:
                continue

        # Check if the first molecule of each random input is the same as in the original input
        first_molecule_matches = all(random_input.reset().get_next_molecule_idx() == first_input_variants[i]
                                     for i, random_input in enumerate(random_inputs))

        solution.reset()  # Reset the solution from any prior run (this also picks up seed changes)

        # Run the solution with this seed of the input, checking if it succeeds (ignoring the exact score)
        try:
            # Run the solution
            # if just_run_cycle_count was given, skip the first run to save time
            cycles = just_run_cycle_count if (num_runs == 0 and just_run_cycle_count) \
                     else solution.run(max_cycles=max_cycles).cycles

            # Check how many molecules the solution consumed for each random input, and lower each N if possible
            # Note that if just_run_cycle_count was provided, we initialized N already and reset the solution,
            # so we skip in that case
            if not (num_runs == 0 and just_run_cycle_count):
                for i, random_input in enumerate(random_inputs):
                    # Ignore molecules that only made it into the pipe since their variant can't affect the solution
                    this_N = random_input.num_inputs - sum(1 for mol in random_input.out_pipe if mol is not None)
                    Ns[i] = min(Ns[i], this_N)

            target_variants_data = success_run_variants
            target_variants_data_first_match = success_run_variants_first_match
            num_variants_to_store = Ns  # Direct reference is safe since we only read from this

            min_passing_run_cycles = min(min_passing_run_cycles, cycles)
            num_passing_runs += 1
            if first_molecule_matches:
                num_passing_runs_first_match += 1
        except Exception as e:
            if num_runs == 0:
                # Not allowed to crash on the original seed, otherwise do nothing
                raise Exception(f"Error in base seed: {type(e)}: {e}")

            cycles = solution.cycle
            target_variants_data = fail_run_variants  # The data set that this run's variants should be added to
            target_variants_data_first_match = fail_run_variants_first_match
            # Make sure we don't store data on variants of molecules from after the solution crashed
            num_variants_to_store = [random_input.num_inputs - sum(1 for mol in random_input.out_pipe if mol is not None)
                                     for random_input in random_inputs]

        total_cycles += cycles
        num_runs += 1
        if first_molecule_matches:
            num_runs_first_match += 1

        # Update the success rate, unless we've started skipping seeds
        if not success_check_passed:
            success_rate = num_passing_runs / num_runs
            if first_molecule_matches:
                success_rate_first_match = num_passing_runs_first_match / num_runs_first_match

        # Track all nth input variants that appeared in this run for 2 <= n <= N
        datasets_to_update = ([target_variants_data, target_variants_data_first_match]
                              if first_molecule_matches else
                              [target_variants_data])
        for dataset in datasets_to_update:
            for random_input, variants_data, num_new_variants in zip(random_inputs, dataset, num_variants_to_store):
                random_input.reset().get_next_molecule_idx()  # Reset and skip past n = 0

                if num_new_variants > len(variants_data):
                    variants_data.extend([Counter() for _ in range(num_new_variants - len(variants_data))])

                for n in range(1, num_new_variants):
                    variants_data[n][random_input.get_next_molecule_idx()] += 1

        # To save on futile runs, check if our time constraints can allow for us to get a sufficiently confident answer
        # about the success rate, assuming all future runs are successes or all are failures.
        # If we have so few runs that we can guarantee even our fallback confidence level won't be met, we can
        # immediately timeout
        remaining_cycles = max_total_cycles - total_cycles
        # The number of passing runs we're allowed before timing out is easy to estimate
        max_remaining_passing_runs = remaining_cycles // min_passing_run_cycles
        # On the other hand, the number of failing runs we're allowed before timing out is hard to estimate, since in
        # the case of a solution that assumes the first molecule, the seeds with non-matching first molecule will tend
        # to fail within just a few cycles, while matching seeds will usually fail at more random times.
        # However since the success rate check fails only if both the all-seeds and the first-matching seeds success
        # rates fail, it is sufficient to exit early only when it looks like the first-matching seeds success rate can't
        # fail, and these will tend not to fail immediately in either solution type (or if they do, the regular checks
        # will converge quickly enough that the time constraint won't be approached and this check won't matter).
        # Accordingly, we'll assume the failure runs average half a passing run's cycle count
        max_remaining_failing_runs = (2 * remaining_cycles) // min_passing_run_cycles
        if not (  # Check if pure failures can confirm a too-low success rate (for restricted seeds, as explained above)
                binom.cdf(num_passing_runs_first_match - 1, num_runs_first_match - 1 + max_remaining_failing_runs,
                          NON_PRECOG_MIN_PASS_RATE)
                < MAX_FALSE_POS_RATE
                # Check if pure successes can confirm a sufficiently high success rate (for either seed type)
                or binom.cdf(num_runs - num_passing_runs,
                             num_runs - 1 + max_remaining_passing_runs,
                             1 - NON_PRECOG_MIN_PASS_RATE)
                   < MAX_FALSE_NEG_RATE
                or binom.cdf(num_runs_first_match - num_passing_runs_first_match,
                             num_runs_first_match - 1 + max_remaining_passing_runs,
                             1 - NON_PRECOG_MIN_PASS_RATE)
                   < MAX_FALSE_NEG_RATE):
            raise TimeoutError("Precog check could not be completed to sufficient confidence due to time constraints;"
                               f" too few runs to ascertain {100 * NON_PRECOG_MIN_PASS_RATE}% success rate requirement"
                               f" ({num_passing_runs} / {num_runs} runs passed, or"
                               f" {num_passing_runs_first_match} / {num_runs_first_match} for seeds with same first input).")

        if not success_check_passed:
            if success_rate_too_low(false_pos_rate=PREFERRED_FALSE_POS_RATE):
                return True
            elif success_rate_okay(false_neg_rate=PREFERRED_FALSE_NEG_RATE):
                success_check_passed = True

        molecule_assumption_check_result = check_molecule_assumptions(
            # Skip non-precog exit conditions if we aren't confident in the success rate yet
            skip_non_precog_checks=not success_check_passed,
            false_pos_rate=PREFERRED_FALSE_POS_RATE)

        if molecule_assumption_check_result is not None:
            return molecule_assumption_check_result

    # If we escaped the run loop without returning, we've been time-constrained in our number of runs.
    # Attempt to redo the precog check with our fallback relaxed confidence levels, and if even then we aren't
    # sufficiently confident in our answer, raise an error
    if verbose:
        print("Warning: Precog check terminated early due to time constraints; check accuracy may be reduced.")

    # Skip these checks if the success check already passed. This is important because after the success check passes
    # seeds with particular variants are searched for, which means the success rate is no longer unbiased
    if not success_check_passed:
        if success_rate_too_low(false_pos_rate=MAX_FALSE_POS_RATE):
            return True

        if not success_rate_okay(false_neg_rate=MAX_FALSE_NEG_RATE):
            raise TimeoutError("Precog check could not be completed to sufficient confidence due to time constraints;"
                               f" success rate too near {100 * NON_PRECOG_MIN_PASS_RATE}% requirement"
                               f" ({num_passing_runs} / {num_runs} = {100 * success_rate:.1f}% runs passed).")

    molecule_assumption_check_result = check_molecule_assumptions(false_pos_rate=MAX_FALSE_POS_RATE)
    if molecule_assumption_check_result is not None:
        return molecule_assumption_check_result

    # TODO: Be explicit about how many times we wanted to see each variant and which variants were still uncertain
    raise TimeoutError("Precog check could not be completed due to time constraints; certain non-succeeding molecule"
                       " variants not encountered enough times to be confident they always fail.")
