j124 spinner claim: add ConstantSpinner adversary + spinner A/B arms, panel, analyzer

This commit is contained in:
2026-09-26 04:05:36 +02:00
parent 2a98aba91b
commit 06da7abb0e
11 changed files with 739 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
# ─────────────────────────────────────────────────────────────────────────────
# arms_spinner.txt — the SPINNER-CLAIM A/B (j124, owner claim 1).
#
# Claim: "I never seen a gun that learns wall movement or circular movement like
# spinning bot so fast - like a gun made on purpose for those movements."
#
# Three arms, identical frozen binary, MOVEMENT PINNED (`TR_MOVEMENT=strafe`) in
# every arm (same standard as the gun campaign). Only the gun rack differs.
# Format: name | ENV=value ENV=value | label
# ─────────────────────────────────────────────────────────────────────────────
# 1. REFERENCE — shipped onlyPattern rack, movement pinned.
pattern | TR_MOVEMENT=strafe | shipped onlyPattern rack (REFERENCE)
# 2. BitBrain learned-gain, decay memory (the config the owner most likely ran;
# the arm used in the 32-opponent gauntlet and both j121 batches).
bitbrain | TR_MOVEMENT=strafe TR_RACK_PATTERN=off TR_RACK_BITBRAIN=both TR_BITBRAIN_GAINS=1.0,1.25,1.5,2.0 TR_BITBRAIN_MEM=decay | BitBrain-only, learned gain, decay
# 3. BitBrain learned-gain, RETAINED memory (the "adapt across the whole battle"
# mode the owner described; not previously panel-tested).
bitbrain_ret | TR_MOVEMENT=strafe TR_RACK_PATTERN=off TR_RACK_BITBRAIN=both TR_BITBRAIN_GAINS=1.0,1.25,1.5,2.0 TR_BITBRAIN_MEM=retained | BitBrain-only, learned gain, retained
+21
View File
@@ -0,0 +1,21 @@
# ─────────────────────────────────────────────────────────────────────────────
# panel_spinner.txt — the SPINNER-CLAIM panel (j124).
#
# The legacy roster has no constant-turn spinner (docs/gauntlet_bitbrain_vs_pattern.md,
# docs/gun_campaign.md). This panel adds two: the new ConstantSpinner adversary at
# two spin rates, plus the sample SpinBot as the j121 "spinner" control, plus the
# same kind of regular/dodger controls the j117 gauntlet used, so the
# regular-vs-dodger comparison is directly readable.
#
# Format: <bot dir> | <style group> | <note>. `style` is INFERRED (robots.json /
# the bot name / the fixture's own constants) and used ONLY to explain a result.
# ─────────────────────────────────────────────────────────────────────────────
/tmp/tr_spinners/ConstantSpinner_s3 | spinner | TRUE constant-turn spinner: 3.0 deg/tick @ speed 5 (r=95.5 px, period 120 ticks)
/tmp/tr_spinners/ConstantSpinner_s6 | spinner | TRUE constant-turn spinner: 6.0 deg/tick @ speed 5 (r=47.7 px, period 60 ticks)
/home/davide/Projects/tank-royale/sample-bots/java/build/archive/SpinBot | spinner | sample SpinBot (max-turn circle-mover; the j121 control)
/tmp/tr_bots/WallAvoider | regular | legacy regular mover control (j117/j121)
/tmp/tr_bots/DiamondStealer | regular | legacy rammer / close brawler control (j117/j121)
/tmp/tr_bots/HawkOnFire | regular | legacy corner-camper control (j117/j121)
/tmp/tr_bots/Diamond | dodger | legacy wave-surfing dodger control (j117/j121)
/tmp/tr_bots/CassiusClay | dodger | legacy pattern/surf dodger control (j117/j121)
/tmp/tr_bots/GresSuffurd | dodger | legacy wave-surfing dodger control (j117/j121)
+519
View File
@@ -0,0 +1,519 @@
#!/usr/bin/env python3
"""spinner_analyze.py — the SPINNER-CLAIM analyzer (j124, owner claim 1).
python3 tools/ab/spinner_analyze.py <session_dir> [--reference pattern] [--report FILE]
Reads a session produced by `tools/ab/tournament_run.sh` (layout:
<outdir>/<opponent>/<arm>/run<N>.{battle.log,events.jsonl,jsonl,jsonl.rounds.json,bot.stdout.log})
and answers the owner's claim:
"I never seen a gun that learns wall movement or circular movement like
spinning bot so fast - like a gun made on purpose for those movements."
Unlike the gun campaign's gauntlet_analyze.py (exactly 2 arms), this supports N
arms and adds the CONVERGENCE layer the claim is really about: our gun's per-round
hit rate on the first rounds vs the later rounds, per arm, and within-round
(first-half vs second-half ticks) for the spinner opponents.
Primary metrics stay damage/run and ROUND WINS. Hit rate (ours, per-round) is the
SECONDARY mechanism metric, reported but never the verdict. MDE is printed for
every paired test so a null is distinguishable from an under-powered null.
Standard library only. Deterministic (fixed permutation seed).
"""
import itertools
import json
import math
import os
import random
import re
import statistics
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import gauntlet_analyze as ga # noqa: E402 (reuse the proven parsers/stats)
BOT_NAME = "ModularBot"
EXACT_PAIR_CAP = 20
MC_DRAWS = 200_000
MC_SEED = 0x5EED5EED
# ── parsing one run ──────────────────────────────────────────────────────────
def read_round_starts(path):
try:
data = json.load(open(path))
except (OSError, json.JSONDecodeError):
return {}
return {r["round"]: r["startTick"] for r in data.get("rounds", [])}
def parse_run(armdir, run, env_req=None):
evs = ga.parse_events(os.path.join(armdir, f"run{run}.events.jsonl"))
log_text = "".join(ga.read_lines(os.path.join(armdir, f"run{run}.battle.log")))
counters = ga.parse_counters(log_text)
wins = ga.parse_first_places(log_text)
nrounds = ga.round_count(os.path.join(armdir, f"run{run}.jsonl.rounds.json"))
starts = read_round_starts(os.path.join(armdir, f"run{run}.jsonl.rounds.json"))
r = {"run": run, "valid": False, "rounds": nrounds, "wins": wins,
"damage": 0.0, "damage_taken": 0.0, "mb_fired": 0, "mb_hits": 0,
"opp_fired": 0, "opp_hits": 0, "env_ok": True, "per_round": {}}
if env_req:
text = "".join(ga.read_lines(os.path.join(armdir, f"run{run}.bot.stdout.log")))
r["env_ok"] = all(
re.search(re.escape(k) + r"\s*=\s*" + re.escape(v) + r"(\s|$)", text)
is not None for k, v in env_req.items())
if wins is None or counters is None or nrounds == 0:
return r
subj, other = ga.attribute_subject(evs, counters)
if subj is None:
return r
def slot(rnd):
return r["per_round"].setdefault(rnd, {
"mb_fired": 0, "mb_hits": 0, "mb_damage": 0.0,
"opp_fired": 0, "opp_hits": 0, "opp_damage": 0.0})
for o in evs:
rnd = o.get("round", 0)
t = o.get("type")
if t == "fire":
if o.get("owner") == subj:
r["mb_fired"] += 1; slot(rnd)["mb_fired"] += 1
elif o.get("owner") == other:
r["opp_fired"] += 1; slot(rnd)["opp_fired"] += 1
elif t == "hit":
d = float(o.get("damage", 0.0))
if o.get("owner") == subj:
r["damage"] += d; r["mb_hits"] += 1
slot(rnd)["mb_damage"] += d; slot(rnd)["mb_hits"] += 1
if o.get("owner") == other:
r["opp_hits"] += 1; slot(rnd)["opp_damage"] += d
slot(rnd)["opp_hits"] += 1
if o.get("victim") == subj:
r["damage_taken"] += d
r["valid"] = True
r["round_starts"] = starts
return r
def discover_runs(armdir):
if not os.path.isdir(armdir):
return []
return sorted(int(m.group(1)) for m in
(re.fullmatch(r"run(\d+)\.jsonl", f) for f in os.listdir(armdir))
if m)
def load_arm(opp_dir, arm, runs, env_req):
valid, invalid = [], []
for run in range(1, runs + 1):
r = parse_run(os.path.join(opp_dir, arm), run, env_req)
(valid if (r["valid"] and r["env_ok"]) else invalid).append(r)
return valid, invalid
# ── aggregation ──────────────────────────────────────────────────────────────
def sumruns(runs, key):
return sum(r[key] for r in runs)
def pooled(runs):
n = len(runs)
if n == 0:
return None
rounds = sumruns(runs, "rounds")
mb_fired = sumruns(runs, "mb_fired")
opp_fired = sumruns(runs, "opp_fired")
return {
"runs": n,
"damage": sumruns(runs, "damage") / n,
"damage_taken": sumruns(runs, "damage_taken") / n,
"wins": sumruns(runs, "wins") / n,
"win_rate": sumruns(runs, "wins") / rounds if rounds else float("nan"),
"rounds": rounds,
"hit_rate": 100.0 * sumruns(runs, "mb_hits") / mb_fired if mb_fired else float("nan"),
"incoming_hit_rate": 100.0 * sumruns(runs, "opp_hits") / opp_fired if opp_fired else float("nan"),
"mb_fired": mb_fired, "opp_fired": opp_fired,
}
def round_hit_rates(runs):
"""{round_index: (hits, fires)} pooled over runs."""
out = {}
for r in runs:
for rnd, d in r["per_round"].items():
h, f = out.get(rnd, (0, 0))
out[rnd] = (h + d["mb_hits"], f + d["mb_fired"])
return out
# ── main ─────────────────────────────────────────────────────────────────────
def main():
args = sys.argv[1:]
if not args:
print(__doc__)
return 2
session_dir = args[0].rstrip("/")
ref = "pattern"
if "--reference" in args:
ref = args[args.index("--reference") + 1]
report_path = None
if "--report" in args:
report_path = args[args.index("--report") + 1]
session = json.load(open(os.path.join(session_dir, "session.json")))
arms = [a["name"] for a in session["arms"]]
arm_env = {a["name"]: ga.parse_envspec(a.get("env", "")) for a in session["arms"]}
if ref not in arms:
ref = arms[0]
opps = [o["name"] for o in session["opponents"]]
style_of = {o["name"]: (o.get("style") or "other").strip() or "other"
for o in session["opponents"]}
lines = []
def out(s=""):
lines.append(s)
print(s)
out("### MEASURED: session")
out()
out(f"* commit `{session['commit']}`, frozen binary sha256 "
f"`{session['binary_sha256'][:12]}…`")
out(f"* {len(opps)} opponents × {len(arms)} arms × {session['runs']} runs × "
f"{session['rounds']} rounds = {len(opps) * len(arms) * session['runs']} battles")
out(f"* arms file `{os.path.basename(session.get('arms_file', '?'))}`, "
f"panel file `{os.path.basename(session.get('panel_file', '?'))}`")
out(f"* reference arm: **`{ref}`** (deltas are arm − reference, opponent by opponent)")
out()
data = {}
invalid_total = 0
for o in opps:
data[o] = {}
for a in arms:
valid, invalid = load_arm(os.path.join(session_dir, o), a,
session["runs"], arm_env.get(a))
data[o][a] = valid
invalid_total += len(invalid)
out(f"* liveness: {invalid_total} run(s) excluded "
f"({len(opps) * len(arms) * session['runs']} total)")
out()
# ── spinner movement validation (MEASURED from the opponent trace) ──────
out("### MEASURED: the spinner fixture is a true constant-turn spinner")
out()
out("Per-tick body turn and speed of each spinner opponent, read from the")
out("capture's adversary trace (`s*` fields), pooled over its runs.")
out()
out("| opponent | ticks | turn mode (deg/tick) | mode share | turn SD | speed mode | speed mode share | wall-hug frac |")
out("|---|---:|---:|---:|---:|---:|---:|---:|")
spinner_opps = [o for o in opps if style_of[o] == "spinner"]
for o in spinner_opps:
adir = os.path.join(session_dir, o, ref)
turns, speeds, wall, n, prev = [], [], 0, 0, None
for run in discover_runs(adir):
try:
fh = open(os.path.join(adir, f"run{run}.jsonl"), errors="replace")
except OSError:
continue
with fh:
for line in fh:
if '"sx"' not in line:
continue
try:
e = json.loads(line)
except json.JSONDecodeError:
continue
if "sx" not in e:
continue
x, y, h, s = e["sx"], e["sy"], e["sh"], e.get("ss", 0.0)
n += 1
if min(x, 800 - x, y, 600 - y) < 50:
wall += 1
if prev is not None:
px, py, ph = prev
if math.hypot(x - px, y - py) < 30:
turns.append((h - ph + 180.0) % 360.0 - 180.0)
speeds.append(s)
prev = (x, y, h)
if not turns:
out(f"| {o} | 0 | n/a | n/a | n/a | n/a | n/a | n/a |")
continue
tc = {}
for t in turns:
tc[round(t, 1)] = tc.get(round(t, 1), 0) + 1
tmode, tcnt = max(tc.items(), key=lambda kv: kv[1])
sc = {}
for s in speeds:
sc[round(s, 1)] = sc.get(round(s, 1), 0) + 1
smode, scnt = max(sc.items(), key=lambda kv: kv[1])
sd = statistics.pstdev(turns)
out(f"| {o} | {n} | {tmode} | {100 * tcnt / len(turns):.1f}% | {sd:.3f} "
f"| {smode} | {100 * scnt / len(speeds):.1f}% | {100 * wall / n:.1f}% |")
out()
# ── per-opponent paired table ───────────────────────────────────────────
per_opp = {a: {} for a in arms}
for o in opps:
ref_runs = data[o][ref]
if not ref_runs:
for a in arms:
per_opp[a][o] = None
continue
rm = pooled(ref_runs)
for a in arms:
m = pooled(data[o][a])
if m is None:
per_opp[a][o] = None
continue
per_opp[a][o] = {
"m": m, "ref": rm,
"d_damage": m["damage"] - rm["damage"],
"d_wins": m["wins"] - rm["wins"],
"d_hit": m["hit_rate"] - rm["hit_rate"],
"d_taken": m["damage_taken"] - rm["damage_taken"],
}
out("### MEASURED: per-opponent paired table")
out()
for a in arms:
if a == ref:
continue
out(f"#### `{a}` vs `{ref}`")
out()
out("| opponent | style | dmg/run ref→arm | Δdmg | wins/run ref→arm | Δwins | our hit% ref→arm | Δhit (pp) | dmg taken Δ |")
out("|---|---|---:|---:|---:|---:|---:|---:|---:|")
for o in opps:
e = per_opp[a][o]
if e is None:
out(f"| {o} | {style_of[o]} | n/a | n/a | n/a | n/a | n/a | n/a | n/a |")
continue
m, rm = e["m"], e["ref"]
out(f"| {o} | {style_of[o]} | {rm['damage']:.1f}→{m['damage']:.1f} "
f"| {e['d_damage']:+.1f} | {rm['wins']:.2f}→{m['wins']:.2f} "
f"| {e['d_wins']:+.2f} | {rm['hit_rate']:.2f}→{m['hit_rate']:.2f} "
f"| {e['d_hit']:+.2f} | {e['d_taken']:+.1f} |")
out()
# ── pooled dashboard ────────────────────────────────────────────────────
out("### MEASURED: pooled dashboard (all valid runs; NOT the verdict)")
out()
out("| arm | runs | dmg/run | dmg taken/run | wins/run | round wins | win rate | our hit rate | incoming hit rate |")
out("|---|---:|---:|---:|---:|---:|---:|---:|---:|")
pooled_arm = {}
for a in arms:
runs = [r for o in opps for r in data[o][a]]
m = pooled(runs)
pooled_arm[a] = m
out(f"| `{a}` | {m['runs']} | {m['damage']:.1f} | {m['damage_taken']:.1f} "
f"| {m['wins']:.2f} | {int(sumruns(runs, 'wins'))}/{m['rounds']} "
f"| {100 * m['win_rate']:.1f}% | {m['hit_rate']:.2f}% "
f"| {m['incoming_hit_rate']:.2f}% |")
out()
# ── cross-opponent aggregation ──────────────────────────────────────────
out("### MEASURED: cross-opponent aggregation (the verdict layer)")
out()
out("| arm | metric | mean Δ | spread (SD) | 95% CI | sign test | p(sign) | p(sign-flip) | p(Wilcoxon) | MDE |")
out("|---|---|---:|---:|---|---:|---:|---:|---:|---:|")
stats = {}
for a in arms:
if a == ref:
continue
st = {}
for key, mkey in (("d_damage", "damage"), ("d_wins", "wins"),
("d_hit", "our hit rate (pp)")):
ds = [per_opp[a][o][key] for o in opps
if per_opp[a][o] and not math.isnan(per_opp[a][o][key])]
if len(ds) < 2:
continue
desc = ga.describe(ds)
pos, neg, ties, p_sign = ga.sign_test(ds)
sf = ga.signflip_perm(ds)
pw = wilcoxon_signed(ds)
st[key] = {"desc": desc, "pos": pos, "neg": neg, "ties": ties,
"p_sign": p_sign, "p_flip": sf["p"], "p_wilcox": pw}
out(f"| `{a}` | {mkey} | {desc['mean']:+.2f} | {desc['sd']:.2f} "
f"| [{desc['mean'] - 1.96 * desc['se']:+.2f}, "
f"{desc['mean'] + 1.96 * desc['se']:+.2f}] "
f"| {pos}/{pos + neg} | {p_sign:.4g} | {sf['p']:.4g} "
f"| {pw:.4g} | {desc['mde']:.2f} |")
stats[a] = st
out()
# ── style split ─────────────────────────────────────────────────────────
styles = sorted({style_of[o] for o in opps})
out("### MEASURED: by style (explanation only)")
out()
out("| arm | style | n | mean Δdmg | mean Δwins | mean Δour-hit (pp) |")
out("|---|---|---:|---:|---:|---:|")
for a in arms:
if a == ref:
continue
for s in styles:
es = [per_opp[a][o] for o in opps
if per_opp[a][o] and style_of[o] == s]
if not es:
continue
out(f"| `{a}` | {s} | {len(es)} "
f"| {statistics.mean([e['d_damage'] for e in es]):+.2f} "
f"| {statistics.mean([e['d_wins'] for e in es]):+.2f} "
f"| {statistics.mean([e['d_hit'] for e in es]):+.2f} |")
out()
# ── CONVERGENCE ─────────────────────────────────────────────────────────
out("### MEASURED: CONVERGENCE — our per-round hit rate")
out()
out("Pooled over every valid run (sum of hits / sum of shots, per round index).")
out()
maxr = session["rounds"]
out("| arm | " + " | ".join(f"R{i}" for i in range(1, maxr + 1)) + " | R1→Rlast (pp) |")
out("|---|" + "---:|" * (maxr + 1))
conv_all = {}
for a in arms:
runs = [r for o in opps for r in data[o][a]]
rhs = round_hit_rates(runs)
vals = []
for i in range(1, maxr + 1):
h, f = rhs.get(i, (0, 0))
vals.append(100.0 * h / f if f else float("nan"))
conv_all[a] = vals
delta = (vals[-1] - vals[0]) if not (math.isnan(vals[0]) or math.isnan(vals[-1])) else float("nan")
out(f"| `{a}` | " + " | ".join("n/a" if math.isnan(v) else f"{v:.1f}%"
for v in vals) + f" | {delta:+.1f} |")
out()
out("Same, restricted to the TRUE-SPINNER opponents "
f"({', '.join(spinner_opps)}):")
out()
out("| arm | " + " | ".join(f"R{i}" for i in range(1, maxr + 1)) + " | R1→Rlast (pp) |")
out("|---|" + "---:|" * (maxr + 1))
conv_spin = {}
for a in arms:
runs = [r for o in spinner_opps for r in data[o][a]]
rhs = round_hit_rates(runs)
vals = []
for i in range(1, maxr + 1):
h, f = rhs.get(i, (0, 0))
vals.append(100.0 * h / f if f else float("nan"))
conv_spin[a] = vals
delta = (vals[-1] - vals[0]) if not (math.isnan(vals[0]) or math.isnan(vals[-1])) else float("nan")
out(f"| `{a}` | " + " | ".join("n/a" if math.isnan(v) else f"{v:.1f}%"
for v in vals) + f" | {delta:+.1f} |")
out()
# within-round early vs late (fast adaptation), spinner opponents
out("Within-round adaptation on the true spinners: our hit rate over the")
out("first vs second half of each round (round-relative tick from the")
out("rounds sidecar), pooled over runs.")
out()
out("| arm | first-half hit% | second-half hit% | late−early (pp) |")
out("|---|---:|---:|---:|")
for a in arms:
early = [0, 0]
late = [0, 0]
for o in spinner_opps:
adir = os.path.join(session_dir, o, a)
for run in discover_runs(adir):
evs = ga.parse_events(os.path.join(adir, f"run{run}.events.jsonl"))
rpath = os.path.join(adir, f"run{run}.jsonl.rounds.json")
starts = read_round_starts(rpath)
log_text = "".join(ga.read_lines(os.path.join(adir, f"run{run}.battle.log")))
counters = ga.parse_counters(log_text)
if counters is None or not starts:
continue
subj, other = ga.attribute_subject(evs, counters)
if subj is None:
continue
counts = {rr["round"]: rr["count"]
for rr in json.load(open(rpath))["rounds"]}
for e in evs:
rnd = e.get("round", 0)
st = starts.get(rnd)
if st is None:
continue
rel = e.get("tick", 0) - st
bucket = early if rel < counts.get(rnd, 0) / 2 else late
if e.get("type") == "fire" and e.get("owner") == subj:
bucket[1] += 1
elif e.get("type") == "hit" and e.get("owner") == subj:
bucket[0] += 1
if early[1] and late[1]:
eh = 100.0 * early[0] / early[1]
lh = 100.0 * late[0] / late[1]
out(f"| `{a}` | {eh:.2f}% | {lh:.2f}% | {lh - eh:+.2f} |")
else:
out(f"| `{a}` | n/a | n/a | n/a |")
out()
# ── verdict ─────────────────────────────────────────────────────────────
out("### The pre-registered reading")
out()
out("PRIMARY = dmg/run and round wins. Our per-round hit rate is the")
out("SECONDARY mechanism metric. An arm is BETTER only if a primary improves")
out("with sign-test p<0.05 while the other primary does not go down.")
out()
out("| arm | Δwins/run | Δdmg/run | Δour-hit (pp) | sign test dmg | verdict |")
out("|---|---:|---:|---:|---|---|")
for a in arms:
if a == ref:
continue
st = stats.get(a, {})
d = st.get("d_damage")
w = st.get("d_wins")
h = st.get("d_hit")
if not d or not w:
out(f"| `{a}` | n/a | n/a | n/a | n/a | n/a |")
continue
up_d = d["desc"]["mean"] > 0 and d["p_sign"] < 0.05
up_w = w["desc"]["mean"] > 0 and w["p_sign"] < 0.05
if (up_d and w["desc"]["mean"] >= 0) or (up_w and d["desc"]["mean"] >= 0):
verdict = "**BETTER**"
elif d["desc"]["mean"] < -d["desc"]["mde"] or w["desc"]["mean"] < -w["desc"]["mde"]:
verdict = "WORSE"
else:
verdict = "not distinguishable"
out(f"| `{a}` | {w['desc']['mean']:+.2f} | {d['desc']['mean']:+.2f} "
f"| {h['desc']['mean']:+.2f} | {d['pos']}/{d['pos'] + d['neg']} "
f"p={d['p_sign']:.4g} | {verdict} |")
out()
if report_path:
with open(report_path, "w") as fh:
fh.write("\n".join(lines) + "\n")
return 0
def wilcoxon_signed(deltas):
"""Two-sided Wilcoxon signed-rank normal approximation with tie correction."""
nz = [d for d in deltas if d != 0.0]
n = len(nz)
if n < 3:
return 1.0
order = sorted(range(n), key=lambda i: abs(nz[i]))
ranks = [0.0] * n
i = 0
while i < n:
j = i
while j + 1 < n and abs(nz[order[j + 1]]) == abs(nz[order[i]]):
j += 1
avg = (i + j) / 2.0 + 1.0
for k in range(i, j + 1):
ranks[order[k]] = avg
i = j + 1
w_plus = sum(ranks[i] for i in range(n) if nz[i] > 0)
mu = n * (n + 1) / 4.0
cnt = {}
for d in nz:
cnt[abs(d)] = cnt.get(abs(d), 0) + 1
tie = sum(c ** 3 - c for c in cnt.values())
sigma2 = n * (n + 1) * (2 * n + 1) / 24.0 - tie / 48.0
if sigma2 <= 0:
return 1.0
z = (w_plus - mu - 0.5 * (1 if w_plus > mu else -1)) / math.sqrt(sigma2)
return min(1.0, 2.0 * 0.5 * math.erfc(abs(z) / math.sqrt(2.0)))
if __name__ == "__main__":
sys.exit(main())