"""Exact paired-binary power calculations for the prospective Study 2. The calculation enumerates the multinomial counts for outcomes where the new harness alone succeeds, the baseline alone succeeds, or the pair is concordant. Rejection uses the same two-sided exact McNemar/binomial test preregistered for the primary analysis. No experimental outcome data are read by this script. """ from __future__ import annotations import argparse from math import exp, lgamma def exact_binomial_pvalue(successes: int, trials: int) -> float: """Return scipy-compatible two-sided exact binomial p for p=0.5.""" if trials == 0: return 1.0 observed = _binomial_probability(successes, trials) probability = 0.0 for value in range(trials + 1): candidate = _binomial_probability(value, trials) if candidate <= observed + 1e-15: probability += candidate return min(probability, 1.0) def _binomial_probability(successes: int, trials: int) -> float: log_coefficient = lgamma(trials + 1) - lgamma(successes + 1) - lgamma( trials - successes + 1 ) return exp(log_coefficient - trials * 0.6931471805599453) def _multinomial_probability( total: int, new_only: int, baseline_only: int, new_only_probability: float, baseline_only_probability: float, ) -> float: concordant = total - new_only - baseline_only concordant_probability = 1.0 - new_only_probability - baseline_only_probability counts = (new_only, baseline_only, concordant) probabilities = ( new_only_probability, baseline_only_probability, concordant_probability, ) log_value = lgamma(total + 1) - sum(lgamma(value + 1) for value in counts) for count, probability in zip(counts, probabilities): if count and probability == 0.0: return 0.0 if count: from math import log log_value += count * log(probability) return exp(log_value) def exact_mcnemar_power( tasks: int, new_only_probability: float, baseline_only_probability: float, alpha: float = 0.05, ) -> float: if tasks <= 0: raise ValueError("tasks must be positive") if not 0.0 < alpha < 1.0: raise ValueError("alpha must lie strictly between zero and one") if min(new_only_probability, baseline_only_probability) < 0.0: raise ValueError("discordance probabilities cannot be negative") if new_only_probability + baseline_only_probability > 1.0: raise ValueError("discordance probabilities cannot sum above one") power = 0.0 for new_only in range(tasks + 1): for baseline_only in range(tasks - new_only + 1): discordant = new_only + baseline_only if exact_binomial_pvalue(new_only, discordant) <= alpha: power += _multinomial_probability( tasks, new_only, baseline_only, new_only_probability, baseline_only_probability, ) return power def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("--tasks", type=int, default=60) parser.add_argument("--new-only", type=float, default=0.25) parser.add_argument("--baseline-only", type=float, default=0.05) parser.add_argument("--alpha", type=float, default=0.05) arguments = parser.parse_args() power = exact_mcnemar_power( arguments.tasks, arguments.new_only, arguments.baseline_only, arguments.alpha, ) print( f"tasks={arguments.tasks} new_only={arguments.new_only:.3f} " f"baseline_only={arguments.baseline_only:.3f} alpha={arguments.alpha:.4f} " f"power={power:.6f}" ) if __name__ == "__main__": main()