Research-Stack/4-Infrastructure/shim/lonely_runner_sim.py
allaun a247dcb1b6 feat: complete all four interconnected solves
1. NKHodgeFAMM ↔ BurgersPDE bridge — dq_energy_satisfies_scar_condition, burgers_embedding, applyViscosityN induction, scarDensityFromDQ. Build: 8316 jobs, 0 errors.

2. Lonely Runner sim — lonely_runner_sim.py with circular Betti tracking. All k≤4 test cases confirm beta_0 > 0 (lonely times exist). Fixed uint8 wrap-around bug.

3. Taylor-Green Betti tracker — taylor_green_betti.py with 6 scenarios. beta_2 correctly detects voids: TG smooth=0, TG+void>0, noise=77. Fixed int64 serialization in betti_tracker.py.

4. Q1/Q2 vorticity decomposition — VorticityDecomposition.lean: Q1_is_dilatational, Q2_is_solenoidal, enstrophy_proportional_to_Q2, velocity_hodge_decomposition. All rfl proofs + native_decide witnesses. Build: 8598 jobs, 0 errors.
2026-06-16 22:21:55 -05:00

480 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""lonely_runner_sim.py — Lonely Runner Betti simulation.
Simulates k runners on S¹, computes scar (uncovered) region M_t,
tracks β₀(M_t) — connected components of uncovered set.
β₀(M_t) > 0 ⇔ lonely runner exists at time t.
Imports scar_density, threshold_scar_support, compute_betti_numbers
from betti_tracker for compatibility; uses direct 1D circular β₀
calculator for correctness on S¹ topology.
Usage:
python3 lonely_runner_sim.py --test
python3 lonely_runner_sim.py --k 3 --speeds 0 1 2 --time 5 --steps 1000
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
import numpy as np
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
HAVE_MPL = True
except ImportError:
HAVE_MPL = False
sys.path.insert(0, str(Path(__file__).resolve().parent))
from betti_tracker import threshold_scar_support, compute_betti_numbers
# ── simulation core ──────────────────────────────────────────────────
def runner_positions(
speeds: list[float], times: np.ndarray
) -> np.ndarray:
"""Runner positions on S¹ at each time step.
Returns (T, k) array in [0, 1).
"""
speeds_arr = np.array(speeds, dtype=np.float64)
pos = np.outer(times, speeds_arr) % 1.0
return pos
def coverage_density(
positions: np.ndarray, delta: float, N: int = 512
) -> np.ndarray:
"""Coverage density Φ(t,θ) = Σᵢ 𝟙(|θ - vᵢt| < δ mod 1).
Returns (T, N) integer count array.
"""
T, k = positions.shape
theta = np.linspace(0, 1, N, endpoint=False)
phi = np.zeros((T, N), dtype=np.int32)
for t in range(T):
diff = np.abs(theta[:, np.newaxis] - positions[t, np.newaxis, :])
dist = np.minimum(diff, 1.0 - diff)
phi[t, :] = (dist < delta).sum(axis=1)
return phi
def scar_field(phi: np.ndarray) -> np.ndarray:
"""Scar (loneliness) field μ(t,θ) = 1 - min(Φ(t,θ), 1).
Returns (T, N) float64 array where 1 = uncovered, 0 = covered.
"""
return (1.0 - np.minimum(phi, 1)).astype(np.float64)
# ── 1D circular Betti-0 ─────────────────────────────────────────────
def betti_1d_circular(mask: np.ndarray) -> int:
"""β₀ of a 1D binary mask on S¹ (circular topology).
Counts connected components of the foreground (1 = scar),
accounting for wrap-around adjacency (first and last elements
are neighbours on the circle).
"""
if not mask.any():
return 0
if mask.all():
return 1
signed = 2 * mask.astype(np.int8) - 1
padded = np.concatenate([[signed[-1]], signed])
trans_up = int(((padded[1:] - padded[:-1]) > 0).sum())
return trans_up
# ── simulate ────────────────────────────────────────────────────────
SIM_CASES: dict[str, dict] = {
"k2_simple": {
"k": 2,
"speeds": [0, 1],
"description": "k=2 with speeds [0, 1]",
},
"k3_012": {
"k": 3,
"speeds": [0, 1, 2],
"description": "k=3 with speeds [0, 1, 2]",
},
"k4_0134": {
"k": 4,
"speeds": [0, 1, 3, 4],
"description": "k=4 with speeds [0, 1, 3, 4] — known lonely times",
},
"k3_rational": {
"k": 3,
"speeds": [0, 0.5, 1.0],
"description": "k=3 with rational multiples [0, 0.5, 1.0]",
},
"k4_random": {
"k": 4,
"speeds": [0, 1.0, 2.71, 3.14],
"description": "k=4 with random-ish speeds",
},
}
def simulate(
speeds: list[float],
total_time: float,
time_steps: int,
N: int = 512,
) -> dict:
"""Run a Lonely Runner simulation.
Returns a results dict with positions, scar data, and Betti series.
"""
k = len(speeds)
delta = 1.0 / (k + 1.0)
times = np.linspace(0, total_time, time_steps, endpoint=False)
pos = runner_positions(speeds, times)
phi = coverage_density(pos, delta, N=N)
mu = scar_field(phi)
masks = (mu > 0).astype(np.uint8)
betti_series: list[int] = []
scar_fractions: list[float] = []
for t in range(time_steps):
b0 = betti_1d_circular(masks[t])
betti_series.append(b0)
scar_fractions.append(float(masks[t].mean()))
theta = np.linspace(0, 1, N, endpoint=False)
return {
"k": k,
"speeds": speeds,
"delta": delta,
"total_time": total_time,
"time_steps": time_steps,
"N": N,
"times": times.tolist(),
"theta": theta.tolist(),
"positions": pos.tolist(),
"phi": phi.tolist(),
"mu": mu.tolist(),
"masks": masks.tolist(),
"betti_series": betti_series,
"scar_fractions": scar_fractions,
"min_betti_0": min(betti_series),
"max_betti_0": max(betti_series),
"lonely_times_found": max(betti_series) > 0,
"always_covered": max(betti_series) == 0,
"fraction_with_scar": sum(1 for b in betti_series if b > 0) / time_steps,
}
def build_report(results: dict) -> dict:
"""Build a JSON-serialisable report with concise summary."""
b0 = results["betti_series"]
summary_parts = []
if results["lonely_times_found"]:
summary_parts.append(
f"β₀(M_t) > 0 at some t (max={results['max_betti_0']})"
)
else:
summary_parts.append("β₀(M_t) = 0 ∀t — complete coverage")
summary_parts.append(
f"scar present {results['fraction_with_scar']*100:.1f}% of time"
)
summary_parts.append(
f"min β₀ = {results['min_betti_0']}, max β₀ = {results['max_betti_0']}"
)
return {
"schema": "lonely_runner_sim_v1",
"claim_boundary": "numerical-simulation;no-lean-proof",
"k": results["k"],
"speeds": results["speeds"],
"delta": results["delta"],
"total_time": results["total_time"],
"time_steps": results["time_steps"],
"N": results["N"],
"summary": "; ".join(summary_parts),
"lonely_times_found": results["lonely_times_found"],
"always_covered": results["always_covered"],
"min_betti_0": results["min_betti_0"],
"max_betti_0": results["max_betti_0"],
"fraction_with_scar": results["fraction_with_scar"],
"scar_fraction_mean": float(np.mean(results["scar_fractions"])),
"betti_series": b0,
"scar_fractions": results["scar_fractions"],
}
# ── plotting ────────────────────────────────────────────────────────
def plot_results(
results: dict, outdir: str, prefix: str = "lonely_runner"
) -> str | None:
"""Generate three-panel diagnostic plot.
Returns path to saved PNG, or None if matplotlib unavailable.
"""
if not HAVE_MPL:
return None
out_path = Path(outdir)
out_path.mkdir(parents=True, exist_ok=True)
ts = np.array(results["times"])
theta = np.array(results["theta"])
pos = np.array(results["positions"])
phi = np.array(results["phi"])
mu = np.array(results["mu"])
masks = np.array(results["masks"])
b0 = results["betti_series"]
fig, axes = plt.subplots(4, 1, figsize=(12, 11), sharex=False)
# Panel 1: runner trajectories + uncovered regions
ax = axes[0]
for i in range(results["k"]):
ax.plot(ts, pos[:, i], linewidth=0.8, label=f"R{i} (v={results['speeds'][i]})")
scar_t, scar_theta = np.where(masks > 0)
if len(scar_t) > 0:
ax.scatter(
ts[scar_t], theta[scar_theta],
s=0.5, c="red", alpha=0.3, label="uncovered",
)
ax.set_ylabel("position on S¹")
ax.set_title(
f"k={results['k']} speeds={results['speeds']} δ={results['delta']:.4f}"
)
ax.legend(fontsize=7, ncol=min(results["k"], 4))
ax.set_ylim(0, 1)
# Panel 2: coverage heatmap
ax = axes[1]
extent = [0, results["total_time"], 0, 1]
im = ax.imshow(
phi.T, aspect="auto", origin="lower", extent=extent,
cmap="YlOrRd", interpolation="nearest",
)
ax.set_ylabel("angle θ")
ax.set_title("Coverage density Φ(t,θ)")
plt.colorbar(im, ax=ax)
# Panel 3: scar field heatmap
ax = axes[2]
im2 = ax.imshow(
mu.T, aspect="auto", origin="lower", extent=extent,
cmap="Blues", interpolation="nearest", vmin=0, vmax=1,
)
ax.set_ylabel("angle θ")
ax.set_title("Scar (loneliness) field μ(t,θ)")
plt.colorbar(im2, ax=ax)
# Panel 4: β₀ over time
ax = axes[3]
ax.plot(ts, b0, "o-", color="C0", markersize=2, linewidth=0.8)
ax.axhline(y=0, color="gray", linestyle="--", alpha=0.5)
ax.set_ylabel("β₀")
ax.set_xlabel("time")
ax.set_title(f"β₀(M_t) — {'lonely times found' if results['lonely_times_found'] else 'always covered'}")
ax.set_ylim(-0.5, max(b0) + 1.5)
fig.suptitle(
f"Lonely Runner Simulation (k={results['k']}, δ={results['delta']:.4f})",
fontsize=13,
)
plt.tight_layout(rect=[0, 0, 1, 0.97])
png_path = out_path / f"{prefix}_k{results['k']}.png"
fig.savefig(png_path, dpi=150)
plt.close(fig)
return str(png_path)
# ── case runner ─────────────────────────────────────────────────────
def run_case(
name: str,
config: dict,
total_time: float = 5.0,
time_steps: int = 1000,
N: int = 512,
outdir: str = ".",
) -> dict:
"""Run one simulation case and write output files."""
out_path = Path(outdir) / name
out_path.mkdir(parents=True, exist_ok=True)
print(f"\n Case: {name} ({config['description']})")
print(f" k={config['k']} speeds={config['speeds']} δ={1.0/(config['k']+1):.4f}")
results = simulate(
config["speeds"],
total_time=total_time,
time_steps=time_steps,
N=N,
)
report = build_report(results)
png = plot_results(results, str(out_path), prefix=f"lonely_runner_{name}")
json_path = out_path / f"{name}_report.json"
with open(json_path, "w") as f:
json.dump(report, f, indent=2)
print(f" min β₀ = {report['min_betti_0']}, max β₀ = {report['max_betti_0']}")
print(f" scar fraction: {report['scar_fraction_mean']:.4f}")
print(f" lonely times: {report['lonely_times_found']}")
if png:
print(f" plot: {png}")
print(f" JSON: {json_path}")
return report
# ── tests ────────────────────────────────────────────────────────────
def test_betti_1d():
"""Unit tests for the 1D circular Betti-0 calculator."""
# all zeros → β₀ = 0
assert betti_1d_circular(np.zeros(10, dtype=np.uint8)) == 0
# all ones → β₀ = 1 (whole circle)
assert betti_1d_circular(np.ones(10, dtype=np.uint8)) == 1
# single block, no wrap
assert betti_1d_circular(np.array([1, 1, 0, 0, 0], dtype=np.uint8)) == 1
# two blocks, no wrap
assert betti_1d_circular(np.array([1, 1, 0, 0, 1], dtype=np.uint8)) == 1
# two blocks, wraps around
assert betti_1d_circular(np.array([1, 0, 1, 0, 1], dtype=np.uint8)) == 2
# alternating single
assert betti_1d_circular(np.array([1, 0, 1, 0, 1, 0], dtype=np.uint8)) == 3
# single isolated 1
assert betti_1d_circular(np.array([0, 0, 1, 0, 0], dtype=np.uint8)) == 1
print(" ✓ betti_1d_circular — all unit tests passed")
def test_coverage_sanity():
"""Sanity checks on coverage density calculation."""
# k=3, at t=0 each runner covers 2δ = 0.5 of circle
# with runners at 0, overlap means total covered < 3*0.5
speeds = [0, 1, 2]
delta = 1.0 / (3 + 1) # 0.25
times = np.array([0.0])
pos = runner_positions(speeds, times)
phi = coverage_density(pos, delta, N=100)
frac_covered = (phi[0] > 0).mean()
# At t=0 all runners at 0 → exactly δ on each side → 0.5
assert abs(frac_covered - 0.5) < 0.06, f"Expected ~0.5, got {frac_covered}"
# At t=0, coverage count at θ=0 should be 3 (all runners)
assert phi[0, 0] == 3, f"Expected phi=3 at origin, got {phi[0, 0]}"
print(f" ✓ coverage_sanity — k=3 at t=0 covers {frac_covered:.3f} (expected ~0.5)")
def test_small_k_cases():
"""Check well-known small-k cases produce lonely times."""
cases = [
("k=2 [0,1]", [0, 1]),
("k=3 [0,1,2]", [0, 1, 2]),
("k=4 [0,1,3,4]", [0, 1, 3, 4]),
]
for label, speeds in cases:
k = len(speeds)
res = simulate(speeds, total_time=5.0, time_steps=1000, N=512)
assert res["lonely_times_found"], f"{label}: expected lonely times, got always covered"
print(f"{label}: β₀ max = {res['max_betti_0']}, scar {res['fraction_with_scar']*100:.1f}%")
def _test():
"""Full test suite."""
print("=" * 60)
print("lonely_runner_sim — test suite")
print("=" * 60)
# Unit tests
print("\n--- Unit tests ---")
test_betti_1d()
test_coverage_sanity()
# Small-k verification
print("\n--- Small-k lonely times ---")
test_small_k_cases()
# Rational-multiple case (more coverage overlap)
print("\n--- Rational speeds (more overlap expected) ---")
r = run_case(
"k3_rational",
SIM_CASES["k3_rational"],
total_time=5.0, time_steps=1000, N=512,
outdir="/tmp/lonely_runner_test",
)
print(f" (rational case: scar {r['fraction_with_scar']*100:.1f}% of time)")
# Random speeds
print("\n--- Random-ish speeds ---")
r = run_case(
"k4_random",
SIM_CASES["k4_random"],
total_time=5.0, time_steps=1000, N=512,
outdir="/tmp/lonely_runner_test",
)
print("\n" + "=" * 60)
print("ALL TESTS PASSED")
print("=" * 60)
# ── CLI ─────────────────────────────────────────────────────────────
def main():
ap = argparse.ArgumentParser(
description="Lonely Runner Betti simulation"
)
ap.add_argument("--k", type=int, default=3, help="Number of runners")
ap.add_argument("--speeds", type=float, nargs="+", default=None, help="Runner speeds")
ap.add_argument("--time", type=float, default=5.0, help="Total simulation time")
ap.add_argument("--steps", type=int, default=1000, help="Number of time steps")
ap.add_argument("--N", type=int, default=512, help="Spatial discretisation points")
ap.add_argument("--outdir", default=".", help="Output directory")
ap.add_argument("--name", default="lonely_runner", help="Run name")
ap.add_argument("--test", action="store_true", help="Run test suite")
args = ap.parse_args()
if args.test:
_test()
return
speeds = args.speeds
if speeds is None:
speeds = list(range(args.k))
elif len(speeds) != args.k:
print(f"Error: --speeds must have {args.k} values for k={args.k}")
sys.exit(1)
config = {
"k": args.k,
"speeds": speeds,
"description": f"k={args.k} speeds={speeds}",
}
run_case(
args.name, config,
total_time=args.time,
time_steps=args.steps,
N=args.N,
outdir=args.outdir,
)
if __name__ == "__main__":
main()