#!/usr/bin/env python3
"""
The mice problem / cyclic pursuit — numerical verifier.

n bugs sit at the vertices of a regular n-gon. Each moves at constant speed v,
always aimed straight at the next bug counter-clockwise. We simulate the literal
chase (each bug takes a small step toward its target, repeat) and check the exact
theory against what the emergent motion actually does. Nothing here is asserted;
every number is measured from the simulation and compared to a closed form.

The theory (derived in the stratum), for a regular n-gon, side s, circumradius
r0 = s / (2 sin(pi/n)), every bug at speed v:

  1. The inward radial speed is CONSTANT:      -dr/dt = v * sin(pi/n)
  2. So the radius shrinks linearly and the meet time is  T = r0 / (v sin(pi/n))
  3. Each bug's total path length is             L = v*T = r0 / sin(pi/n)
                                                   = s / (2 sin^2(pi/n))
     -> for the SQUARE (n=4):  L = s exactly.
  4. The path is a logarithmic spiral  r = r0 * exp(-tan(pi/n) * phi),
     i.e. the angle between path and radius is the constant  pi/2 - pi/n.
  5. The bugs stay on a regular n-gon that only shrinks and rotates.
  6. The total angle swept (winding) DIVERGES like  cot(pi/n) * ln(r0/r):
     finite path, infinitely many turns.

Run:  python3 verify.py
"""
import math

def simulate(n, v=1.0, s=1.0, dt=2e-5, r_stop_frac=1e-3):
    """Literal cyclic pursuit. Returns sampled (t, r, phi, path) for bug 0
    and the accumulated path length down to r = r_stop_frac * r0."""
    r0 = s / (2 * math.sin(math.pi / n))
    # start on a regular n-gon, circumradius r0, bug i at angle 2*pi*i/n
    ang = [2 * math.pi * i / n for i in range(n)]
    x = [r0 * math.cos(a) for a in ang]
    y = [r0 * math.sin(a) for a in ang]
    r_stop = r_stop_frac * r0

    path0 = 0.0
    t = 0.0
    # unwound angle of bug 0 (accumulate so we can measure total winding)
    phi_prev = math.atan2(y[0], x[0])
    phi_unwound = phi_prev
    samples = []  # (t, r0_dist, phi_unwound, path0)

    step = 0
    while True:
        r = math.hypot(x[0], y[0])
        # sample sparsely
        if step % 200 == 0:
            samples.append((t, r, phi_unwound, path0))
        if r <= r_stop:
            break
        nx, ny = [0.0]*n, [0.0]*n
        for i in range(n):
            j = (i + 1) % n
            dx, dy = x[j] - x[i], y[j] - y[i]
            d = math.hypot(dx, dy)
            if d == 0:
                nx[i], ny[i] = x[i], y[i]
                continue
            ux, uy = dx/d, dy/d
            step_len = v * dt
            nx[i] = x[i] + ux * step_len
            ny[i] = y[i] + uy * step_len
            if i == 0:
                path0 += step_len
        x, y = nx, ny
        # track unwound angle of bug 0
        phi_now = math.atan2(y[0], x[0])
        dphi = phi_now - phi_prev
        # unwrap
        while dphi > math.pi:  dphi -= 2*math.pi
        while dphi < -math.pi: dphi += 2*math.pi
        phi_unwound += dphi
        phi_prev = phi_now
        t += dt
        step += 1

    return r0, r_stop, path0, samples


def regularity_error(n, v=1.0, s=1.0, dt=2e-5):
    """Check the bugs stay on a regular n-gon: max relative spread of the n
    side lengths, sampled midway through the chase."""
    r0 = s / (2 * math.sin(math.pi / n))
    ang = [2 * math.pi * i / n for i in range(n)]
    x = [r0 * math.cos(a) for a in ang]
    y = [r0 * math.sin(a) for a in ang]
    worst = 0.0
    while math.hypot(x[0], y[0]) > 0.05 * r0:
        # measure side spread
        sides = [math.hypot(x[(i+1)%n]-x[i], y[(i+1)%n]-y[i]) for i in range(n)]
        rad = [math.hypot(x[i], y[i]) for i in range(n)]
        spread = (max(sides)-min(sides))/max(sides) + (max(rad)-min(rad))/max(rad)
        worst = max(worst, spread)
        nx, ny = [0.0]*n, [0.0]*n
        for i in range(n):
            j = (i+1) % n
            dx, dy = x[j]-x[i], y[j]-y[i]
            d = math.hypot(dx, dy)
            nx[i], ny[i] = x[i]+v*dt*dx/d, y[i]+v*dt*dy/d
        x, y = nx, ny
    return worst


def check(label, got, want, tol, unit=""):
    rel = abs(got-want)/max(abs(want), 1e-12)
    ok = rel <= tol
    print(f"  [{'PASS' if ok else 'FAIL'}] {label:<46} got={got:.6f}{unit}  "
          f"want={want:.6f}{unit}  rel={rel:.2e}  (tol {tol:.0e})")
    return ok


def main():
    print("Cyclic pursuit — the mice problem. Every number below is measured")
    print("from a literal step-by-step chase, then matched to closed form.\n")
    v = 1.0
    all_ok = True

    for n in [3, 4, 5, 6, 7, 12]:
        s = 1.0
        r0 = s / (2 * math.sin(math.pi / n))
        r0_sim, r_stop, path_sim, samples = simulate(n, v=v, s=s)

        # (1) path length from r0 down to r_stop should be (r0 - r_stop)/sin(pi/n)
        want_path = (r0 - r_stop) / math.sin(math.pi / n)
        print(f"n = {n}   (side s = {s}, circumradius r0 = {r0:.6f})")
        ok1 = check("path length  L = (r0 - r_stop)/sin(pi/n)",
                    path_sim, want_path, 5e-3)

        # (2) full closed form to the centre, and the square identity
        L_full = r0 / math.sin(math.pi / n)          # v=1 so length = time
        L_formula = s / (2 * math.sin(math.pi/n)**2)  # equivalent form
        ok2 = check("closed form  r0/sin = s/(2 sin^2)",
                    L_full, L_formula, 1e-12)
        if n == 4:
            ok2 &= check("SQUARE identity  L == s",
                         L_full, s, 1e-12)

        # (3) constant inward radial speed: fit r(t) slope from samples
        # r ~ r0 - c t  with c = v sin(pi/n)
        ts = [t for (t, r, ph, p) in samples if r > 0.05*r0]
        rs = [r for (t, r, ph, p) in samples if r > 0.05*r0]
        # least squares slope
        m = len(ts); st = sum(ts); sr = sum(rs)
        stt = sum(t*t for t in ts); srt = sum(t*r for t, r in zip(ts, rs))
        slope = (m*srt - st*sr)/(m*stt - st*st)
        ok3 = check("radial speed  -dr/dt = v sin(pi/n)",
                    -slope, v*math.sin(math.pi/n), 5e-3)

        # (4) logarithmic spiral: ln(r) linear in phi with slope -tan(pi/n)
        pts = [(ph, r) for (t, r, ph, p) in samples if 0.02*r0 < r < 0.9*r0]
        phs = [ph for ph, r in pts]; lnr = [math.log(r) for ph, r in pts]
        mm = len(phs); sph = sum(phs); slnr = sum(lnr)
        sphph = sum(p*p for p in phs); sphlnr = sum(p*l for p, l in zip(phs, lnr))
        sp = (mm*sphlnr - sph*slnr)/(mm*sphph - sph*sph)
        ok4 = check("log spiral  d(ln r)/dphi = -tan(pi/n)",
                    -sp, math.tan(math.pi/n), 2e-2)

        # (5) regularity: the n-gon stays regular (spread ~ 0)
        worst = regularity_error(n, v=v, s=s)
        ok5 = worst < 5e-3
        print(f"  [{'PASS' if ok5 else 'FAIL'}] {'regular n-gon preserved (max spread)':<46} "
              f"spread={worst:.2e}  (tol 5e-3)")

        # (6) winding diverges: total unwound angle vs cot(pi/n)*ln(r0/r).
        # Measure winding and the radius at the SAME endpoint (last sample).
        t_end, r_end, phi_end, _ = samples[-1]
        phi_start = samples[0][2]
        winding = phi_end - phi_start
        want_wind = (1/math.tan(math.pi/n)) * math.log(r0 / r_end)
        ok6 = check("winding angle  = cot(pi/n) ln(r0/r)",
                    winding, want_wind, 2e-2, unit=" rad")

        all_ok &= (ok1 and ok2 and ok3 and ok4 and ok5 and ok6)
        print()

    print("=" * 62)
    print("ALL CHECKS PASSED" if all_ok else "SOME CHECKS FAILED")
    return 0 if all_ok else 1


if __name__ == "__main__":
    raise SystemExit(main())
