symplectic-nbody

A dependency-free computational-physics library. Every claim below is produced by code in this repo and checked by its test suite. source on GitHub

Energy conservation: symplectic vs RK4

Symplectic integrators keep total energy bounded forever; RK4's energy walks off in one direction despite higher local accuracy.

Eccentric two-body (e=0.7), dt=0.01, 40k steps, G=1

method        max |dE/E|      net dE/E        drift shape
------------------------------------------------------------------------
verlet        4.916e-03       -1.112e-07       # @ # # * * + = = - : : . . .                               
forest_ruth   1.080e-05       1.114e-11        # @ # # * * + + = - - : : : . . . .                         
rk4           1.379e-04       1.379e-04               ........:::::::-------========++++++++*******#######@

Symplectic methods (verlet, forest_ruth) oscillate around 0.
RK4's error walks off in one direction -- that's secular energy drift.

Convergence order (vs the exact Kepler orbit)

Halving the step cuts verlet's error 4x (order 2) and forest_ruth/rk4's error 16x (order 4) -- measured, not assumed.

Convergence to the exact Kepler orbit (e=0.5, one period, G=1)
error = max position error at t=T vs analytic solution

verlet:
    steps         error    ratio   order
      500     4.500e-04       --      --
     1000     1.125e-04     4.00    2.00
     2000     2.813e-05     4.00    2.00
     4000     7.034e-06     4.00    2.00
     8000     1.758e-06     4.00    2.00

forest_ruth:
    steps         error    ratio   order
      500     8.095e-07       --      --
     1000     5.069e-08    15.97    4.00
     2000     3.170e-09    15.99    4.00
     4000     1.981e-10    16.00    4.00
     8000     1.242e-11    15.95    4.00

rk4:
    steps         error    ratio   order
      500     9.910e-08       --      --
     1000     5.798e-09    17.09    4.10
     2000     3.498e-10    16.57    4.05
     4000     2.146e-11    16.30    4.03
     8000     1.350e-12    15.90    3.99

verlet doubles-steps -> ~4x smaller error (order 2).
forest_ruth & rk4 -> ~16x smaller error (order 4).
Same order, but forest_ruth is symplectic: it ALSO keeps energy
bounded forever, which rk4 does not (see energy_drift_demo.py).

Hermite: 4th order at one force call per step

RK4 and Forest-Ruth reach 4th order at 4 and 3 force calls per step; the Hermite predictor-corrector reaches it with a single force+jerk call, so for a fixed force budget it takes more steps and lands far more accurate. It's the integrator real star-cluster codes use.

Accuracy vs force-evaluation budget (exact Kepler orbit, e=0.5)

method           steps   f-evals     end error   order
------------------------------------------------------
rk4               6000     24000     4.230e-12    4.10
forest_ruth       8000     24000     1.242e-11    4.00
hermite          24000     24000     1.964e-13    4.01

All three are 4th order, but for the SAME force-evaluation budget
Hermite takes 4x as many steps as RK4 (1 call/step vs 4), so it reaches
a much smaller error. That efficiency is why Hermite runs star clusters.

Adaptive stepping: same accuracy, far less work

Dormand-Prince RK45 spends tiny steps at pericenter and long steps at apocenter, matching fixed-RK4 accuracy with ~9x fewer force evals.

Eccentric two-body (e=0.7), one full period T=4.4429, G=1

adaptive step size over the orbit (small=pericenter, large=apocenter):
    .=+**###@##*+==---::..::::::........ ...                           .............::::::::::--===++*#######*-
  steps accepted=109  rejected=13  h_min=1.00e-03  h_max=1.17e-01  ratio=117x

method                     force evals     end error
----------------------------------------------------
adaptive DP45                      854      3.95e-08
fixed RK4 (2000 steps)            8000      8.02e-09

adaptive reaches the same accuracy with 9.4x fewer force evaluations.

Barnes-Hut scaling

An octree collapses distant bodies to their centre of mass, turning O(N^2) direct summation into a sub-quadratic force evaluation.

Force-evaluation time: direct O(N^2) vs Barnes-Hut O(N log N), theta=0.5

     N    direct (ms)     bh (ms)   speedup
---------------------------------------------
   200          14.88       19.75      0.8x
   400          60.72       46.69      1.3x
   800         192.12      132.34      1.5x
  1600         655.16      369.68      1.8x
  3200        2595.83     1003.31      2.6x

empirical scaling exponent  direct ~ N^1.83   barnes-hut ~ N^1.43
direct sits at ~2.0 (quadratic). barnes-hut is clearly sub-quadratic;
its exponent falls toward the N log N asymptote as N grows and the
per-call tree-build overhead is amortized over more force terms.

Orbit gallery

Trajectories integrated and rendered to dependency-free SVG. The animated versions move the bodies along their paths via SMIL (no JS).

Figure-eight choreography 3 equal masses, one shared orbit | forest_ruth, dt=0.002, 3140 steps, net dE/E=3.1e-13 plane=xy animated (SMIL, no JS)
figure-eight choreography (animated)
Eccentric two-body (e=0.7) started at apoapsis, G=1 | verlet, dt=0.005, 4000 steps, net dE/E=1.2e-03 plane=xy hollow=start filled=end
eccentric two-body, e=0.7
Burrau pythagorean 3-body masses 3-4-5, chaotic close encounters | forest_ruth, dt=0.0005, 120000 steps, net dE/E=1.8e-09 plane=xy hollow=start filled=end
Burrau pythagorean 3-body

The real solar system & Kepler's third law

Eight planets from published orbital elements, in AU/years/solar masses. T^2/a^3 comes out constant -- Kepler's third law, straight from Newtonian gravity and a symplectic integrator, no fitting.

Inner solar system Mercury, Venus, Earth, Mars (+ Sun), 2 Mars years, verlet plane=xy hollow=start filled=end
inner solar system (2 Mars years)
Solar system in AU / years / solar masses (G = 4*pi^2)

planet      a [AU]  T measured   T Kepler    T real   T^2/a^3
-------------------------------------------------------------
Mercury      0.387      0.2410     0.2408    0.2408    1.0010
Venus        0.723      0.6155     0.6152    0.6152    1.0010
Earth        1.000      1.0005     1.0000    1.0000    1.0010
Mars         1.524      1.8817     1.8808    1.8808    1.0010
Jupiter      5.204     11.9080    11.8724   11.8620    1.0060
Saturn       9.582     29.6906    29.6610   29.4570    1.0020
Uranus      19.189     84.1010    84.0589   84.0210    1.0010
Neptune     30.070    164.9740   164.8916  164.7900    1.0010

T^2/a^3 is constant across all planets -- that constant is 1 in these
units, which IS Kepler's third law. It falls straight out of Newtonian
gravity + a symplectic integrator, no fitting.

wrote C:\Users\acwic\symplectic-nbody\examples\output\inner_planets.svg

Exoplanet detection: transits & radial velocity

Two methods, both simple geometry + Kepler: a transit dims the star by (R_p/R_star)^2 (Jupiter ~1%, Earth 0.008%), and the star wobbles by a radial-velocity K (Jupiter 12 m/s, Earth 9 cm/s). Hot Jupiters give the biggest signals -- which is why they were found first.

Transit light curve (Jupiter across the Sun) brightness dips by (R_p/R_star)^2 = 1.01% during transit time -> relative brightness
a transit light-curve dip
Exoplanet detection: transit depth and radial-velocity wobble

  planet            a (AU)       depth   RV K (m/s)      period
  -------------------------------------------------------------
  hot Jupiter        0.050    1.01e-02       127.07       4.1 d
  Jupiter            5.204    1.01e-02        12.46     11.9 yr
  Earth              1.000    8.39e-05         0.09     365.2 d

  A transiting Jupiter dims its star ~1%; an Earth only 0.008%. Jupiter
  wobbles the Sun 12 m/s, Earth just 9 cm/s. Hot Jupiters -- big, close,
  fast -- give the strongest signals, which is why they were found first.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\exoplanet.svg

The habitable zone: where liquid water survives

A planet's equilibrium temperature T_eq ~ L^1/4 / sqrt(d) sets the band of orbits where water stays liquid. Earth's T_eq is 255 K (greenhouse warms it to 288 K). The zone marches out as sqrt(L) -- close in for red dwarfs, far out for luminous stars.

Earth Habitable zone vs stellar luminosity (log-log) outer edge inner edge (green band = HZ) log10 L / L_sun -> log10 orbital distance (AU)
HZ inner/outer edges vs stellar luminosity
The habitable zone: liquid-water orbits around a star

  Earth's equilibrium temperature: 255 K
  (greenhouse warms the surface to ~288 K)

  star                L (L_sun)   HZ inner   HZ outer
  ---------------------------------------------------
  red dwarf (0.3)          0.01     0.06 AU    0.11 AU
  Sun (1.0)                1.00     0.47 AU    0.87 AU
  F star (1.5)             4.13     0.95 AU    1.77 AU
  A star (2.0)            11.31     1.57 AU    2.93 AU

  The zone marches out as sqrt(L): red-dwarf HZs hug the star (and risk
  tidal locking), while luminous stars push it far out. This is the target
  band for finding worlds that could have surface oceans.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\habitable_zone.svg

Gravitational focusing & runaway growth

Colliding bodies don't need a direct hit -- gravity bends distant trajectories in, enhancing the cross-section by 1 + v_esc^2/v_inf^2. In a cold planetesimal swarm this makes the biggest bodies grow fastest (runaway growth), seeding planetary embryos.

v_inf ~ v_esc Gravitational focusing enhancement vs encounter speed huge (runaway) when v_inf < v_esc, -> 1 (geometric) when fast log10 encounter speed (m/s) -> log10 cross-section / geometric
cross-section enhancement plunging with encounter speed
Gravitational focusing (100 km planetesimal)

  escape speed: 130 m/s

   v_inf (m/s)    focusing    Safronov      regime
  ------------------------------------------------
             1     16775.3     8387.17     runaway
            10       168.7       83.87     runaway
            50         7.7        3.35     runaway
           100         2.7        0.84   geometric
           500         1.1        0.03   geometric
          1000         1.0        0.01   geometric

  When the swarm is dynamically cold (v_inf << v_esc) gravity bends
  distant trajectories into collisions, so the biggest bodies grow
  fastest -- runaway growth that seeds planetary embryos. Stir the swarm
  up (high v_inf) and only direct hits count: growth slows to geometric.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\focusing.svg

Hulse-Taylor binary pulsar (GW before LIGO)

PSR B1913+16's orbit shrinks as it radiates gravitational waves; the predicted period decay dP/dt = -2.40e-12 s/s matches the measured value to 99%. Tracking the cumulative shift for decades won the 1993 Nobel Prize, 22 years before LIGO's direct detection.

Hulse-Taylor cumulative periastron shift GR prediction (blue) with sample epochs (gold) -- the orbit is decaying years -> shift (s), downward
the famous cumulative-periastron-shift parabola
PSR B1913+16 (Hulse-Taylor): gravitational waves before LIGO

  orbital period      : 7.752 hours
  eccentricity        : 0.6171334
  dP/dt (GR predicted): -2.4031e-12 s/s
  dP/dt (measured)    : -2.423e-12 s/s
  agreement           : 99.2% of measured

  cumulative shift over 30 yr : -38.6 s
  P/|dP/dt| timescale         : 368 Myr

  The orbit's period shrinks by ~76 microseconds per year as the system
  radiates gravitational waves. Tracking that decay for decades -- the
  parabola below -- won the 1993 Nobel Prize, 22 years before LIGO.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\pulsar.svg

Gravitational-wave inspiral (the LIGO chirp)

A 2.5PN radiation-reaction force bleeds orbital energy into gravitational waves. The binary spirals inward and its frequency chirps upward -- the same mechanism as GW150914. The energy-loss rate matches Peters (1964) to a few percent.

Gravitational-wave inspiral relative orbit shrinks to merger as it radiates (GR amplified)
relative orbit spiralling to merger
Gravitational-wave inspiral (equal-mass binary, GR amplified: v/c~0.20)

  energy-loss rate  dE/dt: measured -1.426e-04  Peters -1.243e-04  ratio 1.147

  separation: 1.00 -> 0.38  (orbit shrinks as it radiates)
  orbital frequency chirps up 4.2x:
                                            ...............:::::::---==+*@

  wrote C:\Users\acwic\symplectic-nbody\examples\output\gw_inspiral.svg
  Same mechanism as GW150914: the binary radiates orbital energy as
  gravitational waves and spirals to merger, chirping as it goes.

GW strain: the number LIGO measures

The wave's amplitude h ~ (G M_c/c^2)^{5/3}(pi f/c)^{2/3}/d. For GW150914 (chirp mass ~28 M_sun, 410 Mpc) that is h ~ 1e-21, moving LIGO's 4 km arms by ~1e-18 m -- a thousandth of a proton's width.

GW150914 (410 Mpc, h~2e-21) GW strain vs distance (36+29 M_sun binary) log10 distance (Mpc) -> log10 strain h
strain vs distance, with GW150914 marked
Gravitational-wave strain: measuring a proton-width in kilometers

  GW150914 (36 + 29 M_sun, 410 Mpc, f_gw~150 Hz):
    chirp mass    = 28.1 M_sun
    strain h      = 2.13e-21
    LIGO arm move = 8.50e-18 m (1.1% of a proton width)

  binary                        distance    strain h
  --------------------------------------------------
  GW150914 (36+29)                410 Mpc     2.1e-21
  neutron stars (1.4+1.4)         130 Mpc     3.6e-23
  supermassive (1e6+1e6)        10000 Mpc     2.7e-15

  A strain of 1e-21 moves LIGO's 4 km arms by ~1e-18 m -- a thousandth
  of a proton's width. Detecting it is why LIGO is one of the most
  sensitive instruments ever built.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\gw_strain.svg

Gravitational waves circularize binaries (Peters 1964)

The coupled Peters (a, e) equations: as a binary radiates, both its size and its eccentricity shrink, with e falling faster near merger. Every orbit -- even a wildly eccentric one -- is nearly circular by the time it merges.

e0=0.2 e0=0.4 e0=0.6 e0=0.8 e0=0.9 All binaries circularize as they inspiral Peters (a,e) tracks; x = semi-major axis, y = eccentricity; hollow = start a decreases -> merger
(a, e) tracks all bending to e=0
Peters (1964): gravitational waves circularize binaries

    e0     a_final     e_final   e reduction
--------------------------------------------
  0.20      0.0098      0.0001       1406.2x
  0.40      0.0099      0.0004       1093.2x
  0.60      0.0098      0.0009        688.1x
  0.80      0.0099      0.0031        259.9x
  0.90      0.0099      0.0099         90.9x

wrote C:\Users\acwic\symplectic-nbody\examples\output\circularization.svg
Every track bends toward e=0: by merger, even a wildly eccentric
binary is nearly circular. That's why LIGO templates start circular.

Galaxy collision & tidal tails

Two disk galaxies (heavy cores + cold tracer disks) pass close, and differential gravity draws their disks into bridges and tails -- the same physics as the Antennae and the Mice. Barnes-Hut forces, ~1000 bodies. Frames left-to-right in time.

t = 4.0 two disk galaxies + tidal tails (blue=A, pink=B, gold=cores)
approach
t = 8.0 two disk galaxies + tidal tails (blue=A, pink=B, gold=cores)
close passage
t = 16.0 two disk galaxies + tidal tails (blue=A, pink=B, gold=cores)
tidal tails
Two-galaxy encounter: 1002 bodies, Barnes-Hut forces

  wrote C:\Users\acwic\symplectic-nbody\examples\output\galaxy_t0.svg  (t=0.0)
  wrote C:\Users\acwic\symplectic-nbody\examples\output\galaxy_t1.svg  (t=4.0)
  wrote C:\Users\acwic\symplectic-nbody\examples\output\galaxy_t2.svg  (t=8.0)
  wrote C:\Users\acwic\symplectic-nbody\examples\output\galaxy_t3.svg  (t=12.0)
  wrote C:\Users\acwic\symplectic-nbody\examples\output\galaxy_t4.svg  (t=16.0)

487 tracer particles pulled into tidal bridges/tails.
Same physics as the Antennae and the Mice: differential gravity
stretches a cold disk into long tails during a close passage.

Dynamical friction: satellites spiralling in

A massive body moving through a star field pulls a wake behind it that drags it back (Chandrasekhar friction ~ M^2 rho / v^2). Satellites and globular clusters spiral into their host on a time that scales as 1/M -- heavier sinks faster, dragging black holes to centres.

Dynamical friction vs speed zero as v->0 (no wake), peaks near the dispersion, ~1/v^2 at high v speed / velocity dispersion -> deceleration (m/s^2)
drag vs speed: zero at rest, peaks, then 1/v^2
Chandrasekhar dynamical friction: satellites spiralling in

  sinking time into a Milky-Way-like halo (r0=50 kpc, v_circ=220 km/s):
      satellite mass    sinking time
  ----------------------------------
  globular cluster (1e+08)       292.55 Gyr
   LMC-scale (1e+10)         2.93 Gyr
  massive dwarf (1e+11)         0.29 Gyr

  Heavier objects sink faster (t ~ 1/M): a massive satellite merges in a
  few Gyr, while a light globular cluster survives ~a Hubble time. This
  drags massive black holes to galactic centres and erodes cluster orbits.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\dynamical_friction.svg

Sitnikov problem: a clean route to chaos

A massless body on the z-axis through a binary. For a circular binary the stroboscopic map is smooth nested curves (integrable); give the binary eccentricity and the inner curves shred into a chaotic layer -- the system Moser used to prove chaos exists.

Sitnikov: circular binary (e=0) -- integrable axes (z, vz) sampled once per binary period; hollow curves = tori, dust = chaos
e=0: nested tori (integrable)
Sitnikov: eccentric binary (e=0.3) -- chaotic layer axes (z, vz) sampled once per binary period; hollow curves = tori, dust = chaos
e=0.3: chaotic layer
Sitnikov problem: stroboscopic Poincare maps (sample once per binary period)

  e=0.0: 10 orbits survived, 2500 section points, mean z-spread 1.83
  wrote C:\Users\acwic\symplectic-nbody\examples\output\sitnikov_circ.svg
  e=0.3: 10 orbits survived, 2500 section points, mean z-spread 198.81
  wrote C:\Users\acwic\symplectic-nbody\examples\output\sitnikov_ecc.svg

e=0 draws smooth nested curves (each orbit lies on an invariant torus).
e=0.3 tears the inner curves into a chaotic sea -- the Sitnikov route to chaos.

Poincare surface-of-section

Many orbits at the SAME Jacobi energy, their y=0 crossings overlaid on the (x, vx) plane. Smooth closed loops are quasi-periodic KAM tori; the scattered dust is chaos -- coexisting at one energy.

Poincare section: CR3BP at fixed energy mu=0.1, Jacobi C=3.9, surface y=0; axes (x, vx). closed loops = tori, scatter = chaos
tori and chaotic sea at one energy
Poincare section of the CR3BP (mu=0.1, Jacobi C=3.9, surface y=0)

integrated 19 orbits; 2 trace tight closed curves (KAM tori), the rest fill chaotic regions.

wrote C:\Users\acwic\symplectic-nbody\examples\output\poincare_section.svg

Sedov-Taylor blast wave

A point energy release drives a self-similar shock, R ~ (E t^2/rho)^1/5. The same law dates supernova remnants (pc-scale, thousands of km/s) and -- run backwards -- let G. I. Taylor weigh the Trinity bomb from a movie of the fireball while its yield was still classified.

Supernova remnant: radius (blue) & shock speed (pink) radius ~ t^2/5 (rises) shock speed ~ t^-3/5 (decelerates) remnant age (yr) ->
remnant radius (t^2/5) and decelerating shock speed
Sedov-Taylor blast wave

  Trinity test (Taylor's declassification trick):
    fireball R=130 m at t=25 ms in air -> yield ~ 9 kilotons
    (the actual device was ~21 kt -- right order from a movie frame)

  Supernova remnant (E=1e51 erg, ISM ~1 H/cc):
    age (yr)   radius (pc)  shock (km/s)       T (K)
  --------------------------------------------------
         100          2.05          8018     8.8e+08
         300          3.18          4148     2.3e+08
        1000          5.15          2014     5.5e+07
        3000          7.99          1042     1.5e+07
       10000         12.93           506     3.5e+06

  R grows as t^2/5 and the shock decelerates as t^-3/5. The same self-
  similar law dates supernova remnants and (run backwards) weighed the bomb.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\sedov.svg

Jeans instability: the birth of a star

A gas cloud collapses when gravity beats pressure. The dispersion relation omega^2 = c_s^2 k^2 - 4 pi G rho splits into stable sound waves (short wavelength) and collapsing modes (long wavelength) at the Jeans length -- the threshold for all star and structure formation.

k_J Jeans dispersion: omega^2 (blue) and growth rate (pink) left of k_J: omega^2<0, collapse. right: sound waves. wavenumber k ->
omega^2 goes negative below k_J: collapse
Jeans instability: the collapse threshold (c_s = rho0 = G = 1)

  Jeans wavenumber k_J : 3.5449
  Jeans length         : 1.7725
  Jeans mass           : 2.9156
  free-fall time       : 0.5427

    k/k_J     omega^2       behaviour
  -----------------------------------
     0.25     -11.781        collapse
     0.50      -9.425        collapse
     0.75      -5.498        collapse
     1.00      -0.000        collapse
     1.50      15.708      sound wave
     2.00      37.699      sound wave

  Below k_J (long wavelength / large cloud) gravity beats pressure and
  the gas collapses -- the birth of a star. Above it, pressure wins and
  the perturbation is just a sound wave.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\jeans.svg

Neutron stars & the TOV maximum mass

For a neutron star, gravity is strong enough that Newtonian hydrostatics fails -- you need the relativistic TOV equation. The mass-radius curve turns over at a maximum mass (no static star above it), while the Newtonian version has no limit. GR makes black holes possible.

TOV maximum mass 1.35 M_sun Neutron-star mass-radius relation (TOV) radius (km); the curve turns over at the maximum mass mass (M_sun)
neutron-star mass-radius curve with a maximum mass
Neutron-star structure: the TOV maximum mass

       rho_c    R (km)     M_TOV    M_Newton
  ------------------------------------------
     1.0e-03     10.37     0.946        1.70
     2.3e-03      8.80     1.274        3.89
     5.2e-03      7.09     1.342        8.91
     1.2e-02      5.77     1.211       20.41
     2.8e-02      5.10     1.066       46.75
     6.3e-02      4.92     0.991      107.11
     1.4e-01      4.92     0.969      245.37
     3.3e-01      4.93     0.965      562.11
     7.6e-01      4.94     0.965     1287.71

  TOV maximum mass       : 1.351 M_sun (the sequence turns over)
  Newtonian, densest star: 1698 M_sun (no limit -- grows forever)

  General relativity imposes a maximum neutron-star mass. Above it the
  star cannot support itself and collapses to a black hole.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\tov.svg

Fermi degeneracy pressure

The Pauli principle makes a cold, dense electron gas resist compression -- the quantum pressure that supports white dwarfs. It softens from P ~ n^5/3 to P ~ n^4/3 as electrons turn relativistic, and that softer exponent is the seed of the Chandrasekhar mass.

p_F = m_e c Degeneracy pressure vs density (log-log) non-rel P ~ n^5/3 (steeper) relativistic P ~ n^4/3 (softer -> Chandrasekhar) log10 n (/m^3) -> log10 P (Pa)
pressure laws vs density with the relativistic transition
Fermi degeneracy pressure: the quantum floor under dead stars

  relativistic transition density: 5.87e+35 /m^3

      n (/m^3)            regime        P (Pa)
  ----------------------------------------------
         1e+29           non-rel      5.03e+10
         1e+32           non-rel      5.03e+15
         1e+35           non-rel      5.03e+20
         1e+36      relativistic      2.45e+22
         1e+38      relativistic      1.13e+25

  Below the transition the pressure goes as n^5/3; above it, as n^4/3.
  That softer exponent is exactly why gravity eventually wins in a
  massive white dwarf -- the origin of the Chandrasekhar mass.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\degeneracy.svg

The Chandrasekhar mass

White dwarfs are held up by electron degeneracy pressure. Integrating the full relativistic degenerate equation of state, the mass climbs toward a hard limit ~1.44 M_sun as the star shrinks -- above it no white dwarf is stable, the trigger for type-Ia supernovae.

Chandrasekhar limit ~1.44 M_sun White-dwarf mass-radius relation radius (km) -- smaller = denser -> approaches the mass limit mass (M_sun)
mass-radius curve approaching the 1.44 M_sun limit
White-dwarf structure and the Chandrasekhar limit

  n=3 Lane-Emden mass factor : 2.01824
  Chandrasekhar mass (mu_e=2): 1.435 M_sun (the famous 1.44)

    rho_c (kg/m^3)   radius (km)  mass (M_sun)
  --------------------------------------------
           3.2e+08         13220         0.246
           1.0e+09         10780         0.395
           3.2e+09          8680         0.588
           1.0e+10          6920         0.802
           3.2e+10          5400         1.001
           1.0e+11          4140         1.157
           3.2e+11          3100         1.264
           1.0e+12          2260         1.325
           3.2e+12          1600         1.353
           1.0e+13          1080         1.357

  As central density rises, the electrons turn relativistic, the star
  shrinks, and its mass climbs toward -- but never past -- 1.44 M_sun.
  Beyond it there is no stable white dwarf: it collapses (type-Ia SN).

  wrote C:\Users\acwic\symplectic-nbody\examples\output\chandrasekhar.svg

Stellar structure (Lane-Emden)

A self-gravitating polytropic gas sphere obeys the Lane-Emden equation. Its density profile and surface radius depend on the index n: n=1 is the exact sin(xi)/xi, n=3 the Eddington standard model, n=5 has finite mass but infinite radius.

n = 0.0 n = 1.0 n = 1.5 n = 3.0 n = 5.0 Lane-Emden density profiles theta(xi) scaled radius xi -> theta (scaled density)
density profiles for several polytropic indices
Lane-Emden stellar structure: polytrope index n

     n    surface xi_1     mass -xi1^2 theta                   meaning
  --------------------------------------------------------------------
   0.0           2.449                 4.899    uniform-density sphere
   1.0           3.142                 3.142       analytic sin(xi)/xi
   1.5           3.654                 2.714  convective / white dwarf
   3.0           6.897                 2.018  Eddington standard model
   5.0             inf                   n/a           infinite radius

  As n rises the star grows more centrally concentrated. n=1 is the
  exact sin(xi)/xi; n=3 is the Eddington standard model of a star;
  n=5 has finite mass but infinite radius.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\lane_emden.svg

The main sequence & the HR diagram

Mass rules a star's life: L ~ M^3.5, so massive blue stars are millions of times brighter but burn out in a few Myr, while red dwarfs live hundreds of Gyr. Luminosity vs temperature traces the main sequence -- the backbone of the Hertzsprung-Russell diagram.

Sun The main sequence (HR diagram) log10 T_eff -- hot/blue to the LEFT (astronomer convention) log10 L / L_sun
the main sequence on an HR diagram
The main sequence: mass sets luminosity, temperature, and lifetime

  mass (M_sun)   L (L_sun)  T_eff (K)      lifetime
  -------------------------------------------------
           0.3         0.0       3258     202.9 Gyr
           0.5         0.1       4153      56.6 Gyr
           1.0         1.0       5772      10.0 Gyr
           2.0        11.3       8023       1.8 Gyr
           5.0       279.5      12398       179 Myr
          10.0      3162.3      17232        32 Myr
          30.0    147885.1      29037         2 Myr

  L ~ M^3.5, so massive O/B stars are millions of times brighter but
  burn out in a few Myr, while red dwarfs sip fuel for hundreds of Gyr.
  Plotting L vs T gives the main sequence -- the backbone of the HR diagram.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\main_sequence.svg

Kelvin-Helmholtz time: why gravity can't power the Sun

Gravitational contraction (t_KH = G M^2 / R L) could light the Sun for only ~30 Myr -- far short of Earth's 4.5 Gyr age, the historic proof that stars need nuclear fusion. It is instead how long a protostar contracts before fusion ignites.

Timescales vs stellar mass (log-log, Myr) nuclear (main-sequence) lifetime Kelvin-Helmholtz (gravity) time -- far shorter log10 mass (M_sun) ->
Kelvin-Helmholtz vs nuclear timescale by mass
Kelvin-Helmholtz (gravity) vs nuclear timescales

  Sun's KH time: 31 Myr -- vs Earth's 4500 Myr age.
  Gravity alone runs the Sun for only ~30 Myr, so it MUST fuse hydrogen.

  mass (M_sun)    t_KH (Myr)   t_nuclear (Myr)
  --------------------------------------------
           0.5        154.71           56568.5
           1.0         31.42           10000.0
           2.0          6.38            1767.8
           5.0          0.78             178.9
          10.0          0.16              31.6

  The nuclear lifetime dwarfs the KH time at every mass -- fusion, not
  contraction, powers stars. The KH time is instead how long a
  protostar contracts before fusion ignites.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\kelvin_helmholtz.svg

Cosmic distances & the discovery of acceleration

Every cosmological distance is one integral of 1/E(z). A dark-energy universe puts a given redshift farther away, so distant type-Ia supernovae look ~0.4 mag fainter -- the 1998 acceleration result. The angular-diameter distance also turns over near z~1.6.

distance modulus, LCDM distance modulus, EdS (fainter gap = dark energy) angular-diameter distance, LCDM (turns over) Hubble diagram: dark energy makes distant SNe fainter redshift z ->
Hubble diagram: LCDM vs decelerating, plus D_A turnover
Cosmic distances: the Hubble diagram and cosmic acceleration

      z     mu LCDM      mu EdS    Delta mu (fainter)
  ---------------------------------------------------
    0.2      39.956      39.760                +0.196
    0.5      42.261      41.862                +0.399
    1.0      44.100      43.502                +0.598
    1.5      45.189      44.480                +0.709

  Type-Ia supernovae at z~0.5 sit ~0.4 mag ABOVE the decelerating
  prediction -- fainter, hence farther, hence the expansion is
  accelerating. That is the 1998 result (2011 Nobel Prize).

  angular-diameter distance peaks at z = 1.61
  (past it, more distant objects look angularly LARGER -- why the CMB
  acoustic spots subtend about a degree).

  wrote C:\Users\acwic\symplectic-nbody\examples\output\distances.svg

Expansion of the universe (Friedmann)

The scale factor a(t) under the Friedmann equation: radiation gives a ~ t^1/2, matter a ~ t^2/3, dark energy exponential growth. A flat LCDM universe ages to ~0.96/H0 (~13.5 Gyr) and is now entering its accelerating dark-energy era.

a=1 (today) radiation matter dark energy flat LCDM Expansion of the universe: scale factor a(t) time (1/H0) ->
scale factor for radiation, matter, dark energy, LCDM
Friedmann cosmology: the expansion of the universe (time in 1/H0)

  age of a flat LCDM universe : 0.964/H0
    (with H0 = 70 km/s/Mpc this is 13.5 Gyr)

  universe        expansion exponent a ~ t^n
  ------------------------------------------
  radiation                         n ~ 0.50
  matter                            n ~ 0.67
  dark energy     exponential (accelerating)
  flat LCDM                         n ~ 1.14

  Radiation gives t^1/2, matter t^2/3, dark energy exponential growth.
  Our universe (LCDM) coasted through matter domination and is now
  entering the accelerating dark-energy era.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\friedmann.svg

Cosmic recombination (Saha equation)

The universe went neutral -- releasing the cosmic microwave background -- at z~1400, T~3700 K, NOT at kT = 13.6 eV (~158000 K). The ~1.6 billion photons per baryon keep hydrogen ionized far below its binding energy; the Saha equation pins the transition.

recombination z~1379 Cosmic recombination: ionization fraction vs redshift redshift (earlier/hotter to the left) -- the CMB is released as x -> 0 ionized fraction x
ionization fraction plunging to zero at recombination
Cosmic recombination: when the universe went neutral

  naive guess (kT = 13.6 eV)     : 157821 K
  actual recombination (x = 0.5) : z = 1379, T = 3760 K
  ~40x cooler than the naive value, because there are ~1.6 billion
  photons per baryon -- the hot tail keeps hydrogen ionized.

    redshift    temp (K)   ionized x
  ----------------------------------
        1600        4363      0.9925
        1400        3818      0.6037
        1300        3545      0.1867
        1200        3273      0.0339
        1100        3000      0.0041
        1000        2728      0.0003

  Below z~1100 the universe is neutral and transparent: the photons
  free-stream to us as the cosmic microwave background.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\saha.svg

CMB acoustic scale: the 1-degree spots

The sound horizon at recombination is a fixed ruler; seen across the distance to last scattering it subtends ~1 degree, putting the first acoustic peak at multipole l ~ 220. Its position pins the universe's geometry to flat.

first peak l~225 CMB acoustic peaks (schematic power spectrum) multipole l (angular scale ~ 180/l degrees) -> temperature power (arb.)
schematic acoustic peaks with the first at l~220
The CMB acoustic scale (flat LCDM)

  recombination redshift    : z = 1379
  sound horizon r_s         : 192 Mpc
  distance to last scattering: 13734 Mpc
  acoustic angle theta      : 0.80 deg
  first acoustic peak       : l ~ 225  (WMAP/Planck: 220)

  A fixed sound-horizon ruler at the edge of the observable universe
  subtends about a degree; the harmonics of that standing wave are the
  acoustic peaks, and their position pins the geometry to flat.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\cmb.svg

Blackbody radiation: Planck, Wien, Stefan-Boltzmann

The universal thermal spectrum: hotter bodies peak bluer (lambda_max T = 2.9 mm K) and radiate as T^4. It sets stellar colors, the Sun's 500 nm peak, and the 2.725 K CMB's microwave peak.

3000 K 5772 K 20000 K Planck spectra (hotter = bluer & brighter) wavelength (nm); shaded = visible band
Planck spectra: hotter = bluer and brighter
Blackbody radiation: Wien's peak and Stefan-Boltzmann

  object                   T (K)          peak    flux (W/m^2)
  ------------------------------------------------------------
  CMB                          3       1.06 mm        3.13e-06
  human body                 310       9348 nm        5.24e+02
  cool star (M)             3000        966 nm        4.59e+06
  Sun (G)                   5772        502 nm        6.29e+07
  hot star (B)             20000        145 nm        9.07e+09

  Hotter bodies peak bluer (Wien) and radiate vastly more (Stefan-
  Boltzmann T^4): a 20000 K B-star outshines the Sun per unit area by
  144x. The 2.725 K CMB peaks in the microwave.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\blackbody.svg

Compton & inverse-Compton scattering

Photons trade energy with electrons: Compton down-shifts a photon (shift = lambda_C(1-cos theta), lambda_C = 2.426 pm), while inverse Compton off a relativistic electron boosts it by ~gamma^2 -- turning CMB and starlight into X-rays and gamma-rays.

Compton: scattered photon energy vs angle (500 keV in) scattering angle (deg); most energy lost at back-scatter (180) E scattered (keV)
scattered photon energy falling with angle
Compton scattering (photon off a stationary electron)

  Compton wavelength   : 2.426 pm
  electron rest energy : 511 keV

   angle (deg)  shift (pm)   E scattered (keV)
  --------------------------------------------
             0       0.000               500.0
            45       0.711               388.6
            90       2.426               252.7
           135       4.142               187.2
           180       4.853               169.1

  Inverse Compton (fast electron kicks a photon UP):
    gamma=   10: boost x132  (a 1 meV CMB photon -> 0 eV)
    gamma=  100: boost x13332  (a 1 meV CMB photon -> 13 eV)
    gamma= 1000: boost x1333332  (a 1 meV CMB photon -> 1333 eV)

  Compton down-shifts photons off electrons; inverse Compton up-shifts
  them off relativistic electrons -- how CMB and starlight become X-rays.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\compton.svg

The Larmor formula: radiation from acceleration

Any accelerating charge radiates, with power P ~ q^2 a^2. Relativistically a circular accelerator boosts it by gamma^4 and a linear one by gamma^6. It also dooms the classical atom (an electron spirals in in ~1.6e-11 s) -- and it's the engine under synchrotron.

Radiated power vs Lorentz factor (log-log) parallel accel ~ gamma^6 (linear accelerator) perpendicular accel ~ gamma^4 (synchrotron) log10 gamma -> log10 P (W)
power vs gamma: gamma^4 (circular) and gamma^6 (linear)
The Larmor formula: accelerating charges radiate (P ~ a^2)

  non-relativistic power at a=1e+20 m/s^2: 5.71e-14 W

     gamma    perp (gamma^4)  parallel (gamma^6)
  ----------------------------------------------
         1          5.71e-14            5.71e-14
        10          5.71e-10            5.71e-08
       100          5.71e-06            5.71e-02
      1000          5.71e-02            5.71e+04

  classical hydrogen atom collapse time: 1.55e-11 s
  -- an orbiting electron radiating by Larmor spirals in almost instantly.
  That catastrophe is what quantum mechanics had to fix. The same formula,
  boosted by gamma^4, is the engine of synchrotron radiation.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\larmor.svg

Synchrotron radiation: cosmic radio glow

Relativistic electrons spiralling in magnetic fields radiate at a critical frequency ~ gamma^2 B (GHz radio for gamma~1e4 in microgauss fields). A power-law electron distribution N(E)~E^-p gives a power-law spectrum with index (p-1)/2 -- how we read jets and supernova remnants.

GHz (radio) Synchrotron critical frequency vs electron energy log10 Lorentz factor gamma; nu_c ~ gamma^2 B log10 nu_c (Hz)
critical frequency climbing with electron energy
Synchrotron radiation (B = 1 nT)

     gamma     nu_c (Hz)       P (W)       cooling
  ------------------------------------------------
     1e+02      4.20e+05    1.06e-28    2.5e+09 yr
     1e+03      4.20e+07    1.06e-26    2.5e+08 yr
     1e+04      4.20e+09    1.06e-24    2.5e+07 yr
     1e+05      4.20e+11    1.06e-22    2.5e+06 yr
     1e+06      4.20e+13    1.06e-20    2.5e+05 yr

  spectral index alpha = (p-1)/2 of a power-law electron population:
    p=2.0: alpha=0.50  (S(nu) ~ nu^-0.50)
    p=2.5: alpha=0.75  (S(nu) ~ nu^-0.75)
    p=3.0: alpha=1.00  (S(nu) ~ nu^-1.00)

  Relativistic electrons in weak cosmic fields shine in the radio; the
  spectral slope reveals the electron energy distribution -- how we read
  jets, radio galaxies, and supernova remnants.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\synchrotron.svg

Optical depth: where a star's surface is

Light is attenuated as exp(-tau) crossing matter. A star has no solid surface -- its photosphere is the layer where the inward optical depth reaches tau ~ 2/3, the depth photons escape from and that sets the effective temperature.

photosphere (tau=2/3) Radiative transfer: transmitted fraction e^-tau optical depth tau; thin (see through) to thick (see only surface) transmitted fraction
transmitted fraction falling as exp(-tau)
Optical depth: how far light sees into matter

     tau   transmitted          regime
  --------------------------------------
    0.10         0.905            thin
    0.50         0.607            thin
    0.67         0.513thin (photosphere)
    1.00         0.368            thin
    3.00         0.050           thick
   10.00         0.000           thick

  at n_e = 1e+20 /m^3 (Thomson): mean free path 150319 km,
  photosphere depth 100213 km.

  A star has no solid surface -- its photosphere is simply the layer
  where the inward optical depth reaches ~2/3, the depth from which
  photons finally escape and set the effective temperature.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\optical_depth.svg

Big Bang nucleosynthesis: the primordial 25% helium

In the first minutes the neutron/proton ratio freezes at ~1/6, decays to ~1/7, and nearly all surviving neutrons lock into helium-4, giving Y_p ~ 0.25. That quarter-helium, seen everywhere, is a triumph of the hot Big Bang.

freeze-out (0.8 MeV, n/p~1/6) Neutron/proton ratio vs temperature log10 temperature (MeV), hot to the left -- freezing sets the helium yield n/p ratio
n/p ratio freezing out vs temperature
Big Bang nucleosynthesis: the origin of primordial helium

  stage                                  n/p
  ------------------------------------------
  equilibrium at 10 MeV (t~0.01 s)     0.879
  freeze-out at 0.8 MeV (t~1 s)        0.199
  after neutron decay (t~200 s)        0.152

  primordial helium mass fraction Y_p = 0.264
  (observed ~0.245-0.25 -- a triumph of the hot Big Bang)

  Neutrons and protons start nearly equal, the ratio freezes at ~1/6
  as the weak interaction shuts off, decays to ~1/7, and nearly all
  surviving neutrons end up in helium-4: about a quarter of all mass.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\bbn.svg

The Eddington luminosity & black-hole growth

Radiation pressure caps how bright -- and how fast-growing -- an accreting object can be: L_Edd = 4 pi G M m_p c / sigma_T, linear in mass. The e-folding growth time is ~45 Myr, so building a billion-solar-mass quasar from a seed takes ~0.8 Gyr.

1e9 M_sun quasar Eddington-limited black-hole growth time (Myr); reaching a quasar mass takes ~830 Myr log10 mass (M_sun)
Eddington-limited exponential growth to a quasar
The Eddington luminosity: the brightness limit of accretion

  Salpeter e-folding time: 45.0 Myr (eta=0.1, Eddington-limited)

  object                    M (Msun)   L_Edd (L_sun)  Mdot (Msun/yr)
  ------------------------------------------------------------------
  Sun                          1e+00        3.28e+04        2.22e-08
  stellar BH                   1e+01        3.28e+05        2.22e-07
  Sgr A*                       4e+06        1.31e+11        8.88e-02
  quasar                       1e+09        3.28e+13        2.22e+01

  L_Edd is linear in mass and radius-independent. Above it, radiation
  pressure blows the accreting gas away, so it caps how fast a black
  hole can grow -- the exponential Salpeter track below.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\eddington.svg

Bondi accretion: feeding on ambient gas

Spherical accretion onto a compact object: Mdot ~ M^2 rho / c_s^3. It runs away with mass and is far stronger in cold gas -- a black hole in a molecular cloud eats millions of times faster than one in hot coronal gas. Compared to Eddington, it says whether growth is supply- or radiation-limited.

Bondi accretion rate vs gas temperature Mdot ~ c_s^-3 ~ T^-3/2: cold gas is devoured, hot gas barely touched log10 gas temperature (K) -> log10 Mdot (M_sun/yr)
accretion rate plunging with gas temperature
Bondi accretion (10 M_sun object, n ~ 1 /cm^3)

   gas T (K)  c_s (km/s)  r_B (AU)  Mdot (Msun/yr)
  ------------------------------------------------
       1e+02         1.2    6450.0        9.09e-11
       1e+03         3.7     645.0        2.87e-12
       1e+04        11.7      64.5        9.09e-14
       1e+06       117.3       0.6        9.09e-17
       1e+07       370.9       0.1        2.87e-18

  Rate ~ M^2 rho / c_s^3, so accretion runs away with mass and is far
  stronger in cold gas: a black hole in a 100 K molecular cloud accretes
  millions of times faster than one in hot 10^7 K coronal gas.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\bondi.svg

Hawking radiation & black-hole thermodynamics

Quantum effects at the horizon give a black hole a temperature T ~ 1/M and an entropy = 1/4 its area in Planck units. Big holes are colder and live longer (t_evap ~ M^3); a ~1.7e11 kg primordial hole evaporates in a Hubble time, while a solar-mass one is ~60 nK and eternal.

1 M_sun Black-hole thermodynamics vs mass (log-log) log T (K): colder for bigger holes log t_evap (yr): longer for bigger holes log10 mass (kg) ->
temperature and evaporation time vs black-hole mass
Hawking radiation: black holes are not black

  primordial mass evaporating in a Hubble time: 1.73e+11 kg
    (about the mass of a large asteroid, in a horizon ~1e-16 m across)

  object                       T (K)   t_evap (yr)       S/k_B
  ------------------------------------------------------------
  primordial (now)          7.09e+11      1.38e+10    7.94e+38
  1 solar mass              6.17e-08      2.10e+67    1.05e+77
  Sgr A* (4e6 Msun)         1.54e-14      1.34e+87    1.68e+90
  M87* (6.5e9 Msun)         9.49e-18      5.76e+96    4.43e+96

  Bigger holes are COLDER and live longer (T ~ 1/M, t_evap ~ M^3).
  A stellar black hole is nanokelvin-cold and effectively eternal;
  its entropy exceeds that of everything else in the observable universe.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\hawking.svg

Kerr black holes: spin & frame-dragging

A rotating black hole drags spacetime around it. Its horizon shrinks with spin, an ergosphere appears outside it, and the ISCO splits: prograde orbits reach down toward 1M at extremal spin while retrograde ones recede to 9M -- how black-hole spins are measured.

ISCO vs spin prograde -> 1M retrograde -> 9M a/M -> a=0.9M: horizon + ergosphere orange = ergosphere (frame-dragging region)
ISCO vs spin (prograde/retrograde) + horizon & ergosphere
Kerr black hole: spin sets the horizon and the ISCO (units of M)

     a/M   horizon   ISCO pro  ISCO retro   Omega_H
  -------------------------------------------------
    0.00     2.000      6.000       6.000     0.000
    0.30     1.954      4.979       6.949     0.077
    0.60     1.800      3.829       7.851     0.167
    0.90     1.436      2.321       8.717     0.313
    0.99     1.141      1.454       8.972     0.434
    1.00     1.000      1.000       9.000     0.500

  A fast-spinning hole drags its prograde ISCO down toward the horizon
  (1M at extremal spin), so its accretion disk reaches deeper and radiates
  more efficiently. Measuring the disk's inner edge is how spins are found.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\kerr.svg

Penrose process: mining spin energy

Inside the ergosphere a fragment can carry negative energy, so the escaping piece leaves with more than it entered -- energy mined from the hole's spin. Up to 29% of an extremal hole's mass-energy is extractable, and removing it only grows the horizon area (area theorem).

max 29.3% (extremal) Extractable rotational energy vs black-hole spin spin a/M; extraction grows the irreducible mass (area theorem) extractable fraction of M c^2 (%)
extractable rotational-energy fraction vs spin
The Penrose process: extracting a black hole's rotational energy

  maximum extractable fraction (extremal a=M): 29.3% of M c^2

     a/M   M_irr/M  E_rot fraction  horizon area
  ----------------------------------------------
    0.00    1.0000            0.0%         50.27
    0.30    0.9884            1.2%         49.11
    0.60    0.9487            5.1%         45.24
    0.90    0.8473           15.3%         36.09
    0.99    0.7553           24.5%         28.68
    1.00    0.7071           29.3%         25.13

  Up to ~29% of an extremal hole's mass-energy can be mined from its
  spin. Doing so raises the irreducible mass and horizon area -- the
  area never shrinks (Hawking), which is the second law for black holes.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\penrose.svg

Schwarzschild black-hole orbits

Strong-field GR from the effective potential V=(1-2M/r)(1+L^2/r^2): the ISCO at 6M, the photon sphere at 3M, bound orbits that precess tens of degrees per orbit, and low-angular-momentum orbits that plunge through the horizon.

Schwarzschild orbits: precessing (teal) & plunging (pink) white=horizon(2M), orange=photon sphere(3M), blue=ISCO(6M)
precessing (teal) and plunging (pink) geodesics
Orbits around a Schwarzschild black hole (units of M)

  event horizon  : r = 2 M
  photon sphere  : r = 3 M
  ISCO           : r = 6 M

  bound orbit (r: 10-20 M): perihelion advance 126.2 deg/orbit
    (Mercury's is 43 arcsec/CENTURY; here it's tens of degrees PER ORBIT)

  plunging orbit: falls from r=12 M through the horizon to r=0.01 M

  wrote C:\Users\acwic\symplectic-nbody\examples\output\schwarzschild.svg
  The bound orbit is a precessing rosette; the plunger spirals through
  the horizon. Both are exact Schwarzschild geodesics.

Gravitational time: redshift, GPS & Shapiro delay

Clocks run slower deeper in gravity and light lags near mass. Pound-Rebka measured the redshift on a tower; GPS satellites gain ~38 us/day (correct it or navigation fails); the Shapiro radar delay past the Sun is the tightest Solar-System test of GR.

Shapiro delay vs impact parameter impact parameter (solar radii); grazing rays are delayed most round-trip delay (microseconds)
Shapiro delay diverging as the ray grazes the Sun
Gravitational time effects (classic GR tests)

  Pound-Rebka (22.5 m tower)  : z = 2.45e-15   (measured 2.5e-15)
  GPS clock gain              : +38.5 us/day  (must be corrected or GPS drifts km/day)
  Sun surface redshift        : z = 2.12e-06   (2.12e-6)
  Shapiro delay (past the Sun): 281 us      (Cassini ~240-280 us)

  Clocks run slower deeper in gravity and light lags near mass. The GPS
  correction is relativity you rely on daily; the Shapiro delay is the
  tightest Solar-System test of general relativity.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\gr_time.svg

Frame-dragging & geodetic precession (Gravity Probe B)

An orbiting gyroscope precesses two ways: geodetic (from spatial curvature, ~6600 mas/yr) and frame-dragging (from Earth's rotation twisting spacetime, ~40 mas/yr). Gravity Probe B measured both -- the frame-dragging term is ~180x smaller and took near-perfect gyros to see.

Gyroscope precession vs orbit radius (log-log) geodetic ~ r^-5/2 frame-dragging ~ r^-3 (smaller, steeper) log10 (r / R_Earth) -> log10 precession (mas/yr)
both precession rates vs orbit radius
Gravity Probe B: two relativistic gyroscope precessions

  effect                         predicted    measured
  ----------------------------------------------------
  geodetic (de Sitter)             6638 mas/yr        6602
  frame-dragging (LT)              41.1 mas/yr        37.2

  Geodetic precession comes from the curvature of space the gyro is
  carried through; frame-dragging comes from Earth's rotation twisting
  spacetime. The latter is ~180x smaller -- it took a dedicated
  experiment with near-perfect gyroscopes to measure it.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\lense_thirring.svg

Galaxy rotation curves & dark matter

A visible disk alone gives a Keplerian decline (v ~ r^-1/2) past its edge; real galaxies stay flat. Adding an NFW dark halo, whose enclosed mass keeps growing as ~r, flattens the curve -- the classic evidence for dark matter.

Galaxy rotation curve: dark matter keeps it flat disk + dark halo (flat) visible disk only (declines) radius ->
visible declines (red), disk+halo stays flat (blue)
Galaxy rotation curves: the case for dark matter

  visible disk only : outer slope -0.490 (Keplerian decline = -0.50)
  disk + dark halo  : outer slope -0.000 (flat = 0.00)

  visible-only v(r): .::--------------------::::::::::::::::::::::::::::::::.....
  disk+halo  v(r): :-=+++*****################################@################

  The visible curve falls off; the observed curve is flat. The gap is
  the dark-matter halo, whose enclosed mass keeps growing as ~ r.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\rotation_curve.svg

Faber-Jackson: elliptical galaxy scaling

Elliptical galaxies obey L ~ sigma^4 -- luminosity from the random stellar velocity dispersion, following from the virial theorem plus a roughly constant mass-to-light ratio. A line width gives the luminosity, hence the distance -- the elliptical twin of Tully-Fisher.

L* (200 km/s) Faber-Jackson: L ~ sigma^4 (log-log) log10 velocity dispersion (km/s) -> log10 L / L_sun
L climbing as the fourth power of sigma
The Faber-Jackson relation: L ~ sigma^4 for ellipticals

  sigma (km/s)     L (L_sun)  virial M (M_sun)
  --------------------------------------------
            50      7.81e+07          2.91e+09
           100      1.25e+09          1.16e+10
           200      2.00e+10          4.65e+10
           300      1.01e+11          1.05e+11
           400      3.20e+11          1.86e+11

  Luminosity climbs as the fourth power of the velocity dispersion, so
  a spectral line width (sigma) pins a galaxy's luminosity -- and,
  against its apparent brightness, its distance. It is the elliptical-
  galaxy twin of the Tully-Fisher relation for spirals.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\faber_jackson.svg

MOND: flat curves without dark matter

The rival to the dark halo: instead of adding unseen mass, MOND modifies gravity below a0 ~ 1.2e-10 m/s^2. A bare baryonic mass then has a naturally flat rotation curve and obeys the tight baryonic Tully-Fisher law v^4 = G M a0 -- MOND's sharpest prediction.

MOND (blue, flat) vs Newton on visible mass (red, declining) MOND: modified gravity, no dark matter Newton (baryons only): Keplerian decline radius (kpc) ->
MOND (flat) vs Newton on visible mass (declining)
MOND: modified gravity instead of dark matter

  a0 = 1.20e-10 m/s^2   baryonic mass = 6e10 M_sun
  asymptotic flat speed v = (G M a0)^1/4 = 175.8 km/s

   r (kpc)   Newton (km/s)   MOND (km/s)
  --------------------------------------
         2           359.2         368.9
         5           227.2         257.1
        10           160.7         215.4
        20           113.6         195.0
        40            80.3         185.2
        80            56.8         180.5

  Newtonian gravity on the visible mass alone falls off (Keplerian);
  MOND flattens the curve with no dark matter. Its sharpest prediction is
  the baryonic Tully-Fisher law v^4 = G M a0, tight across real galaxies.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\mond.svg

Gravitational lensing

Mass bends light by 4GM/(c^2 b) -- twice the Newtonian value, the 1919 eclipse result (1.75 arcsec at the Sun's limb). A point-mass lens splits a source into two images, an Einstein ring at perfect alignment, and the symmetric microlensing brightening used to find exoplanets.

Microlensing light curve total magnification vs source position (u0=0.2) Einstein ring & two images
microlensing light curve + Einstein ring and images
Gravitational lensing

  light deflection at the Sun's limb : 1.751 arcsec (Eddington 1919 measured ~1.75)

  microlensing light curve (min impact u0=0.2):
                                                                 ............:::::::----====++***####@@####***++====----:::::::............                                                               
  peak magnification A_max = 5.07 at closest approach

  A point-source drifting behind a mass brightens symmetrically then
  fades -- the achromatic, time-symmetric microlensing signature used
  to find exoplanets and dark compact objects.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\lensing.svg

Tisserand parameter & gravity assists

Across a planetary flyby a small body's semi-major axis and eccentricity change a lot, but the Tisserand parameter T = a_p/a + 2 sqrt(a/a_p (1-e^2)) cos i barely moves -- how Tisserand recognized comets Jupiter had reshaped, and what bounds a gravity assist.

Gravity assist: a (red), e (orange) jump; Tisserand (blue) flat each curve auto-scaled; the blue Tisserand track is nearly a straight line
a & e jump at the flyby; Tisserand stays flat
Tisserand parameter across a gravity assist

              before     after    change
----------------------------------------
a (AU)         1.281     0.890    -0.391
e              0.330     0.265    -0.065
T_planet      2.9404    2.9432  +0.00277

a and e are reshaped by the flyby; the Tisserand parameter is nearly
unchanged. That is how Tisserand recognized returning comets whose orbits
Jupiter had scrambled -- and it bounds what one flyby can do.

wrote C:\Users\acwic\symplectic-nbody\examples\output\tisserand.svg

Coorbital orbits: tadpoles & horseshoes

A body sharing a planet's orbit librates in the rotating frame: a tadpole loops one Lagrange point (Jupiter's Trojans); a horseshoe wraps around L3 enclosing both L4 and L5, turning back before it reaches the planet (Saturn's Janus & Epimetheus, Earth's Cruithne).

L1 L2 L3 L4 L5 Coorbital orbits: tadpole (teal) & horseshoe (grey) rotating frame; gold=primary, white=secondary, orange=Lagrange points
tadpole (teal) and horseshoe (grey) in the rotating frame
Coorbital motion in the CR3BP rotating frame (mu = 0.001)

  tadpole   (near L4): angular range    78 deg -> tadpole
  horseshoe (near L3): angular range   315 deg -> horseshoe

  The tadpole loops one Lagrange point (like Jupiter's Trojans); the
  horseshoe wraps around L3 enclosing both L4 and L5, turning back before
  it reaches the planet (like Saturn's moons Janus & Epimetheus).

  wrote C:\Users\acwic\symplectic-nbody\examples\output\coorbital.svg

Mean-motion resonance

Two planets at the 2:1 spacing lock into resonance: the resonant argument phi librates in a bounded band instead of circulating. This is what carves the Kirkwood gaps and binds the Laplace resonance of Io-Europa-Ganymede.

2:1 resonant argument: locked (red) vs free (grey) phi vs time in [-pi, pi]; red librates about a fixed value, grey fills the range
phi librates when locked (red), circulates when free (grey)
Mean-motion resonance: 2:1 resonant argument phi

  resonant (2:1 spacing) : phi range 2.10 rad -> LIBRATES (locked)
  off-resonance          : phi range 6.27 rad -> circulates (2pi=6.28)

  resonant phi  : ====++=====-----:-----======+====---::-----===+======------::-----======
  off-res  phi  : -.#+-.*=: *=:#+-.*=: *=:#+-.*+: *=: +-.#=: *=:#+-.#+: *=: +=.#+- *=: *-.

  A librating resonant argument IS the lock: the pair's periods stay
  commensurate. This carves the Kirkwood gaps and builds the Laplace
  resonance of Io-Europa-Ganymede.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\resonance.svg

Kozai-Lidov cycles

In a hierarchical triple the inner orbit trades eccentricity for inclination and back, conserving sqrt(1-e^2) cos i. Above a critical inclination (~39.2 deg) the eccentricity is driven to large values -- the mechanism behind hot-Jupiter migration and compact-binary mergers.

Kozai-Lidov cycles: e (red) and inclination (blue) out of phase -- when e rises, i falls; Theta=sqrt(1-e^2)cos i is fixed
e (red) and inclination (blue) oscillating out of phase
Kozai-Lidov cycles in a hierarchical triple

  critical inclination      : 39.23 deg
  start                     : e=0.01, i=80 deg
  e_max measured / analytic : 0.975 / 0.975
  inclination swings        : 39.3 - 80.0 deg

  eccentricity :  # #  :+*=.  =: # # -: :#.  .:---:.   =#. -= -- # * * # -- +: -*. :#=.  .:=+
  inclination  : @ #=####*#######-#.#####=##############+########:#*#*#:########*###-########

  e peaks exactly when i dips: they trade the conserved
  Theta = sqrt(1-e^2) cos i. This drives hot-Jupiter migration
  and compact-binary mergers.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\kozai.svg

Roche limit & tidal disruption

A rubble-pile satellite survives only outside the Roche limit; inside it, the tide overwhelms self-gravity and tears it into a stream -- how planetary rings and Shoemaker-Levy 9's fragment chain formed. The surviving bound fraction drops sharply across the limit.

Tidal disruption inside the Roche limit start half orbit 1.5 orbits dashed = Roche limit; gold = primary
satellite shredding into a tidal stream
Roche limit: tidal disruption of a rubble-pile satellite

 d / d_Roche  surviving bound fraction
------------------------------------------------
        0.40  ------------------------------ 0.00
        0.60  #################------------- 0.57
        0.80  ##########################---- 0.88
        1.00  ###########################--- 0.90
        1.25  ############################-- 0.95
        1.50  #############################- 0.98
        2.00  ############################## 1.00
        3.00  ############################## 1.00

Below ~1 Roche the satellite is shredded; above it, it survives.

wrote C:\Users\acwic\symplectic-nbody\examples\output\roche_disruption.svg
The rubble pile stretches into a tidal stream -- how Saturn's rings
and Shoemaker-Levy 9's fragment chain came to be.

Tidal heating: why Io erupts

A moon on an eccentric orbit is flexed by the varying tide and dissipates the energy as heat: dE/dt ~ (k2/Q) e^2 R^5 / a^{15/2}. For Io this is ~1e14 W (40x Earth's heat flux), and the eccentricity is forced by the Laplace resonance -- resonance and volcanoes linked.

Io (e=0.0041) Tidal heating vs eccentricity (Io parameters) orbital eccentricity; power ~ e^2 (units of 1e14 W) heating (1e14 W)
heating vs eccentricity, with Io marked
Tidal heating of the Galilean moons (k2/Q ~ 0.015)

  moon         power (W)  flux (W/m^2)
  ------------------------------------
  Io            9.33e+13         2.238
  Europa        6.37e+12         0.208
  Ganymede      5.48e+10         0.001
  Callisto      1.65e+10         0.000

  Io: 9.3e+13 W ~ 40x Earth's internal heat flux, which is why
  it is the most volcanic body known. The heating goes as e^2, and Io's
  eccentricity is forced by the Laplace 4:2:1 resonance -- no resonance,
  no eccentricity, no volcanoes.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\tidal_heating.svg

Roche lobes: binary mass transfer

Each star in a binary owns a Roche lobe meeting its companion's at L1. When a star fills its lobe, gas pours through L1 onto the companion. From a lighter donor the orbit widens (stable transfer); from a heavier one it runs away -- the physics of X-ray binaries and type-Ia progenitors.

q=1: stability boundary Roche-lobe radius vs mass ratio (Eggleton) log10 q = M_donor/M_accretor; left of q=1 = stable transfer R_L / a
Eggleton lobe radius vs mass ratio, with the stability line
Roche lobes and binary mass transfer

   q = M_donor/M_acc    R_L/a   d ln a/d ln M    transfer
  -------------------------------------------------------
                 0.2    0.252            -1.6      stable
                 0.5    0.321            -1.0      stable
                 1.0    0.379             0.0    unstable
                 2.0    0.440             2.0    unstable
                 5.0    0.521             8.0    unstable

  When a star fills its Roche lobe, gas pours through L1 onto its
  companion. From a lighter donor the orbit widens and transfer is stable
  (cataclysmic variables, X-ray binaries); from a heavier donor it runs
  away -- the path to mergers and type-Ia supernovae.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\roche_lobe.svg

Virial theorem & violent relaxation

For a bound gravitational system 2T + U = 0. A Plummer sphere sits at 2T/U = -1; a cold, sub-virial cluster collapses, overshoots, and relaxes to the same value -- forgetting its initial state through violent relaxation.

2T/U = -1 (virial) Virial theorem & violent relaxation running <2T/U> vs time; green=equilibrium, red=cold cluster
running 2T/U converging on -1
Virial theorem: a bound gravitational system settles at 2T/U = -1

  equilibrium Plummer : running <2T/U> -> -0.992  (target -1)
  cold cluster        : start -0.083 -> running <2T/U> -0.952

  cold-cluster instantaneous 2T/U (collapse & relaxation):
  #*:...::.:.::::::---::::::::.:::::::.:::::::::::::::::::::::::::::::::::::::::::.::-:::::::::::::.::

  wrote C:\Users\acwic\symplectic-nbody\examples\output\virial.svg
  Both running averages converge on -1: the cold cluster forgets its
  cold start through violent relaxation and lands in virial balance.

Galaxy clusters: virial temperature & X-rays

The virial theorem applied to a cluster's gas: falling into a 10^15-solar-mass well heats it to a few keV (~10^8 K), radiating X-rays. Because kT ~ M^{2/3}, an X-ray temperature weighs the cluster's total (mostly dark) mass.

Cluster mass-temperature relation (kT ~ M^2/3) log10 cluster mass (M_sun) -> log10 kT (keV)
the kT ~ M^2/3 mass-temperature relation
Galaxy clusters: virial temperature and the M-T relation

    mass (M_sun)  radius (Mpc)  kT (keV)       T (K)
  --------------------------------------------------
           1e+13          0.43      0.31     3.6e+06
           3e+13          0.63      0.67     7.8e+06
           1e+14          0.93      1.45     1.7e+07
           3e+14          1.36      3.13     3.6e+07
           1e+15          2.00      6.74     7.8e+07

  A massive cluster reaches several keV (~10^8 K), radiating thermal
  bremsstrahlung X-rays. Because kT ~ M^{2/3}, an X-ray temperature
  measures the cluster's total (mostly dark) mass -- how clusters are weighed.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\cluster.svg

Sunyaev-Zeldovich effect: clusters shadowing the CMB

The same hot cluster gas inverse-Compton scatters CMB photons, imprinting a Compton-y distortion: a cold spot dT/T = -2y in the radio. It is redshift-independent, so SZ surveys find clusters clear across the universe.

SZ Rayleigh-Jeans decrement vs Compton y dT = -2 y T_CMB: a cluster is a cold spot, redshift-independent Compton y-parameter -> |dT| (microkelvin)
the CMB temperature decrement vs Compton y
The Sunyaev-Zeldovich effect: hot clusters distorting the CMB

  cluster           n_e (/m^3)  kT (keV)           y     dT (uK)
  --------------------------------------------------------------
  group                  3e+02       2.0    1.21e-06        -6.6
  Coma-like              1e+03       8.0    3.21e-05      -175.1
  massive                3e+03      12.0    2.89e-04     -1576.3

  In the Rayleigh-Jeans band a cluster is a COLD spot: dT/T = -2y.
  redshift-independent? True -- the SZ signal does not dim
  with distance, so SZ surveys find clusters clear across the universe.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\sz.svg

Bremsstrahlung: the X-rays of cluster gas

Free electrons braking in ion fields radiate free-free X-rays with emissivity ~ n^2 sqrt(T). The cooling time ~ sqrt(T)/n drops below a Hubble time in dense cluster cores (cooling flows) but never in the outskirts -- the emission whose CMB imprint is the SZ effect.

Hubble time (cooling-flow threshold) Bremsstrahlung cooling time vs density log10 electron density (/m^3); below the line = cooling flow log10 cooling time (Gyr)
cooling time crossing the Hubble threshold
Thermal bremsstrahlung of cluster gas (T = 5e+07 K)

    n_e (/m^3)  emissivity (W/m^3)  t_cool (Gyr)   flow?
  ------------------------------------------------------
         1e+02            9.90e-33         662.9      no
         1e+03            9.90e-31          66.3      no
         1e+04            9.90e-29           6.6     yes
         1e+05            9.90e-27           0.7     yes

  Emissivity goes as n^2, so dense cores glow brightest and cool fastest
  (t_cool ~ sqrt(T)/n). A cooling flow develops where t_cool drops below
  the Hubble time; the tenuous outskirts effectively never cool. This is
  the X-ray emission whose CMB imprint is the SZ effect.

  wrote C:\Users\acwic\symplectic-nbody\examples\output/bremsstrahlung.svg

Pair production & the gamma-ray horizon

Two photons colliding turn into an electron-positron pair once E1 E2 (1 - cos theta) >= 2(m_e c^2)^2, so two 511 keV gammas just pair head-on. A single high-energy gamma pair-produces off a soft background photon above (m_e c^2)^2 / E_bg -- TeV gammas from blazars are eaten by starlight/IR, PeV gammas by the CMB. The universe is opaque to gamma rays beyond a horizon that shrinks as energy rises.

Gamma-ray horizon: pair-production threshold E_gamma = (m_e c^2)^2 / E_background: high-E gammas absorbed by low-E fields log10 background photon energy (eV) -> log10 threshold gamma energy (GeV)
threshold gamma energy vs background photon energy
Photon-photon pair production: gamma + gamma -> e+ e-

  electron rest energy: 511 keV
  head-on threshold (each photon): 511 keV

     background photon    E (eV)   threshold gamma
  ------------------------------------------------
                   CMB   6.0e-04           435 TeV
        infrared (EBL)   1.0e-01             3 TeV
         optical (EBL)   2.0e+00           131 GeV
                 X-ray   1.0e+03           261 MeV

  A gamma ray is absorbed once its energy exceeds (m_e c^2)^2 / E_bg,
  so TeV photons from distant blazars are eaten by starlight/IR and
  PeV photons by the CMB -- the universe has a gamma-ray horizon that
  shrinks as the photon energy rises.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\pair_production.svg

Precession of the equinoxes

Earth's equatorial bulge, tilted 23.4 deg to the ecliptic, feels an uneven Sun+Moon pull that torques the spin axis into a 26,000-year cone -- like a leaning gyroscope. Torque scales as M/r^3, so the nearby Moon beats the Sun ~2.2 to 1; the sum is ~50.3 arcsec/yr. That is why Polaris is only a temporary North Star and why zodiac dates have slipped a whole sign since antiquity.

ecliptic pole Polaris (now) Thuban (~2700 BC) Vega (~14000 AD) The wandering celestial pole 50.6 arcsec/yr -> one circuit every 25591 yr
the circle the celestial pole traces over a Great Year
Precession of the equinoxes (luni-solar torque on Earth's bulge)

      source     arcsec/yr     period (yr)
  ----------------------------------------
         Sun         15.95           81269
        Moon         34.70           37352
    Sun+Moon         50.64           25591

  Moon/Sun torque ratio: 2.18 (nearby body wins: torque ~ M / r^3)
  obliquity: 23.44 deg
  measured luni-solar precession: 50.29 arcsec/yr, period ~25772 yr

  The pole traces a 47-deg-wide circle on the sky, so Polaris is only
  our temporary North Star -- Vega held the title ~12000 BC and will
  again ~14000 AD. The same drift slips the equinox one zodiac sign
  every ~2150 years (the astrological 'ages').

  wrote C:\Users\acwic\symplectic-nbody\examples\output\axial_precession.svg

Alfven waves & the magnetized solar wind

A magnetic field threading a plasma behaves like a set of tensioned strings: pluck the field lines and they spring back at the Alfven speed v_A = B / sqrt(mu0 rho). The plasma beta = p_gas/p_mag says who is in charge -- beta << 1 in the field-dominated corona, beta > 1 in gas-dominated interiors. The solar wind starts sub-Alfvenic (the Sun's field co-rotates and brakes it), then crosses the Alfven surface near ~15 R_sun and coasts out decoupled from the Sun's spin.

Alfven surface (14 R_sun) The Alfven surface: where the solar wind breaks free v_A (Alfven speed) u (wind speed) log10 distance (R_sun) -> speed (km/s)
wind speed overtaking the Alfven speed at the Alfven surface
Alfven waves in magnetized plasma: v_A = B / sqrt(mu0 rho)

             environment     B (T)    n (/m^3)  v_A (km/s)      beta
  ------------------------------------------------------------------
           active corona   1.0e-02     1.0e+15      6897.6    0.0007
            quiet corona   1.0e-03     1.0e+14      2181.2    0.0035
         solar wind 1 AU   5.0e-09     5.0e+06        48.8    0.6940
                warm ISM   5.0e-10     1.0e+06        10.9    1.1104

  beta < 1 (corona): magnetic tension rules -- the field channels the
  plasma and stores the energy that heats the corona and drives flares.
  beta > 1 (dense interiors): gas pressure drags the field around.
  The solar wind starts sub-Alfvenic (Sun's field co-rotates the plasma
  and brakes the spin), then crosses the Alfven surface near ~10-20 R_sun
  and coasts out super-Alfvenic -- decoupled from the Sun's rotation
  (Parker Solar Probe crossed the real surface near ~15-20 R_sun).

  wrote C:\Users\acwic\symplectic-nbody\examples\output\alfven.svg

The Parker spiral

The solar wind drags the Sun's magnetic field radially outward while its footpoints stay rooted in a Sun that rotates every ~25 days -- a rotating sprinkler. Each parcel flies straight out, but the field line traces an Archimedean spiral: nearly radial near the Sun, bent ~45 deg at Earth (the garden-hose angle), nearly azimuthal by Jupiter. It is why western-limb flares connect best to Earth along the spiral.

Earth Jupiter The Parker spiral (ecliptic plane) radial wind + solar rotation = Archimedean spiral field lines
field lines spiralling out through the ecliptic
The Parker spiral: the Sun's field wound up by its rotation

  solar wind speed: 400 km/s, rotation period: 25.4 days

        location   r (AU)  angle (deg)   |B| (nT)
  -----------------------------------------------
         Mercury     0.39         22.7      35.63
           Venus     0.72         37.7      12.18
           Earth     1.00         47.0       7.33
            Mars     1.52         58.5       4.14
         Jupiter     5.20         79.8       1.05
          Saturn     9.58         84.4       0.56

  Near the Sun the field is nearly radial; by Earth it is bent ~45 deg
  (the classic garden-hose angle); past Jupiter it is nearly azimuthal.
  B_r falls as 1/r^2 but B_phi only as 1/r, so the distant heliospheric
  field is mostly the wound-up azimuthal component. This is why western-
  limb solar flares connect best to Earth along the spiral field line.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\parker_spiral.svg

Magnetic braking & gyrochronology

That same magnetized wind is a brake. Plasma stays locked to the field out to the Alfven radius (~15 R_sun), so it corotates on a long lever arm and bleeds angular momentum -- fast rotators brake hardest, so stellar spins converge onto one sequence. Skumanich's law P ~ t^(1/2) then turns a measured rotation period into an age: the Sun's 25-day spin reads 4.6 Gyr, a 3-day Pleiad reads ~60 Myr.

Pleiades (4 d) Hyades (10 d) Sun (25 d) Gyrochronology: rotation period vs stellar age Skumanich P ~ t^(1/2): a slow spin means an old star log10 age (Gyr) -> log10 rotation period (days)
the Skumanich age-period sequence, Sun and clusters marked
Magnetic braking & gyrochronology: a star's spin is its clock

  Skumanich law: P(t) = P_sun sqrt(t / t_sun), so t = t_sun (P/P_sun)^2

                 rotator  P (days)  gyro age (Gyr)
  ------------------------------------------------
         Pleiades member       3.0            0.06
        young field star       6.0            0.25
           Hyades member       8.5            0.51
                 the Sun      25.4            4.57
     old thick-disk star      35.0            8.67

  solar-wind Alfven radius ~ 16 R_sun -- the long lever arm that
  lets a feeble ~1e-14 Msun/yr mass loss brake the whole star. Fast
  rotators brake hardest, so a broad spread of young spins converges
  onto one age-period sequence -- which is what makes the clock work.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\magnetic_braking.svg

Tidal locking

Internal friction drags a body's tidal bulge out of line with its primary; the misaligned bulge feels a torque that despins it toward synchronous rotation. The locking time goes as a^6, so close-in moons lock in a geological blink (Phobos, Io) while distant ones never do -- the Moon locked to Earth long ago, but the Earth needs far longer than the universe is old to lock back to the Moon's weaker tide.

age of the solar system (4.57 Gyr) Moon (60 R_earth) Tidal locking time ~ a^6 below the red line: locked; above it: still spinning freely log10 distance (R_earth) -> log10 locking time (Gyr)
the a^6 locking time crossing the age of the solar system
Tidal locking: t_lock ~ a^6 I Q / (G M_p^2 k2 R^5)

  age of the solar system: 4.57 Gyr

           body -> primary  t_lock (Gyr)   locked?
  ------------------------------------------------
             Moon -> Earth       0.00933       yes
            Phobos -> Mars      7.87e-11       yes
             Io -> Jupiter      1.01e-07       yes
             Earth -> Moon          17.5        no
            Mercury -> Sun         0.349       yes

  The a^6 dependence is everything: close-in moons lock in a geological
  blink, while the Earth (braking only on the Moon's weak tide) needs far
  longer than the universe is old. Mercury dodged full locking into a 3:2
  spin-orbit resonance; hot Jupiters at a few stellar radii are all locked.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\tidal_locking.svg

Jeans escape & the cosmic shoreline

At the exobase, molecules faster than escape speed leave for good. Light, hot gases have a fatter Maxwell-Boltzmann tail, so the escape parameter lambda = v_esc^2/v_th^2 decides who keeps an atmosphere: Earth holds N2/O2/CO2 but loses H2 and He, the hot low-gravity Moon holds almost nothing, cold Titan clings even to nitrogen, and Jupiter keeps everything. Escape speed vs temperature is a cosmic shoreline.

v_esc = 6 v_th (retention line) Ea:H2 Ea:He Ea:H2O Ea:N2 Ea:O2 Ea:CO2 Mo:H2 Mo:He Mo:H2O Mo:N2 Mo:O2 Mo:CO2 Ma:H2 Ma:He Ma:H2O Ma:N2 Ma:O2 Ma:CO2 Ti:H2 Ti:He Ti:H2O Ti:N2 Ti:O2 Ti:CO2 Ju:H2 Ju:He Ju:H2O Ju:N2 Ju:O2 Ju:CO2 The cosmic shoreline: escape vs thermal speed green = retained over geologic time, red = escapes log10 thermal speed (km/s) -> log10 escape speed (km/s)
worlds and gases sorted by the v_esc = 6 v_th retention line
Jeans escape: lambda = v_esc^2 / v_th^2 = G M m / (R k_B T)

  retention rule of thumb: keep a gas when v_esc >= 6 v_th (lambda >= 36)

      body    v_esc     H2     He    H2O     N2     O2    CO2   (Y=kept, .=lost)
  -------------------------------------------------------------------
     Earth    11.2k      .      .      Y      Y      Y      Y
      Moon     2.4k      .      .      .      .      .      Y
      Mars     5.0k      .      .      Y      Y      Y      Y
     Titan     2.6k      .      .      Y      Y      Y      Y
   Jupiter    60.2k      Y      Y      Y      Y      Y      Y

  Earth keeps N2/O2/CO2/water but loses H2 and He (why our air is heavy);
  the hot, low-gravity Moon loses all but the heaviest (and even CO2 goes
  to non-thermal escape); cold Titan clings even to N2;
  giant Jupiter keeps everything, including hydrogen. This 'cosmic
  shoreline' -- escape speed vs temperature -- sorts which worlds have air.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\jeans_escape.svg

The snow line

A protoplanetary disk cools with distance as T ~ r^(-1/2), so ice condenses only beyond the point where it drops past ~160 K -- the snow line, at ~3 AU for the young Sun. Inside, water is vapour and planets grow small and dry; outside, ice roughly triples the solid density and lets giant cores grow fast enough to grab gas. Water, CO2 and CO each have their own frost line, sorting the disk by composition.

H2O (3.0 AU) CO2 (15.8 AU) Mer Ven Ear Mar Jup Sat Ura Nep The snow line: disk temperature vs distance T ~ r^(-1/2); ice condenses where the disk cools past ~160 K log10 disk temperature (K)
the disk temperature profile with frost lines and planets
The snow line: disk T(r) = (L / 16 pi sigma r^2)^(1/4) ~ r^(-1/2)

      planet   r (AU)    T (K)     state
  --------------------------------------
     Mercury     0.39    445.7      rock
       Venus     0.72    328.0      rock
       Earth     1.00    278.3      rock
        Mars     1.52    225.8      rock
     Jupiter     5.20    122.1  rock+ice
      Saturn     9.58     89.9  rock+ice
      Uranus    19.20     63.5  rock+ice
     Neptune    30.10     50.7  rock+ice

  water snow line: 3.03 AU  (T = 160 K)
  CO2  frost line: 15.8 AU
  CO   frost line: 194 AU (out past Neptune)

  Inside ~3 AU water is vapour, so only rock and metal condense and the
  terrestrial planets grew small and dry. Beyond it, ice roughly triples
  the solid surface density, letting Jupiter's core grow fast enough to
  grab nebular gas before the disk dissipated. The snow line is the
  dividing line between the rocky inner and giant/icy outer solar system.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\snow_line.svg

Poynting-Robertson drag

A dust grain re-radiates absorbed sunlight isotropically in its own frame, but aberration turns that into a faint forward headwind in the Sun's frame -- draining angular momentum so the grain spirals in. The inspiral time goes as r^2 and grain size, so micron grains at 1 AU fall in within a few thousand years; grains below the ~0.4 micron blow-out size are unbound and ejected. The zodiacal dust must be resupplied.

age of the solar system from 1 AU from 3 AU from 30 AU Poynting-Robertson inspiral time vs grain size t_PR ~ r^2 s: small grains near the Sun vanish in millennia log10 grain radius (micron) -> log10 inspiral time (yr)
inspiral time vs grain size, blow-out and solar age marked
Poynting-Robertson drag: dust's own re-radiated light is a headwind

  blow-out size (beta = 1/2): 0.38 micron (smaller grains are blown straight out)

    grain size     beta     fate / t_PR from 1 AU
  ------------------------------------------------
        0.1 um    1.914       blown out (unbound)
        0.3 um    0.638       blown out (unbound)
        0.5 um    0.383                  1,046 yr
          1 um    0.191                  2,092 yr
         10 um    0.019                 20,923 yr
        100 um    0.002                209,231 yr
          1 mm    0.000              2,092,311 yr

  Micron grains at 1 AU spiral into the Sun in a few thousand years --
  thousands of times shorter than the solar system's age. So the
  zodiacal dust cloud cannot be primordial; it is continuously
  resupplied by comet trails and asteroid collisions. Grains below the
  blow-out size never orbit at all -- they leave as beta meteoroids.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\poynting_robertson.svg

Toomre Q & disk stability

A rotating disk balances self-gravity against pressure (small scales) and rotation via the epicyclic frequency (large scales). Toomre's Q = c_s kappa / (pi G Sigma) captures it in one number: Q > 1 is stable, Q < 1 fragments into clumps and spiral arms. The Milky Way hovers at Q ~ 1.5-2 -- marginally stable, because star formation heats a cooling disk back up, so disks self-regulate to the stability line.

Q < 1: unstable, fragments into clumps/arms Sun (Q = 1.8) Toomre Q across a galactic disk the Milky Way hovers just above Q = 1: self-regulated marginal stability galactocentric radius (kpc) -> gas Toomre Q
gas Q across the galactic disk with the unstable band shaded
Toomre Q: rotation + pressure vs self-gravity in a disk

  Q = c_s kappa / (pi G Sigma)   [gas]
  Q = sigma_R kappa / (3.36 G Sigma)  [stars]

  solar neighbourhood (R = 8 kpc):
    gas Q     = 1.77  (stable)
    stellar Q = 1.61  (stable)
    Toomre wavelength = 1.46 kpc (the scale of the structures)

    R (kpc)  kappa (/Gyr)    gas Q       state
  --------------------------------------------
          2         159.1     2.61      stable
          4          79.6     1.82      stable
          6          53.0     1.69      stable
          8          39.8     1.77      stable
         10          31.8     1.98      stable
         14          22.7     2.75      stable
         18          17.7     4.17      stable

  The Milky Way sits at Q ~ 1.5-2 -- marginally stable. That is no
  accident: a disk that cools below Q = 1 fragments into clumps and
  spiral arms, and the resulting star formation heats it back up, so
  disks self-regulate to hover near the stability line.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\toomre.svg

Accretion disks: why black holes glow

Gas with angular momentum settles into a disk and spirals in only as viscosity carries momentum outward, dissipating gravitational energy as heat radiated as a blackbody. The Shakura-Sunyaev profile T ~ r^(-3/4) makes the inner edge hottest: a 10-solar-mass hole peaks in soft X-rays (~keV), a billion-solar-mass one in the UV (the quasar 'big blue bump'). Both convert ~6% of rest mass to light -- ~8x fusion.

UV (1e+05 K) optical (1e+04 K) stellar-mass (10 Msun) supermassive (1e8 Msun) Accretion-disk temperature: T ~ r^(-3/4) heavier hole = cooler disk (T_* ~ M^(-1/4)): X-ray binary vs UV quasar log10 radius (r / r_in) -> log10 temperature (K)
T(r) for a stellar-mass and a supermassive disk, wavebands marked
Shakura-Sunyaev disk: T(r) ~ r^(-3/4), L = eta Mdot c^2

  radiative efficiency eta = 0.057 (vs ~0.007 for H fusion)

              object    r_in (km)   peak T (K)     peak kT   L_Edd (Lsun)
  -----------------------------------------------------------------------
     stellar-mass BH         88.6     8.64e+06    0.74 keV       3.28e+05
     supermassive BH  886237966.5     1.54e+05       13 eV       3.28e+12

  The stellar-mass disk peaks around a keV -- soft X-rays, which is how
  black-hole binaries are found. The billion-times-heavier supermassive
  disk is a thousand times cooler at its edge (T_* ~ M^(-1/4)), peaking
  in the ultraviolet: the 'big blue bump' of quasar spectra. Both convert
  ~6% of infalling rest mass to light -- ~8x more than fusion manages.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\accretion_disk.svg

Fermi acceleration & cosmic rays

A charged particle repeatedly crossing a shock front gains energy at first order in the shock speed each time and has a fixed escape chance, producing a scale-free power-law spectrum N(E) ~ E^(-p). The index depends only on the compression ratio, p = (r+2)/(r-1), and every strong shock converges to r = 4, p = 2 -- the near-universal E^(-2) spectrum injected by supernova remnants across the Galaxy.

M=1.5 (p=5.20) M=2 (p=3.33) M=3 (p=2.50) M=5 (p=2.17) M>>1 (p=2) (p=2.00) Diffusive shock acceleration: N(E) ~ E^(-p) p = (r+2)/(r-1); every strong shock converges to r=4, p=2 log10 energy (E / E0) -> log10 N(E)
power-law spectra steepening as the shock weakens toward p=2
Fermi acceleration: shocks stamp a power law N(E) ~ E^(-p)

  strong-shock (M -> inf) index: p = 2.00 -> the universal E^(-2) spectrum

     Mach  compression r   index p
  --------------------------------
      1.5          1.714     5.200
        2          2.286     3.333
        3          3.000     2.500
        5          3.571     2.167
       10          3.883     2.040
       50          3.995     2.002
     1000          4.000     2.000

  per-cycle energy gain at beta = 0.03:
    first-order (shock): 0.0400  (~4/3 beta)
    second-order (clouds): 0.00120  (~4/3 beta^2)
    first-order wins by 1/beta ~ 33x -- why shocks dominate.

  The magic of diffusive shock acceleration: the index depends only on
  the compression ratio, p = (r+2)/(r-1), not on the messy microphysics.
  Every strong shock converges to r=4, p=2 -- so supernova remnants across
  the Galaxy all inject nearly the same E^(-2) cosmic-ray spectrum.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\fermi_acceleration.svg

Stellar opacity

Opacity kappa sets the photon mean free path 1/(kappa rho) and so how slowly a star leaks its light. Electron scattering is a flat floor in hot ionized gas; Kramers bound-free/free-free absorption rises with density and falls as T^(-7/2), making cool outer layers far more opaque than the core. That steep temperature dependence is what flips stellar envelopes from radiative to convective energy transport.

electron-scattering floor total kappa Kramers (~rho T^-3.5) Stellar opacity vs temperature Kramers T^-3.5 fall-off flattening onto the electron-scattering floor log10 temperature (K) -> log10 opacity (m^2/kg)
Kramers T^-3.5 fall-off flattening onto the electron-scattering floor
Stellar opacity: how slowly light escapes matter (kappa, m^2/kg)

  electron scattering floor: 0.0338 m^2/kg (T- and rho-independent)

            region       rho     T (K)  kappa_es   Kramers    total
  -----------------------------------------------------------------
      solar centre   1.5e+05   1.5e+07     0.034     0.074    0.108
    radiative zone   2.0e+04   5.0e+06     0.034     0.461    0.494
      near surface   1.0e-03   1.0e+05     0.034     0.020    0.054
       photosphere   1.0e-04   6.0e+03     0.034    38.468   38.502

  Kramers opacity ~ rho T^(-7/2): the cool outer layers are far more
  opaque than the blazing core, which is why energy switches from
  radiative diffusion to convection in stellar envelopes. In the hot,
  dilute deep interior everything drops to the electron-scattering floor
  -- the same opacity the Eddington luminosity is built on.

  photon mean free path at the solar centre: 61.9 microns
  -- a photon random-walks out over ~100,000 years.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\opacity.svg

Brunt-Vaisala frequency & convection

Displace a fluid parcel upward: if it ends up denser than its new surroundings, gravity pulls it back and it oscillates at the buoyancy frequency N (internal gravity waves, ~5-10 min in the troposphere). If it ends up lighter, buoyancy runs away and the layer convects. N^2 > 0 means stable, N^2 < 0 unstable -- the sign change is exactly the Schwarzschild convection criterion, set by the adiabatic lapse rate.

convective (lapse > 9.8 K/km) N^2 = 0 Buoyancy frequency squared vs lapse rate N^2 > 0 stable (gravity waves); N^2 < 0 convects (Schwarzschild) environmental lapse rate (K/km) -> N^2 (s^-2)
N^2 vs lapse rate, with the convective region beyond adiabatic shaded
Brunt-Vaisala buoyancy frequency: N^2 = (g/T)(dT/dz + g/c_p)

  dry adiabatic lapse rate g/c_p = 9.76 K/km (the stability threshold)

                   layer  dT/dz (K/km)   N (1/s)      period        state
  -----------------------------------------------------------------------
        strong inversion          10.0    0.0266     3.9 min       stable
            stratosphere           2.0    0.0229     4.6 min       stable
              isothermal           0.0    0.0187     5.6 min       stable
     typical troposphere          -6.5    0.0108     9.7 min       stable
           dry adiabatic          -9.8    0.0000         --    CONVECTIVE
          superadiabatic         -15.0    0.0000         --    CONVECTIVE

  A parcel displaced in a stable layer overshoots and oscillates at N --
  these buoyancy (internal gravity) waves ripple through the stratosphere
  and the Sun's radiative core. Once the environment cools faster than the
  adiabatic lapse rate, N^2 flips negative: buoyancy runs away and the
  layer convects. That sign change IS the Schwarzschild convection criterion.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\brunt_vaisala.svg

Ram-pressure stripping

A galaxy plunging through a cluster's hot gas feels a wind of ram pressure rho v^2. The Gunn-Gott criterion strips its interstellar gas wherever that wind beats the disk's gravitational hold 2 pi G Sigma_star Sigma_gas, so the galaxy keeps only the gas inside a stripping radius. In a rich cluster a Milky-Way-like spiral is stripped to a few kpc in one pass -- quenching it into a gas-poor S0.

n=1e-4 (group) n=5e-4 n=1e-3 (cluster) n=3e-3 (core) Ram-pressure stripping radius vs infall speed faster, denser environments strip a galaxy to its dense core infall speed (km/s) -> surviving gas radius (kpc)
surviving gas radius vs infall speed for a range of ICM densities
Ram-pressure stripping (Gunn-Gott): rho_icm v^2 > 2 pi G Sigma_s Sigma_g

  Milky-Way-like disk (scale length 3 kpc); gas kept inside R_strip:

             environment   n (/cc)  v (km/s)  R_strip (kpc)
  ---------------------------------------------------------
        field / isolated     1e-05       300          16.62
              poor group     1e-04       500          11.63
       cluster outskirts     5e-04      1000           7.14
            rich cluster     1e-03      1500           4.88
        dense core, fast     3e-03      2000           2.37

  A disk keeps only the gas inside R_strip, where its self-gravity still
  beats the cluster wind. In a rich cluster a Milky-Way-like galaxy is
  stripped down to a few kpc on a single pass -- its star formation
  quenches from the outside in, helping turn infalling spirals into the
  gas-poor S0s and ellipticals that crowd cluster cores.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\ram_pressure.svg

Free-fall: the universal clock of gravity

Remove a body's pressure support and it collapses in the free-fall time t_ff = sqrt(3 pi / 32 G rho) -- which depends only on mean density, not size or mass. So a galaxy and a raindrop of equal density collapse in the same time. The Sun would free-fall in ~30 minutes, a molecular-cloud core in a few hundred kyr, a neutron star in under a millisecond: one 1/sqrt(G rho) line spanning 37 decades of density.

cloud Sun Earth neutron star The universal clock of gravity: t_ff ~ rho^(-1/2) one line over 37 decades in density -- size and mass drop out log10 mean density (kg/m^3) -> log10 free-fall time (s)
free-fall time vs mean density from clouds to neutron stars
Free-fall time t_ff = sqrt(3 pi / 32 G rho): the clock of gravity

                    system   rho (kg/m^3)              t_ff
  ---------------------------------------------------------
     giant molecular cloud       3.85e-19          3.39 Myr
          dense cloud core       3.85e-17         339.4 kyr
         protostellar core       3.85e-13           3.4 kyr
            the Sun (mean)       1.41e+03          29.5 min
                 the Earth       5.51e+03          14.9 min
               white dwarf       1.00e+09             2.1 s
              neutron star       5.00e+17           0.09 ms

  Notice: t_ff depends only on density, not size or mass. A galaxy and a
  raindrop of the same mean density collapse in the same time. That is why
  low Earth orbit is always ~90 minutes, why a molecular cloud core forms
  stars in a few hundred kyr, and why a neutron star's dynamical time is
  well under a millisecond -- denser means faster, universally.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\free_fall.svg

Shock jumps: Rankine-Hugoniot

Move faster than the sound speed c_s = sqrt(gamma P/rho) and the gas cannot get out of the way -- a shock forms. Conservation of mass, momentum and energy fix the jumps from the upstream Mach number: density saturates at 4 (gamma=5/3), but pressure and temperature climb as M^2 without limit, which is why strong shocks heat gas to millions of kelvin while barely compressing it. The downstream flow is always subsonic.

density ceiling = 4 rho2/rho1 P2/P1 T2/T1 Rankine-Hugoniot jumps vs Mach number density saturates at 4; pressure and temperature diverge as M^2 log10 Mach number -> log10 jump ratio
density saturating at 4 while pressure and temperature diverge as M^2
Rankine-Hugoniot shock jumps (gamma = 5/3)

  sound speed, ionized gas at 1e4 K: 15.1 km/s
  sound speed, air at 288 K:         339 m/s

     Mach   rho2/rho1       P2/P1       T2/T1   M2 (down)
  -------------------------------------------------------
        1       1.000         1.0         1.0       1.000
      1.5       1.714         2.6         1.5       0.716
        2       2.286         4.8         2.1       0.607
        3       3.000        11.0         3.7       0.522
        5       3.571        31.0         8.7       0.475
       10       3.883       124.8        32.1       0.454
       30       3.987      1124.8       282.1       0.448
      100       3.999     12499.8      3125.9       0.447

  Density compression saturates at (gamma+1)/(gamma-1) = 4 -- a strong shock can pack gas only
  fourfold. But pressure and temperature jumps grow as M^2 without limit,
  which is why strong shocks heat gas to millions of kelvin (supernova
  remnants, re-entry plasma) while barely compressing it. The downstream
  flow is always subsonic (M2 < 1) -- the shock is a one-way valve.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\shock_jump.svg

The Stromgren sphere

A hot star's ultraviolet photons ionize a bubble of hydrogen around it. In equilibrium every ionizing photon replaces one recombination, fixing the Stromgren radius R = (3Q / 4 pi n^2 alpha_B)^(1/3). Because R ~ Q^(1/3) and R ~ n^(-2/3), an O star lights up a ~25 pc nebula in diffuse gas but only a fraction of a parsec in a dense clump -- the pink emission nebulae (Orion, the Rosette) that flag recent massive-star formation.

O5 (5e49) O9 (5e48) B0 (1e48) Stromgren radius vs cloud density R ~ n^(-2/3): diffuse nebula to compact HII region log10 density (atoms/cc) -> log10 Stromgren radius (pc)
Stromgren radius shrinking as n^(-2/3) for three stellar types
Stromgren sphere: R_s = (3 Q / 4 pi n^2 alpha_B)^(1/3)

  balance: every ionizing photon replaces one recombination

        star (Q, /s)   n (/cc)  R_s (pc)  M_ion (Msun)
  ----------------------------------------------------
           O5 (5e49)        10     25.00         16172
           O5 (5e49)       100      5.39          1617
           O5 (5e49)      1000      1.16           162
           O9 (5e48)        10     11.60          1617
           O9 (5e48)       100      2.50           162
           O9 (5e48)      1000      0.54            16
           B0 (1e48)        10      6.79           323
           B0 (1e48)       100      1.46            32
           B0 (1e48)      1000      0.31             3

  R shrinks as n^(-2/3): the same O star lights up a ~25 pc bubble in
  diffuse gas but only a fraction of a parsec in a dense clump (a compact
  HII region). These are the pink emission nebulae -- Orion, the Rosette --
  that flag where massive stars formed in the last few million years.
  Switch the star off and the bubble recombines in ~1219 yr.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\stromgren.svg

Two-body relaxation & evaporation

Every stellar flyby deflects a star a little; the accumulated random kicks change its velocity by order itself in the relaxation time t_relax ~ (N / 8 ln N) t_cross. Because that grows almost linearly with N, a globular cluster relaxes in ~1 Gyr and slowly evaporates, while a galaxy's relaxation time is millions of Hubble times -- effectively collisionless, which is why it keeps its spiral arms and streams.

Hubble time -- above: collisionless globular galaxy Relaxation time vs particle number (t_cross = 1 Myr) t_relax ~ N/ln N: clusters relax, galaxies never do log10 number of stars N -> log10 relaxation time (Gyr)
relaxation time vs N crossing the Hubble-time line
Two-body relaxation: t_relax ~ (N / 8 ln N) t_cross

  Hubble time = 13.8 Gyr (the collisionless threshold)

                system         N    t_cross     t_relax           state
  ---------------------------------------------------------------------
          open cluster     1e+03   1956 kyr      35 Myr     collisional
      globular cluster     1e+05    978 kyr    1062 Myr     collisional
  nuclear star cluster     1e+07     49 kyr       4 Gyr     collisional
          dwarf galaxy     1e+08     33 Myr   22117 Gyr   collisionless
             Milky Way     1e+11     73 Myr   3e+06 t_H   collisionless

  t_relax grows almost linearly with N, so small systems relax and
  mass-segregate fast while big ones never do. A globular cluster relaxes
  in ~1 Gyr and slowly evaporates; the Milky Way's relaxation time is
  millions of Hubble times, so it is collisionless -- which is why galaxies
  keep their spiral arms, streams and cold disks intact for a Hubble time.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\relaxation_time.svg

The Parker wind

Parker showed a hot corona cannot stay static: an isothermal atmosphere keeps a finite pressure at infinity, far above interstellar space, so it must expand. The correct steady solution passes smoothly through Mach 1 at the sonic critical radius r_c = GM/2c_s^2 (a few solar radii), staying subsonic inside and supersonic out, and reaches a few hundred km/s by 1 AU -- the solar wind Mariner 2 confirmed.

1 MK 1.5 MK 2 MK 3 MK Parker wind: transonic velocity profiles dots mark the sonic critical point (Mach 1); hotter = faster wind log10 radius (R_sun) -> wind speed (km/s)
transonic velocity profiles through the Mach-1 critical point
Parker wind: the transonic solution through the sonic critical point

    T (MK)  c_s (km/s)  r_c (R_sun)   v(1 AU) km/s  Mach(1 AU)
  ------------------------------------------------------------
       1.0       117.3         6.94            430        3.67
       1.5       143.7         4.62            559        3.89
       2.0       165.9         3.47            671        4.05
       3.0       203.2         2.31            864        4.25

  A hotter corona has a larger sound speed but a SMALLER critical radius
  (r_c ~ 1/c_s^2), so the wind goes supersonic sooner and reaches a higher
  terminal speed. Parker's key point: the only solution that stays finite
  at both ends passes smoothly through Mach 1 -- a static corona is
  impossible, and the supersonic solar wind confirmed by Mariner 2 follows.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\parker_wind.svg

The greenhouse effect

An atmosphere transparent to sunlight but opaque in the infrared lets light in and traps the outgoing heat, so the surface runs hotter than the equilibrium temperature: T_surf = T_eq (1 + 3 tau/4)^(1/4). Earth's modest tau ~ 0.8 lifts 255 K to a life-friendly 288 K; Venus, wrapped in dense CO2 (tau ~ 150), runs away to a lead-melting 737 K; airless Mars sits at its equilibrium temperature.

Venus (tau=147.3) Earth (tau=0.8) Mars (tau=0.0) Titan (tau=0.7) Greenhouse warming vs infrared optical depth T_surf / T_eq = (1 + 3 tau/4)^(1/4): Earth mild, Venus runaway log10 IR optical depth tau -> log10 (T_surf / T_eq)
surface warming vs optical depth with the terrestrial planets marked
Greenhouse effect: T_surf = T_eq (1 + 3 tau / 4)^(1/4)

    planet  T_eq (K)  T_surf (K)   warming  tau needed
  ----------------------------------------------------
     Venus     226.8       737.0     510.2       147.3
     Earth     254.7       288.0      33.3         0.8
      Mars     209.9       210.0       0.1         0.0
     Titan      84.6        94.0       9.4         0.7

  Earth's modest tau ~ 0.8 lifts its 255 K skin temperature to a
  life-friendly 288 K -- a 33 K blanket. Venus, wrapped in a dense CO2
  atmosphere of tau ~ 150, is heated from a 227 K equilibrium to a
  lead-melting 737 K: the runaway greenhouse. Nearly airless Mars and
  hazy-but-cold Titan sit close to their equilibrium temperatures.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\greenhouse.svg

Rossby number & geostrophic balance

On a rotating planet the Coriolis force deflects moving air at rate f = 2 Omega sin(lat). The Rossby number Ro = U/(fL) decides whether it matters: Ro << 1 (big, slow flows) means geostrophic balance -- wind blows along the isobars, so cyclones and ocean gyres are rotating vortices. Ro >> 1 (tornadoes, bathtub drains) ignores rotation entirely, which is why the draining-sink Coriolis story is a myth.

Ro < 0.1: geostrophic (rotation rules) tornado hurricane cyclone Rossby number vs length scale (U = 10 m/s, 45 deg) big slow flows are rotation-dominated; small fast ones are not log10 length scale (m) -> log10 Rossby number
Rossby number vs length scale with the geostrophic band shaded
Rossby number Ro = U / (f L): does planetary rotation matter?

  Coriolis parameter at 45 deg: f = 1.03e-04 s^-1
  inertial period there: 16.9 hr (half a pendulum day)

                  flow  U (m/s)           L          Ro          regime
  ---------------------------------------------------------------------
         bathtub drain      0.2       0.1 m    1.94e+04    ageostrophic
               tornado    100.0     100.0 m     9.7e+03    ageostrophic
            sea breeze      5.0       20 km        2.42    ageostrophic
             hurricane     50.0      500 km        0.97    ageostrophic
    cyclone (synoptic)     10.0     1000 km       0.097     geostrophic
            ocean gyre      0.1     2000 km    0.000485     geostrophic

  A 1 mb / 100 km pressure gradient drives a ~8 m/s geostrophic wind
  -- blowing ALONG the isobars, not across them, because Coriolis balances
  the pressure force. Small, fast flows (Ro >> 1) ignore rotation; large,
  slow ones (Ro << 1) are ruled by it, which is why weather systems and
  ocean gyres are big rotating vortices rather than simple radial flows.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\rossby.svg

Rayleigh-Benard convection

Heat a fluid from below and buoyancy fights viscosity and diffusion. The Rayleigh number Ra = g alpha dT d^3 / (nu kappa) measures the contest: below Ra_c ~ 1708 the layer just conducts, above it convection switches on sharply in rolls and cells. The heat enhancement Nu ~ (Ra/Ra_c)^(1/3) climbs steeply -- and at the Ra ~ 10^30 of the solar convection zone the flow is violently turbulent, driving granulation and mantle plate motion.

Ra_c ~ 1708 (onset) Nu = 1 (pure conduction) Heat transport across the convective onset Nu flat at 1 below Ra_c, then climbing as (Ra/Ra_c)^(1/3) log10 Rayleigh number -> log10 Nusselt number
Nusselt number flat at 1 below onset then rising past Ra_c
Rayleigh-Benard: Ra = g alpha dT d^3 / (nu kappa); onset at Ra_c ~ 1708

                  system          Ra           state      Nu
  ----------------------------------------------------------
   lab cell (near onset)     3.6e+01      conducting         1
           mug of coffee     4.7e+07      convecting        30
          pot on a stove     3.6e+08      convecting        60
          Earth's mantle     2.2e+08      convecting        50
   solar convection zone     7.8e+30      convecting     2e+09

  Below Ra_c the layer just conducts (Nu = 1); above it convection
  switches on sharply and carries far more heat (Nu ~ (Ra/Ra_c)^(1/3)).
  Astrophysical layers -- the mantle, the solar convection zone -- run
  at Ra of 10^20 or more, so they are violently, turbulently convective:
  the granulation on the Sun and the plates under our feet both follow.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\rayleigh_benard.svg

Terminal velocity & drag

A falling body speeds up until drag balances gravity. Which drag law applies is set by the Reynolds number: viscous Stokes drag (v ~ r^2) for tiny slow particles, quadratic drag (v ~ sqrt(r)) for big fast ones. So a fog droplet 200x smaller than a raindrop falls 40000x slower and effectively floats, a raindrop settles at ~9 m/s, and a belly-down skydiver tops out near 50 m/s.

Re ~ 1 (Stokes | quadratic) fog raindrop hail Terminal velocity vs particle radius (water in air) Stokes v ~ r^2 for small drops, quadratic v ~ sqrt(r) for big ones log10 radius (m) -> log10 terminal velocity (m/s)
terminal velocity vs radius bending from Stokes r^2 to quadratic sqrt(r)
Terminal velocity: drag balances gravity (regime set by Reynolds number)

              object        v_term          Re      regime
  --------------------------------------------------------
    fog droplet 10um    12.03 mm/s       0.016      Stokes
       drizzle 100um       2.1 m/s          29   quadratic
        raindrop 2mm       9.5 m/s     2.6e+03   quadratic
       hailstone 1cm      20.2 m/s     2.7e+04   quadratic
      steel ball 1cm      59.5 m/s     8.1e+04   quadratic

    skydiver (belly)        49 m/s  (~176 km/h)

  Stokes drag (viscous) gives v ~ r^2, so a fog droplet 200x smaller than
  a raindrop falls ~40000x slower -- effectively floating. Big drops cross
  into quadratic drag where v ~ sqrt(r), so a hailstone and a steel ball of
  the same size differ only by sqrt(density). The regime boundary is Re ~ 1.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\terminal_velocity.svg

Supernova remnant phases

A supernova dumps ~10^51 erg into the ISM, and the shell ages through four phases: ballistic free expansion for a few centuries, then the long adiabatic Sedov-Taylor phase (R ~ t^2/5) for tens of thousands of years, then a radiative snowplow coasting on momentum, finally merging into the ISM near 100 pc after ~10^6 yr -- seeding the galaxy with the elements it forged along the way.

free -> Sedov Sedov -> snowplow merge Supernova remnant: radius vs age ballistic R~t, then Sedov R~t^(2/5), then snowplow R~t^(2/7) log10 age (yr) -> log10 radius (pc)
the radius-vs-age track with the phase transitions marked
Supernova remnant: free expansion -> Sedov -> snowplow -> merge

  E = 1e51 erg, M_ej = 5 Msun, n = 1 /cc

  sweep-up radius (free expansion ends): 3.3 pc at 318 yr
  merge radius (~10 km/s): 167 pc

      age (yr)    R (pc)    v (km/s)           phase
  --------------------------------------------------
           100       1.0       10000  free expansion
           300       3.1       10000  free expansion
          1000       5.0        1947    Sedov-Taylor
          5000       9.5         741    Sedov-Taylor
         20000      16.5         323    Sedov-Taylor
         50000      23.8         186        snowplow
        200000      41.4          81        snowplow
       1000000      78.9          31          merged

  The blast coasts ballistically for a few centuries, then sweeps up
  enough gas to enter the long adiabatic Sedov phase (R ~ t^2/5) for tens
  of thousands of years. When the shell cools it radiates and coasts on
  momentum (snowplow), finally merging into the ISM near 100 pc after a
  million years -- seeding the galaxy with the elements it forged.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\snr_phases.svg

The magnetic mirror & loss cone

A charged particle spiraling along a field line conserves its magnetic moment mu = m v_perp^2 / 2B. Drifting into stronger field, v_perp must grow and v_parallel shrink until the particle reflects -- a magnetic mirror. Trapping depends only on pitch angle: sin^2(alpha) > 1/R_m holds the particle, else it falls into the loss cone and escapes. This traps the Van Allen belts and lights the aurora.

trapped (mirrors) loss cone (escapes) The loss cone shrinks as the mirror tightens loss-cone angle = arcsin(sqrt(1/R_m)); above it particles are held log10 mirror ratio R_m -> equatorial pitch angle (deg)
loss-cone angle shrinking as the mirror ratio grows
Magnetic mirror: trapped if sin^2(pitch) > B_min/B_max = 1/R_m

    mirror ratio R_m   loss cone (deg)   20 deg   60 deg
  ------------------------------------------------------
                   2              45.0     lost     trap
                   4              30.0     lost     trap
                  10              18.4     trap     trap
                  50               8.1     trap     trap
                1000               1.8     trap     trap

  A bigger mirror ratio means a narrower loss cone, so more particles are
  held. Earth's dipole (R_m ~ tens between equator and pole) traps the Van
  Allen belts: particles bounce pole to pole, reflected where the field
  tightens, unless their pitch angle drops them into the loss cone and down
  into the atmosphere -- which is what lights up the aurora.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\magnetic_mirror.svg

Debye shielding & the plasma frequency

Ionized gas screens any test charge within the Debye length lambda_D = sqrt(eps0 kT / n e^2), and behaves as a collective plasma only when many particles sit inside a Debye sphere. Disturb the electrons and they ring at the plasma frequency omega_p = sqrt(n e^2 / eps0 m_e); waves below it are reflected -- which is why the ionosphere's ~9 MHz cutoff bounces AM radio around the Earth but lets FM escape to space.

AM ~1 MHz FM ~100 MHz WiFi 2.4 GHz ionosphere corona fusion core Plasma frequency vs electron density waves below f_p are reflected: the ionosphere bounces AM, passes FM log10 electron density (m^-3) -> log10 plasma frequency (Hz)
plasma frequency vs density with the radio bands marked
Debye model: C_V rises as T^3, plateaus at Dulong-Petit 3R = 24.94 J/mol/K

      material  Theta_D (K)   C_V @300K   % of 3R
  ------------------------------------------------
          lead          105       24.79     99.4%
        copper          343       23.39     93.8%
     aluminium          428       22.58     90.5%
       diamond         2230        4.13     16.6%

  Every solid follows one universal curve in T/Theta_D. A stiff, light
  lattice has a high Debye temperature, so at room temperature it is still
  'cold' -- diamond stores only ~1/6 of its classical heat capacity, while
  soft heavy lead has long since reached the 3R plateau. The T^3 falloff at
  low temperature is the fingerprint of phonon quantization.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\debye_heat.svg

Spectral line broadening

Atomic lines have width from three causes. Thermal Doppler motion gives a Gaussian of width ~ sqrt(T/m) -- the standard plasma thermometer. The finite excited-state lifetime gives an irreducible natural (Lorentzian) width A/4pi. Collisions add a pressure (Lorentzian) width that grows with density, so dense dwarf photospheres show broad wings absent in thin gas. The observed profile is their Voigt convolution.

Doppler (Gaussian, thermal) pressure (Lorentzian, dense) -- broad wings Line profiles: Gaussian core vs Lorentzian wings thermal motion gives a Gaussian; collisions give broad Lorentzian wings frequency offset from line centre (GHz) -> normalized intensity
a thermal Gaussian core beside a collisional Lorentzian with broad wings
Line broadening of H-alpha (656.3 nm): Doppler / natural / pressure

  natural width (fixed): 5.2 MHz

             environment    T (K)     Doppler    pressure    dominant
  -------------------------------------------------------------------
              HII region    10000     19.57G      0.000G     Doppler
       solar photosphere     6000     15.16G      0.159G     Doppler
               red giant     4000     12.38G      0.002G     Doppler
       white-dwarf atmos    10000     19.57G   1591.549G    pressure
          cool ISM cloud      100      1.96G      0.000G     Doppler

  Doppler width ~ sqrt(T/m) reads the temperature (and, from the ratio of
  species widths, tells light atoms from heavy). Pressure width grows with
  density, so a dense white-dwarf photosphere has hugely broadened,
  Lorentzian-winged lines while a thin HII region is purely Doppler. The
  observed shape is the Voigt convolution of the Gaussian and Lorentzian.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\line_broadening.svg

The curve of growth

How an absorption line's equivalent width grows with column density has three regimes: linear (W ~ N) for weak lines, a flat saturated plateau once the core goes black (W barely moves over decades of N), and a square-root damping tail (W ~ sqrt(N)) when the Lorentzian wings go thick. Matching a measured equivalent width to this curve is how stellar abundances are read from spectra.

linear W~N saturated (flat) damped W~sqrt(N) The curve of growth linear rise -> saturated plateau -> square-root damping tail log10 central optical depth tau0 -> log10 equivalent width
the linear rise, saturated plateau and square-root damping tail
Curve of growth: equivalent width vs central optical depth

          tau0     W / dnu_D        regime
  ----------------------------------------
          0.01          0.01        linear
           0.1           0.1        linear
             1             1     saturated
            10         3.097     saturated
           100         4.297     saturated
          1000         5.257     saturated
        100000         17.72        damped
         1e+07         177.2        damped
         1e+09          1772        damped

  Weak lines grow linearly (W ~ N): every atom adds absorption. Once the
  core saturates the line is already black, so W barely moves over decades
  of column density -- the flat part where abundances are hardest to pin
  down. At enormous columns the Lorentzian damping wings go optically thick
  and W ~ sqrt(N) again. Matching a measured W to this curve is how stellar
  abundances are read off absorption spectra.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\curve_of_growth.svg

Sackur-Tetrode entropy

Quantum state-counting fixes the absolute entropy of an ideal gas: S = N k_B [ln((V/N)(4 pi m U / 3 N h^2)^(3/2)) + 5/2]. Planck's constant enters explicitly (a phase-space cell is h^3) and the 1/N! for indistinguishable atoms resolves the Gibbs paradox. Evaluated for the noble gases at STP it reproduces the measured standard molar entropies to under a fifth of a percent -- entropy really is log of microstates.

He Ar Xe Absolute entropy vs temperature (Sackur-Tetrode) rings mark measured STP values; heavier gas = more entropy temperature (K) -> molar entropy (J/mol/K)
molar entropy vs temperature with measured STP values ringed
Sackur-Tetrode: absolute entropy from counting quantum microstates

  standard molar entropy at STP (298.15 K, 1 atm):

     gas  mass (amu)   S predicted   S measured    error
  ------------------------------------------------------
      He       4.003         126.0        126.2   -0.12%
      Ne      20.180         146.2        146.3   -0.06%
      Ar      39.948         154.7        154.8   -0.04%
      Kr      83.798         164.0        164.1   -0.08%
      Xe     131.290         169.6        169.7   -0.07%

  Predicted from nothing but atomic mass, T and P -- and matching the
  calorimetric values to a fraction of a percent. The quantum of action h
  appears explicitly (a phase-space cell is h^3), and the 1/N! for
  indistinguishable atoms is what makes entropy extensive and resolves the
  Gibbs paradox. Entropy really is the logarithm of countable microstates.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\sackur_tetrode.svg

Maxwell-Boltzmann speeds

Molecular speeds in a gas follow f(v) ~ v^2 exp(-mv^2/2kT), whose three characteristic speeds keep a fixed ratio v_p : : v_rms = 1 : 1.128 : 1.225. Speeds scale as 1/sqrt(m), so hydrogen moves ~4x faster than nitrogen at the same temperature, and the thin exp(-v^2) tail -- only ~0.04% above 3 v_p -- is exactly what governs atmospheric escape and the onset of nuclear fusion.

H2 He N2 CO2 v_p v_rms Maxwell-Boltzmann speed distribution (300 K) lighter gases peak faster (v ~ 1/sqrt(m)); ticks on N2 molecular speed (m/s) -> probability density f(v)
speed distributions for several gases with the three speeds marked
Maxwell-Boltzmann speeds at T = 300 K (mean KE = 0.039 eV)

  v_p : <v> : v_rms = 1 : 1.128 : 1.225 (universal)

       gas  mass (amu)      v_p      <v>    v_rms    >3 v_p
  ---------------------------------------------------------
        H2           2     1579     1782     1934   4.4e-04
        He           4     1117     1260     1368   4.4e-04
        N2          28      422      476      517   4.4e-04
        O2          32      395      446      484   4.4e-04
       CO2          44      337      380      412   4.4e-04

  Speeds scale as 1/sqrt(m), so hydrogen zips along four times faster than
  nitrogen at the same temperature -- which is why light gases escape
  atmospheres and why sound (set by ~v_rms) travels faster in helium. The
  fraction above 3 v_p is only ~0.04%: the exp(-v^2) tail is thin, but it
  is exactly that tail that lets atoms escape gravity and nuclei fuse.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\maxwell_boltzmann.svg

The Gamow peak

Nuclei must beat an MeV Coulomb barrier to fuse, yet the Sun's core is only ~1.3 keV. Two factors save it: the Maxwell-Boltzmann tail exp(-E/kT) falls with energy while quantum tunnelling exp(-sqrt(E_G/E)) rises, and their product is sharply peaked at the Gamow energy E0 = (E_G (kT)^2/4)^(1/3). Solar p-p fusion happens in a narrow window at ~6 keV; heavier nuclei need far hotter cores, the thermostat of stellar burning.

Gamow peak E0 = 5.9 keV Boltzmann tail exp(-E/kT) tunnelling exp(-sqrt(E_G/E)) product = Gamow peak The Gamow peak (proton-proton, solar core) energy (keV, each curve self-normalized) ->
the Boltzmann tail and tunnelling probability multiplying to the Gamow peak
Gamow peak: Boltzmann tail x quantum tunnelling = fusion window

  solar core kT = 1.29 keV; barrier ~ MeV -- yet stars burn

      reaction  Z1 Z2     T (K)   E_G (keV)    peak E0
  ----------------------------------------------------
         p + p      1   1.5e+07         493       5.9k
       p + N14      7   1.5e+07       45105      26.6k
       He + He      4   1.0e+08       31560      83.7k
         C + C     36   5.0e+08     7669118    1526.8k

  Fusion lives in a narrow window far out on the thermal tail: the Sun's
  p-p peak sits at ~6 keV, several times the mean 1.3 keV, where enough
  fast protons meet a high-enough tunnelling chance. Because the peak
  climbs steeply with nuclear charge, carbon burning needs ~500 million K
  while hydrogen ignites at 15 million -- the thermostat of stellar life.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\gamow.svg

Parallax & proper motion

As Earth orbits the Sun a nearby star shifts by the parallax angle p, and the parsec is defined so d (pc) = 1/p (arcsec) -- the first rung of the distance ladder Gaia climbed for a billion stars. Proper motion adds the sideways drift: v_t = 4.74 mu d, combined with the Doppler radial velocity into the space velocity. Barnard's Star, the fastest, moves at 142 km/s through space.

Sun Earth (Jan) Earth (Jul) nearby star parallax angle p 1 AU baseline Stellar parallax: distance from Earth-orbit geometry the star shifts by p over six months; d (pc) = 1 / p (arcsec)
the Earth-orbit baseline and the angle a nearby star subtends
Parallax: d (pc) = 1 / p (arcsec);  v_t (km/s) = 4.74 mu d

              star    p (")   d (pc)   d (ly)  mu ("/yr)   v_space
  ----------------------------------------------------------------
       Proxima Cen   0.7687     1.30      4.2       3.85     32.6k
    Barnard's Star   0.5469     1.83      6.0      10.36    142.0k
            Sirius   0.3792     2.64      8.6       1.34     17.6k
              Vega   0.1305     7.66     25.0       0.35     18.8k
        Betelgeuse   0.0055   181.82    593.0       0.03     33.9k

  Parallax is pure geometry -- the star's apparent shift over Earth's orbit
  -- and the first rung of the distance ladder Gaia has now climbed for over
  a billion stars. Proper motion adds the sideways drift: Barnard's Star,
  the fastest, crosses a Moon's width of sky every ~180 years and moves at
  142 km/s through space once its radial and tangential motions combine.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\parallax.svg

Standard candles & the distance ladder

Know an object's true luminosity and its apparent brightness gives its distance: m - M = 5 log10(d/10 pc). Cepheids supply M through Leavitt's period-luminosity law, Type Ia supernovae (M ~ -19.3) extend it to hundreds of Mpc, and chaining parallax -> Cepheids -> supernovae is the cosmic distance ladder. Five magnitudes is exactly 100x in flux.

LMC M31 Virgo parallax Cepheids Type Ia SNe The cosmic distance ladder: modulus vs distance log10 distance (pc) -> distance modulus m - M
distance modulus vs distance with the ladder rungs marked
Standard candles: m - M = 5 log10(d/10 pc); brightness gives distance

                  object      distance   modulus
  ----------------------------------------------
           10 pc (M = m)         10 pc      0.00
          Hyades cluster         47 pc      3.36
         Galactic centre       8200 pc     14.57
                     LMC        50 kpc     18.49
         Andromeda (M31)       778 kpc     24.45
           Virgo cluster      16.5 Mpc     31.09
           SN Ia horizon    1000.0 Mpc     40.00

  Cepheid period-luminosity (Leavitt's law): longer period = brighter
      period (d)       M_V
               3     -2.77
              10     -4.24
              30     -5.58
             100     -7.05

  Five magnitudes is exactly 100x in flux. Knowing M -- from a Cepheid's
  pulsation period or a Type Ia's standardizable peak (M ~ -19.3) -- turns
  the apparent brightness into a distance. Chaining parallax to Cepheids to
  supernovae is the ladder that measures the expanding universe.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\standard_candle.svg

The Tully-Fisher relation

Spiral galaxies obey a tight L ~ v_flat^4 scaling: because v^2 = GM/R and spirals hold roughly constant surface brightness, mass, spin and light rise together. A Milky-Way-like spiral (v ~ 220 km/s) shines ~3x10^10 L_sun. Since the rotation width is easy to measure from the 21-cm line, Tully-Fisher is a redshift-independent distance indicator reaching far beyond resolvable Cepheids -- the spiral cousin of Faber-Jackson.

dwarf Milky Way giant Tully-Fisher: luminosity vs rotation speed slope 4 on a log-log plot: L ~ v_flat^4 log10 flat rotation speed (km/s) -> log10 luminosity (L_sun)
luminosity vs rotation speed with slope 4 on a log-log plot
Tully-Fisher: L ~ v_flat^4 -- spirals that spin faster shine brighter

         galaxy type   v_flat    L (Lsun)    M_abs    M_baryon
  ------------------------------------------------------------
        dwarf spiral       80    5.12e+08   -14.38    2.05e+09
        small spiral      120    2.59e+09   -16.05    1.04e+10
      Milky Way-like      220    2.93e+10   -18.55    1.17e+11
      massive spiral      300    1.01e+11   -19.83    4.05e+11
        giant spiral      400    3.20e+11   -21.02    1.28e+12

  A four-fold jump in luminosity for every doubling of rotation speed --
  because v^2 = GM/R and spirals hold roughly constant surface brightness,
  so mass, spin and light rise together. Since the rotation width is easy
  to measure (the 21-cm line), Tully-Fisher gives redshift-independent
  distances far beyond where individual Cepheids can be resolved.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\tully_fisher.svg

The Tolman dimming test

Surface brightness is distance-independent in a static universe -- flux and angular area fall together. Expansion breaks that with four factors of (1+z), so SB ~ (1+z)^-4: a z=1 galaxy is dimmed 16x per square arcsecond, a z=3 galaxy 256x. A static tired-light universe would dim only as (1+z); observations back the (1+z)^4 law -- direct evidence the redshift is real expansion, not photons losing energy en route.

expanding: (1+z)^4 dimming tired light: (1+z)^1 (ruled out) z=1: 3 mag (16x) The Tolman test: surface-brightness dimming vs redshift redshift z -> dimming (magnitudes / arcsec^2)
expanding (1+z)^4 dimming diverging from the tired-light (1+z)^1 line
Tolman test: surface brightness ~ (1+z)^-4 if the universe expands

       z     expanding     mag   tired-light  ratio E/T
  -----------------------------------------------------
     0.2           1/2    0.79         1/1.2      0.579
     0.5           1/5    1.76         1/1.5      0.296
     1.0          1/16    3.01         1/2.0      0.125
     2.0          1/81    4.77         1/3.0      0.037
     3.0         1/256    6.02         1/4.0      0.016
     5.0        1/1296    7.78         1/6.0      0.005

  Surface brightness is distance-independent in a static Euclidean universe
  -- flux and angular area fall together. Expansion breaks that with four
  factors of (1+z): redshift, time dilation, and the D_A/D_L geometry. So a
  z=1 galaxy is dimmed 16x per square arcsecond, a z=3 galaxy 256x. A
  tired-light universe would dim only as (1+z); observations back the (1+z)^4,
  direct evidence the redshift is genuine expansion, not photons tiring out.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\tolman.svg

Olbers' paradox

In an infinite, eternal, static universe every line of sight would end on a star and the whole sky would blaze. It does not -- the night is dark. Stars would tile the sky only after ~10^16 light-years (the mean free path 1/n sigma), but the cosmic horizon (c x age) is a million times closer, so only ~10^-6 of the sky is covered. The finite age of the universe, not infinite space, is what makes night dark.

cosmic horizon (we see only this far) sky ~0 covered -> dark Olbers: sky covered by stars vs distance stars would tile the sky near 1 mfp -- but the horizon is 10^-6 of that log10 (distance / mean free path) -> fraction of sky covered by stars
sky-covering fraction vs distance with the horizon far short of tiling
Olbers' paradox: in an infinite static universe the sky would blaze

  star density ~ 0.1 /pc^3  ->  mean free path to a star = 2.0e+16 ly
  cosmic horizon (c x age)  = 1.4e+10 ly
  ratio horizon / mfp       = 6.8e-07

          distance     d / mfp     sky covered
  --------------------------------------------
    cosmic horizon    6.76e-07       6.757e-07
      100x horizon    6.76e-05       6.757e-05
           0.1 mfp    1.00e-01         0.09516
             1 mfp    1.00e+00          0.6321
             5 mfp    5.00e+00          0.9933
            20 mfp    2.00e+01               1

  Within the observable universe only ~7e-07
  of the sky is covered by stellar disks -- which is why night is dark. The
  paradox is real: every line of sight WOULD end on a star, but only after
  ~10^16 light-years, a million times farther than light has travelled since
  the Big Bang. The finite age of the universe, not infinite space, saves us.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\olbers.svg

Bi-elliptic transfer

The Hohmann two-burn transfer is cheapest for modest orbit changes, but for large radius ratios a three-burn bi-elliptic transfer -- flinging the craft far beyond the target and dropping back -- costs less total delta-v, because the mid-course burn happens where orbital speeds are tiny. Below R = 11.94 Hohmann always wins; above R = 15.58 bi-elliptic always does. The saving is paid for with a much longer, sometimes years-long, transfer.

R = 11.94 crossover Hohmann (2 burns) bi-elliptic (3 burns, r_b = 50 r2) Transfer delta-v vs orbit radius ratio bi-elliptic dips below Hohmann past R ~ 12 radius ratio R = r2 / r1 -> total delta-v / circular speed
Hohmann and bi-elliptic delta-v crossing near R = 12
Hohmann vs bi-elliptic transfer (r_b = 100 r1 detour)

  crossover ratio R = r2/r1 = 11.94

     R = r2/r1   Hohmann dv  bi-elliptic       winner
  ---------------------------------------------------
          5.00        3622m        4482m      Hohmann
         10.00        3998m        4120m      Hohmann
         11.94        4030m        4050m      Hohmann
         13.00        4039m        4020m  bi-elliptic
         16.00        4046m        3953m  bi-elliptic
         30.00        3980m        3810m  bi-elliptic
         60.00        3836m        3735m  bi-elliptic

  For modest orbit changes the Hohmann two-burn is unbeatable. Past R ~ 12,
  flinging the craft far beyond the target and dropping back costs less
  total delta-v, because the mid-course burn happens where orbital speeds
  are tiny. The catch: the detour can take years, so bi-elliptic is used
  only for the most extreme orbit raises. The 11.94 crossover is exact.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\bi_elliptic.svg

Gravity assist

A spacecraft flying past a planet follows a hyperbola: it leaves at the same speed relative to the planet but bent by the turn angle sin(delta/2) = 1/e. In the Sun's frame the planet is moving, so that rotation adds up to 2 v_inf of free heliocentric speed. A slower, deeper pass bends more and steals more; Voyager chained Jupiter-Saturn-Uranus-Neptune this way to reach escape speed for almost no fuel.

v_inf = 5 km/s v_inf = 10 km/s v_inf = 15 km/s Slingshot boost vs flyby periapsis deeper, slower passes bend more and steal more of the planet's motion periapsis distance (Jupiter radii) -> max heliocentric boost (km/s)
slingshot boost vs flyby periapsis for several approach speeds
Gravity assist: bend v_inf in the planet's frame, gain in the Sun's

  Jupiter orbital speed 13.1 km/s; max possible gain = 2 v_inf

   v_inf (km/s)   periapsis  turn (deg)  boost (km/s)
  ---------------------------------------------------
              5     2 R_jup       153.1          9.73
              5     5 R_jup       138.2          9.34
             10     2 R_jup       127.9         17.97
             10     5 R_jup       102.5         15.60
             15     2 R_jup       105.8         23.92
             15     5 R_jup        75.4         18.35

  A slower, deeper pass swings the velocity vector through a bigger angle
  and adds more of Jupiter's 13 km/s orbital motion -- up to 2 v_inf for a
  near-reversal. Voyager 2 chained Jupiter, Saturn, Uranus and Neptune this
  way to reach Solar-System escape speed on a fraction of the fuel a direct
  burn would need, while the planets lost a laughably tiny bit of energy.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\gravity_assist.svg

Synodic periods & alignments

The geometry we see -- oppositions, launch windows, new Moons -- repeats on the synodic period, the beat between two orbital rates: 1/S = |1/P1 - 1/P2|. Mars returns to opposition every ~780 days, exactly the ~26-month cadence of Mars missions; the synodic month is 29.5 days, longer than the 27.3-day sidereal month. Near Earth's own orbit S blows up; distant planets approach a one-year synodic period.

Earth: S -> infinity 1 year floor Mercury Mars Jupiter Synodic period vs sidereal period (from Earth) diverges at Earth's orbit; approaches 1 year for distant planets log10 sidereal period (days) -> log10 synodic period (days)
synodic period diverging at Earth's orbit and settling to one year
Synodic period: 1/S = |1/P_planet - 1/P_earth| -- when alignments repeat

      planet  sidereal (d)   synodic (d)  per year
  ------------------------------------------------
     Mercury          88.0         115.9     3.152
       Venus         224.7         583.9     0.626
        Mars         687.0         779.9     0.468
     Jupiter        4332.6         398.9     0.916
      Saturn       10759.2         378.1     0.966
     Neptune       60190.0         367.5     0.994

  synodic month (new Moon to new Moon): 29.53 days

  Mars returns to opposition every ~780 days -- exactly the ~26-month
  cadence of Mars launch windows. Inner planets lap Earth quickly; distant
  planets barely move, so their synodic period approaches one Earth year
  (Earth does the lapping). Near Earth's own orbit the synodic period blows
  up: two bodies at the same distance never change their alignment.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\synodic.svg

The black-hole shadow

A black hole casts a dark disk larger than its horizon: light inside the critical impact parameter b = 3 sqrt(3) GM/c^2 is captured, so lensing magnifies the shadow to 5.2 Schwarzschild radii across (vs 2 r_s for the horizon). M87* and Sgr A* each subtend only ~40-50 microarcseconds -- the angular size of an orange on the Moon -- which is why the Event Horizon Telescope had to link radio dishes across the whole Earth.

shadow edge (2.6 r_s) photon sphere (1.5 r_s) horizon 1 r_s The black-hole shadow (to scale) lensing makes the dark disk 5.2 r_s across -- bigger than the horizon
horizon, photon sphere and lensed shadow edge to scale
Black-hole shadow: apparent size 3 sqrt(3) r_s = 5.2 r_s (EHT)

      object   mass (Msun)    distance  shadow (uas)
  --------------------------------------------------
        M87*      6.50e+09    16.8 Mpc          39.7
      Sgr A*      4.15e+06    8.15 kpc          52.2
  stellar BH      1.00e+01       3 kpc       3.4e-04

  shadow diameter = 5.196 r_s (vs 2 r_s for the horizon)

  Light is captured inside the critical impact parameter b = 3 sqrt(3) GM/c^2,
  so the dark disk is bigger than the event horizon -- gravitational lensing
  magnifies it. M87* and Sgr A* both subtend only ~40-50 microarcseconds, the
  angular size of an orange on the Moon, which is why the EHT had to link radio
  dishes across the whole Earth to resolve them.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\black_hole_shadow.svg

The Hill sphere

A moon is held by its planet only inside the Hill sphere, r_H = a (m/3M)^(1/3), where the planet's pull beats the star's tide. Earth's is ~1.5 million km, four times the Moon's distance; Jupiter's reaches ~53 million km. Real moons survive out to ~1/2 r_H prograde, and the same balance sets the feeding zone of a forming planet and the mutual Hill spacing that keeps planetary orbits stable.

Earth-mass reference (r_H ~ a) Mercury Venus Earth Mars Jupiter Saturn Neptune Hill radius across the solar system r_H ~ a m^(1/3): the giants (yellow) command the widest domains log10 orbital distance (AU) -> log10 Hill radius (million km)
Hill radius vs orbital distance for the planets
Hill sphere: r_H = a (m/3M)^(1/3) -- how far a planet holds its moons

      planet   a (AU)  mass (Me)   r_H (Mkm)  stable limit
  --------------------------------------------------------
     Mercury    0.387      0.055        0.22        0.11M
       Venus    0.723      0.815        1.01        0.51M
       Earth    1.000      1.000        1.50        0.75M
        Mars    1.524      0.107        1.08        0.54M
     Jupiter    5.203    317.800       53.13       26.57M
      Saturn    9.537     95.200       65.16       32.58M
     Neptune   30.070     17.100      115.93       57.96M

  A bigger orbit or heavier planet widens the Hill sphere (r_H ~ a m^1/3),
  so Jupiter commands a ~53-million-km domain while a hot Jupiter tucked
  against its star could barely hold a moon. Real moons survive out to only
  ~1/2 r_H prograde -- the Moon at 0.384 Mkm sits well inside Earth's 0.75
  Mkm limit, and the same math sets the mutual spacing of planetary orbits.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\hill_sphere.svg

J2 orbital precession

A planet's equatorial bulge (coefficient J2) makes satellite orbits precess: the node line regresses and the ellipse rotates in-plane. Tune the inclination so the nodal drift matches the Sun's 0.9856 deg/day and you get a sun-synchronous orbit (~98 deg) crossing the equator at fixed local time; at the 63.4-degree critical inclination the apsides freeze -- the Molniya orbit that parks apogee over the far north.

sun-sync critical 63.4 nodal dOmega/dt apsidal domega/dt J2 precession rates vs inclination inclination (deg) -> precession rate (deg/day)
nodal and apsidal rates vs inclination with the special angles marked
J2 orbital precession (Earth, 700 km circular orbit)

                   orbit  incl (deg)  nodal (deg/d)  apsidal (deg/d)
  ------------------------------------------------------------------
              equatorial        0.00         -6.921           13.842
                ISS-like       51.60         -4.299            3.215
      critical (Molniya)       63.43         -3.095            0.000
         sun-synchronous       98.19          0.986           -3.110
                   polar       90.00         -0.000           -3.461

  The equatorial bulge (J2) drags orbit planes and rotates ellipses. Tune
  the inclination so the nodal drift equals the Sun's 0.9856 deg/day and the
  orbit stays fixed relative to the Sun -- a sun-synchronous orbit crossing
  the equator at the same local time daily. At 63.4 deg the apsides freeze
  (Molniya), parking apogee over the far north for long dwell times.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\j2_precession.svg

Solar sails & radiation pressure

Sunlight carries momentum: a mirror at 1 AU feels ~9 uPa (2F/c), a feather touch that never runs out. Since both sunlight and gravity fall as 1/r^2, the lightness number beta = radiation force / solar gravity is a fixed property of the sail; beta = 1 (a ~1.5 g/m^2 mirror) cancels the Sun's pull and beta > 1 escapes on sunlight alone. Today's sails sit at beta ~ 0.01 -- gentle but propellant-free.

beta = 1: radiation cancels gravity IKAROS LightSail 2 NEA Scout beta = 1 sail Solar-sail lightness number vs area-to-mass beta ~ A/m; reach beta = 1 and sunlight balances the Sun's gravity log10 area-to-mass ratio (m^2/kg) -> log10 lightness number beta
lightness number vs area-to-mass with the beta=1 line and missions
Solar sail: sunlight pressure 2F/c on a mirror, ~9 uPa at 1 AU

  solar flux at 1 AU: 1361 W/m^2, mirror pressure 9.08 uPa

                sail area (m^2)  mass (kg)  accel (mm/s^2)     beta
  -----------------------------------------------------------------
              IKAROS        196        315         0.00565 0.000953
         LightSail 2         32          5         0.05812   0.0098
           NEA Scout         86         14         0.05578   0.0094
       Starshot chip         16      0.001           145.3     24.5
       beta = 1 sail        653          1            5.93        1

  Both sunlight and gravity fall as 1/r^2, so the lightness number beta =
  radiation force / solar gravity is a fixed property of the sail. beta = 1
  (area-to-mass ~653 m^2/kg, a ~1.5 g/m^2 mirror) exactly cancels the Sun's
  pull; beta > 1 escapes the Solar System on sunlight alone. Today's sails
  sit at beta ~ 0.01 -- gentle, but propellant-free and endless.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\solar_sail.svg

Relativistic beaming

Radiation from a source moving near light speed is swept forward into a cone of half-angle ~1/gamma and Doppler-boosted, so the observed flux scales as D^(3+alpha). An approaching jet is brightened hundreds of times while its receding twin is dimmed by the same powers -- why M87's jet looks one-sided. The same geometry makes blobs appear to move faster than light, an illusion of light-travel time.

D = 1 gamma = 2 (cone 29 deg) gamma = 5 (cone 11 deg) gamma = 10 (cone 6 deg) Doppler factor vs viewing angle faster jets beam into a narrower, brighter forward cone viewing angle theta (deg) -> log10 Doppler factor D
Doppler factor vs viewing angle for several Lorentz factors
Relativistic beaming: D = 1/(gamma(1 - beta cos theta)); flux ~ D^(3+a)

    gamma   theta  Doppler D  flux boost      jet/cj   v_app/c
  ------------------------------------------------------------
        2      5d       3.64         119     1.6e+04       0.5
        2     20d       2.69        38.7     4.5e+03       1.6
        5      5d       8.36    2.58e+03     1.2e+07       3.6
        5     20d       2.52        30.7     1.3e+05       4.2
       10      5d      11.37    8.05e+03     5.2e+08       9.9
       10     20d       1.54        4.92     2.8e+05       5.2

  Aberration sweeps the emission into a cone of half-angle ~1/gamma, and the
  Doppler shift boosts the flux by D^(3+alpha). An approaching jet is brightened
  hundreds of times while its receding twin is dimmed by the same powers, which
  is why M87's jet looks one-sided. The same geometry makes blobs appear to move
  faster than light -- superluminal motion, an illusion of light-travel time.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\beaming.svg

The relativistic rocket

A ship at constant 1 g follows a hyperbolic worldline: velocity c tanh(a tau/c) saturates just short of c, but proper time uses cosh/sinh, so the crew clock falls ever further behind Earth's. The galactic centre is ~10 crew-years away (27,000 pass on Earth), Andromeda ~15 -- ship time grows only logarithmically with distance. The catch is fuel: a photon drive needs exp(2 phi) times the payload mass.

Earth time (~ distance in ly) ship (proper) time -- logarithmic! gal. centre (11 yr) Andromeda (15 yr) Constant-1-g travel: ship time vs Earth time ship time grows only logarithmically with distance -- the Galaxy in a lifetime log10 distance (light-years) -> log10 elapsed time (years)
ship time vs Earth time diverging with distance at 1 g
Relativistic rocket at constant 1 g (acceleration only)

           destination    distance  ship (yr)   Earth (yr)  v_peak/c
  ------------------------------------------------------------------
      Proxima Centauri     4.37 ly        2.3         5.25    0.9834
                  Vega    25.00 ly        3.9           26    0.9993
       Galactic centre      27 kly       10.6      2.7e+04    1.0000
       Andromeda (M31)     2.5 Mly       15.0      2.5e+06    1.0000
    edge of observable 4.6e+04 Mly       24.5      4.6e+10    1.0000

  Velocity is c tanh(a tau/c) -- it saturates just short of c -- but proper
  time uses cosh/sinh, so the crew's clock falls ever further behind Earth's.
  A 1-g ship reaches the galactic centre in ~10 crew-years (27,000 pass on
  Earth) and could cross to Andromeda in ~15. The impossible part is fuel: a
  photon drive needs exp(2 phi) times the payload mass, astronomically much.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\relativistic_rocket.svg

Relativistic Doppler shift

A moving light source shifts in frequency by the classical Doppler effect times time dilation: receding sources redshift, approaching ones blueshift. The purely relativistic surprise is the transverse shift -- a source moving exactly across the line of sight still reddens by 1/gamma because its clock runs slow, the effect Ives and Stilwell measured in 1938. A measured redshift maps straight back to a speed.

z = 0 receding (redshift) transverse (1/gamma, pure time dilation) approaching (blueshift, z<0) Relativistic Doppler redshift vs speed beta = v/c -> redshift z
redshift vs speed for receding, approaching and transverse cases
Relativistic Doppler: classical shift x time dilation

     beta   receding z  approaching z   transverse z
  --------------------------------------------------
     0.10       0.1055        -0.0955         0.0050
     0.30       0.3628        -0.2662         0.0483
     0.50       0.7321        -0.4226         0.1547
     0.70       1.3805        -0.5799         0.4003
     0.90       3.3589        -0.7706         1.2942
     0.99      13.1067        -0.9291         6.0888

  Receding sources redshift, approaching ones blueshift -- the familiar
  Doppler shift, but amplified by time dilation. The purely relativistic
  effect is the transverse shift: a source moving exactly across the line of
  sight still reddens by 1/gamma because its clock runs slow. Ives and
  Stilwell measured it in 1938 -- direct proof of time dilation. A measured
  redshift maps back to a speed: z = 1 means beta = 0.60.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\relativistic_doppler.svg

de Broglie matter waves

Every particle has a wavelength lambda = h/p, inversely proportional to its momentum. A baseball's is 10^-34 m -- undetectable -- but a 100 keV electron's is ~4 pm, thousands of times finer than light, which is why electron microscopes resolve atoms; a thermal neutron's ~0.1 nm matches crystal spacing for diffraction. The wave nature takes over once lambda rivals the interparticle spacing -- the onset of quantum degeneracy.

atomic spacing ~0.2 nm electron proton neutron de Broglie wavelength vs kinetic energy faster/heavier = shorter wave; below atomic spacing = resolves atoms log10 kinetic energy (eV) -> log10 wavelength (m)
wavelength vs energy for electron/proton/neutron with reference scales
de Broglie wavelength: lambda = h / p -- everything is a wave

                      object          lambda
  --------------------------------------------
       100 keV microscope e-         3.88 pm
               1 eV electron         1.23 nm
     thermal neutron (300 K)          100 pm
     thermal He atom (300 K)         50.2 pm
         100 m/s N2 molecule          141 pm
           baseball (40 m/s)      1.14e-34 m

  Momentum sets the wavelength, so heavy or fast things have vanishingly
  short waves -- a baseball's is 10^-34 m, undetectable. But a 100 keV
  electron's 4 pm is thousands of times finer than visible light, which is
  why electron microscopes resolve atoms, and a thermal neutron's ~0.1 nm
  matches crystal spacing, making neutron diffraction a structural probe.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\de_broglie.svg

The Bohr model of hydrogen

Quantizing angular momentum (L = n hbar) forces the electron onto discrete orbits: energy E_n = -13.6/n^2 eV, radius n^2 a_0 (a_0 ~ 52.9 pm). Transitions emit fixed-energy photons -- the sharp hydrogen lines: Lyman in the UV, Balmer in the visible (H-alpha at 656 nm, the red of nebulae), Paschen in the infrared. The n=1 orbital speed is alpha*c, v/c ~ 1/137.

n=1 -13.61 eV n=2 -3.40 eV n=3 -1.51 eV n=4 -0.85 eV n=5 -0.54 eV n=6 -0.38 eV ionized Hydrogen energy levels and spectral series purple: Lyman (UV, to n=1); blue: Balmer (visible, to n=2); orange: Paschen (IR, to n=3)
the hydrogen energy levels with the Lyman/Balmer/Paschen transitions
Bohr model of hydrogen: E_n = -13.6/n^2 eV, r_n = n^2 a_0

  Bohr radius a_0 = 52.92 pm, fine-structure alpha = 1/137.0

     n   E_n (eV)   r_n (pm)
  --------------------------
     1    -13.606       52.9
     2     -3.401      211.7
     3     -1.512      476.3
     4     -0.850      846.7
     5     -0.544     1322.9

  spectral series (to lower level n1):
            Lyman (UV) to n=1:  121.5, 102.5, 97.2 nm ...
      Balmer (visible) to n=2:  656.1, 486.0, 433.9 nm ...
          Paschen (IR) to n=3:  1874.6, 1281.5, 1093.5 nm ...

  Quantizing angular momentum (L = n hbar) forces the electron onto discrete
  orbits, so it can only emit photons of fixed energy -- the sharp lines of
  the hydrogen spectrum. H-alpha at 656 nm gives nebulae their red glow;
  the Lyman series lands in the UV, Paschen in the infrared. The model nails
  the energies exactly, and v/c at n=1 is the fine-structure constant.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\bohr.svg

The photoelectric effect

Light ejects electrons from a metal only above a threshold frequency, no matter how bright a redder beam is -- Einstein's proof that light comes in photons of energy hf. One photon gives one electron K_max = hf - phi, so the stopping voltage climbs linearly with frequency at the universal slope h/e; only the intercept (the work function) differs between metals. Millikan measured that line and pinned down Planck's constant.

cesium (phi=2.1 eV) sodium (phi=2.3 eV) zinc (phi=4.3 eV) platinum (phi=6.3 eV) Stopping voltage vs frequency parallel lines: common slope h/e, intercepts = work functions frequency (10^15 Hz) -> stopping voltage (V)
stopping voltage vs frequency: parallel lines of slope h/e
Photoelectric effect: K_max = h f - phi (Einstein 1905)

  slope of V_stop vs f is h/e = 4.136e-15 V/Hz (universal)

       metal  phi (eV)   threshold  V_stop @254nm  V_stop @400nm
  --------------------------------------------------------------
      cesium      2.14       579nm         2.74 V         0.96 V
      sodium      2.28       544nm         2.60 V         0.82 V
        zinc      4.31       288nm         0.57 V           none
    platinum      6.35       195nm           none           none

  Below the threshold frequency, no electrons come off however bright the
  light -- energy comes in photon lumps, not a continuous wave. Above it, the
  stopping voltage climbs linearly with frequency at the SAME slope h/e for
  every metal; only the intercept (the work function) differs. Millikan
  measured exactly this line and pinned down Planck's constant.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\photoelectric.svg

The uncertainty principle

Position and momentum cannot both be sharp: dx dp >= hbar/2. Confining a particle to a box forces a momentum spread and thus an irreducible zero-point energy E ~ hbar^2/(m dx^2). An electron squeezed to atomic size (~0.1 nm) carries ~1 eV -- why it never falls into the nucleus -- and minimizing that against the Coulomb pull reproduces hydrogen's 13.6 eV; a nucleon in a femtometre nucleus carries MeV.

atom (0.1 nm) nucleus (5 fm) 1 eV 1 MeV electron nucleon (proton) Confinement (zero-point) energy vs box size E ~ hbar^2 / (m dx^2): tighter box, higher irreducible energy log10 confinement size dx (m) -> log10 confinement energy (eV)
confinement energy vs box size for an electron and a nucleon
Heisenberg: dx dp >= hbar/2 -> irreducible confinement energy

           confinement      size    electron E     nucleon E
  ----------------------------------------------------------
              molecule      1 nm     0.0095 eV    5.2e-06 eV
                  atom    0.1 nm       0.95 eV    0.00052 eV
            inner atom   0.01 nm         95 eV      0.052 eV
        atomic nucleus      5 fm   3.8e+02 MeV      0.21 MeV
          nucleon core      1 fm   9.5e+03 MeV       5.2 MeV

  minimizing confinement + Coulomb energy gives hydrogen's binding:
    13.61 eV -- the atomic scale from uncertainty alone

  Squeezing a particle into a smaller box forces a larger momentum spread
  and thus more kinetic energy (E ~ 1/dx^2). That is why electrons don't
  fall into the nucleus, why atoms are ~0.1 nm (eV energies), and why
  nucleons confined to femtometres carry MeV -- the energy scale of nuclei.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\uncertainty.svg

Quantum tunneling

A particle with too little energy to climb a barrier can still leak through: its wavefunction decays as exp(-kappa x) inside, so transmission is T ~ exp(-2 kappa L), plunging exponentially with barrier width. A nanometre barrier is essentially opaque, yet shaving an Angstrom raises the current ~8x -- the razor sensitivity that lets a scanning tunneling microscope feel individual atoms, and the physics of alpha decay and fusion.

V-E = 1 eV V-E = 4 eV V-E = 10 eV Tunneling transmission vs barrier width (electron) straight lines on a log plot: T falls exponentially with width barrier width (nm) -> log10 transmission probability
transmission falling exponentially with barrier width
Quantum tunneling: T ~ exp(-2 kappa L), kappa = sqrt(2m(V-E))/hbar

    V - E (eV)  width (nm)    transmission
  ----------------------------------------
             1         0.2        1.29e-01
             1         0.5        5.96e-03
             1         1.0        3.55e-05
             4         0.2        1.66e-02
             4         0.5        3.55e-05
             4         1.0        1.26e-09

  STM tip-surface gap sensitivity (4 eV work function):
    +0.1 nm gap  ->  current x 1.29e-01  (1/8)
    +0.2 nm gap  ->  current x 1.66e-02  (1/60)
    +0.3 nm gap  ->  current x 2.14e-03  (1/468)

  Transmission plunges exponentially with width, so a barrier a nanometre
  thick is essentially opaque -- yet shave off an Angstrom and the current
  jumps ~8x. That razor sensitivity is how a scanning tunneling microscope
  feels individual atoms, and the same barrier penetration drives alpha
  decay (tunneling out) and stellar fusion (tunneling in).

  wrote C:\Users\acwic\symplectic-nbody\examples\output\tunneling.svg

The particle in a box

Trap a particle in a well and only standing waves fit, quantizing the energy: E_n = n^2 h^2 / (8 m L^2). Levels rise as n^2, the ground state is nonzero (confinement zero-point energy), and every level scales as 1/L^2 -- which is why shrinking a quantum dot widens its gaps and shifts its glow bluer, giving size-tunable colour for displays and bio-markers.

n=1 0.38 eV n=2 1.50 eV n=3 3.38 eV n=4 6.02 eV Particle in a box: levels and wavefunctions E_n ~ n^2; psi_n has n-1 nodes; nonzero ground state position across the well (width L)
the first energy levels with their wavefunctions in the well
Particle in a box: E_n = n^2 h^2 / (8 m L^2)  (electron, L = 1 nm)

     n   E_n (eV)   gap to n-1 (eV)
  ----------------------------------
     1      0.376             0.000
     2      1.504             1.128
     3      3.384             1.880
     4      6.016             2.632
     5      9.401             3.384

  quantum-dot n=1->2 emission (smaller box = bluer):
    5 nm dot  ->  27477 nm
    3 nm dot  ->  9892 nm
    2 nm dot  ->  4396 nm
    1 nm dot  ->  1099 nm

  Only whole numbers of half-wavelengths fit between the walls, so energy
  comes in n^2 rungs with a nonzero ground state (confinement zero-point
  energy). Because every level scales as 1/L^2, shrinking a quantum dot
  widens the gaps and shifts its glow toward the blue -- size-tunable colour,
  used in displays and biological markers.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\particle_box.svg

The quantum harmonic oscillator

Near any potential minimum a system is a spring, so the oscillator is everywhere -- molecular vibrations, phonons, cavity photons. Its levels are EVENLY spaced by hbar omega (unlike the box or atom), so a molecule absorbs one sharp infrared line per vibrational quantum (CO at 4.6 um). The ground state is nonzero: the zero-point energy (1/2) hbar omega is forced by uncertainty and keeps helium liquid at absolute zero.

n=0 (zero-point) n=1 n=2 n=3 n=4 n=5 Harmonic oscillator: evenly-spaced levels (CO) gap = hbar omega = 0.269 eV; ground state at half that
equally-spaced levels inside the parabolic well
Quantum harmonic oscillator: E_n = (n + 1/2) hbar omega

    molecule   k (N/m)   hbar omega (eV)   IR wavelength
  ------------------------------------------------------
          H2       570            0.5454         2.27 um
          CO      1902            0.2690         4.61 um
          N2      2294            0.2924         4.24 um
         HCl       516            0.3721         3.33 um

  Unlike the box (n^2) or atom (-1/n^2), the oscillator's levels are EVENLY
  spaced by hbar omega, so a molecule absorbs a single sharp infrared line
  per vibrational quantum. The ground state is not zero -- the zero-point
  energy (1/2) hbar omega is forced by the uncertainty principle, and it is
  real: it keeps helium liquid at absolute zero and shifts bond energies.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\harmonic_oscillator.svg

Rutherford scattering

Firing alphas at gold foil, a few bounced almost straight back -- impossible off diffuse charge. Rutherford's Coulomb cross section dsigma/dOmega ~ 1/sin^4(theta/2) soars at small angles but stays nonzero at 180 degrees, exactly those hard bounces, revealing a tiny dense nucleus. The head-on closest approach (~45 fm for 5 MeV alphas on gold) bounded the nuclear size.

nonzero back-scatter -> nucleus! Rutherford cross section vs scattering angle 5 MeV alpha on gold; ~1/sin^4(theta/2), diverging at small angle scattering angle (deg) -> log10 dsigma/dOmega (rel. to 90 deg)
the 1/sin^4 angular distribution with nonzero back-scatter
Rutherford scattering: dsigma/dOmega ~ 1/sin^4(theta/2)

  5 MeV alpha on gold: head-on closest approach = 45.5 fm

     angle   dsigma/dOmega (rel)   impact b (fm)
  ----------------------------------------------
       10d              4.33e+03           260.1
       30d                  55.7            84.9
       60d                     4            39.4
       90d                     1            22.8
      120d                 0.444            13.1
      150d                 0.287             6.1
      179d                  0.25             0.2

  The cross section soars at small angles (grazing passes barely deflect)
  and falls toward back-scattering -- but stays NONZERO at 180 degrees. Those
  rare hard bounces are impossible off diffuse charge; they proved the atom's
  positive charge sits in a tiny dense nucleus. The head-on approach distance
  put an upper bound of tens of femtometres on the nuclear size.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\rutherford.svg

Radioactive decay & decay chains

Unstable nuclei decay at a constant per-nucleus rate, so a population falls exponentially N = N0 2^(-t/t_half) -- the clock behind carbon-14 dating (25% remaining is two half-lives, ~11,500 yr). In a parent-daughter chain the daughter follows the Bateman rise-and-fall, reaching secular equilibrium where its activity equals the parent's -- the principle of medical radioisotope generators and uranium-fed radon.

parent (t_half = 8 d) daughter (Bateman, t_half = 0.6 d) Parent decay and daughter build-up daughter rises then tracks the parent -- secular equilibrium time (days) -> number of nuclei
parent exponential decay and the daughter's Bateman build-up
Radioactive decay: N = N0 2^(-t/t_half); age = t_half log2(N0/N)

  carbon-14 dating (t_half = 5730 yr):
     % remaining  half-lives    age (yr)
  ----------------------------------------
             90%        0.15         871
             50%        1.00        5730
             25%        2.00       11460
             10%        3.32       19035
              1%        6.64       38069

  Parent -> daughter chain (parent 8 d, daughter 0.6 d):
    time (d)    parent  daughter
           0      1000         0
           1       917        49
           2       841        60
           5       648        52
          10       420        34
          20       177        14

  The daughter starts at zero, builds up as the parent feeds it, and once
  it is decaying as fast as it is produced it tracks the parent -- secular
  equilibrium, where daughter activity equals parent activity. That balance
  runs medical radioisotope generators and the radon from uranium in rock.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\radioactive_decay.svg

Nuclear binding: the mass formula

Weizsacker's liquid-drop model sums volume, surface, Coulomb, asymmetry and pairing terms into the nuclear binding energy. Their balance gives the binding-energy-per-nucleon curve, peaking near iron at ~8.8 MeV/nucleon -- which is exactly why fusion releases energy up to iron and fission beyond it. Minimizing over Z traces the valley of stability, drifting to neutron excess in heavy nuclei (U-238 sits at Z=92).

iron peak (A~58) fusion -> <- fission He-4 Fe-56 U-238 Binding energy per nucleon vs mass number the curve of nuclear stability: iron at the summit mass number A -> B/A (MeV per nucleon)
the binding-energy-per-nucleon curve peaking at iron
Semi-empirical mass formula: B/A peaks at iron (~8.8 MeV/nucleon)

       nucleus    Z    A   B/A (MeV)
  ----------------------------------
          He-4    2    4       5.710
          C-12    6   12       7.468
          O-16    8   16       7.873
         Fe-56   26   56       8.846
         Ni-62   28   62       8.863
         Kr-84   36   84       8.781
        Sn-120   50  120       8.548
        Pb-208   82  208       7.857
         U-238   92  238       7.625

  peak of the curve: A = 58, 8.86 MeV/nucleon

  Light nuclei fuse toward the peak, releasing energy; heavy nuclei fission
  toward it, also releasing energy. Iron-56 sits at the summit -- the ash of
  stellar fusion and the end of the line for energy release, which is why a
  massive star's iron core cannot burn and collapses into a supernova.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\mass_formula.svg

Nuclear Q-value: E = mc^2 at work

A nuclear reaction releases energy equal to its mass defect times c^2 (1 amu = 931.5 MeV). D-T fusion yields 17.6 MeV, U-235 fission ~200 MeV -- only a fraction of a percent of the mass, but c^2 makes it millions of times a chemical bond: fission is ~2 million times TNT, fusion ~4x fission, and total matter-antimatter annihilation converts 100% of the mass at ~9x10^16 J/kg.

1e7 TNT (chemical) 1e8 gasoline 1e14 U-235 fission 1e15 D-T fusion 1e17 matter-antimatter Energy density of fuels (E = mc^2 at work) chemical -> nuclear -> pure mass: each step a millionfold leap log10 energy per kg (J/kg)
fuel energy density from chemical to nuclear to pure mass-energy
Nuclear Q = (m_reactants - m_products) c^2  (1 amu = 931.494 MeV)

                reaction    Q (MeV)   mass converted
  --------------------------------------------------
        D + T -> He4 + n      17.59          0.375 %
        D + D -> He3 + n       3.26          0.087 %
         p-p chain (net)      26.70          0.711 %
           U-235 fission     197.01          0.090 %

  energy density of fuels (joules per kilogram):
          TNT (chemical): 4.60e+06 J/kg
                gasoline: 4.60e+07 J/kg
           U-235 fission: 8.05e+13 J/kg
              D-T fusion: 3.38e+14 J/kg
       matter-antimatter: 8.99e+16 J/kg

  A nuclear reaction converts only a fraction of a percent of its mass, but
  c^2 makes that millions of times more than any chemical bond -- fission
  ~2 million times TNT, fusion ~4x fission. Total annihilation converts 100%
  of the mass, the ultimate energy density c^2 ~ 9x10^16 J/kg.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\q_value.svg

Quantum statistics

Identical particles come in two kinds. Fermions obey Pauli exclusion, so their Fermi-Dirac occupation never exceeds one and at T=0 fills states in a sharp step up to the Fermi level -- electron degeneracy, white-dwarf pressure. Bosons pile up without limit (Bose-Einstein), condensing into the ground state below a critical temperature. Far above the chemical potential both fade into the classical Maxwell-Boltzmann exponential.

E = mu Fermi-Dirac (fermions) Bose-Einstein (bosons) Maxwell-Boltzmann (classical) Occupation of the three statistics (E - mu) / kT -> average occupation <n>
the fermion step, boson divergence and classical merge
Quantum statistics: occupation <n> vs (E-mu)/kT

     (E-mu)/kT   Fermi-Dirac   Bose-Einstein   Maxwell-Boltz
  ----------------------------------------------------------
          -4.0        0.9820             inf         54.5982
          -1.0        0.7311             inf          2.7183
           0.5        0.3775           1.541          0.6065
           1.0        0.2689           0.582          0.3679
           2.0        0.1192           0.157          0.1353
           4.0        0.0180           0.019          0.0183
           8.0        0.0003           0.000          0.0003

  Fermions can never exceed one per state (Pauli), so at low temperature
  they stack into a sharp step up to the Fermi level -- electron degeneracy,
  white-dwarf pressure. Bosons pile up without limit as E -> mu, condensing
  into the ground state below a critical temperature (BEC, superfluid He).
  Far above mu both fade into the classical Maxwell-Boltzmann exponential.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\quantum_stats.svg

Debye specific heat

Classically a solid stores 3R of heat per mole (Dulong-Petit), but measured heat capacities plunge toward zero in the cold. Debye treated the vibrations as quantized phonons with a maximum frequency (the Debye temperature), giving C_V ~ T^3 at low T and 3R at high T. A stiff light lattice like diamond (Theta_D ~ 2230 K) is still 'cold' at room temperature -- only ~1/6 of 3R -- while soft heavy lead has long reached the plateau.

Dulong-Petit 3R T^3 law diamond copper The universal Debye heat-capacity curve C_V / 3R vs T/Theta_D: T^3 rise to the classical plateau (dots at 300 K) T / Theta_D -> C_V / 3R
the universal C_V/3R vs T/Theta_D curve
Debye model: C_V rises as T^3, plateaus at Dulong-Petit 3R = 24.94 J/mol/K

      material  Theta_D (K)   C_V @300K   % of 3R
  ------------------------------------------------
          lead          105       24.79     99.4%
        copper          343       23.39     93.8%
     aluminium          428       22.58     90.5%
       diamond         2230        4.13     16.6%

  Every solid follows one universal curve in T/Theta_D. A stiff, light
  lattice has a high Debye temperature, so at room temperature it is still
  'cold' -- diamond stores only ~1/6 of its classical heat capacity, while
  soft heavy lead has long since reached the 3R plateau. The T^3 falloff at
  low temperature is the fingerprint of phonon quantization.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\debye_heat.svg

The Carnot cycle

No heat engine between reservoirs at T_h and T_c can beat eta = 1 - T_c/T_h -- the second law forces some heat to be dumped to the cold side. A steam plant at 800 K exhausting to 300 K is capped at 62%; ocean-thermal at a 20 K gap only 7%. Reversed, the cycle is a heat pump with COP >> 1, delivering many times the heat of the work it draws -- why heat pumps beat resistive heaters.

forbidden (2nd law) achievable car steam plant OTEC Carnot efficiency vs reservoir temperature ratio eta = 1 - T_c/T_h: the ceiling every real engine sits under T_cold / T_hot -> maximum efficiency
efficiency vs reservoir temperature ratio with real engines marked
Carnot: no engine beats eta = 1 - T_c/T_h

                  engine  T_hot (K)  T_cold (K)   max eta
  -------------------------------------------------------
              car engine       2000         300     0.850
       steam power plant        800         300     0.625
              geothermal        450         300     0.333
    ocean thermal (OTEC)        298         278     0.067

  running the cycle backwards (T_hot=293 K room, T_cold=273 K):
    refrigerator COP = 13.7 (heat removed per unit work)
    heat pump COP    = 14.7 (heat delivered per unit work)

  Some heat must always be dumped to the cold reservoir, so no engine hits
  100% -- a steam plant at 800 K exhausting to 300 K is capped at 62%, and
  real losses cut it to ~40%. Reversed, the same cycle is a heat pump that
  delivers many times the heat of the work it draws (COP >> 1), which is why
  heat pumps beat resistive heaters -- they move heat rather than make it.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\carnot.svg

Adiabatic processes

Compress or expand a gas with no time to shed heat and it obeys P V^gamma = const, T V^(gamma-1) = const -- and its temperature changes. A diesel engine's 22:1 squeeze reaches ~1000 K and ignites fuel without a spark; expansion cools (rising air, released spray). Sound waves compress air adiabatically, so the speed of sound carries Laplace's sqrt(gamma) factor -- the fix that corrected Newton's ~18% error.

adiabat P V^1.4 (steeper -- T changes) isotherm P V (T constant) Adiabat vs isotherm on a P-V diagram the adiabat is steeper: compressing without heat loss also raises T volume (rel.) -> pressure (rel.)
an adiabat steeper than the isotherm through the same point
Adiabatic: P V^gamma = const, T V^(gamma-1) = const (no heat flow)

  speed of sound in air (293 K): 343.2 m/s
    (Laplace's gamma vs Newton's wrong 290 m/s)

  adiabatic compression of air from 300 K:
     ratio V1/V2    T2 (K)     P2/P1
  ----------------------------------
               2       396       2.6
               5       571       9.5
              10       754      25.1
              22      1033      75.8
              50      1435     239.1

  Compressing a gas with no time to shed heat drives its temperature up
  steeply -- a diesel engine's 22:1 squeeze reaches ~1000 K and ignites fuel
  without a spark. Expansion does the reverse, cooling the gas (rising air,
  released CO2). Sound waves compress air adiabatically too, so the speed of
  sound carries Laplace's sqrt(gamma) factor -- the fix to Newton's estimate.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\adiabatic.svg

The van der Waals gas

Give the ideal gas molecules a finite size (b) and mutual attraction (a) and it can condense: (P + a n^2/V^2)(V - nb) = nRT. Below the critical temperature the isotherm develops an unstable loop where gas turns to liquid. The critical constants follow from a and b alone -- CO2's 304 K, 7.4 MPa -- and the compressibility Pc Vc / R Tc = 3/8 is universal, the law of corresponding states.

critical point Tr = 1.15 (gas) Tr = 1.00 (critical) Tr = 0.90 (loop) Tr = 0.85 Van der Waals reduced isotherms below T_c the loop appears -- where the gas condenses to liquid reduced volume V/V_c -> reduced pressure P/P_c
reduced isotherms with the sub-critical condensation loop
Van der Waals: (P + a n^2/V^2)(V - nb) = nRT -- a gas that can condense

         gas   T_c (K)   P_c (MPa)   Pc Vc / R Tc
  ------------------------------------------------
      helium       5.2        0.23         0.3750
         CO2     304.0        7.40         0.3750
       water     647.0       22.06         0.3750

  The finite-size (b) and attraction (a) terms give the ideal gas something
  it lacks: a liquid-vapour transition. Above T_c the isotherm is smooth, but
  below it a wiggle appears -- pressure would rise with volume, which is
  unstable, so the gas condenses across it. At the critical point that wiggle
  is an inflection, and Pc Vc / R Tc = 3/8 for EVERY van der Waals gas.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\van_der_waals.svg

The Joule-Thomson effect

Push a real gas through a valve and it cools or warms depending on which wins: attraction (cools) or finite molecular size (warms). Below the inversion temperature T_inv = (27/4) T_c throttling cools -- so nitrogen and CO2 liquefy by repeated expansion at room temperature, but hydrogen and helium (low T_inv) warm and must be pre-cooled first. An ideal gas has no Joule-Thomson effect at all.

mu = 0: below cools, above warms N2 (T_inv=852 K) H2 (T_inv=224 K) He (T_inv=35 K) Joule-Thomson coefficient vs temperature mu > 0 cools on throttling; the zero crossing is the inversion temperature temperature (K) -> mu_JT (K / MPa)
the JT coefficient crossing zero at each gas's inversion temperature
Joule-Thomson: throttling cools below T_inv, warms above

       gas   T_inv (K)      at 300 K  mu (K/MPa) @300K
  ----------------------------------------------------
       CO2        2052         cools             6.717
        N2         852         cools             2.445
        H2         224         warms            -0.235
        He          35         warms            -1.011

  Two effects compete when a real gas expands through a valve: attraction
  cools it (work pulling molecules apart), finite size warms it. Below the
  inversion temperature attraction wins, so nitrogen and CO2 cool at room
  temperature and can be liquefied by repeated throttling. Hydrogen and
  helium have low inversion temperatures, so they must be pre-cooled first
  -- otherwise throttling heats them, an early liquefaction hazard.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\joule_thomson.svg

Clausius-Clapeyron: vapor pressure

Along a liquid-vapour line, pressure and temperature are locked by dP/dT = L/(T dV), integrating to P(T) = P0 exp(-(L/R)(1/T - 1/T0)) -- vapor pressure climbs exponentially, roughly doubling every ~15 K. Boiling is where it meets the ambient pressure, so water boils at 72 C atop Everest (thin air) and 121 C in a pressure cooker. The same curve sets atmospheric humidity and cloud formation.

1 atm -> boils at 100 C Everest (72 C) Denver (95 C) cooker 2atm (121 C) Water vapor pressure vs temperature boiling is where the curve meets the ambient pressure temperature (C) -> vapor pressure (kPa)
the exponential vapor-pressure curve with altitude markers
Clausius-Clapeyron: P(T) = P0 exp(-(L/R)(1/T - 1/T0))

  water boiling point vs altitude:
            location  altitude (m)  pressure (kPa)  boils at (C)
  --------------------------------------------------------------
           sea level             0           101.3         100.0
              Denver          1609            83.7          94.6
              La Paz          3640            65.7          88.1
        Everest base          5364            53.5          82.7
      Everest summit          8848            35.3          72.2

  Vapor pressure climbs exponentially with temperature -- roughly doubling
  every ~15 K -- so water boils when its vapor pressure reaches the ambient
  air pressure. Up a mountain the thinner air lets it boil cooler (72 C on
  Everest, too cold to cook an egg), while a pressure cooker raises the
  boiling point to ~121 C. The same curve sets humidity and cloud formation.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\clausius_clapeyron.svg

The Reynolds number

One dimensionless ratio Re = rho v L / mu decides whether a flow is smooth or turbulent: viscosity damps disturbances at low Re, inertia tears them into eddies at high Re, with pipe flow transitioning near Re ~ 2300. It spans 13 orders of magnitude -- a bacterium at Re ~ 1e-5 lives in pure viscosity and cannot coast, a whale at Re ~ 1e8 glides on inertia. Laminar flow follows Hagen-Poiseuille's r^4 law.

transition laminar turbulent bacterium (Re 3e-05) sperm cell (Re 1e-02) blood in capillary (Re 8e-03) water tap (pipe) (Re 2e+04) swimming human (Re 3e+06) airliner wing (Re 5e+07) blue whale (Re 2e+08) Reynolds number across the natural world 13 decades from a bacterium to a whale; pipe transition near Re ~ 2300 log10 Reynolds number ->
systems from bacterium to whale on a log Reynolds axis
Reynolds number Re = rho v L / mu: inertia vs viscosity

              system   v (m/s)     L (m)          Re        regime
  ----------------------------------------------------------------
           bacterium     3e-05     1e-06     3.0e-05       laminar
          sperm cell    0.0002     5e-05     1.0e-02       laminar
  blood in capillary     0.001     8e-06     8.0e-03       laminar
    water tap (pipe)         1      0.02     2.0e+04     turbulent
      swimming human       1.5       1.8     2.7e+06     turbulent
       airliner wing   2.5e+02         3     5.1e+07     turbulent
          blue whale        10        25     2.5e+08     turbulent

  Thirteen orders of magnitude of Re separate a bacterium from a whale.
  At tiny Re viscosity rules -- a microbe cannot coast, it is like swimming
  in honey. At huge Re inertia rules and the flow tumbles into turbulence.
  For pipe flow the crossover sits near Re ~ 2300, which is why a slow tap
  runs in smooth laminar sheets but a fast one roars and sputters.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\reynolds.svg

Bernoulli & the Venturi effect

Along a streamline P + 1/2 rho v^2 + rho g h is constant, so speeding up a flow drops its pressure. A narrowing Venturi pipe is fastest and lowest-pressure at the throat -- drawing fuel into a carburettor, reading flow in a meter, and (with circulation) helping lift a wing. A Pitot tube runs it backwards to give airspeed, and Torricelli's sqrt(2gh) jet is the same law with the pressures cancelled.

throat (narrow, fast, low P) velocity (peaks at throat) pressure (dips at throat) The Venturi effect (Bernoulli) where the pipe narrows the flow speeds up and the pressure falls
a Venturi tube: velocity peaks and pressure dips at the throat
Bernoulli: P + 1/2 rho v^2 + rho g h = const -- fast flow, low pressure

  Pitot airspeed from dynamic pressure (air):
     true airspeed   dynamic P (kPa)
             50 m/s              1.53
            100 m/s              6.13
            250 m/s             38.28

  Torricelli efflux (water jet from a hole at depth h):
    depth   1 m  ->  4.4 m/s
    depth   5 m  ->  9.9 m/s
    depth  20 m  ->  19.8 m/s

  A narrowing pipe (Venturi) speeds the flow -- so by Bernoulli its pressure
  drops right where it is fastest. That suction draws fuel into a carburettor,
  lets a Venturi meter read flow from a pressure gap, and, with circulation,
  helps hold an airfoil up. A Pitot tube runs it backwards, stopping the flow
  to turn its speed into a pressure an aircraft reads as airspeed.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\bernoulli.svg

Surface tension: capillary rise & Laplace pressure

A liquid surface costs energy per unit area (gamma), so it behaves like a stretched skin. In a thin tube that pull lifts water against gravity by Jurin's law h = 2 gamma cos(theta) / (rho g r) -- a 1 mm bore climbs ~1.5 cm, a 1 micron root pore tens of metres. A curved surface also holds a pressure jump 2 gamma/r (a droplet) or 4 gamma/r (a soap bubble's two films), so smaller drops run at higher pressure and empty into larger ones.

Capillary rise vs tube radius (Jurin's law) water at 20 C -- narrower bore climbs higher, rise proportional to 1/r 10^-8 m 10^-7 m 10^-6 m 10^-5 m 10^-4 m 10^-3 m 10^-2 m 10^-3 m 10^-2 m 10^-1 m 10^0 m 10^1 m 10^2 m 10^3 m 10^4 m 0.5 mm tube: 3.0 cm 1 um pore: 15 m a slope of -1 on log-log: rise doubles when radius halves
capillary rise vs tube radius on log-log axes: narrower climbs higher
Surface tension: gamma = 0.0728 N/m for water at 20 C

  Capillary rise (Jurin's law  h = 2 gamma cos(theta) / (rho g r)):
     tube radius            rise
       5.000 mm         2.98 mm
       1.000 mm         1.49 cm
       0.500 mm         2.98 cm
       0.100 mm        14.88 cm
       0.010 mm           1.5 m
       0.001 mm          14.9 m

  Young-Laplace overpressure  (droplet 2 gamma/r,  bubble 4 gamma/r):
        radius       droplet     soap bubble
       5.0 mm      29.1 Pa         58.2 Pa
       1.0 mm     145.6 Pa        291.2 Pa
       0.1 mm    1456.0 Pa       2912.0 Pa

  Narrower bore -> higher climb (rise ~ 1/r): a 1 micron root pore lifts
  water tens of metres. Smaller drops hold higher pressure, so when a small
  bubble meets a big one through a tube, the small one empties into the big.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\surface_tension.svg

The Ekman spiral: wind, rotation & the ocean

Steady wind drags the sea surface, but Coriolis deflects the current 45 degrees to the right of the wind (northern hemisphere). Balancing friction against rotation, the current spirals clockwise and decays exponentially with depth over the Ekman depth D = pi sqrt(2 A_z/|f|). The vertically integrated transport ends up exactly 90 degrees to the right of the wind with magnitude tau/(rho |f|), independent of viscosity -- the sideways pumping behind coastal upwelling and the ocean gyres.

The Ekman spiral (hodograph) current vector turns clockwise and shrinks with depth; 45 deg right of wind at surface wind surface z=-0.25D z=-0.5D z=-1D axes: along-wind (x) & cross-wind (y) current components
hodograph: the current vector turns clockwise and shrinks with depth
Ekman spiral: wind-driven ocean boundary layer at 45 N

  eddy viscosity A_z = 0.05 m^2/s   wind stress tau = 0.10 Pa
  Coriolis f = 1.031e-04 /s   Ekman depth D = 97.8 m

  Current spirals CLOCKWISE, decaying with depth (wind blows toward +x/east):
     depth       speed                 direction
    -0.0 m     4.30 cm/s               -45 deg from wind
    -9.8 m     3.14 cm/s               -63 deg from wind
   -24.5 m     1.96 cm/s               -90 deg from wind
   -48.9 m     0.89 cm/s              -135 deg from wind
   -73.4 m     0.41 cm/s              -180 deg from wind
   -97.8 m     0.19 cm/s               135 deg from wind

  Net Ekman transport = 0.946 m^2/s, directed 90 deg to the RIGHT of the wind.
  Surface current sits 45 deg right of the wind; each deeper layer is dragged
  further right and weaker, so the vertical sum points fully across the wind --
  the sideways pumping that drives coastal upwelling and the ocean gyres.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\ekman.svg

Milankovitch cycles: orbits and the ice ages

Slow changes in Earth's orbit -- eccentricity (~100 kyr), obliquity (~41 kyr) and precession (~23 kyr) -- redistribute sunlight between seasons and latitudes even though the annual total barely moves. Computing daily top-of-atmosphere insolation from the astronomical formula reproduces the ~478 W/m^2 65N midsummer peak; that high-latitude summer sun is the knob that decides whether winter snow survives to build ice sheets, so cool summers (low tilt, summer at aphelion) grow the glaciers.

Seasonal insolation map daily top-of-atmosphere sunlight (W/m^2) by latitude and time of year 90 deg 45 deg 0 deg -45 deg -90 deg Mar eq Jun sol Sep eq Dec sol 65N June (ice-sheet knob) dark wedges top-left/bottom-right = polar night; bright bands = midnight sun
daily insolation over latitude and season, polar day/night and the 65N target marked
Milankovitch: 65N midsummer insolation drives ice sheets (grow when it's weak)

  Present orbit: e=0.0167, obliquity=23.44 deg, perihelion in NH winter

  orbital state                             65N June (W/m^2)
  present day                                     477.8   (+0.0)
  low obliquity 22.1 deg (cool summers)           456.5   (-21.4)
  high obliquity 24.5 deg (warm summers)          495.7   (+17.8)
  high ecc 0.05, summer at aphelion               446.6   (-31.2)
  high ecc 0.05, summer at perihelion             545.6   (+67.8)

  Cool NH summers (low obliquity + summer at aphelion) let winter snow survive
  and ice sheets grow -- the ~41 kyr tilt and ~23 kyr precession beats, modulated
  by the ~100 kyr eccentricity envelope, that pace the Pleistocene glacial cycles.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\milankovitch.svg

Equipartition & the heat-capacity staircase

Classically every quadratic degree of freedom carries (1/2) k_B T, so an ideal gas has C_V = (f/2)R and gamma = (f+2)/f: 3R/2 and 5/3 for a monatomic gas, 5R/2 and 7/5 for a diatomic at room temperature, and 3R for a solid (Dulong-Petit). But equipartition is only the hot-limit ceiling -- quantum mechanics freezes a mode out below its energy quantum, so H2 climbs a staircase from 3R/2 to 5R/2 (rotation thaws near 100 K) toward 7R/2 (vibration near 1000s K).

The heat-capacity staircase (H2) C_V/R climbs as rotation then vibration thaw -- equipartition is only the hot ceiling 3R/2 translation 1.5 5R/2 + rotation 2.5 7R/2 + vibration 3.5 10^1 K 10^2 K 10^3 K 10^4 K theta_rot theta_vib molar C_V / R vs temperature
H2 molar C_V/R vs temperature: plateaus at 3/2, 5/2, 7/2 as modes thaw
Equipartition: (1/2) k_B T per quadratic degree of freedom

  gas / solid                 f   C_V/R   C_P/R   gamma
  monatomic (He, Ar)          3    1.50    2.50   1.667
  diatomic, room T (N2)       5    2.50    3.50   1.400
  diatomic, hot (+vib)        7    3.50    4.50   1.286
  nonlinear triatomic         6    3.00    4.00   1.333
  solid (Dulong-Petit)        6    3.00    4.00   1.333

  H2 heat-capacity staircase (theta_rot=85 K, theta_vib=6000 K):
       T (K)     C_V/R
          20      1.77
          50      2.29
         100      2.44
         300      2.49
        1000      2.59
        3000      3.22
        6000      3.42
       10000      3.47

  Classical equipartition is the high-T ceiling; each mode contributes only
  once k_B T tops its quantum. So H2 sits at 3R/2 while rotation is frozen, steps
  up to 5R/2 once it thaws (~100 K), and only near ~1000s K does vibration lift it
  toward 7R/2 -- the staircase Maxwell could see but not explain before quanta.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\equipartition.svg

Osmotic pressure: van't Hoff across a membrane

Water crosses a semipermeable membrane into a solution until the built-up head balances it. For dilute solutions the equilibrium pressure follows van't Hoff's law Pi = i c R T -- the ideal-gas law with solute particles as the gas, so a salt that splits into i ions pushes i times as hard. It reproduces seawater's ~27 atm (the wall reverse-osmosis desalination must beat) and blood plasma's ~7.6 atm (which sets isotonic IV fluids), and it weighs macromolecules by the tiny pressure they raise.

Osmotic pressure vs concentration Pi = i c R T at 25 C -- more dissociated particles push harder 0 atm 10 atm 20 atm 30 atm 40 atm 50 atm 0.0 M 0.1 M 0.2 M 0.3 M 0.4 M 0.5 M 0.6 M 0.7 M glucose (i=1) NaCl (i=2) CaCl2 (i=3) seawater ~27 atm blood ~7.6 atm reverse osmosis must apply more than Pi to push solvent back out
osmotic pressure vs concentration for glucose, NaCl and CaCl2, seawater & blood marked
Osmotic pressure: Pi = i c R T (van't Hoff -- the ideal-gas law for solutes)

  solution                                  conc   i    Pi (atm)
  glucose, 0.30 mol/L (isotonic)         0.30 M   1        7.63
  blood plasma (~0.30 osmol/L)           0.30 M   1        7.63
  normal saline 0.9% (~0.15 M NaCl)      0.15 M   2        7.63
  seawater (~0.6 M NaCl-equiv)           0.60 M   2       28.36
  Dead Sea brine (~5 M ions)             2.50 M   2      122.27

  Same molarity, more particles -> more pressure: CaCl2 (i~3) pushes three times
  as hard as glucose (i=1). Seawater's ~27 atm is the wall reverse-osmosis must
  beat to squeeze fresh water back out; blood's ~7.6 atm sets isotonic IV fluids.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\osmosis.svg

Fick diffusion: spreading as the root of time

A random walk spreads a concentration downhill: Fick's first law J = -D dC/dx and the diffusion equation dC/dt = D d^2C/dx^2. A point release stays a Gaussian whose rms width grows as sqrt(2 D t) -- the diffusive sqrt(t), never the ballistic t -- and a step interface relaxes through an error-function profile. Because the time to cross a length scales as L^2/D, diffusion is fast across a cell (~0.1 s) but hopeless across a room (~30 years), which is why life is small and large systems need flow. Stokes-Einstein ties D to temperature and drag.

Diffusion of a point release each cloud stays a Gaussian; width grows as sqrt(t), height falls to conserve area t 4t 16t 64t position (dots mark +1 sigma = sqrt(2Dt)) 0
a point release spreading into wider, lower Gaussians at t, 4t, 16t, 64t
Fick diffusion: a random walk spreads as sqrt(t), not t

  Small molecule in water, D = 1e-9 m^2/s

        distance      diffusion time
  1 um (organelle)              1.0 ms
    10 um (cell)            100.0 ms
   1 mm (tissue)           1000.00 s
            1 cm             27.8 hr
      1 m (room)               32 yr

  Spread of a point release (sigma = sqrt(2 D t)):
    t =        1 s  ->  sigma = 0.045 mm
    t =      100 s  ->  sigma = 0.447 mm
    t =    10000 s  ->  sigma = 4.472 mm

  Stokes-Einstein for a 1 nm sphere in water at 25 C: D = 2.18e-10 m^2/s
  Diffusion is quick across a cell but takes ~30 years across a room, since time
  scales as L^2/D -- why microscopic life relies on it and large bodies need flow.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\diffusion.svg

The Peclet number: carried vs spreading

Heat and solutes move both by being carried in the flow (advection) and by spreading down their gradient (diffusion); the Peclet number Pe = U L / D says which wins. Pe << 1 (a cell, a still cup) is diffusion-controlled; Pe >> 1 (a river, an artery) is swept along in thin plumes. It factors as Pe = Re*Pr for heat and Re*Sc for mass, so water's Pr ~ 7, air's Pr ~ 0.7 and an aqueous solute's Sc ~ 1000 set boundary-layer thicknesses, and the crossover Pe = 1 sits at the length L = D/U.

Advection-diffusion regime map Peclet = U L / D (aqueous solute); blue = diffusion wins, orange = advection wins Pe = 1 crossover 10^-8 10^-7 10^-6 10^-5 10^-4 10^-3 10^-2 10^-1 10^0 10^1 10^-7 10^-6 10^-5 10^-4 10^-3 10^-2 10^-1 10^0 10^1 10^2 flow speed U (m/s) length scale L (m) inside a cell blood in a capillary slowly stirred cup stream / small river
regime map over flow speed and length, shaded by Peclet with the Pe=1 crossover
Peclet number Pe = U L / D: advection (carried) vs diffusion (spreads)

  system                           U (m/s)     L (m)            Pe        regime
  inside a cell                    1.0e-07   1.0e-05      1.00e-03     diffusion
  blood in a capillary             5.0e-04   8.0e-06      4.00e+00     advection
  slowly stirred cup               5.0e-02   5.0e-02      2.50e+06     advection
  stream / small river             5.0e-01   1.0e+00      5.00e+08     advection

  Fluid diffusivity ratios:
    water:  Pr = nu/alpha = 7.0   Sc = nu/D = 1000   Le = alpha/D = 144
    air:    Pr = 0.70   (heat and momentum spread alike)

  Pe = Re*Pr for heat and Re*Sc for mass. The crossover Pe=1 sits at L = D/U:
    U =    1e-06 m/s  ->  crossover length = 1.00e-03 m
    U =    1e-03 m/s  ->  crossover length = 1.00e-06 m
    U =    1e+00 m/s  ->  crossover length = 1.00e-09 m
  Below that length diffusion wins; above it the flow carries faster than spreading.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\peclet.svg

Convective heat transfer & Newton cooling

A moving fluid strips heat off a surface at q = h (T_s - T_inf), and the coefficient h follows from the Nusselt number Nu = h L / k -- the ratio of convective to conductive transport. Correlations give it: Dittus-Boelter Nu = 0.023 Re^0.8 Pr^0.4 for turbulent pipe flow, 0.664 Re^0.5 Pr^(1/3) for a laminar plate. A lumped object then cools exponentially with tau = rho c_p V/(h A) provided the Biot number Bi = h L/k_solid stays below ~0.1, so an aluminium block cools in an hour in still air but seconds in forced water.

Newtonian cooling of a hot block stiffer convection (bigger h) = shorter time constant; T = T_inf + (T0-T_inf) e^(-t/tau) 20 C 40 C 60 C 80 C 100 C ambient 20 C still air (tau=8.4 min) breeze / fan (tau=1.7 min) forced water (tau=2.0 s) time (window = 3x the still-air tau)
a hot block cooling: time constant shrinks from still air to forced water
Convective heat transfer: q = h (T_s - T_inf),  Nu = h L / k

  Forced water in a pipe (Dittus-Boelter Nu = 0.023 Re^0.8 Pr^0.4):
    Re=2e4, Pr=7  ->  Nu = 138,  h = 4147 W/(m^2 K)

  1 cm aluminium cube cooling from 100 C in 20 C surroundings:
  regime            h (W/m^2K)      Biot         tau   t to 30 C
  still air                  8    0.0002     8.4 min    17.5 min
  breeze / fan              40    0.0010     1.7 min     3.5 min
  forced water            2000    0.0488       2.0 s       4.2 s

  All three stay near-isothermal inside (Bi << 1), so cooling is a clean
  exponential T(t) = T_inf + (T0 - T_inf) e^(-t/tau). Stiffer convection = bigger h
  = shorter tau: still air takes ~an hour, forced water seconds. Newton's law again.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\convection.svg

The Stefan problem: a freezing front as sqrt(t)

A melting or freezing interface is driven by latent heat, not temperature alone: the heat released as water freezes must conduct out through the ice already formed, so the front advances as X = 2 lambda sqrt(alpha t), slowing as it deepens. The growth coefficient lambda solves the Stefan condition lambda e^(lambda^2) erf(lambda) = St/sqrt(pi), where the Stefan number St = c_p dT/L weighs sensible against latent heat. It reproduces Stefan's classic estimate -- about 10 cm of ice after a day of hard frost -- and the depth^2 time law that makes the next foot take weeks.

Ice growth: the Stefan sqrt(t) front the freezing front slows as it deepens -- latent heat must conduct out through the ice 0 cm 10 cm 20 cm 30 cm 40 cm 50 cm 60 cm 70 cm 80 cm 0d 2d 4d 6d 8d 10d 12d 14d light frost (-5 C) hard frost (-15 C) arctic (-40 C) ~13 cm after 1 day of hard frost
ice thickness vs time for light, hard and arctic frost -- the sqrt(t) slowdown
Stefan problem: freezing front X(t) = 2 lambda sqrt(alpha t)

  ice: alpha = 1.14e-06 m^2/s,  latent heat L = 334 kJ/kg

  frost                       St   lambda   ice @ 1 day   ice @ 1 wk
  light frost (-5 C)       0.031    0.125        7.8 cm      20.7 cm
  hard frost (-15 C)       0.094    0.214       13.4 cm      35.6 cm
  arctic (-40 C)           0.251    0.341       21.4 cm      56.7 cm

  To reach a given thickness (hard frost, -15 C):
      5 cm  ->  0.1 days
     10 cm  ->  0.6 days
     30 cm  ->  5.0 days
     50 cm  ->  13.8 days

  The front slows as sqrt(t): the latent heat released at the interface must
  conduct out through the ice already formed, and that layer thickens, so the
  first few cm come in hours but the next foot takes weeks. Same law crusts lava.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\stefan.svg

The capillary length: surface tension vs gravity

The same liquid makes a round dewdrop and a flat puddle -- the difference is size. Surface tension pulls toward a sphere, gravity flattens anything taller than the capillary length l_c = sqrt(gamma/(rho g)) (~2.7 mm for water). The Bond number Bo = (L/l_c)^2 says which wins: below 1 drops are round, above 1 they puddle out (capped at ~2 l_c deep). A moving drop adds inertia through the Weber number and shatters once We tops ~12, and a thin jet pinches into drops spaced ~9 radii apart by the Rayleigh-Plateau instability.

Drop shape across the capillary length below l_c surface tension keeps drops round; above it gravity flattens them into puddles 0.3 mm Bo=0.0 0.8 mm Bo=0.1 1.5 mm Bo=0.3 2.7 mm Bo=1.0 5 mm Bo=3.4 9 mm Bo=10.9 16 mm Bo=34.4 l_c = 2.7 mm (Bo = 1) blue = surface tension wins (round) orange = gravity wins (flat)
drops morphing from round spheres to flat puddles as size crosses the capillary length
Capillary length l_c = sqrt(gamma / (rho g)): surface tension vs gravity

  liquid                  gamma (N/m)     rho   l_c (mm)
  water                        0.0728     998       2.73
  mercury                      0.4870   13534       1.92
  ethanol                      0.0223     789       1.70
  liquid nitrogen              0.0089     807       1.06

  Water crossover l_c = 2.73 mm.  Bond number Bo = (L/l_c)^2:
  drop / feature                  size        Bo          regime
  mist droplet                  0.02mm      0.00 round (tension)
  raindrop                      2.00mm      0.54 round (tension)
  water strider foot dimple     3.00mm      1.21  flat (gravity)
  coin of water                10.00mm     13.44  flat (gravity)
  spilled puddle               50.00mm    336.09  flat (gravity)

  Max non-wetting puddle depth = 2 l_c = 5.5 mm (water can't pile higher).
  A moving 2 mm drop shatters once it tops the critical Weber number, at
  v = 0.66 m/s -- why rain fragments and sprays atomize.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\capillary.svg

The Froude number: racing your own waves

A surface disturbance travels at the shallow-water wave speed sqrt(g h), and the Froude number Fr = U/sqrt(g h) compares the flow to it. Fr < 1 is tranquil (subcritical) flow whose ripples run upstream; Fr > 1 is shooting (supercritical) flow that outruns its waves, so a sudden slowing throws up a hydraulic jump. For a ship the hull Froude number Fr = U/sqrt(g L) sets wave-making drag, walling a displacement hull near Fr ~ 0.4 (the 1.34 sqrt(L_ft) knots rule), and its wake wedge holds a fixed 19.47-degree half-angle at any speed.

The hydraulic jump fast shallow (supercritical) water leaps to a deep slow (subcritical) pool below a weir Fr1 = 3.0 (supercritical) h1 = 0.20 m, U1 = 4.2 m/s Fr2 = 0.41 (subcritical) h2 = 0.75 m, U2 = 1.1 m/s 0.75 m
a hydraulic jump: thin fast supercritical water leaping to a deep slow pool
Froude number Fr = U / sqrt(g h): flow speed vs its own wave speed

  flow                            U (m/s)   h (m)     Fr          regime
  lazy river                         0.50   2.000   0.11     subcritical
  brisk stream                       1.50   0.500   0.68     subcritical
  below a spillway                   6.00   0.200   4.28   supercritical
  kitchen-sink disc                  1.00   0.001  10.10   supercritical

  Hull speed (displacement wall at Fr ~ 0.40):
  boat / waterline               L (m)  V_hull (m/s)    knots
  kayak                            4.0          2.52      4.9
  day-sailer                       7.0          3.34      6.5
  yacht                           12.0          4.37      8.5
  clipper                         60.0          9.77     19.0

  Kelvin ship-wake half-angle: 19.47 deg -- fixed, whatever the speed.

  A hydraulic jump: supercritical Fr1=3 water 0.2 m deep leaps to 0.75 m and goes
  subcritical, dumping its excess energy into turbulence -- the step below a weir.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\froude.svg

The Mach cone: the geometry of going supersonic

When a source outruns sound, its wavelets pile into a trailing cone whose half-angle obeys sin(mu) = 1/M -- 90 degrees at Mach 1, tightening to 30 at Mach 2 and 11.5 at Mach 5. That cone is the shock a ground observer hears as a sonic boom, laid down behind the overhead point and sweeping a continuous carpet along the track. Below Mach 1, thin-airfoil lift diverges by the Prandtl-Glauert factor 1/sqrt(1-M^2) toward the sound barrier; above it, a flow turning a corner expands through the Prandtl-Meyer angle.

The Mach cone at M = 2 each wavelet expands at the sound speed; the source outruns them, leaving a cone of half-angle arcsin(1/M) mu = 30 deg source at Mach 2 the cone sweeps the ground as a continuous sonic-boom carpet along the flight track
a supersonic source, its expanding wavelets, and the trailing Mach cone
Mach cone: sin(mu) = 1/M -- faster flight, tighter cone

    Mach   cone half-angle    boom lag @ 12 km
     1.0          90.00 deg              0.0 s
     1.2          56.44 deg             22.5 s
     2.0          30.00 deg             35.2 s
     3.0          19.47 deg             38.4 s
     5.0          11.54 deg             39.9 s

  Subsonic compressibility (Prandtl-Glauert lift factor 1/sqrt(1-M^2)):
    M = 0.30  ->  x1.05
    M = 0.60  ->  x1.25
    M = 0.80  ->  x1.67
    M = 0.90  ->  x2.29
    M = 0.95  ->  x3.20

  Supersonic turning (Prandtl-Meyer angle, gamma=1.4):
    M = 1.5  ->  nu = 11.9 deg
    M = 2.0  ->  nu = 26.4 deg
    M = 3.0  ->  nu = 49.8 deg
    M = 5.0  ->  nu = 76.9 deg

  At Mach 2 and 12 km, the boom carpet lands 20.8 km behind the overhead point,
  reaching the ground seconds after the jet has already gone by -- and because the
  cone trails at a fixed angle, it sweeps a continuous 'boom carpet' along the track.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\mach_cone.svg

The de Laval nozzle: making exhaust supersonic

Subsonic flow speeds up as a pipe narrows, but supersonic flow speeds up as it widens -- so to push exhaust past Mach 1 you squeeze the gas to a sonic throat and then expand it through a diverging bell. The isentropic relations fix everything from the local Mach number: the area-Mach relation A/A* is minimal at the throat, the flow chokes there once the pressure ratio drops below ~0.528 (air), and the exit Mach number is then set purely by the bell's area ratio -- 25 gives Mach 5. It is how every rocket and supersonic tunnel works.

The de Laval nozzle flow chokes at the throat (M=1); Mach climbs and pressure falls into the diverging bell throat M = 1 subsonic supersonic M = 1 Mach number pressure P/P0
a converging-diverging nozzle with Mach rising through 1 and pressure falling
de Laval nozzle: converge to sonic throat, diverge to supersonic exhaust

  Air (gamma=1.4). Choking pressure ratio P*/P0 = 0.5283

   area ratio A_e/A*   exit Mach      P_e/P0
                 1.0        1.00      0.5283
                 2.0        2.20      0.0939
                 4.0        2.94      0.0298
                10.0        3.92      0.0073
                25.0        5.00      0.0019
               100.0        6.94      0.0003

  Rocket-ish chamber: P0 = 5 MPa, T0 = 3000 K, throat A* = 10 cm^2:
    choked mass flow  = 3.69 kg/s
    area ratio 25 -> exit Mach 5.00, exhaust 2241 m/s

  Subsonic flow accelerates as area shrinks, but past Mach 1 it reverses: a
  supersonic stream speeds up as the area GROWS, so the throat must sit exactly at
  Mach 1. Once choked, mass flow is capped by the throat and only the bell sets M_e.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\nozzle.svg

The Blasius boundary layer

No-slip makes a thin sheared film cling to any surface in a stream. Blasius solved the laminar flat-plate case exactly: the 99%-thickness grows as delta = 5.0 x/sqrt(Re_x) -- only millimetres over the front of a wing -- with the displacement and momentum thicknesses tracking it at 1.721 and 0.664. The wall shear gives a local skin friction c_f = 0.664/sqrt(Re_x) (heaviest at the sharp leading edge) and a plate drag C_D = 1.328/sqrt(Re_L), and the layer stays laminar until Re_x ~ 5e5, where it trips to turbulence.

Blasius boundary layer on a flat plate the 99%-thickness grows as sqrt(x); no-slip at the wall climbs to the free stream U free stream U delta(x) = 5x/sqrt(Re_x) transition Re_x ~ 5e5 (x = 0.75 m) skin friction c_f ~ 1/sqrt(x) (peaks at the leading edge) distance x along the plate
the boundary layer thickening as sqrt(x) with velocity profiles and the transition point
Blasius boundary layer: delta = 5.0 x / sqrt(Re_x)  (air, U = 10 m/s)

     x (m)        Re_x   delta (mm)        c_f
      0.01        6667         0.61    0.00813
      0.05       33333         1.37    0.00364
      0.10       66667         1.94    0.00257
      0.30      200000         3.35    0.00148
      0.75      500000         5.30    0.00094

  Laminar until Re_x ~ 5e5, i.e. x = 0.75 m; beyond that it turns turbulent.

  A 0.5 m x 0.2 m plate: C_D = 0.00230, friction drag = 0.0141 N per side.
  The layer thickens as sqrt(x) -- a couple of mm over the front of a wing -- while
  the skin friction thins as 1/sqrt(x), heaviest right at the sharp leading edge.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\blasius.svg

The Strouhal number: von Karman vortex streets

A blunt body in a steady flow sheds vortices alternately from each side, a staggered von Karman street whose frequency obeys St = f d / U with St ~ 0.2 nearly constant over a huge Reynolds-number range. So shedding frequency scales linearly with wind speed -- the aeolian hum of a wire (a 5 mm wire in 10 m/s wind sings at 400 Hz), the flutter of an antenna. When that frequency crosses a structure's natural frequency the flow locks in and the alternating side-force can drive destructive vortex-induced vibration, the reason chimneys wear helical strakes.

The von Karman vortex street a cylinder sheds vortices alternately at f = St U / d, St ~ 0.2 -- the staggered wake that hums flow U wake wavelength lambda = d/St ~ 5d shed frequency vs wind speed (20 mm rod): linear, f = St U/d 40 m/s
vortices peeling alternately off a cylinder into the staggered von Karman wake
Strouhal number St = f d / U ~ 0.2: the rhythm of vortex shedding

  body                                d     wind   shed freq
  telephone wire                    5mm     10 m/s    400.0 Hz
  car antenna                       8mm     25 m/s    625.0 Hz
  ship rigging                     20mm     15 m/s    150.0 Hz
  factory chimney                3000mm     12 m/s   800.0 mHz

  Roshko St = 0.212(1 - 21.2/Re) rises toward ~0.21 as Re grows:
    Re =      100  ->  St = 0.167
    Re =      300  ->  St = 0.197
    Re =     1000  ->  St = 0.208
    Re =    10000  ->  St = 0.212
    Re =  1000000  ->  St = 0.212

  A 3 m chimney with a 0.25 Hz sway mode locks in at wind ~3.8 m/s -- when the
  shedding beat hits the structure's resonance, the alternating side-force can
  build destructive vortex-induced vibration (why chimneys wear helical strakes).
  Wake vortices trail ~5 diameters apart.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\strouhal.svg

Weighing clusters: the virial mass & dark matter

The virial theorem turns a cluster's own motion into a scale: M = alpha sigma^2 R/G from the velocity dispersion sigma and size R. Only the line-of-sight dispersion is observable, so sigma^2 = 3 sigma_los^2 for an isotropic system. This is Zwicky's 1933 Coma calculation -- galaxies moving at ~1000 km/s across ~1.5 Mpc demand a dynamical mass ~10^15 solar masses, about a hundred times the visible stars. The mass-to-light ratio jumps from a few for stars to hundreds for clusters: the first evidence for dark matter.

The mass-to-light ladder dynamical M/L climbs from a few (stars) to hundreds (clusters) -- the dark-matter signal 1 10 100 1000 M/L (solar units) stars alone 1 Sun (star) 3 stellar population 30 spiral galaxy (+halo) 300 galaxy cluster
the mass-to-light ladder climbing from a star to a cluster
Weighing clusters by their motion: M = alpha sigma^2 R / G (virial)

  cluster            sigma_los   R (Mpc)     M (Msun)     M/L
  Virgo                 700 km/s       1.5     2.56e+15     854
  Coma                 1000 km/s       1.5     5.23e+15    1046
  Norma                 900 km/s       1.3     3.67e+15     918
  Bullet               1200 km/s       2.0     1.00e+16    1674

  Escape velocity from Coma (1e15 Msun, 1.5 Mpc): 2395 km/s -- its galaxies at
  ~1000 km/s would fly apart on stellar mass alone. Zwicky (1933) found the mass
  needed to bind Coma was ~100x its visible stars: the first case for dark matter.
  With only ~5% of the mass luminous, the dark-matter fraction is ~95%.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\cluster_mass.svg

The Sersic profile: the shape of a galaxy's light

A galaxy's surface brightness falls off as I(R) = I_e exp{-b_n[(R/R_e)^(1/n)-1]}, the Sersic law, with the index n setting the concentration. n=1 is the exponential disk of a spiral (scale length R_e/1.678); n=4 is the de Vaucouleurs law of a giant elliptical -- a bright cusped core and enormous faint wings. Half the light always sits inside the effective radius R_e (that is its definition), and integrating the profile gives the total luminosity in closed form via the gamma function, so I_e, R_e and n weigh a galaxy's stars.

Sersic surface-brightness profiles bigger n = brighter cusp and more extended wings; all cross at the effective radius R_e 16 18 20 22 24 26 28 30 mu (mag/arcsec^2, brighter up) 0.1 R_e 1 R_e R_e, mu_e = 21 n=1 exponential disk n=2 bulge n=4 de Vaucouleurs
surface brightness vs radius for n=1, 2, 4 -- all crossing at the effective radius
Sersic profile I(R) = I_e exp{-b_n[(R/R_e)^(1/n) - 1]}

  profile                        b_n  I(0.1 R_e)/I_e  R(90% light)/R_e
  n=1 exponential disk         1.677             4.5              2.32
  n=2 bulge                    3.672            12.3              3.31
  n=4 de Vaucouleurs           7.669            28.7              5.55

  n=1 exponential disk: scale length h = R_e/1.678 = 0.596 R_e
  Half the light sits inside R_e for every n (that's the definition), but larger n
  packs a far brighter core AND flings more light into faint outer wings -- the
  cuspy, extended glow of a giant elliptical versus the gentle fade of a disk.

  Total luminosity L = I_e R_e^2 2 pi n e^b_n Gamma(2n)/b_n^2n (I_e=R_e=1):
    n=1 exponential disk       L = 11.95
    n=2 bulge                  L = 16.31
    n=4 de Vaucouleurs         L = 22.67

  wrote C:\Users\acwic\symplectic-nbody\examples\output\sersic.svg

The Grashof number: heat that stirs its own wind

Natural convection has no fan -- warm fluid expands, rises, and drags cooler fluid in behind it. The Grashof number Gr = g beta dT L^3/nu^2 measures that buoyant drive against viscosity, playing the role Reynolds plays in forced flow. Heat transfer correlates against the Rayleigh number Ra = Gr Pr: Nu = 0.59 Ra^(1/4) laminar, 0.10 Ra^(1/3) turbulent past Ra ~ 1e9. The result is the gentle few W/(m^2 K) of a radiator warming a still room, an order of magnitude below forced convection; Gr/Re^2 tells you which regime rules.

Natural convection off a warm wall buoyancy sets the heat-transfer coefficient; the layer trips turbulent at Ra ~ 1e9 Ra = 1e9 (L ~ 0.8 m): laminar -> turbulent 3 4 5 6 7 8 9 0.1 m 1 m 10 m wall height L h (W/m^2 K) h = Nu k / L, 20 K warm wall in still air
natural-convection h vs wall height, tripping from laminar to turbulent at Ra ~ 1e9
Grashof number Gr = g beta dT L^3 / nu^2: buoyancy-driven natural convection

  Vertical wall 20 K above still air (beta=1/300, Pr=0.71):

    height L          Gr          Ra      regime   h (W/m^2K)
      0.05 m    3.63e+05    2.58e+05     laminar         6.91
      0.30 m    7.85e+07    5.57e+07     laminar         4.42
      1.00 m    2.91e+09    2.06e+09   turbulent         3.31
      3.00 m    7.85e+10    5.57e+10   turbulent         3.31
     10.00 m    2.91e+12    2.06e+12   turbulent         3.31

  Buoyancy alone gives only a few W/(m^2 K) -- the gentle warmth off a radiator
  or a sun-baked wall, an order of magnitude below a fan's forced convection. The
  layer stays laminar until Ra ~ 1e9 (around a metre here), then trips turbulent.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\grashof.svg

The Womersley number: why blood flow lags the pulse

A pulsating pressure does not make a pulsating parabola. The Womersley number alpha = R sqrt(omega/nu) compares the heartbeat frequency to how fast viscosity diffuses momentum across a vessel. Small alpha (arterioles, capillaries) stays quasi-steady -- an in-phase Poiseuille parabola; large alpha (the aorta at alpha ~ 15) has too much core inertia to follow, so the flow lags the pressure by up to 90 degrees and flattens into a blunt plug with the shear squeezed into a thin wall layer. The pressure pulse itself races ahead at the Moens-Korteweg speed.

Pulsatile velocity profiles vs Womersley number low alpha follows the pressure as a parabola; high alpha lags it and flattens into a plug alpha=0.5 (parabola) lag ~ 2 deg alpha=3 (transitional) lag ~ 45 deg alpha=15 aorta (plug) lag ~ 87 deg flow (arrow length = velocity across the tube diameter)
velocity profiles from quasi-steady parabola to inertial plug as alpha grows
Womersley number alpha = R sqrt(omega/nu): does flow keep up with the heartbeat?

  Blood nu = 3.5e-6 m^2/s, heart rate 60 bpm

  vessel              radius    alpha   phase lag          regime
  aorta              11.00mm    14.74        86 deg plug (inertial)
  large artery        4.00mm     5.36        69 deg    transitional
  arteriole           0.15mm     0.20         0 deg    quasi-steady
  capillary           0.00mm     0.01         0 deg    quasi-steady

  Aortic pulse-wave speed (Moens-Korteweg): 6.5 m/s -- the pressure pulse races
  down the arterial tree far faster than the blood itself moves. High alpha in the
  aorta flattens the profile to a plug and makes the flow lag the pressure by ~90 deg.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\womersley.svg

The Marangoni effect: flow along a tension gradient

When surface tension varies along a surface -- from a temperature or composition gradient -- the imbalance drags the fluid from low-tension toward high-tension regions. It climbs the tears of wine up a glass, scatters pepper from a soap drop, and stirs weld pools. The Marangoni number Ma = |dgamma/dT| dT L/(mu alpha) measures the drive against diffusion, breaking a heated layer into Benard-Marangoni cells above Ma ~ 80. The dynamic Bond number Ra/Ma decides surface tension vs buoyancy: thin films and microgravity are Marangoni-driven, thick pools on the ground buoyancy-driven.

Marangoni vs buoyancy regime map dynamic Bond number Bo_d = rho g beta L^2 / |dgamma/dT|; thin/low-g = surface tension wins 10^-5 m 10^-4 m 10^-3 m 10^-2 m Earth gravity layer thickness L gravity (m/s^2) Marangoni buoyancy
regime map over layer thickness and gravity: Marangoni vs buoyancy
Marangoni effect: flow from a surface-tension gradient (Ma_c ~ 80)

  Heated water layer, dT = 10 K (dgamma/dT = -1.5e-4 N/m/K):

     layer L          Ma    onset?      flow U
     0.05 mm     5.4e+02  convects      1.50 m/s
     0.20 mm     2.1e+03  convects      1.50 m/s
     1.00 mm     1.1e+04  convects      1.50 m/s
     5.00 mm     5.4e+04  convects      1.50 m/s

  Tears of wine: ethanol evaporates from the film climbing the glass, raising its
  surface tension there, so liquid is pulled UP toward the higher-tension rim until
  it beads and runs back as 'legs'. A pure solutal Marangoni flow, no heat needed.

  Marangoni vs buoyancy (dynamic Bond number Bo_d = Ra/Ma):
    thin film, Earth     Bo_d =  1.37e-04  ->  Marangoni-driven
    thick pool, Earth    Bo_d =  1.37e+00  ->  buoyancy-driven
    thick pool, orbit    Bo_d =  1.40e-05  ->  Marangoni-driven

  wrote C:\Users\acwic\symplectic-nbody\examples\output\marangoni.svg

Kutta-Joukowski: lift is circulation

A wing flies because the flow around it carries a net swirl -- circulation -- and the Kutta-Joukowski theorem makes it exact: lift per span L' = rho U Gamma. The airfoil sets its own circulation through the Kutta condition (smooth flow off the trailing edge), giving the thin-airfoil lift-slope c_l = 2 pi alpha. The same theorem is the Magnus effect -- a spinning ball drags a boundary layer around, generating circulation and a sideways curve. Lift isn't free: finite wings trail vortices and pay induced drag c_l^2/(pi AR e), so soaring birds wear long thin wings.

Lift is circulation: c_l = 2 pi alpha the thin-airfoil lift-slope, and the stall where a real wing departs from it 0 3 6 9 12 15 18 0.5 1.0 1.5 2.0 ideal 2 pi alpha stall angle of attack (deg) fighter airliner glider 5 10 15 20 25 aspect ratio (induced drag c_di at c_l=0.6) long thin wings pay less induced drag
the 2 pi lift-slope with stall, and the induced-drag penalty vs aspect ratio
Kutta-Joukowski: L' = rho U Gamma, and thin-airfoil c_l = 2 pi alpha

   angle of attack  c_l (thin)
              0 deg       0.000
              2 deg       0.219
              5 deg       0.548
              8 deg       0.877
             12 deg       1.316

  Light aircraft (20 m^2 wing, c_l = 0.5, 50 m/s):
    lift = 15.3 kN  (holds ~1.6 tonnes)

  Magnus effect on a spinning ball (side force):
  ball                  spin    speed   side force
  tennis topspin     3000 rpm     25 m/s       4.34 N
  football curl       600 rpm     25 m/s      32.18 N
  golf backspin      3000 rpm     60 m/s       2.75 N

  Lift comes from the circulation the wing sets via the Kutta condition, not any
  equal-transit myth. Finite wings trail vortices and pay induced drag c_l^2/(pi AR e),
  which is why soaring birds and gliders wear long, high-aspect-ratio wings.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\kutta_joukowski.svg

The Knudsen number: when a gas stops being a fluid

A gas behaves as a smooth continuum only while its mean free path lambda is tiny next to the system size L. Their ratio, the Knudsen number Kn = lambda/L, sorts flows into continuum (Kn<0.01, ordinary Navier-Stokes), slip, transitional and free-molecular (Kn>10, molecules fly wall-to-wall) regimes. Air's sea-level lambda ~ 68 nm makes everything macroscopic a perfect fluid -- but shrink L to a MEMS channel or a nanopore, or thin the air at orbital altitude, and Kn climbs past 1, so the gas slips at walls and finally must be computed molecule by molecule.

Rarefied-gas regime map Knudsen number over system size and pressure: fluid (blue) to free-molecular (red) 10^-9 m 10^-7 m 10^-5 m 10^-3 m 10^-1 m 10^1 m 10^-4 10^-2 10^0 10^2 10^4 10^6 system size L (m) pressure (Pa) sea level airliner wing insect wing MEMS microchannel nanopore filter satellite (100 km) vacuum chamber continuum slip transitional free molecular
regime map over system size and pressure: continuum to free-molecular
Knudsen number Kn = lambda / L: continuum fluid vs free-flying molecules

  Air mean free path: 67.2 nm at sea level, mean speed 468 m/s (300 K)

  system                            L          P         Kn          regime
  airliner wing               3.0e+00    1.0e+05   2.24e-08       continuum
  insect wing                 1.0e-03    1.0e+05   6.72e-05       continuum
  MEMS microchannel           1.0e-06    1.0e+05   6.72e-02            slip
  nanopore filter             5.0e-09    1.0e+05   1.34e+01  free molecular
  satellite (100 km)          1.0e+00    3.0e-02   2.27e-01    transitional
  vacuum chamber              1.0e-01    1.0e-03   6.81e+01  free molecular

  Everyday air is a flawless fluid because lambda ~ 68 nm is dwarfed by anything
  we touch. Shrink to a chip's cooling pores, or thin the air at orbital altitude,
  and Kn climbs past 1: the gas slips at walls and finally flies molecule-to-molecule.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\knudsen.svg

The Richardson number: shear vs stratification

A stably stratified fluid resists overturning, but fast enough shear can rip the interface into billows anyway. The gradient Richardson number Ri = N^2/(du/dz)^2 weighs the buoyant stiffness against the squared shear: the Miles-Howard theorem guarantees stability where Ri > 1/4, and below it the Kelvin-Helmholtz instability curls the interface into cat's-eye billows. It governs clear-air turbulence that jolts aircraft, mixing in the ocean thermocline (Ri ~ 4, layered) and the entrainment atop a fog layer.

Richardson stability map strong shear (low Ri) rips a stratified interface into Kelvin-Helmholtz billows Ri = 1/4 stable (layered) KH billows stratification N (rad/s) shear du/dz (1/s) Kelvin-Helmholtz cat's-eye billows (the rolled-up interface): fast light layer slow dense layer
stability map over stratification and shear with the Ri=1/4 threshold and KH billows
Richardson number Ri = N^2 / (du/dz)^2: buoyant stiffness vs shear (KH at Ri<1/4)

  layer                       N (rad/s)     shear       Ri         state
  ocean thermocline               0.010     0.005     4.00layered (stable)
  nocturnal inversion             0.020     0.020     1.00layered (stable)
  jet-stream shear zone           0.012     0.030     0.16    KH billows
  breaking billows                0.008     0.050     0.03    KH billows

  For N = 0.012 rad/s the critical shear is 0.0240 /s: steeper than that drops Ri
  below 1/4 and the interface rolls up into Kelvin-Helmholtz cat's-eye billows --
  the same physics behind clear-air turbulence that jolts aircraft and cloud-edge waves.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\richardson.svg

The Kolmogorov cascade: turbulence shredding into heat

Turbulence hands energy down a cascade: big eddies break into smaller ones until viscosity smears the tiniest into heat. In the inertial range between, statistics depend only on the dissipation rate epsilon, giving Kolmogorov's E(k) ~ epsilon^(2/3) k^(-5/3) -- the -5/3 law seen from wind tunnels to interstellar gas. The cascade ends at the Kolmogorov scale eta = (nu^3/epsilon)^(1/4), where the eddy Reynolds number is 1, and the span L/eta ~ Re^(3/4) makes turbulence cost ~Re^(9/4) grid points to simulate in 3-D.

The Kolmogorov energy spectrum E(k) ~ k^(-5/3) in the inertial range between stirring and dissipation slope -5/3 injection (stirring scale L) dissipation (Kolmogorov eta) wavenumber k (small eddies to the right) energy E(k) energy in -> cascades -> heat out
the E(k) spectrum with its -5/3 inertial range between injection and dissipation
Kolmogorov cascade: big eddies -> small eddies -> heat, E(k) ~ k^(-5/3)

  flow                            Re         eta    tau_eta     L/eta
  stirred coffee             5.0e+03     0.084mm  7.07e-03s   5.9e+02
  room air current           1.0e+05     0.533mm  1.90e-02s   5.6e+03
  river reach                1.0e+07     0.056mm  3.16e-03s   1.8e+05
  atmosphere (km)            6.7e+08     0.241mm  3.87e-03s   4.1e+06

  Energy is fed in at the large scale, cascades untouched through the inertial
  range as E(k) ~ k^(-5/3), and is dissipated only at the Kolmogorov scale eta
  where the eddy Reynolds number drops to 1. The range widens as Re^(3/4), so a
  weather-scale flow spans millions of eddy sizes -- why turbulence is so costly
  to simulate (~Re^(9/4) grid points in 3-D).

  wrote C:\Users\acwic\symplectic-nbody\examples\output\kolmogorov.svg

The Casimir effect: pushed together by empty space

The quantum vacuum has a zero-point energy in every field mode. Slide two conducting plates close and only the modes that fit in the gap survive between them, so the fuller vacuum outside presses them together with a pressure P = pi^2 hbar c/(240 d^4) -- a force from nothing but the structure of empty space, predicted in 1948 and measured in 1997. The steep d^-4 law makes it invisible at human gaps but crushing below 100 nm (an atmosphere by ~10 nm), where it sticks micro-machine parts together.

The Casimir pressure of the vacuum P ~ d^-4: nothing at human gaps, an atmosphere by ~10 nm 10^-5 Pa 10^-3 Pa 10^-1 Pa 10^1 Pa 10^3 Pa 10^5 Pa 10^7 Pa 10^9 Pa 1 nm 10 nm 100 nm 1 um 10 um plate separation d 1 atmosphere 100 nm: ~13 Pa = 1 atm 1 um (MEMS)
Casimir pressure vs plate gap on log-log axes, crossing one atmosphere near 10 nm
Casimir effect: vacuum pushes two plates together, P = pi^2 hbar c / (240 d^4)

       gap d        pressure   force on 1 cm^2
       10 nm     1.30e+05 Pa   13001257.724 uN
      100 nm     1.30e+01 Pa       1300.126 uN
        1 um     1.30e-03 Pa          0.130 uN
       10 um     1.30e-07 Pa        1.30e-11 N
        1 mm     1.30e-15 Pa        1.30e-19 N

  At d = 10.6 nm the Casimir pressure equals one atmosphere. The force is pure
  quantum vacuum -- only the modes that fit between the plates survive inside, so
  the fuller outside vacuum presses them together. The d^-4 law makes it invisible
  at human scales but dominant in MEMS, where it sticks micro-parts together (stiction).

  wrote C:\Users\acwic\symplectic-nbody\examples\output\casimir.svg

The Hall effect: weighing carriers with a magnet

Run a current through a conductor in a perpendicular field and the Lorentz force pushes the carriers sideways until a transverse Hall voltage V_H = I B/(n q t) balances them. Its magnitude gives the carrier density n, and its sign reveals whether the charge carriers are electrons or positive holes -- the result that classical free-electron theory could not explain and that underpins semiconductor doping. Combined with the conductivity it separates density from mobility (mu = |R_H| sigma), so a Hall bar fully characterizes a conductor.

The Hall bar current + perpendicular field push carriers sideways, building a transverse Hall voltage B out of page I carrier F = q v x B - - - - - - + + + + + + V_H V_H = I B / (n q t): its size gives the carrier density, its sign gives the carrier charge
a Hall bar: current, field, deflected carriers and the transverse Hall voltage
Hall effect: V_H = I B / (n q t) reveals carrier density AND sign

  1 mA through a 0.1 mm-thick sample in a 1 T field:

  material                 n (1/m^3)    carrier         V_H       |R_H|
  copper (electrons)         8.5e+28  electrons    0.001 uV    7.34e-11
  aluminium (holes*)         1.8e+29      holes    0.000 uV    3.47e-11
  n-Si (doped)               1.0e+22  electrons     6.24 mV    6.24e-04
  p-Ge (doped)               5.0e+21      holes    12.48 mV    1.25e-03

  (*Al's measured Hall sign is positive -- a famous hint that band structure makes
  some carriers behave as positive 'holes', which classical free electrons can't explain.)

  The doped semiconductors give millivolt Hall signals from their sparse carriers,
  vs microvolts in a metal: fewer carriers, bigger V_H. Mobility follows from
  mu = |R_H| sigma -- n-Si here: mu = 0.624 m^2/Vs, Hall angle 32.0 deg at 1 T.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\hall_effect.svg

Wiedemann-Franz: good conductors of charge and heat

In a metal the same free electrons carry charge and heat, so their conductivities are locked together: kappa/(sigma T) = L, the Lorenz number pi^2 k_B^2/(3 e^2) = 2.44e-8 W ohm/K^2. The material-specific mean free path and carrier density cancel in the ratio, leaving only fundamental constants -- so you can read a metal's thermal conductivity off an easy resistance measurement (copper's ~400 W/(m K) from its sigma). A measured Lorenz number well below L flags heat and charge decoupling, the signature of exotic 'strange metals'.

Wiedemann-Franz: predicted vs measured metals fall on the L = 2.44e-8 line; heat and charge ride the same electrons 0 0 100 100 200 200 300 300 400 400 predicted kappa = L sigma T (W/m K) measured kappa (W/m K) WF law (L = 2.44e-8) silver copper gold aluminium iron lead
predicted vs measured thermal conductivity: metals hug the Lorenz-number line
Wiedemann-Franz: kappa / (sigma T) = L = 2.443e-08 W ohm / K^2 (T = 300 K)

  metal         sigma (S/m)  kappa pred  kappa meas  L_eff/L
  silver           6.30e+07       462         429     0.93
  copper           5.96e+07       437         401     0.92
  gold             4.10e+07       300         318     1.06
  aluminium        3.77e+07       276         237     0.86
  iron             1.00e+07        73          80     1.09
  lead             4.55e+06        33          35     1.05

  The same free electrons carry both currents, so the material-specific mean free
  path and carrier density cancel: kappa/(sigma T) is a near-universal constant. That
  lets you read a metal's heat conductivity off an easy resistance measurement -- and
  a measured L well below 2.44e-8 flags heat and charge decoupling (strange metals).

  wrote C:\Users\acwic\symplectic-nbody\examples\output\wiedemann_franz.svg

Bragg diffraction: reading a crystal with X-rays

Shine X-rays on a crystal and bright reflections flash only where waves scattered from successive atomic planes add in phase: n lambda = 2 d sin(theta). Because the wavelength must match the ~0.1-0.5 nm atomic spacing, X-rays are the natural probe, and reading the diffraction spots backwards gives the structure -- the method that solved salt, DNA and countless proteins. For a cubic lattice each Miller plane (hkl) has its own spacing a/sqrt(h^2+k^2+l^2) and family of Bragg angles, and sin(theta) <= 1 caps the visible orders at 2d/lambda.

Bragg reflection from atomic planes the lower ray travels an extra 2 d sin(theta); reflections flash when that equals n lambda d = 0.314 nm theta = 14.2 deg incident X-rays reflected (in phase) n lambda = 2 d sin(theta) -- constructive interference builds the diffracted beam
X-rays reflecting off two atomic planes with the 2 d sin(theta) path difference
Bragg's law n lambda = 2 d sin(theta): X-ray crystallography

  Cu K-alpha (0.1541 nm) on silicon (a = 0.543 nm)

     (hkl)    d (nm)   1st-order angle  max order
  (1, 1, 1)    0.3135          14.23 deg          4
  (2, 2, 0)    0.1920          23.66 deg          2
  (3, 1, 1)    0.1637          28.07 deg          2
  (4, 0, 0)    0.1358          34.58 deg          1

  Orders off the (111) planes (d = 0.3135 nm):
    n = 1  ->  theta = 14.23 deg
    n = 2  ->  theta = 29.44 deg
    n = 3  ->  theta = 47.50 deg
    n = 4  ->  theta = 79.45 deg

  Each family of planes flashes a reflection only where its path difference
  2 d sin(theta) is a whole number of wavelengths. Reading the spots backwards gives
  the atomic spacings -- how crystallography solved salt, DNA, and countless proteins.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\bragg.svg

The diffraction limit: every aperture's resolution floor

No lens focuses light to a point: an aperture of diameter D spreads a wave into an Airy disk of angular radius theta = 1.22 lambda/D, the Rayleigh criterion, so two sources closer than that blur into one. Bigger apertures resolve finer detail (Hubble's 2.4 m gives ~0.05 arcsec), long wavelengths need huge ones (radio dishes), and a microscope stops at the Abbe limit lambda/(2 NA) ~ 200 nm for light -- which is why electron microscopes with picometre wavelengths see atoms. A grating turns it into a spectrometer of resolving power m N.

The diffraction limit angular resolution (550 nm light) improves as 1/aperture -- why telescopes grow 1 mm 10 mm 100 mm 1 m 10 m 100 m 0.001" 0.01" 0.1" 1" 10" 100" resolution (arcsec) human eye binoculars amateur scope Hubble VLT two sources at the Rayleigh limit -- central peak of one over the first null of the other: just resolved (the dip between peaks is the Rayleigh criterion)
resolution vs aperture with real instruments, and two sources at the Rayleigh limit
Diffraction limit: theta = 1.22 lambda / D -- aperture sets resolution

  instrument                    aperture    lambda      resolution
  human eye                         2mm    550 nm      1.2 arcmin
  binoculars (50 mm)               50mm    550 nm    2.768 arcsec
  amateur scope (200 mm)          200mm    550 nm    0.692 arcsec
  Hubble (2.4 m)                    2 m    550 nm    0.058 arcsec
  VLT (8.2 m)                       8 m    550 nm    0.017 arcsec
  Arecibo radio (305 m)           305 m     21 cm      2.9 arcmin

  Microscope (Abbe d = lambda/2NA):
    light, NA 1.4:   196 nm
    electron, 4 pm:  100.0 pm (resolves atoms)

  A 10000-line grating resolves lambda/dlambda = 10000 -- enough to split the
  sodium doublet (589.0 vs 589.6 nm). Bigger apertures and more grating lines are
  the only way past the wave-optics floor; it's why telescopes and dishes grow huge.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\diffraction_limit.svg

Snell's law: bending, trapping, and reflecting light

Light changes speed across a boundary and so must bend: n1 sin(theta1) = n2 sin(theta2). Entering a denser medium it turns toward the normal; leaving one, away from it -- until, past the critical angle arcsin(n2/n1), the refracted ray cannot exist and all the light is totally internally reflected. That perfect mirror guides light down an optical fibre and makes a diamond (24 deg critical angle) sparkle. At Brewster's angle arctan(n2/n1) the reflection is perfectly polarized, the trick behind polarizing sunglasses.

Refraction and total internal reflection rays leaving water bend away from the normal, then flip to TIR past the critical angle air (n=1.00) water (n=1.333) critical 48.6 deg green = refracts out red = totally reflected (trapped)
rays leaving water bending away from the normal, then flipping to total internal reflection
Snell's law n1 sin(t1) = n2 sin(t2): refraction, TIR, and Brewster

  medium            n    critical angle   Brewster (from air)
  water         1.333           48.6 deg              53.1 deg
  glass         1.520           41.2 deg              56.7 deg
  diamond       2.417           24.4 deg              67.5 deg

  Diamond's tiny 24 deg critical angle traps light through many internal bounces
  before it escapes -- that repeated total internal reflection is the sparkle.

  Air -> water refraction (light bends toward the normal entering denser water):
    incidence 10 deg  ->  refracts to 7.5 deg
    incidence 30 deg  ->  refracts to 22.0 deg
    incidence 50 deg  ->  refracts to 35.1 deg
    incidence 70 deg  ->  refracts to 44.8 deg

  Optical fibre (core 1.48, clad 1.46): NA = 0.242, acceptance cone half-angle 14.0 deg.
  Light inside that cone is trapped by total internal reflection and guided for km.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\snell.svg

Thin-film interference: bubble colours and lens coatings

A transparent film reflects light off both surfaces, and the two waves interfere by the round-trip path 2 n t -- so a film only nanometres thick paints itself in colour, the sheen of a soap bubble or an oil slick. A half-wave phase flip on the denser-medium reflection sets which colours brighten or cancel, and is why a soap film goes black just before it bursts (2 n t -> 0, destructive everywhere). Engineered as a quarter-wave layer t = lambda/(4n) of index sqrt(n_substrate), the same interference cancels reflection -- the anti-glare coating on every lens. Newton's rings are its fringes in an air gap.

Thin-film interference soap-film bright colour vs thickness (left); Newton's rings under a lens (right) film thickness (60-320 nm) bright wavelength dark rings r = sqrt(m lambda R) Newton's rings
soap-film bright colour vs thickness, and Newton's rings under a lens
Thin-film interference: a film nm-thick paints itself in colour

  Soap film (n = 1.33) -- first-order bright reflected wavelength:
     thickness       2 n t   bright lambda      colour
         80 nm      213 nm          426 nm      violet
        110 nm      293 nm          585 nm      yellow
        150 nm      399 nm          798 nm    infrared
        200 nm      532 nm         1064 nm    infrared
        250 nm      665 nm         1330 nm    infrared

  Anti-reflection coating for a glass lens (n_glass = 1.52) at 550 nm:
    ideal index = sqrt(1.52) = 1.233 (MgF2 at 1.38 is the practical pick)
    quarter-wave MgF2 thickness = 100 nm
    -> the two reflections cancel, killing glare and boosting transmission.

  A soap film thins to near-zero before it bursts: 2 n t -> 0 is destructive at
  every colour, so the film looks BLACK -- the classic sign it is about to pop.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\thin_film.svg

Malus's law: dialling light down with polarizers

A polarizer passes only the field component along its axis, so linearly polarized light emerges at I = I0 cos^2(theta) -- Malus's law. Unpolarized light loses exactly half through any one polarizer, and two crossed at 90 degrees pass nothing (the dark LCD pixel); yet slipping a third at 45 degrees between them rescues I0/8, light where there was none. A stack of many slightly rotated polarizers drags the polarization around while passing nearly all the light -- an optical quantum Zeno effect -- and wave plates rotate it losslessly by retarding one component.

Malus's law and the three-polarizer trick I = I0 cos^2(theta) (top); light rescued by a middle polarizer between crossed pair (bottom) 0 45 90 135 180 0.0 0.5 1.0 45 deg: half 90 deg: dark polarizer angle theta (deg) 0 15 30 45 60 75 90 peak I0/8 at 45 deg middle-polarizer angle (deg), outer pair crossed at 0 & 90 transmitted intensity (crossed outer pair alone = 0)
the cos^2 transmission law and the three-polarizer rescue vs middle angle
Malus's law I = I0 cos^2(theta): a polarizer passes the aligned field component

     angle   transmission
      0 deg         1.000
     30 deg         0.750
     45 deg         0.500
     60 deg         0.250
     90 deg         0.000

  Crossed polarizers (90 deg apart) pass nothing: 0.000
  ...but a third polarizer at 45 deg between them rescues 0.125 of the input
  (I0/8) -- light reappears where two polarizers alone gave darkness.

  Rotating a stack of N polarizers through 90 deg (quantum-Zeno-like):
    N =   1  ->  throughput 0.000
    N =   2  ->  throughput 0.250
    N =   5  ->  throughput 0.605
    N =  20  ->  throughput 0.884
    N = 100  ->  throughput 0.976
  With one polarizer a 90 deg turn kills the light; with many tiny steps it
  survives -- each cos^2 is nearly 1, so the polarization is dragged around intact.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\malus.svg

Cherenkov radiation: the blue glow of going too fast

Light slows to c/n in a medium, and a charged particle can outrun it. When beta > 1/n it drags an electromagnetic shock cone behind it -- an optical sonic boom -- radiating the blue glow of a reactor pool. The cone half-angle obeys cos(theta) = 1/(n beta), just like a Mach cone, opening from threshold toward a maximum arccos(1/n) (~41 deg in water) as the particle nears beta = 1. Because the angle reads off the velocity, ring-imaging Cherenkov detectors use it to identify particles, and neutrino observatories watch for the faint cones.

Cherenkov radiation cone half-angle vs speed (top); the radiation cone trailing a superluminal particle (bottom) 0.6 0.7 0.8 0.9 1.0 0 20 40 water glass aerogel particle speed beta = v/c cone angle (deg) particle, beta 0.95 theta_c = 38 deg blue Cherenkov wavefront
cone angle vs speed for several radiators, and the cone trailing a superluminal particle
Cherenkov radiation: cos(theta) = 1/(n beta), emit only if beta > 1/n

  medium                beta_thr  gamma_thr    max cone
  water (n=1.33)           0.750       1.51     41.4 deg
  glass (n=1.52)           0.658       1.33     48.9 deg
  aerogel (n=1.05)         0.952       3.28     17.8 deg

  Cone angle vs speed in water:
    beta = 0.76   ->  theta =  9.2 deg,  rel. photon yield 0.026
    beta = 0.85   ->  theta = 28.0 deg,  rel. photon yield 0.221
    beta = 0.95   ->  theta = 37.8 deg,  rel. photon yield 0.376
    beta = 0.999  ->  theta = 41.3 deg,  rel. photon yield 0.436

  A charged particle outrunning light-in-medium sheds an EM shock cone -- the
  optical sonic boom that glows blue in a reactor pool. The cone angle reads off
  the velocity, so ring-imaging Cherenkov detectors use it to identify particles.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\cherenkov.svg

The Zeeman effect: splitting lines with a magnetic field

An atom's magnetic moment shifts its energy levels in a field, so a spectral line splits: delta_E = g_J m_J mu_B B. The normal Zeeman effect (spin cancels, g=1) gives a clean Lorentz triplet shifted by mu_B B/h = 14 GHz per tesla; the anomalous effect (g != 1, from the Lande factor 1 + [J(J+1)+S(S+1)-L(L+1)]/2J(J+1)) splits into more, unevenly spaced lines whose existence forced the discovery of electron spin. Reading the splitting backwards measures the field -- how magnetograms map sunspots.

The Zeeman effect a magnetic field fans one spectral line into shifted components (normal triplet, top) pi (unshifted) sigma+ sigma- 0 T 1 T 2 T 3 T magnetic field -> (component frequency shift, GHz) Anomalous Zeeman: a J=3/2 level (g = 1.33) splits into 4 evenly spaced m_J sublevels: m_J = +1.5 m_J = +0.5 m_J = -0.5 m_J = -1.5 B = 0
the normal triplet fanning out with field, and an anomalous sublevel ladder
Zeeman effect: delta_E = g_J m_J mu_B B splits levels (mu_B = 9.274e-24 J/T)

     field    normal split       at 500 nm
     0.1 T         1.40 GHz         1.17 pm
     0.3 T         4.20 GHz         3.50 pm
     1.0 T        14.00 GHz        11.67 pm
     3.0 T        41.99 GHz        35.01 pm

  Lande g-factors (anomalous Zeeman -- uneven splitting):
    2S1/2 (ground)           g = 2.000
    2P1/2                    g = 0.667
    2P3/2                    g = 1.333
    3D3 (pure orbital-ish)   g = 1.333

  A sunspot line split by 4.2 GHz implies B = 0.30 T -- how magnetograms map the
  Sun's magnetic field. The normal triplet (g=1) is the Lorentz-triplet classical
  physics got right; the uneven anomalous patterns forced the discovery of spin.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\zeeman.svg

Rabi oscillations: a two-level atom flopping

A near-resonant field does not just excite a two-level system once -- it cycles it coherently between ground and excited at the Rabi frequency Omega = dE/hbar. On resonance P_e(t) = sin^2(Omega t/2) swings fully 0 to 1, so a pi pulse inverts the population (a qubit X gate) and a pi/2 pulse builds an equal superposition. Detuned by delta the flopping runs faster, at sqrt(Omega^2 + delta^2), but only reaches Omega^2/(Omega^2 + delta^2) -- a Lorentzian resonance of width Omega. These are the elementary operations of atomic clocks and quantum bits.

Rabi oscillations excited-state probability flops in time (top); Lorentzian resonance in detuning (bottom) 0.0 0.5 1.0 on resonance detune 1 MHz detune 2 MHz time -> (pi pulse fully inverts on resonance) half-max at detuning = Omega detuning delta (peak excitation, Lorentzian of width Omega)
excited-state probability flopping in time for several detunings, and the Lorentzian resonance
Rabi oscillations: P_e(t) = (O^2/O_R^2) sin^2(O_R t/2), O = 2pi x 1 MHz

  Pulses on resonance:
    pi pulse  (full inversion, X gate): 500 ns
    pi/2 pulse (equal superposition):   250 ns

  Detuning kills contrast (peak excitation P_max = O^2/(O^2+d^2)):
          detuning     gen. Rabi    peak P_e
         0.0 MHz      1.00 MHz       1.000
         0.5 MHz      1.12 MHz       0.800
         1.0 MHz      1.41 MHz       0.500
         2.0 MHz      2.24 MHz       0.200

  On resonance the atom swings all the way to the excited state and back; detuned,
  it oscillates faster but only part-way. A pi pulse flips a qubit, a pi/2 pulse
  builds a superposition -- the elementary gates of atomic clocks and quantum bits.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\rabi.svg

The Franck-Hertz experiment: energy levels in a current

Fire electrons through mercury vapour and ramp the accelerating voltage: the collected current climbs, then drops sharply every 4.9 V. Electrons collide elastically until they gain the atom's excitation energy, then dump exactly that quantum in an inelastic collision and arrive too slow to be collected -- so the current falls, and the evenly spaced dips are direct proof that atomic energy is quantized (the 1914 confirmation of the Bohr atom). The excited atom relaxes by emitting a photon at that energy, mercury's 254 nm UV line.

The Franck-Hertz current curve current drops every 4.9 V -- each drop is electrons dumping one quantum into a mercury atom 4.9 9.8 14.7 19.6 24.5 29.4 0 V 5 V 10 V 15 V 20 V 25 V 30 V accelerating voltage (red lines = 4.9 V dip spacing) collector current
the current-vs-voltage sawtooth with dips at multiples of the 4.9 V excitation
Franck-Hertz: current dips prove quantized atomic energy (mercury, 4.9 eV)

  Current dips at multiples of the excitation voltage:
    dip 1:    4.9 V   (electron has excited the atom 1 time)
    dip 2:    9.8 V   (electron has excited the atom 2 times)
    dip 3:   14.7 V   (electron has excited the atom 3 times)
    dip 4:   19.6 V   (electron has excited the atom 4 times)
    dip 5:   24.5 V   (electron has excited the atom 5 times)

  Dip spacing = 4.9 V = the mercury 6s6p excitation energy in volts.
  Emission on relaxation: lambda = h c / E = 253 nm (the mercury UV line).

     accel V   excitations   residual (eV)
         3 V             0            3.00
         6 V             1            1.10
        11 V             2            1.20
        16 V             3            1.30
        25 V             5            0.50

  Electrons collide elastically (no energy lost) until they reach 4.9 eV, then can
  dump exactly that quantum inelastically and arrive too slow to be collected -- so
  the current drops. Repeating dips = repeated excitations = energy is quantized.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\franck_hertz.svg

Moseley's law: ordering the elements by X-ray colour

An element struck by fast electrons fluoresces characteristic X-rays, and Moseley found the square root of the K-alpha frequency rises linearly with atomic number, sqrt(f) = a(Z-1). Equivalently the K-alpha energy is a Rydberg-like 13.6 (3/4)(Z-1)^2 eV -- copper's 8 keV, molybdenum's 17 keV. This ordered the periodic table by nuclear charge rather than atomic weight, exposed gaps where undiscovered elements had to sit, and is still how an XRF gun reads which elements a sample contains from its X-ray lines.

Moseley's law sqrt(K-alpha frequency) is a straight line in atomic number Z -- the periodic table's true order 10 20 30 40 50 60 70 80 atomic number Z sqrt(K-alpha frequency) Ca (20) Fe (26) Cu (29) Mo (42) Ag (47) W (74) straight line: gaps meant missing elements
the Moseley plot: sqrt(K-alpha frequency) a straight line in atomic number
Moseley's law: sqrt(f) = a(Z - 1) -- X-ray lines order the periodic table

  element      Z    K-alpha energy    wavelength
  Ca          20          3.68 keV     0.3366 nm
  Fe          26          6.38 keV     0.1944 nm
  Cu          29          8.00 keV     0.1550 nm
  Mo          42         17.15 keV     0.0723 nm
  Ag          47         21.59 keV     0.0574 nm
  W           74         54.38 keV     0.0228 nm

  Identifying an unknown from its K-alpha line:
    line at   6.4 keV  ->  Z = 26 (Fe)
    line at   8.0 keV  ->  Z = 29 (Cu)
    line at  21.6 keV  ->  Z = 47 (Ag)

  Because sqrt(f) is exactly linear in Z, Moseley's plot ordered the elements by
  nuclear charge -- not atomic weight -- and its gaps predicted where undiscovered
  elements (technetium, promethium) had to be. Still the basis of XRF elemental ID.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\moseley.svg

The Stark effect: electric fields on atoms

The electric analogue of Zeeman: a field shifts atomic levels and splits lines. Hydrogen's degenerate levels give a LINEAR Stark effect -- a shift proportional to the field, since they mix into a permanent dipole -- fanning level n into 2n-1 equally spaced components. Most atoms have no permanent dipole and shift quadratically, -1/2 alpha E^2, always lowering the energy. Push hard enough and the field strips the electron: the ionization threshold scales as 1/n^4, so a Rydberg atom ionizes in a field ten billion times weaker than the ground state.

The Stark effect hydrogen n=4 levels fanning out linearly with field (top); ionization field vs n (bottom) electric field -> (n=4 splits into 7 equally spaced lines) energy shift 10^5 10^7 10^9 1 10 20 30 40 50 60 principal quantum number n (ionizing field, V/m, log scale) F_ion ~ 1/n^4: Rydberg atoms ionize in tiny fields
the linear Stark fan of hydrogen n=4, and the ionizing field plummeting with n
Stark effect: an electric field splits (hydrogen: linearly) and ionizes atoms

  Linear Stark components of hydrogen at 5 MV/m (energy shift, ueV):
    n=2: 3 lines  [-793.8, +0.0, +793.8]
    n=3: 5 lines  [-2381.3, -1190.6, +0.0, +1190.6, +2381.3]
    n=4: 7 lines  [-4762.6, -3175.1, -1587.5, +0.0, +1587.5, +3175.1, +4762.6]

  Field-ionization threshold vs principal quantum number:
     n    binding (eV)    ionizing field
     1       13.6057      3.21e+10 V/m
     5        0.5442      5.14e+07 V/m
    10        0.1361      3.21e+06 V/m
    30        0.0151      3.97e+04 V/m
    50        0.0054      5.14e+03 V/m

  Hydrogen's degenerate levels give the LINEAR Stark effect (a permanent dipole);
  most atoms shift quadratically as -1/2 alpha E^2. And because binding ~ 1/n^2, a
  Rydberg atom ionizes in a field ten billion times weaker than the ground state --
  which is how Rydberg-atom detectors sense tiny fields and single microwave photons.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\stark.svg

The Aharonov-Bohm effect: a phase from an untouched field

Classically no field means no effect, but a charged particle steered around a solenoid -- with the field entirely confined inside, zero on its path -- still has its interference fringes shift. It responds to the vector potential, picking up a phase delta_phi = q Phi/hbar set purely by the enclosed flux, proof that the potentials are physically real in quantum mechanics. The phase is periodic in the flux quantum h/q (h/2e for Cooper pairs), which quantizes flux through a superconducting ring and drives SQUID magnetometers to sense fields a billion times weaker than Earth's.

The Aharonov-Bohm effect enclosed flux slides the interference fringes (top) though B = 0 on the electron path Phi = 0 Phi = 0.5 Phi_0 Phi = Phi_0 screen position -> (fringes slide by one period per flux quantum) 1 2 3 enclosed flux (units of Phi_0); phase = 2 pi per quantum AB phase delta_phi = q Phi / hbar
interference fringes sliding with enclosed flux, and the phase winding per flux quantum
Aharonov-Bohm: delta_phi = q Phi / hbar, from flux Phi with B=0 on the path

  Electron flux quantum Phi_0 = h/e = 4.136e-15 Wb

    flux / Phi_0   phase (rad)  fringe shift
            0.00         0.000          0.00
            0.25         1.571          0.25
            0.50         3.142          0.50
            1.00         6.283          0.00
            2.50        15.708          0.50

  Superconducting flux quantum h/2e = 2.068e-15 Wb (Cooper pairs).
  One quantum through a 1 mm^2 SQUID loop needs only 2.07e-09 T -- how SQUIDs sense
  fields a billion times weaker than Earth's. The electron never sees the field,
  only the vector potential -- proof the potentials are physically real in QM.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\aharonov_bohm.svg

The Josephson junction: a supercurrent that defines the volt

Cooper pairs tunnel through a thin barrier between superconductors with zero voltage, a supercurrent I = I_c sin(phi) set only by the quantum phase difference (DC effect). Apply a DC voltage and the phase winds, so the current oscillates at the Josephson frequency f = 2eV/h = 483.6 GHz per millivolt -- an exact voltage-to-frequency conversion through only e and h. Irradiating the junction locks it onto quantized Shapiro voltage steps n h f/2e, which is how the SI volt is now defined and how the most accurate voltmeters work.

The Josephson junction DC supercurrent I = I_c sin(phi) (top); irradiated I-V climbing in Shapiro steps (bottom) +I_c -I_c -2pi 0 2pi phase difference phi (zero-voltage DC supercurrent) 145 uV voltage (quantized Shapiro steps at n h f / 2e, 70 GHz drive) each plateau is an exact, constants-only voltage
the DC I = I_c sin(phi) supercurrent, and the irradiated I-V climbing in Shapiro steps
Josephson junction: I = I_c sin(phi), f = 2eV/h = 483.6 GHz/mV

  Josephson constant K_J = 2e/h = 4.8360e+14 Hz/V

     voltage    Josephson freq
       10 uV           4.84 GHz
      100 uV          48.36 GHz
     1000 uV         483.60 GHz

  Shapiro steps under 70 GHz irradiation (the volt standard):
    step  1:   144.75 uV
    step  2:   289.50 uV
    step  5:   723.74 uV
    step 10:  1447.48 uV

  A 1 uA junction has coupling energy E_J = 3.29e-22 J (496.7 GHz x h).
  Cooper pairs tunnel the barrier with zero voltage (DC effect); a DC voltage makes
  the phase wind and the current oscillate (AC effect). The exact V<->f link, tied
  only to e and h, is how the volt is now defined and how quantum voltmeters work.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\josephson.svg

The quantum Hall effect: resistance from pure constants

Cool a 2D electron gas in a strong field and its Hall resistance locks onto flat plateaus R_xy = R_K/nu with R_K = h/e^2 = 25812.807 ohm -- values set by fundamental constants alone, independent of the material. The electron energies collapse into Landau levels (spacing hbar eB/m, degeneracy eB/h), and when nu of them are filled the bulk is insulating while nu chiral edge channels each carry e^2/h of conductance. Reproducible to parts per billion in any device, it now defines the ohm -- the resistance counterpart of the Josephson volt.

The quantum Hall staircase Hall resistance locks onto flat plateaus at R_K/nu -- values set by h/e^2 alone R_K/1 R_K/2 R_K/3 R_K/4 R_K/5 R_K/6 nu=2 nu=3 nu=4 5 T 10 T 15 T 20 T 25 T 30 T magnetic field (n = 5e15 /m^2 fixed) Hall resistance R_xy
the Hall resistance staircase: plateaus at R_K/nu as field sweeps a fixed density
Quantum Hall effect: R_xy = R_K / nu, R_K = h/e^2 = 25812.807 ohm

    filling nu   Hall resistance
             1      25812.8 ohm
             2      12906.4 ohm
             3       8604.3 ohm
             4       6453.2 ohm
             6       4302.1 ohm

  Landau levels vs field (need k_B T << spacing to resolve plateaus):
     B (T)   spacing (meV)    degeneracy (1/m^2)
         2         0.23              4.84e+14
         5         0.58              1.21e+15
        10         1.16              2.42e+15
        20         2.32              4.84e+15

  A 2D electron gas at n = 5e15 /m^2 hits integer filling as field is swept:
    B =  5 T  ->  nu = 4.14
    B = 10 T  ->  nu = 2.07
    B = 20 T  ->  nu = 1.03

  On each plateau the bulk is insulating and only chiral edge channels conduct,
  one per filled Landau level -- so R_xy depends on nothing but h/e^2. Reproducible
  to parts per billion in any device, it is how the ohm is now defined.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\quantum_hall.svg

BCS superconductivity: the gap that kills resistance

Below T_c a phonon-mediated attraction binds electrons into Cooper pairs that condense into one coherent state carrying current without resistance. The theory's core is an energy gap Delta at the Fermi surface -- it costs 2 Delta to break a pair, so nothing scatters the condensate. BCS predicts the universal ratio 2 Delta(0)/(k_B T_c) = 3.53 for every weak-coupling superconductor, a gap that closes as sqrt(1-T/Tc), and T_c = 1.13 hbar wD exp(-1/lambda) -- whose wD ~ 1/sqrt(M) gives the isotope effect that proved phonons do the pairing.

BCS superconductivity the energy gap closes as sqrt(1-T/Tc) (top); T_c rises with coupling (bottom) 0.0 0.5 1.0 Delta(T)/Delta(0) = sqrt(1 - T/Tc) gap closes at T_c T / T_c (gap, in units of Delta(0)) 0.2 0.3 0.4 0.5 electron-phonon coupling lambda (T_c = 1.13 hbar wD e^-1/lambda / kB) exponentially sensitive to coupling
the gap closing as sqrt(1-T/Tc), and T_c rising with electron-phonon coupling
BCS superconductivity: 2 Delta(0) / (k_B T_c) = 3.53 (universal)

  material      T_c (K)   gap (meV)    pair-break
  aluminium         1.2       0.183       0.09 THz
  niobium           9.3       1.414       0.68 THz
  lead              7.2       1.095       0.53 THz

  Isotope effect (T_c ~ M^-1/2): mercury-198 vs mercury-202 (Hg T_c=4.15 K):
    Hg-202 / Hg-198 mass ratio 1.020  ->  T_c = 4.109 K
    heavier isotope, lower T_c -- proof phonons do the pairing.

  T_c from electron-phonon coupling (Debye freq 3e13 rad/s):
    lambda = 0.2  ->  T_c = 1.74 K
    lambda = 0.3  ->  T_c = 9.24 K
    lambda = 0.4  ->  T_c = 21.25 K
    lambda = 0.5  ->  T_c = 35.04 K

  Below T_c an energy gap opens at the Fermi surface: it costs 2 Delta to break a
  Cooper pair, so nothing scatters the condensate and resistance vanishes. The gap
  closes as sqrt(1 - T/Tc) toward T_c, where superconductivity ends.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\bcs.svg

London & Meissner: expelling the magnetic field

A superconductor doesn't just conduct perfectly -- it actively pushes magnetic field out (the Meissner effect), which is why magnets levitate above one. The London equations give the field decaying into the surface as exp(-x/lambda_L) over the penetration depth lambda_L = sqrt(m/(mu0 n_s q^2)), tens of nanometres. The ratio kappa = lambda_L/xi to the coherence length splits superconductors into type I (kappa < 1/sqrt2, full expulsion) and type II (kappa > 1/sqrt2, quantized flux vortices) -- the latter surviving the huge fields of MRI and fusion magnets.

The Meissner effect field decays into the superconductor over lambda_L (top); type I vs II by kappa (bottom) lambda_L (B -> B0/e) vacuum (field B0) superconductor (field expelled) kappa = 1/sqrt2 type I (expels field) type II (flux vortices) aluminium tin niobium Nb-Ti YBCO kappa=10^-1 kappa=10^0 kappa=10^1 kappa=10^2
the Meissner field decaying into the surface, and materials across the type-I/II boundary
London/Meissner: B(x) = B0 exp(-x/lambda_L), lambda_L = sqrt(m/(mu0 n_s q^2))

  Penetration depth vs superconducting carrier density:
    n_s = 1e+28 /m^3  ->  lambda_L = 37.6 nm
    n_s = 4e+28 /m^3  ->  lambda_L = 18.8 nm
    n_s = 1e+29 /m^3  ->  lambda_L = 11.9 nm

  Type classification (kappa = lambda_L/xi, boundary 1/sqrt2 = 0.707):
  material           lambda (nm)  xi (nm)    kappa     type
  aluminium                   16     1600     0.01        I
  tin                         34      230     0.15        I
  niobium                     40       38     1.05       II
  Nb-Ti                      300        4    75.00       II
  YBCO (high-Tc)             150      1.5   100.00       II

  Type I expels field until it abruptly goes normal; type II lets field thread
  through as quantized vortices (each carrying 2.07e-15 Wb) between H_c1 and H_c2 --
  which is how Nb-Ti and high-Tc magnets survive the huge fields of MRI and fusion.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\london.svg

Mean-field ferromagnetism: order from disorder

Below a Curie temperature a magnet spontaneously aligns -- countless spins tip into one direction with no applied field. Weiss mean-field theory of the Ising model captures it: each spin feels the average of its neighbours, m = tanh((z J m + B)/T), with T_c = z J. Above T_c the only zero-field solution is m = 0 (paramagnet); below it a nonzero magnetization appears, vanishing near T_c as (1 - T/Tc)^(1/2) (the mean-field beta = 1/2), while the susceptibility diverges as the Curie-Weiss 1/(T - T_c) -- the hallmarks of a second-order phase transition.

Mean-field ferromagnetism magnetization collapses to zero at the Curie point; susceptibility diverges there 0.0 0.5 1.0 T_c ferromagnet (m != 0) paramagnet (m = 0) magnetization m(T) susceptibility chi ~ 1/(T-T_c) 0.5 T_c 1.0 T_c 1.5 T_c 2.0 T_c temperature
magnetization collapsing to zero at the Curie point, and the diverging susceptibility
Mean-field Ising: m = tanh((z J m + B)/T), T_c = z J = 6

     T / T_c   magnetization           phase
        0.20           1.000     ferromagnet
        0.50           0.958     ferromagnet
        0.80           0.710     ferromagnet
        0.95           0.379     ferromagnet
        1.00           0.010      paramagnet
        1.20           0.000      paramagnet

  Curie-Weiss susceptibility above T_c (chi = C/(T - T_c)):
    T = 1.05 T_c  ->  chi = 3.333
    T = 1.20 T_c  ->  chi = 0.833
    T = 1.50 T_c  ->  chi = 0.333
    T = 2.00 T_c  ->  chi = 0.167

  Below T_c countless spins tip collectively into one direction with no applied
  field -- spontaneous symmetry breaking. The magnetization vanishes as (1-T/Tc)^1/2
  (mean-field beta=1/2) and the susceptibility diverges at T_c: a phase transition.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\ising_mft.svg

Percolation: the sudden onset of connectivity

Occupy each lattice site with probability p and ask if a connected path spans the system. Below a sharp threshold p_c (~0.59 for a 2D square lattice) the occupied sites form isolated islands; above it a single cluster abruptly spans the whole lattice -- a geometric phase transition. The largest-cluster fraction jumps from near zero to order one through p_c. The same threshold governs forest fires spreading, oil seeping through rock, disease jumping a contact network, and current finding a path through a random resistor grid.

Percolation spanning probability sharpens at p_c ~ 0.59 (top); lattice snapshots below/at/above (bottom) 0.0 0.5 1.0 p_c ~ 0.59 0.3 0.5 0.7 0.9 occupation probability p (fraction of lattices that span) p=0.50 (islands) no spanning path p=0.59 (threshold) spans p=0.72 (spanning) spans yellow = largest cluster
the spanning probability sharpening at p_c, and lattices below/at/above threshold
Percolation: occupy sites with probability p, look for a spanning cluster

  2D square-lattice site threshold p_c ~ 0.5927

       p          spans?   largest cluster
    0.40            0 %              1 %
    0.50            0 %              3 %
    0.55           13 %              9 %
    0.59           60 %             30 %
    0.65          100 %             50 %
    0.75          100 %             75 %

  Below p_c occupied sites form isolated islands; right at ~0.59 a single cluster
  abruptly spans the whole lattice -- a geometric phase transition. Same threshold
  governs forest fires, oil in rock, disease on a contact network, and random resistor grids.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\percolation.svg

Polya's random walk: home, or lost forever?

A random walker on an infinite lattice steps to a random neighbour forever -- does it ever return to the origin? Polya proved the answer depends only on dimension: in 1D and 2D the walk is recurrent, returning with probability 1 (and visiting every site infinitely often); in 3D and above it is transient, escaping to infinity with nonzero probability (a ~0.34 chance of ever returning in 3D). The knife-edge is exactly two dimensions, because the probability of being back at the origin decays as n^(-d/2) -- summable only for d >= 3. 'A drunk man finds his way home, but a drunk bird may get lost forever.'

Polya's theorem: return probability vs dimension recurrent (returns for sure) in 1D-2D, transient (can escape) in 3D and above 0.0 0.5 1.0 recurrent (p=1) transient (p<1) 1.000 1D 1.000 2D 0.341 3D 0.193 4D 0.135 5D 0.113 6D lattice dimension
return probability dropping below 1 past two dimensions -- recurrent to transient
Polya's random walk: does an infinite lattice walk return to the origin?

  'A drunk man finds his way home; a drunk bird may get lost forever.'

   dim  return prob  escape prob  exp. visits       class
     1       1.0000       0.0000          inf   recurrent
     2       1.0000       0.0000          inf   recurrent
     3       0.3405       0.6595        1.516   transient
     4       0.1932       0.8068        1.239   transient
     5       0.1352       0.8648        1.156   transient

  Simulated return fraction (finite walks, 3000 steps, 200 trials):
    1D  ->  97 % returned
    2D  ->  67 % returned
    3D  ->  34 % returned

  In 1D and 2D the walk is certain to return (and visits every site infinitely
  often); in 3D+ it can escape to infinity. The knife-edge is exactly two dimensions,
  because P(at origin) decays as n^(-d/2) -- summable (transient) only for d >= 3.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\polya.svg

Langevin paramagnetism: moments vs thermal chaos

A paramagnet's independent magnetic moments each favour aligning with an applied field (energy -mu.B) while temperature randomizes them. Averaging over the Boltzmann distribution gives the Langevin function m/mu = L(x) = coth(x) - 1/x with x = mu B/(k_B T). Weak field or high temperature is the linear regime L(x) ~ x/3, so the susceptibility follows Curie's law chi ~ 1/T -- the fingerprint of a paramagnet; strong field or low temperature saturates every moment at L = 1 and the magnetization can grow no further.

Langevin paramagnetism alignment L(x) from the x/3 Curie slope to saturation (top); chi ~ 1/T (bottom) 0.0 0.5 1.0 saturation L=1 Curie slope L ~ x/3 x = mu B / kT (alignment fraction L(x)) 50 K 100 K 200 K 300 K temperature (susceptibility chi = C/T, Curie law) chi diverges as T -> 0
the Langevin function from the Curie slope to saturation, and the 1/T susceptibility
Langevin paramagnetism: m/mu = L(x) = coth(x) - 1/x, x = mu B / kT

  Alignment fraction L(x) for a 5-Bohr-magneton moment:
     B (T)   T (K)         x   aligned
         1     300     0.011     0.4 %
        10     300     0.112     3.7 %
        10       4     8.396    88.1 %
        50       1   167.928    99.4 %

  Curie law chi = C/T (C = n mu^2/3k):
    T =    1 K  ->  chi (per moment) = 5.191e-23
    T =   10 K  ->  chi (per moment) = 5.191e-24
    T =  100 K  ->  chi (per moment) = 5.191e-25
    T =  300 K  ->  chi (per moment) = 1.730e-25

  Weak field or high temperature: L(x) ~ x/3, so susceptibility falls as 1/T --
  Curie's law, the fingerprint of a paramagnet. Strong field or low temperature:
  every moment aligns and L saturates at 1, the magnetization can grow no further.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\langevin_para.svg

Buffon's needle: estimating pi by dropping sticks

Rule a floor with parallel lines a distance d apart and drop a needle of length L <= d at random: it crosses a line with probability 2 L/(pi d). So counting crossings estimates pi -- pi ~ 2 L N/(d C) for N drops and C crossings -- the first problem in geometric probability (Buffon, 1777). pi emerges from a purely mechanical experiment with no measurement of pi anywhere, from the geometry of random position and angle. Convergence is the slow Monte Carlo 1/sqrt(N): 1% needs ~10000 drops, 0.1% about a million.

Buffon's needle needles dropped on ruled lines (crossings in red); pi estimate converging (right) red = crosses a line, blue = lands between pi 10^2 10^3 10^4 10^5 10^6 3.00 3.14 3.40 drops N (pi estimate, error ~ 1/sqrt N)
needles dropped across ruled lines (crossings red), and the pi estimate converging
Buffon's needle: P(cross) = 2L/(pi d), so pi ~ 2 L N / (d C)

  Crossing probability for L = d: 0.6366 (= 2/pi)

     drops N   crossings   pi estimate       error
         100          58       3.44828     0.30668
        1000         645       3.10078     0.04082
       10000        6450       3.10078     0.04082
      100000       63921       3.12886     0.01273
     1000000      636951       3.13996     0.00163

  Convergence is slow (Monte Carlo, error ~ 1/sqrt(N)):
    5.0 % accuracy  ->  ~399 drops
    1.0 % accuracy  ->  ~10,000 drops
    0.1 % accuracy  ->  ~1,000,000 drops

  pi falls out of a purely mechanical experiment -- counting how often a tossed
  stick lands across a floorboard. No measurement of pi enters anywhere; it emerges
  from the geometry of random position and angle. The first problem in geometric probability.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\buffon.svg

Metropolis Monte Carlo: sampling the Ising transition

Systems too large to sum are sampled: propose a change and accept it with probability min(1, exp(-dE/T)) -- always downhill, Boltzmann-weighted uphill -- and the chain visits states with exactly the thermal probability exp(-E/T). Run on the 2D Ising ferromagnet it reproduces the real phase transition that mean-field theory only approximates: spins order below the exact Onsager T_c ~ 2.269 J/k_B and disorder above it, with genuine critical fluctuations -- domains at every scale near T_c -- that mean field cannot capture.

Metropolis Monte Carlo: the 2D Ising transition simulated magnetization drops to zero at the Onsager T_c (left); a near-critical spin snapshot (right) 0.0 0.5 1.0 T_c=2.27 1 2 3 temperature (J/k_B) -- avg |m| spins near T_c (up=yellow, down=blue): domains at every scale
the simulated magnetization dropping to zero at the Onsager T_c, and a near-critical spin snapshot
Metropolis MCMC: accept a flip with prob min(1, exp(-dE/T)) -- samples exp(-E/T)

  2D Ising ferromagnet, exact Onsager T_c = 2.269 J/k_B

    T (J/kB)     avg |m|           phase
        1.00       0.999         ordered
        1.80       0.959         ordered
        2.27       0.763         ordered
        2.60       0.543         ordered
        3.50       0.084      disordered

  The simulation reproduces the real phase transition mean-field theory only
  approximates: spins order below ~2.27 J/k_B and disorder above it, with the true
  Onsager critical temperature and genuine critical fluctuations near T_c.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\metropolis.svg

The logistic map: period doubling into chaos

The one-line map x' = r x(1-x) is the textbook birth of chaos. As the growth rate r rises, a stable population splits into a 2-cycle at r=3, then 4, 8, 16, ... in a cascade that accumulates at r ~ 3.5699 -- the onset of aperiodic, initial-condition-sensitive chaos, interrupted by periodic windows (the famous period-3 near 3.83). The bifurcation spacings shrink by the universal Feigenbaum constant 4.669, the same for any smooth single-humped map, and a positive Lyapunov exponent marks the chaotic regime.

The logistic-map bifurcation diagram attractor vs growth rate: one point, then 2, 4, 8, ... into chaos (Lyapunov below) chaos onset 3.57 1 0 attractor x 0 Lyapunov > 0 = chaos 2.8 3.2 3.6 4.0 growth rate r
the bifurcation diagram doubling into chaos, above the Lyapunov exponent turning positive
Logistic map x' = r x (1-x): the route to chaos (Feigenbaum delta = 4.6692)

         r    period    Lyapunov        regime
      2.50         1      -0.693      period-1
      3.20         2      -0.916      period-2
      3.50         4      -0.873      period-4
      3.55         8      -0.100      period-8
      3.83         3      -0.369      period-3
      3.90        64       0.492         chaos
      4.00         1       0.693         chaos

  Period doublings 2,4,8,... accumulate at r ~ 3.5699, then chaos -- broken by
  periodic windows (the period-3 near 3.83 is the most famous). The bifurcation
  spacings shrink by the universal Feigenbaum ratio 4.669, the same for any smooth
  unimodal map -- one of the deepest facts in nonlinear dynamics.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\logistic_map.svg

The Henon map: a strange attractor in two lines

Henon's 1976 map, x' = 1 - a x^2 + y, y' = b x, is the canonical low-dimensional strange attractor. At a=1.4, b=0.3 the iterates never settle and never repeat, tracing a fractal of nested arcs that -- zoomed in -- resolve into a Cantor set of ever-finer strands. It is dissipative (areas shrink by |b| each step) yet chaotic (largest Lyapunov exponent ~0.42): a blob is squeezed in area while stretched and folded, collapsing onto a fractal of dimension ~1.26. Chaos with structure at every scale.

The Henon strange attractor the full attractor (left) and a zoom (right) showing the fractal Cantor strands full attractor (yellow box = zoom region) zoom: single arcs resolve into many parallel strands
the Henon attractor and a zoom revealing the fractal Cantor strands
Henon map: x'=1-a x^2+y, y'=b x  (a=1.4, b=0.3)

  Area contraction per step |det J| = |b| = 0.30 (dissipative -> attractor)
  Fixed points:
    (+0.6314, +0.1894)
    (-1.1314, -0.3394)

  Largest Lyapunov exponent = 0.421 nat/iteration (positive -> chaos).
  Fractal (correlation) dimension ~ 1.26 -- between a curve and a filled region.

  The map stretches and folds the plane each step: a blob shrinks in area (by |b|)
  yet is pulled apart along the unstable direction, so it collapses onto a fractal
  of nested arcs. Zoom in and each arc is really a Cantor set of finer strands --
  the hallmark of a strange attractor, chaos with structure at every scale.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\henon.svg

The Lorenz attractor: the butterfly effect

Lorenz's three equations for toy convection never repeat and cannot be forecast for long. At sigma=10, beta=8/3, rho=28 the trajectory winds around two spiral lobes, jumping between them unpredictably -- the butterfly-shaped strange attractor. The flow is dissipative (phase volume shrinks at -(sigma+1+beta) so everything collapses onto the zero-volume fractal) yet chaotic: two starts a millionth apart diverge to opposite wings, the largest Lyapunov exponent ~0.9 meaning prediction error grows tenfold every ~2.5 time units. The reason weather is unforecastable beyond ~two weeks.

The Lorenz attractor the butterfly (x-z projection, top); two trajectories a millionth apart diverging (bottom) x-z projection (yellow = convection fixed points C+/-) 10^-5 10^-3 10^-1 10^1 time (separation of two starts 1e-6 apart, log scale) exponential growth = the butterfly effect
the butterfly attractor and two nearby trajectories diverging exponentially
Lorenz system (sigma=10, beta=8/3, rho=28): toy weather that never repeats

  Fixed points:
    (+0.000, +0.000, +0.000)
    (+8.485, +8.485, +27.000)
    (-8.485, -8.485, +27.000)

  Phase-space volume contraction rate div F = -13.667 (dissipative)
  Largest Lyapunov exponent = 0.917 (positive -> chaos).
  Prediction error grows e^(0.92 t): 10x every ~2.5 time units.

  Two starts a millionth apart, separation over time:
    t =   0.0  ->  separation 1.000e-06
    t =   8.0  ->  separation 2.049e-06
    t =  16.0  ->  separation 5.507e-05
    t =  24.0  ->  separation 2.650e-02
    t =  32.0  ->  separation 2.086e+01
    t =  40.0  ->  separation 1.653e+01

  A millionth of a degree in the initial state grows to opposite wings of the
  butterfly -- why weather is unforecastable beyond ~two weeks. Deterministic, yet
  unpredictable: the discovery that founded chaos theory.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\lorenz.svg

The double pendulum: chaos you can hang from a nail

Hang one pendulum off another and you get the simplest chaotic mechanical system: fully deterministic, yet two nearly identical releases flail into totally different motions within seconds. Its coupled equations of motion have no closed form and are integrated numerically (RK4 here). Two things stay clean: the total energy is conserved (a stringent check the integrator passes over a well-resolved window), and the sensitive dependence on initial conditions is real -- a hair's difference in the start grows exponentially, the butterfly effect on a tabletop.

The double pendulum the lower bob's chaotic trace (left); two near-identical starts diverging (right) pivot lower-bob trace (never repeats) t = 0.0 s t = 4.2 s t = 8.4 s two starts 0.01 rad apart (green/red) drift apart
the lower bob's never-repeating trace, and two near-identical pendulums drifting apart
Double pendulum: deterministic yet chaotic (two rods, gravity)

  Energy conservation (RK4, dt=1e-5, 0.05 s run):
    E(0) = -27.676215 J,  E(end) = -27.676315 J  (relative drift 3.6e-06)
    (RK4 is not symplectic, so energy slowly drifts over long chaotic runs; over a
     short well-resolved window it holds to a part in a million or better.)

  Two releases 1e-4 rad apart, angle-space separation over time:
    t =   0.0 s  ->  separation 1.000e-04
    t =   2.0 s  ->  separation 6.552e-04
    t =   4.0 s  ->  separation 9.042e-04
    t =   6.0 s  ->  separation 3.022e-03
    t =   8.0 s  ->  separation 3.079e-03
    t =  10.0 s  ->  separation 1.200e-03

  A ten-thousandth of a radian grows until the two pendulums are doing utterly
  different things -- the same sensitive dependence that makes weather chaotic, in a
  system you can hang from a nail. Energy is conserved; the motion is still unpredictable.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\double_pendulum.svg

The Mandelbrot set: infinite detail from z -> z^2 + c

Iterate z -> z^2 + c from z=0: the Mandelbrot set is the c for which the orbit stays bounded. Since |z|>2 guarantees escape, the escape time -- how many steps to cross that radius -- colours the famous images, painting the filaments just outside the set. One quadratic rule generates a boundary of endless detail: the cardioid body, the period-2 bulb at c=-1, ever-smaller bulbs around the edge, and tiny copies of the whole set at every magnification. Its real slice is the logistic map's period-doubling route in complex dress.

The Mandelbrot set coloured by escape time: black = bounded (in the set), bright = fast escape near the edge white = in the set; the boundary is an infinitely detailed fractal
the set coloured by escape time: black interior, bright fast-escape filaments
Mandelbrot set: c stays bounded under z -> z^2 + c (escape when |z| > 2)

  Escape time along the real axis (in-set = 100):
    c = -2.50  ->  escapes at 1
    c = -2.00  ->  IN SET
    c = -1.00  ->  IN SET
    c = -0.50  ->  IN SET
    c =  0.00  ->  IN SET
    c =  0.25  ->  IN SET
    c =  0.35  ->  escapes at 8
    c =  0.50  ->  escapes at 5
    c =  1.00  ->  escapes at 3

  The set fills 24% of its [-2,0.5]x[-1.25,1.25] bounding box.

  ASCII view (# = in set):
                                                            
                                                            
                                                            
                                           ####             
                                           ####             
                                    ###############         
                                    #################       
                                  #################### #    
                        ## #     ######################     
                      ######### #######################     
                    ##################################      
                    ##################################      
                      ######### #######################     
                        ## #     ######################     
                                  #################### #    
                                    #################       
                                    ###############         
                                           ####             
                                           ####             
                                                            
                                                            
                                                            

  One quadratic rule, iterated, produces a fractal of endless detail -- the
  cardioid body, the period-2 bulb at c=-1, ever-smaller bulbs around the edge, and
  a boundary that carries tiny copies of the whole set at every magnification.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\mandelbrot.svg

The Van der Pol oscillator: a self-sustaining rhythm

Unlike a pendulum that dies out, the Van der Pol oscillator x'' - mu(1-x^2)x' + x = 0 pumps itself: its damping is negative at small amplitude (energy in) and positive at large (energy out), so from almost any start it settles onto the same closed loop -- a limit cycle of amplitude ~2 that forgets its initial conditions. It is the model for self-regulated rhythms: heartbeats, firing neurons, bowed strings. Small mu gives near-sinusoidal oscillation; large mu gives relaxation oscillation -- slow charges broken by fast jumps, period ~1.6 mu.

The Van der Pol oscillator two starts spiral onto the same limit cycle (left); waveform vs nonlinearity mu (right) phase space x vs x' (red from outside, green from inside) mu = 0.3: near-sinusoidal mu = 5: relaxation (slow charge, fast jump) x(t): small mu smooth, large mu spiky
two starts spiralling onto the same limit cycle, and the waveform from smooth to spiky
Van der Pol: x'' - mu(1-x^2)x' + x = 0 -- damping that changes sign with amplitude

      mu   amplitude      period           character
     0.1        1.41        6.29     near-sinusoidal
     0.5        2.00        6.38        transitional
     1.0        2.01        6.66        transitional
     3.0        2.02        8.86          relaxation
     5.0        2.02       11.61          relaxation

  The amplitude settles near 2 for every mu -- the limit cycle forgets how it
  started, whether launched tiny (grows in) or huge (decays in). That self-regulated
  rhythm is the model for heartbeats, firing neurons, and bowed strings; at large mu
  it becomes relaxation oscillation, slow charges broken by fast jumps (period ~1.6 mu).

  wrote C:\Users\acwic\symplectic-nbody\examples\output\van_der_pol.svg

The Duffing oscillator: a spring that bends the rules

Add a cubic term to a spring -- x'' + delta x' + alpha x + beta x^3 = gamma cos(omega t) -- and it stops behaving linearly. Its resonance peak bends over with amplitude (the backbone sqrt(alpha + 3/4 beta A^2)), so the response is multi-valued and jumps between branches as you sweep the drive frequency (hysteresis). With alpha<0, beta>0 the potential is a double well -- a buckled beam or a bistable switch -- and a damped mass rolls into one of two stable states. Driven hard, the forced Duffing is one of the classic routes to chaos.

The Duffing oscillator the double-well potential with a mass rolling into a well (left); the resonance backbone (right) V(x) double well; mass settles in one well hardening -> <- softening 0.5 1.0 1.5 2.0 resonance frequency (backbone leans with amplitude)
the double-well potential with a mass settling into a well, and the leaning resonance backbone
Duffing: x'' + delta x' + alpha x + beta x^3 = gamma cos(omega t)

    alpha   beta        regime            minima
      1.0    1.0     hardening             +0.00
      1.0   -0.5     softening             +0.00
     -1.0    1.0   double-well      -1.00, +1.00
      1.0    0.0        linear             +0.00

  Backbone (amplitude-dependent resonance frequency, hardening spring):
    amplitude 0.0  ->  omega_res = 1.000
    amplitude 0.5  ->  omega_res = 1.090
    amplitude 1.0  ->  omega_res = 1.323
    amplitude 1.5  ->  omega_res = 1.639

  The cubic term makes the spring's resonance bend: its peak frequency shifts with
  amplitude, so the response is multi-valued and JUMPS between branches as you sweep
  the drive (hysteresis). With alpha<0 the potential is a double well -- a buckled
  beam -- and a damped mass rolls into one side; driven hard, the Duffing goes chaotic.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\duffing.svg

The Kuramoto model: oscillators falling into sync

A population of oscillators, each with its own natural frequency, pulls itself into step through coupling: dtheta_i/dt = omega_i + (K/N) sum sin(theta_j - theta_i). The synchrony is measured by the order parameter r (0 = phases scattered, 1 = all in phase), and there is a sharp phase transition -- below a critical coupling K_c the oscillators drift independently (r~0), above it a synchronized cluster spontaneously forms and r climbs toward 1. It is the canonical model of emergent collective order: fireflies flashing in unison, pacemaker cells, applause locking into rhythm, generators on a grid.

The Kuramoto synchronization transition order parameter r vs coupling (left); phase circles, scattered vs clustered (right) 0.0 0.5 1.0 ~K_c 0 2 4 6 8 coupling K (order parameter r) weak K: scattered (r = 0.20) strong K: clustered (r = 1.00)
the synchronization transition r(K), and phase circles scattered vs clustered
Kuramoto: dtheta_i/dt = omega_i + (K/N) sum sin(theta_j - theta_i)

  Steady-state synchrony r vs coupling K (N=50, freq spread +/-1):

       K   order r             state
     0.0     0.100        incoherent
     0.5     0.125        incoherent
     1.0     0.198        incoherent
     2.0     0.953      synchronized
     4.0     0.989      synchronized
     8.0     0.997      synchronized

  Below a critical coupling the oscillators drift independently (r ~ 0); above it a
  synchronized cluster spontaneously forms and r climbs toward 1 -- a phase
  transition to collective order. Same mechanism: fireflies flashing in unison,
  pacemaker cells, applause locking into rhythm, and generators on a power grid.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\kuramoto.svg

The Abelian sandpile: self-organized criticality

Drop grains one at a time; wherever a pile reaches 4 it topples, one grain to each neighbour, and a single grain can set off an avalanche of any size. With no parameter tuning the pile drives itself to a critical state where avalanche sizes follow a power law -- mostly tiny, rarely system-spanning. The toppling is Abelian (the final state is independent of relaxation order). It is the founding model of self-organized criticality, a candidate for the scale-free statistics of earthquakes, forest fires, and neuronal avalanches.

The Abelian sandpile a relaxed central stack (left); the heavy-tailed avalanche-size distribution (right) relaxed stack (colour = grains 1-3): self-similar avalanche size (log bins) -> count (log) straight-ish decline on log-log = power-law tail
a relaxed self-similar sandpile pattern, and the heavy-tailed avalanche-size distribution
Abelian sandpile: pile topples at 4 grains, sending one to each neighbour

  Relaxing a central stack (no parameter tuning -> self-organized pattern):
      100 grains  ->     225 topples, 100 left on grid
      500 grains  ->    4702 topples, 500 left on grid
     1000 grains  ->   18182 topples, 872 left on grid

  Avalanche statistics from 8000 random drops (20x20):
    avalanches with topples: 1613 of 4000
    mean size 16.8, max size 528
    (mostly small, with rare system-spanning cascades -- a power-law tail)

  With no tuning, the pile drives itself to a critical state where one grain can
  trigger an avalanche of any size, scale-free like earthquakes, forest fires, and
  neuronal cascades. The founding model of self-organized criticality.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\sandpile.svg

Elementary cellular automata: complexity from 8 bits

A row of 0/1 cells, each updated from itself and its two neighbours -- 2^8 = 256 possible rules, and from that trivial definition comes Wolfram's whole zoo: rule 0 dies to uniform, rule 90 draws the Sierpinski fractal by XOR, rule 30 is chaotic enough to have served as Mathematica's random-number generator, and rule 110 is Turing-complete -- a universal computer from an eight-bit lookup table. That computation needs almost no ingredients is one of the most surprising results in the field.

Elementary cellular automata space-time diagrams (time downward) from a single seed: fractal, chaotic, complex Rule 90: Sierpinski Rule 30: chaos Rule 110: complex
space-time diagrams of rules 90 (fractal), 30 (chaos), and 110 (complex)
Elementary cellular automata: 256 rules, each an 8-bit lookup on 3 cells

    rule       table                      class / note
       0    00000000                1: dies to uniform
      90    01011010       2: Sierpinski fractal (XOR)
      30    00011110       3: chaotic (used as an RNG)
     110    01101110       4: complex, Turing-complete
     184    10111000             2: traffic-flow model

  Rule 90 from a single seed (the Sierpinski triangle):
                                  #                              
                                 # #                             
                                #   #                            
                               # # # #                           
                              #       #                          
                             # #     # #                         
                            #   #   #   #                        
                           # # # # # # # #                       
                          #               #                      
                         # #             # #                     
                        #   #           #   #                    
                       # # # #         # # # #                   
                      #       #       #       #                  
                     # #     # #     # #     # #                 
                    #   #   #   #   #   #   #   #                
                   # # # # # # # # # # # # # # # #               
                  #                               #              
                 # #                             # #             
                #   #                           #   #            
               # # # #                         # # # #           
              #       #                       #       #          

  From the simplest imaginable rule -- one output bit per 3-cell neighbourhood --
  come fractals (90), chaos indistinguishable from random (30, once Mathematica's
  RNG), and universal computation (110). Complexity needs almost no ingredients.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\cellular_automaton.svg

Conway's Game of Life: a universe from four rules

On a 2D grid, a live cell survives with 2-3 live neighbours and a dead cell is born with exactly 3 (B3/S23) -- and that is the whole rule. From it come still lifes (the block, unchanging), oscillators (the blinker, period 2), and spaceships (the glider, translating one cell diagonally every four generations). Because gliders can be fired from guns and collided to build logic gates, Life is Turing-complete: a computer can be built inside it. The canonical proof that simple local rules generate open-ended complexity.

Conway's Game of Life the glider's four phases (top), a mixed board of a still life, oscillator, and spaceship (bottom) t=0 t=1 t=2 t=3 t=4 glider: same shape, shifted one cell down-right, every 4 generations a mixed population: block (still), blinker (oscillator), glider (spaceship)
the glider's four phases and a mixed board of still life, oscillator, and spaceship
Conway's Game of Life (B3/S23): live cell survives on 2-3, dead born on 3

  Classic patterns:
    block   : still life (population 4, unchanged: True)
    blinker : period-2 oscillator (returns in 2: True)
    glider  : spaceship, moves (1,1) every 4 gens

  A glider crossing an 11x11 grid:
    generation 0:
      .#.........
      ..#........
      ###........
      ...........
      ...........
      ...........
      ...........
      ...........
      ...........
      ...........
      ...........

    generation 4:
      ...........
      ..#........
      ...#.......
      .###.......
      ...........
      ...........
      ...........
      ...........
      ...........
      ...........
      ...........

    generation 8:
      ...........
      ...........
      ...#.......
      ....#......
      ..###......
      ...........
      ...........
      ...........
      ...........
      ...........
      ...........

    generation 12:
      ...........
      ...........
      ...........
      ....#......
      .....#.....
      ...###.....
      ...........
      ...........
      ...........
      ...........
      ...........

  From B3/S23 come still lifes, blinking oscillators, gliders that fly, and -- via
  glider guns and collisions building logic gates -- a Turing-complete computer.
  Simple local rules, open-ended complexity.
  wrote C:\Users\acwic\symplectic-nbody\examples\output\game_of_life.svg

Reaction-diffusion: Turing's spots and stripes

Turing showed in 1952 that patterns can form from chemistry alone: a slowly diffusing self-promoting activator and a fast-diffusing inhibitor make a uniform mixture unstable, and it settles into standing spots or stripes with no template. The Gray-Scott model du/dt = Du lap(u) - u v^2 + F(1-u), dv/dt = Dv lap(v) + u v^2 - (F+k)v produces, depending on the feed F and kill k rates, spots, stripes, mazes, self-replicating blobs, or waves -- a working model of morphogenesis behind leopard spots and seashell ridges.

Gray-Scott reaction-diffusion (Turing patterns) the autocatalyst field growing from a central seed into standing structure seed t=600 t=1800 t=4000 a small seed self-organizes into spots -- no template, pure reaction + diffusion
the autocatalyst field growing from a seed into standing Turing spots
Gray-Scott: du/dt = Du lap(u) - u v^2 + F(1-u), dv/dt = Dv lap(v) + u v^2 - (F+k)v

  Seeding autocatalyst in a bare substrate and letting a pattern grow (F=0.035, k=0.06):

      step     total v    contrast
         0        16.0       0.250
       200        86.5       0.373
       600       192.3       0.354
      1500       365.6       0.349

  ASCII snapshot of the autocatalyst field (denser = more v):
         ....                              ....     
       ..:::...........          ...........:::..   
      .:-==---::-------:.      .:-------::---==-:.  
     .:=***+++==++++*++=:.    .:=++*++++==+++***=:. 
     .-*###*********###+=:.  .:=+###*********###*-. 
    .:=*###*******#####*+-....-+*#####*******###*=:.
    .:=*##**+++++****###+-:..:-+###****+++++**##*=:.
    .:-+***++=====++**##*=::::=*##**++=====++***+-:.
    ..-+**++-:::::-=+****+-::-+****+=-:::::-++**+-..
     .-+**+=:......:-+***+=--=+***+-:......:=+**+-. 
     .:=**+=:.     .:-=+++====+++=-:.     .:=+**=:. 
     .:=**+=:.      .:-=++++++++=-:.      .:=+**=:. 
     .-+**+=:.       .:-=++++++=-:.       .:=+**+-. 
     .-+***=:.        .:=++**++=:.        .:=***+-. 
     .-+*#*+-.        .:=+****+=:.        .-+*#*+-. 
     .-+*#*+=:.        :=**##**=:        .:=+*#*+-. 
     .-*##**+-:.       :=*####*=:       .:-+**##*-. 
     .-+###**+-:.      .-+####+-.      .:-+**###+-. 
     .-+####**=-:..   ..:=*##*=:..   ..:-=**####+-. 
      :=+*##**+=-::::...:-=++=-:...::::-=+**##*+=:  
      .:=++***++=====-::..::::..::-=====++***++=:.  
       .:--=+++++++**+=-........-=+**+++++++=--:.   
        ..::-==+++**##*=:.    .:=*##**+++==-::..    
         ..::-=++**####+:.    .:+####**++=-::..     
         ..::-=++**####+:.    .:+####**++=-::..     
        ..::-==+++**##*=:.    .:=*##**+++==-::..    
       .:--=+++++++**+=-........-=+**+++++++=--:.   
      .:=++***++=====-::..::::..::-=====++***++=:.  
      :=+*##**+=-::::...:-=++=-:...::::-=+**##*+=:  
     .-+####**=-:..   ..:=*##*=:..   ..:-=**####+-. 
     .-+###**+-:.      .-+####+-.      .:-+**###+-. 
     .-*##**+-:.       :=*####*=:       .:-+**##*-. 
     .-+*#*+=:.        :=**##**=:        .:=+*#*+-. 
     .-+*#*+-.        .:=+****+=:.        .-+*#*+-. 
     .-+***=:.        .:=++**++=:.        .:=***+-. 
     .-+**+=:.       .:-=++++++=-:.       .:=+**+-. 
     .:=**+=:.      .:-=++++++++=-:.      .:=+**=:. 
     .:=**+=:.     .:-=+++====+++=-:.     .:=+**=:. 
     .-+**+=:......:-+***+=--=+***+-:......:=+**+-. 
    ..-+**++-:::::-=+****+-::-+****+=-:::::-++**+-..
    .:-+***++=====++**##*=::::=*##**++=====++***+-:.
    .:=*##**+++++****###+-:..:-+###****+++++**##*=:.
    .:=*###*******#####*+-....-+*#####*******###*=:.
     .-*###*********###+=:.  .:=+###*********###*-. 
     .:=***+++==++++*++=:.    .:=++*++++==+++***=:. 
      .:-==---::-------:.      .:-------::---==-:.  
       ..:::...........          ...........:::..   
         ....                              ....     

  Two chemicals -- a slow self-promoting activator and a fast inhibitor -- turn a
  uniform mix unstable, and it settles into standing spots and stripes with no
  template. Turing's model of morphogenesis: leopard spots, seashell ridges, and more.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\reaction_diffusion.svg

Boids: flocking from three local rules

Reynolds showed that a flock needs no leader -- each boid just follows its neighbours by three rules: separation (avoid crowding), alignment (match heading), and cohesion (stay together). Sum those urges into an acceleration and a swarm of identical agents produces lifelike murmurations from a random scatter: the alignment (polarization) climbs toward 1 while separation keeps them from colliding, all bottom-up with no flock-level rule. The model behind starling murmurations, sardine bait balls, and the crowds in films and games.

Boids: emergent flocking random scatter (left) organizes into aligned motion (middle); polarization over time (right) t=0: random headings t=200: flocking 0.0 0.5 1.0 time -> polarization
a random scatter organizing into aligned flocking, with polarization rising over time
Boids: separation + alignment + cohesion -> emergent flocking, no leader

      step    polarization    mean spacing
         0           0.146            7.90
        30           0.282            9.04
        80           0.337            8.28
       200           0.727            8.54

  From a random scatter (polarization near 0) the flock aligns into coherent motion
  (polarization toward 1) while separation keeps the birds from colliding -- the
  murmuration of starlings, the bait ball of sardines, all bottom-up from local rules.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\boids.svg

Diffusion-limited aggregation: a fractal from random walkers

Release a particle far from a seed and let it random-walk until it touches the cluster, where it sticks; repeat. What grows is not a blob but a feathery, self-similar fractal, because a wanderer almost always brushes an outer tip long before it can diffuse into an interior fjord -- the tips screen the inside and grow faster still. The cluster's mass scales as N(r) ~ r^D with D ~ 1.71 in the plane, not 2: the branches leave most of the plane empty. The same instability draws mineral dendrites, electrodeposits, viscous fingers in a Hele-Shaw cell, lightning, and soot.

Diffusion-limited aggregation: a fractal from random walkers the cluster (left) and its mass-radius scaling N(r) ~ r^D (right); D = 1.57, not 2 slope 2 (disc) log r log N D = 1.57
the branching cluster and the log-log mass-radius scaling whose slope is the fractal dimension
Diffusion-limited aggregation: random walkers stick to a growing seed

   particles    R_gyration   fractal D
         200         10.81       1.530
         600         21.94       1.587
        1200         31.81       1.609

  Mass grows as N(r) ~ r^D with D ~ 1.7 in the plane, not 2: the branches
  leave most of the plane empty. Outer tips screen the interior fjords -- a
  wanderer brushes a tip long before it diffuses inside -- so the tips grow
  faster and the cluster stays sparse and self-similar. Mineral dendrites,
  electrodeposits, viscous fingers, lightning, and soot all grow this way.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\dla.svg

Benford's law: why leading digits are not uniform

Count the leading digit of river areas, physical constants, stock prices, or file sizes and you do not get each of 1-9 a ninth of the time: 1 leads about 30% and 9 barely 4.6%, following P(d) = log10(1 + 1/d). The reason is scale invariance -- a quantity spanning many orders of magnitude is uniform in its logarithm, and a uniform log-mantissa maps to this logarithmic digit law, the only distribution invariant under a change of units. Multiplicative data (Fibonacci numbers, powers, factorials, populations) obey it, and departures flag fabricated accounting and election returns, which is why forensic auditors test for it.

Benford's law: leading digits follow log10(1 + 1/d) 0.0 0.1 0.2 0.3 1 2 3 4 5 6 7 8 9 leading digit Benford P(d) Fibonacci (fits) uniform (fails)
the Benford curve with the Fibonacci leading digits hugging it while a uniform control does not
Benford's law: P(d) = log10(1 + 1/d) -- 1 leads ~30% of the time, 9 only ~4.6%

   digit   Benford   Fibonacci   uniform
       1     0.301       0.301     0.177
       2     0.176       0.177     0.159
       3     0.125       0.125     0.128
       4     0.097       0.096     0.087
       5     0.079       0.080     0.088
       6     0.067       0.067     0.089
       7     0.058       0.056     0.090
       8     0.051       0.053     0.091
       9     0.046       0.045     0.092

  Fibonacci:  chi2 =   0.17   TV dist = 0.0037   -> follows Benford
  uniform:    chi2 =  168.7   TV dist = 0.1509   -> does NOT

  Multiplicative data (Fibonacci, powers, factorials, populations) spans many
  orders of magnitude, so its mantissa is uniform in log space -- and that maps
  to the logarithmic digit law. Forensic auditors flag fabricated ledgers and
  election tallies by their departure from it.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\benford.svg

The coupon collector: how long to collect the whole set

Each cereal box holds one of n equally likely coupons; how many boxes to collect all n? Once you hold k of them a fresh box is new with probability (n-k)/n, so the wait for the next new one is geometric with mean n/(n-k), and summing gives E[T] = n H_n ~ n ln n. The last few coupons dominate: collecting the final one alone averages n boxes. The number needed is sharply concentrated, with a tail bound P(T > n ln n + c n) <= e^{-c}. The same law sets cache warmup, random test-coverage of n branches, and how many samples it takes to see every category at least once -- here the analytic E[T], variance, and completion CDF are checked against a seeded Monte-Carlo run.

The coupon collector: the last coupons dominate the wait n = 50 coupons; expected draws to collect k of them (left), completion probability (right) last coupon costs ~n coupons collected k draws E[T]=225 0.0 0.5 1.0 draws t -> P(complete)
the collection-progress curve (the last coupons cost the most) and the completion-probability CDF
Coupon collector: expected draws to collect all n coupons is E[T] = n * H_n

      n      E[T]   n ln n+..   std dev  sim mean
      6     14.70       14.71      6.24     14.59
     20     71.95       71.96     23.80     71.58
     50    224.96      224.96     61.95    224.84
    100    518.74      518.74    125.82    517.80

  For n = 50: the first half of the coupons costs only 34 draws, but the last one alone
  averages another 50 -- the tail dominates. Getting from 49 to 50 takes as long
  as getting the first 35. That n ln n law sets cache warmup, random test coverage,
  and how many samples it takes to see every category at least once.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\coupon_collector.svg

The secretary problem: optimal stopping and the 1/e rule

Interview n candidates one at a time in random order, accept or reject on the spot, and you only care about landing the single best. The optimal policy is a cutoff: reject the first r-1 (a look phase), then take the first later candidate who beats all seen so far. The win probability P(r) = (r-1)/n sum_{i=r}^{n} 1/(i-1) is maximized near r ~ n/e, and as n grows both the optimal look-fraction and the win probability tend to 1/e ~ 0.368: look at 37% of the field, then leap at the next record, and you land the very best about 37% of the time no matter how large n is. The same optimal-stopping law governs flat-hunting, parking, and online auctions -- here the exact probabilities are checked against a seeded Monte-Carlo run.

The secretary problem: the 1/e optimal-stopping rule win probability vs cutoff for n=100 (left); optimum converging to 1/e (right) look 1/e P = 1/e 0.0 0.5 1.0 look fraction (r-1)/n 1/e = 0.368 n (log scale) look fraction win probability
the win probability peaking near the 1/e look-fraction, and the optimum converging to 1/e as n grows
Secretary problem: reject the first ~37%, then take the next record. Win ~37%.

       n  r* (cutoff)  look frac   P(win)      sim
      10            4     0.3000   0.3987   0.4045
      50           19     0.3600   0.3743   0.3712
     100           38     0.3700   0.3710   0.3768
     500          185     0.3680   0.3685   0.3550
    1000          369     0.3680   0.3682   0.3643

  Both the optimal look-fraction and the win probability tend to 1/e = 0.3679
  as n grows: look at (and reject) 37% of the candidates, then leap at the next one
  better than all of them, and you land the very best about 37% of the time -- no
  matter how large n is. The same rule governs flat-hunting, parking, and auctions.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\secretary.svg

The birthday problem: coincidences are more common than they feel

How many people before two share a birthday with better-than-even odds? Only 23, not hundreds, because k people make k(k-1)/2 pairs and it is the pair count, growing like k^2, that drives collisions. The probability all k are distinct is prod (365-i)/365, so a collision passes 1/2 at 23 and 99.9% by 70. In general a collision becomes likely once k ~ 1.177 sqrt(d), a square-root law that sizes hash tables and UUID spaces and sets the birthday attack: a b-bit hash collides after ~2^(b/2) tries, not 2^b, which is why collision resistance needs twice the bits of preimage resistance. Exact and Poisson-approximate probabilities are checked against a seeded Monte-Carlo run.

The birthday problem: 23 people, better-than-even odds collision probability vs group size for 365 days (left); the 50% crossover ~ sqrt(days) (right) k=23, 50% 0.0 0.5 1.0 0 40 80 group size k exact Poisson approx 365 -> 23 number of days d k* (50%) 1.177 sqrt(d)
the collision-probability curve crossing 50% at 23 people, and the crossover growing like sqrt(days)
Birthday problem: with 365 days, just 23 people make a shared birthday likelier than not

   people k     exact   Poisson      sim
         10    0.1169    0.1160   0.1222
         23    0.5073    0.5000   0.5147
         40    0.8912    0.8820   0.8960
         57    0.9901    0.9874   0.9880
         70    0.9992    0.9987   0.9992

  50% at k = 23,  99% at k = 57,  99.9% at k = 70.
  The crossover grows only like ~1.177 sqrt(days) (~22.5); the first
  collision arrives after ~24 draws. It is the number of
  PAIRS, ~k^2/2, that drives collisions -- the same square-root law makes a b-bit hash
  collide after ~2^(b/2) tries (the birthday attack), not 2^b.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\birthday.svg

Gambler's ruin: the walk that ends at a wall

Start with i dollars, bet $1 a round with win probability p, and stop at broke (0) or a target N -- a random walk with two absorbing walls. In a fair game the ruin chance is exactly 1 - i/N (your stake as a fraction of the table) and the game lasts i(N-i) rounds. But shift the odds a hair to p=0.49 and, starting at the halfway mark, the ruin chance leaps from 50% to 88%; against an infinitely rich house any p <= 1/2 is ruin with certainty. That asymmetry is why the house always wins, and the same absorbing-walk math models allele fixation in a finite population and sequential hypothesis tests. Exact ruin probabilities and durations are checked against a seeded Monte-Carlo run.

Gambler's ruin: a tiny edge decides everything ruin probability vs starting stake for N=100 (left); sample $1 walks to a wall (right) 0.0 0.5 1.0 0 50 100 starting stake i p=0.60 (favorable) p=0.50 (fair) p=0.45 p=0.40 (unfavorable) ruin (0) target N rounds -> (start i=50, p=0.49)
ruin probability vs starting stake for several win rates, and sample walks absorbed at a wall
Gambler's ruin: bet $1 a round from i dollars, absorbed at 0 or N=100

       p     i      ruin  sim ruin    duration   sim dur
    0.50    50    0.5000    0.5098      2500.0    2464.0
    0.50    25    0.7500    0.7572      1875.0    1827.9
    0.49    50    0.8808    0.8818      1904.1    1867.9
    0.60    50    0.0000    0.0000       250.0     249.0

  In a fair game the ruin chance is exactly 1 - i/N: your stake as a fraction of the
  table. But shift the odds a hair to p=0.49 and starting at the halfway mark the ruin
  chance leaps from 50% to 88% -- and against an infinitely rich house any p <= 1/2 is
  ruin with certainty. That asymmetry is why the house always wins.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\gamblers_ruin.svg

Parrondo's paradox: two losing games that together win

Two gambling games, each a sure loser played alone, can be alternated -- or chosen at random each round -- to make your capital drift UP. Game A is a slightly-losing flat coin; game B flips a terrible coin whenever your capital is a multiple of 3 and a good one otherwise, and loses because the walk gets stuck in the bad state too often. Mixing in game A reshuffles that state occupancy so the good coin comes up more, and the combined drift -- the stationary average of (2p-1) over the capital-mod-3 Markov chain -- turns positive. The same flashing-ratchet mechanism drives molecular motors, pumping directed motion from noise. Here the exact stationary drift is checked against a seeded Monte-Carlo trajectory.

Parrondo's paradox: losing + losing = winning capital over time for A, B, and the mix (left); combined drift vs mixing fraction (right) rounds (100k) -> capital mix (wins) A (loses) B (loses) break even pure B pure A best mix fraction playing game A
capital rising for the mixture while both games fall, and the winning window in the mixing fraction
Parrondo's paradox: game A loses, game B loses, but A-and-B-mixed WINS

        game   drift/round   sim drift
     A alone      -0.01000    -0.00681
     B alone      -0.00870    -0.00763
   50/50 mix       0.01570     0.01846

  Game A is a slightly-losing flat coin. Game B flips a terrible coin whenever your
  capital is a multiple of 3 and a good one otherwise -- and loses because the walk
  gets stuck visiting the bad state too often. Mixing in game A reshuffles that
  occupancy so the good coin comes up more, and the combined drift turns positive.
  The same flashing-ratchet trick drives molecular motors: order pumped from noise.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\parrondo.svg

The Galton board: coin flips converge to a bell curve

Galton's bean machine is a board of n staggered peg rows; a bead bounces left or right with probability 1/2 at each row and lands in one of n+1 slots. Its slot is just the number of right-bounces in n coin flips, so the slot occupancy is the binomial C(n,k) p^k (1-p)^(n-k) -- and because it is a sum of n independent steps, the central limit theorem makes the histogram converge to a Gaussian of mean np and variance np(1-p) as n grows. It is the CLT made physical: no bead is steered, yet thousands pile into a smooth bell curve, and biasing the pegs slides the peak to np. The binomial-to-Gaussian distance shrinks like 1/sqrt(n), verified here against exact values and a seeded Monte-Carlo bead drop.

The Galton board: coin flips converge to a bell curve simulated slot histogram with the CLT Gaussian (left); binomial-to-Gaussian distance ~ 1/sqrt(rows) (right) 0 10 20 slot (20 rows) simulated beads CLT Gaussian 1/sqrt(n) 4 8 16 32 64 128 256 rows n (log) TV dist
the simulated slot histogram matching the CLT Gaussian, and the binomial-to-Gaussian distance falling like 1/sqrt(rows)
Galton board: 12 peg rows, each bead L/R by a coin flip -> binomial -> Gaussian

   slot   simulated   binomial
      0      0.0002     0.0002  
      1      0.0033     0.0029  
      2      0.0161     0.0161  ##
      3      0.0547     0.0537  #######
      4      0.1188     0.1208  ##############
      5      0.1952     0.1934  #######################
      6      0.2264     0.2256  ###########################
      7      0.1940     0.1934  #######################
      8      0.1189     0.1208  ##############
      9      0.0541     0.0537  ######
     10      0.0149     0.0161  ##
     11      0.0033     0.0029  
     12      0.0002     0.0002  

  mean = np = 6.0, variance = np(1-p) = 3.00
  binomial -> Gaussian distance shrinks like 1/sqrt(rows):
    rows =    8:  TV distance = 0.0139
    rows =   16:  TV distance = 0.0077
    rows =   32:  TV distance = 0.0037
    rows =   64:  TV distance = 0.0018
    rows =  128:  TV distance = 0.0009

  No bead is steered, yet thousands pile into a smooth bell curve -- the central
  limit theorem made physical. Bias the pegs and the whole pile slides to np.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\galton.svg

The Monty Hall problem: why switching doors wins

A car hides behind one of three doors; you pick one, and the host -- who knows where the car is -- opens a different door revealing a goat and offers a switch. Switching wins 2/3 of the time, staying only 1/3, because your first pick is right just 1/3 of the time and the host's forced reveal concentrates the whole remaining 2/3 onto the other closed door. The paradox lives in the host's knowledge: if he opened a door blindly and it happened to show a goat, switching would only be 1/2. Generalized to N doors with the host opening all but one other, switching wins (N-1)/N -- 99% at 100 doors. The exact stay and switch probabilities are checked against a seeded Monte-Carlo play.

The Monty Hall problem: always switch classic 3-door win rates (left); switching wins (N-1)/N as the game scales (right) 0.00 0.33 0.67 1.00 0.333 stay 0.667 switch 0.500 switch (blind host) 0.0 0.5 1.0 3 10 100 doors N (log) switch (N-1)/N stay 1/N
the classic 2/3-vs-1/3 win rates (and the 1/2 blind-host variant), and switching approaching certainty as doors grow
Monty Hall: the knowing host's reveal shifts the odds -- switch and win 2/3

                strategy   theory      sim
          stay (classic)    0.333    0.333
        switch (classic)    0.667    0.667
    switch (random host)    0.500    0.498

  Scaling up: N doors, the host opens all but one other goat door, you switch:
     doors N   stay 1/N    switch
           3     0.3333    0.6667
          10     0.1000    0.9000
          50     0.0200    0.9800
         100     0.0100    0.9900

  Your first pick is right only 1/N; the host's knowing reveal piles the whole
  remaining (N-1)/N onto the last closed door. With 100 doors, switching wins 99%.
  (If the host opened doors blindly, the effect vanishes -- it is his knowledge.)

  wrote C:\Users\acwic\symplectic-nbody\examples\output\monty_hall.svg

Bayes and the base-rate fallacy: a positive test can still mean healthy

A disease affects 1 in 1000; a test is 99% sensitive and 99% specific; you test positive. The chance you are actually sick is not 99% but about 9%. Bayes' theorem combines the prior with the test's likelihoods, and the rare base rate makes false positives swamp the true ones: among 100,000 people, 99 true positives are buried under 999 false ones. A positive becomes more-likely-than-not only once the prevalence passes (1-spec)/(sens+1-spec) = 1%, and two independent positives push the posterior above 90%. This is the base-rate fallacy behind medical screening, spam filters, and security profiling -- here the posterior, likelihood ratios, and retest odds are checked against a seeded Monte-Carlo cohort.

Bayes: a positive test on a rare disease is usually a false alarm positive predictive value vs prevalence (left); a 100,000-person cohort (right) 50-50 at 1% 1-in-1000 -> 9% 0.0 0.5 1.0 0.0001 0.001 0.01 0.1 1 prevalence (log) false positives: 999 true positives: 99 the 1098 positives only 9% real
the positive predictive value rising with prevalence (50-50 only at 1%), and a cohort where 91% of positives are false
Bayes & the base-rate fallacy: a 99%-accurate test, a 1-in-1000 disease

  prevalence           = 0.100%
  sensitivity          = 99%   specificity = 99%
  P(sick | positive)   = 9.0%   <- not 99%!   (Monte-Carlo: 9.1%)
  P(sick | 2 positives)= 90.7%
  break-even prevalence= 1.00%  (a positive is 50-50 here)

  In 100,000 people: 100 are sick and 99 test positive, but 99,900 are healthy and
  1% of them -- 999 -- test positive too. So 99 of 1098 positives are real: 9%. The
  rare disease lets false positives swamp true ones however good the test sounds --
  the base-rate fallacy behind medical screening, spam filters, and profiling.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\bayes_test.svg

Shannon entropy and Huffman coding: the limit of lossless compression

How few bits, on average, to record one symbol from a source? Shannon's entropy H = -sum p log2 p is the answer, and no lossless code can beat it: the average codeword length L obeys L >= H, with a code always existing at L < H+1. Entropy is maximal (log2 n) for a uniform source and zero when one symbol is certain -- it measures surprise. Huffman's algorithm merges the two least-likely symbols repeatedly to build the optimal prefix code, provably landing in the [H, H+1) band and obeying the Kraft inequality sum 2^-len <= 1. This module computes entropy, builds the Huffman code, verifies the Shannon bound and a lossless encode/decode round-trip -- the Huffman stage inside ZIP, JPEG, and MP3.

Shannon entropy & Huffman coding binary-source entropy, maximal at a fair coin (left); Huffman codeword lengths vs the entropy limit (right) fair coin: 1 bit 0.0 0.5 1.0 0.0 0.5 1.0 P(symbol = 1) H (bits) e 10 t 00 a 111 o 110 n 010 s 0111 z 0110 H = 2.63 L = 2.65 symbol (by frequency) -> codeword length
the binary-entropy curve peaking at a fair coin, and Huffman codeword lengths hugging the entropy limit
Shannon entropy & Huffman coding: compressing a skewed source

   symbol    prob    codeword  bits
        e    0.27          10     2
        t    0.20          00     2
        a    0.16         111     3
        o    0.13         110     3
        n    0.12         010     3
        s    0.07        0111     4
        z    0.05        0110     4

  entropy  H = 2.6318 bits/symbol
  Huffman  L = 2.6500 bits/symbol   (fixed-length would need 3)
  bound: H <= L < H+1  ->  2.6318 <= 2.6500 < 3.6318   OK
  efficiency H/L = 99.3%,  Kraft sum = 1.000

  'tenants' -> 0010010111010000111  (19 bits vs 56 raw), decodes back: ok
  No lossless code can beat the entropy; Huffman lands within one bit of it and is
  optimal among prefix codes -- the reason ZIP, JPEG, and MP3 all carry a Huffman stage.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\shannon.svg

The Kelly criterion: how much to bet to grow fastest

Given an edge -- a bet paying b-to-1 that wins with probability p above break-even -- how much of your bankroll should you stake? Kelly's answer maximizes long-run compound growth and is a fixed fraction f* = p - (1-p)/b, the edge over the odds. The growth rate g(f) = p ln(1+bf) + (1-p) ln(1-f) is a concave curve peaking at f*: betting less is safe but slow, betting past 2f* drives the growth rate negative and you go broke despite a winning edge. Half-Kelly keeps about 3/4 of the growth at far less volatility, which is why traders bet fractional Kelly. The same log-optimal rule (which Kelly derived from Shannon's channel capacity) sizes positions in quantitative finance -- here it is checked against a seeded Monte-Carlo of the compounding bankroll.

The Kelly criterion: bet f* to grow fastest growth rate vs bet fraction, peaking at f* (left); compounding bankrolls (right) f* = 0.20 break-even growth g(f) bet fraction f 0.10 (under) 0.20 (Kelly) 0.45 (over) bets -> log bankroll
the growth-rate curve peaking at f* and going negative past break-even, with sample bankrolls under-, Kelly-, and over-betting
Kelly criterion: even-money bet, win probability p=0.6, edge 20%

  optimal fraction f*     = 0.200  (bet 20% of bankroll each time)
  growth rate g(f*)       = 0.0201 per bet
  Monte-Carlo growth      = 0.0204
  doubling time           = 34.4 bets
  break-even fraction     = 0.389  (overbetting past this loses)

    fraction    growth   vs f*
        0.05    0.0088    0.43
        0.10    0.0150    0.75
        0.20    0.0201    1.00  <- Kelly
        0.30    0.0147    0.73
        0.40   -0.0024   -0.12
        0.50   -0.0340   -1.69

  Bet the Kelly fraction and the bankroll compounds fastest in the long run. Bet
  more and volatility eats the growth -- past 2f* the growth rate goes negative and
  you go broke despite a winning edge. Half-Kelly keeps ~3/4 the growth at far less
  risk, which is why real traders and gamblers bet fractional Kelly.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\kelly.svg

Hamming codes: correcting a bit error from the syndrome

Bits flip on a noisy channel. A parity bit can detect a single error but not fix it; Hamming's 1950 codes correct it. Placing parity bits at the power-of-two positions so each data bit is covered by a unique combination of checks, the pattern of failed checks -- the syndrome -- reads out, in binary, the exact position of the flipped bit. The classic Hamming(7,4) carries 4 data bits in 7 and corrects any single error; Hamming(2^m-1, 2^m-1-m) needs only m parity bits, so the overhead shrinks as blocks grow. Every Hamming code has minimum distance 3, and one extra overall parity bit gives SECDED (single-correct, double-detect), the scheme in ECC memory. Verified by exhaustively correcting every single-bit error in every codeword.

Hamming(7,4): parity coverage and the code rate which positions each parity bit checks (left); code rate k/n vs block size (right) 1 P 2 P 3 D 4 P 5 D 6 D 7 D p1 check check check check p2 check check check check p4 check check check check A flipped bit fails exactly the checks whose bit is set in its position number, so the syndrome (p4 p2 p1 read as binary) IS the error position. rate -> 1 (7,4) 0.50 0.75 1.00 3 31 255 block length n (log)
each parity bit's coverage that makes the syndrome name the error position, and the code rate rising toward 1
Hamming(7,4): 4 data bits -> 7 transmitted bits, corrects any single error

  data [1, 0, 1, 1] -> codeword [0, 1, 1, 0, 0, 1, 1]   (parity bits at positions 1,2,4)

   flipped pos            received  syndrome  corrected?
             1[1, 1, 1, 0, 0, 1, 1]         1         yes
             2[0, 0, 1, 0, 0, 1, 1]         2         yes
             3[0, 1, 0, 0, 0, 1, 1]         3         yes
             4[0, 1, 1, 1, 0, 1, 1]         4         yes
             5[0, 1, 1, 0, 1, 1, 1]         5         yes
             6[0, 1, 1, 0, 0, 0, 1]         6         yes
             7[0, 1, 1, 0, 0, 1, 0]         7         yes

  The failed parity checks spell out, in binary, the exact position of the flipped
  bit -- flip it back and the message is restored. No retransmission needed.

        code  data k  total n  rate k/n
     (3,1)       1        3     0.333
     (7,4)       4        7     0.571
   (15,11)      11       15     0.733
   (31,26)      26       31     0.839
   (63,57)      57       63     0.905

  More parity bits per block -> higher rate: the (255,247) code spends just 8 bits
  to protect 247. Add one overall parity bit for SECDED -- the scheme in ECC memory.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\hamming.svg

RSA: public-key cryptography from the hardness of factoring

RSA lets two strangers communicate secretly without ever sharing a key, resting on one asymmetry: multiplying two large primes into n = pq is easy, but factoring n back apart is (as far as anyone knows) astronomically hard. Pick primes p, q, take phi = (p-1)(q-1), a public exponent e coprime to phi, and the private d = e^-1 mod phi. Encryption is c = m^e mod n, decryption m = c^d mod n, and they undo each other because ed = 1 mod phi (Euler). The same keys sign: encrypt with the private key, verify with the public. This pure-stdlib reference implements Miller-Rabin primality (catching Carmichael numbers that fool Fermat), the extended Euclidean inverse, fast square-and-multiply exponentiation, and full encrypt/decrypt/sign/verify round-trips -- the number theory behind TLS and SSH.

RSA: encrypt with the public key, decrypt with the private the one-way key flow (left); modular-exponentiation cost is linear in bits (right) Alice has public (e, n) c = m^e mod n Bob has private (d, n) m = c^d mod n ciphertext c (safe on open channel) Eve (attacker) sees c, e, n must factor n: hard 8 1024 2048 ~2 log2(e) mults exponent size (bits) multiplications (vs 2^bits for naive repeated multiply)
the one-way public-key flow an eavesdropper cannot invert, and the log-time cost of modular exponentiation
RSA from scratch: two primes -> a public key anyone can encrypt to

  public key  (e, n): e = 65537
                      n = 88411744846329904466854580393080427256266851130989221887466523070287323859577
  private key (d, n): d = 35466145414193710246633305133498396822429053497000535443257497919929133786793

  message   m = 42424242
  encrypt   c = m^e mod n = 84839668179731333482683177734528306802513032577352587067916241783443511071227
  decrypt   m'= c^d mod n = 42424242   -> round-trip OK

  sign      s = m^d mod n = 26946381523425824622003667727963989899574990650435005788254097895555661275214
  verify    s^e mod n = m ? True   (tampered: False)

  bytes: b'public-key crypto, no shared secret'
    -> 2 ciphertext blocks -> decrypts to b'public-key crypto, no shared secret'  (OK)

  Multiplying the primes is easy; factoring n back apart is not -- that gap is the
  whole of RSA's security. Modular exponentiation costs ~log2(e) multiplications by
  square-and-multiply, so even a 2048-bit exponent is fast.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\rsa.svg

Diffie-Hellman: agreeing on a secret in the open

Two people who have never met, on a wiretapped line, can agree on a shared secret the eavesdropper cannot learn. Fix a prime p and generator g; Alice sends g^a mod p, Bob sends g^b mod p, and each raises what they received to their own secret, both landing on g^(ab) mod p while the wire carried only g^a and g^b. Stealing the secret means recovering a from g^a mod p -- the discrete-logarithm problem, easy to state and (for large p) astronomically hard. This module generates safe-prime parameters, finds a generator, runs the exchange, and includes a baby-step/giant-step discrete-log solver whose sqrt(p) cost dwarfs the parties' log(p) work -- the gap that keeps the secret safe. Without authentication a man-in-the-middle can still intercept, which is why real protocols sign the exchange.

Diffie-Hellman: a shared secret over an open channel the exchange both sides can complete (left); the attacker's sqrt(p) vs honest log(p) cost (right) Alice secret a sends A = g^a mod p gets B -> B^a = g^ab Bob secret b sends B = g^b mod p gets A -> A^b = g^ab A B Eve sees p, g, A, B needs a = log_g A mod p discrete log: ~sqrt(p) hard Both reach g^ab; Eve never does. attacker ~sqrt(p) honest ~log(p) log10(steps) ~10^308 16 1024 2048 prime size (bits)
the exchange both sides complete to the same secret, and the attacker's sqrt(p) cost against the honest log(p)
Diffie-Hellman: agree on a secret while an eavesdropper listens

  public parameters:  p = 48563  (safe prime),  g = 2  (generator)

              secret    sends g^secret mod p                  computes
     Alice     12345                   30204          B^a mod p = 9050
       Bob     54321                   20462          A^b mod p = 9050

  shared secret g^(ab) mod p = 9050   (both match: True)
  the wire carried only p, g, 30204, 20462 -- never a or b.

  An eavesdropper must solve g^x = 30204 (mod 48563) for x. Baby-step/giant-step finds
  x = 12345 in ~sqrt(p) = 220 steps -- feasible ONLY because p is tiny here.
  Scale p to 2048 bits and sqrt(p) is ~10^308 steps: the secret is safe, even though
  Alice and Bob each did only a few thousand multiplications. That gap is the point.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\diffie_hellman.svg

CRC: catching transmission errors with polynomial division

A cyclic redundancy check is the checksum on almost every digital frame -- Ethernet packets, ZIP files, PNG chunks, disk sectors. Treat the message as a polynomial over GF(2) (arithmetic mod 2, addition = XOR), divide by a fixed generator, and append the remainder; the receiver divides again and a nonzero remainder flags corruption. A degree-r generator guarantees detection of every single-bit error, every burst shorter than r+1 bits, and misses a random error only with probability ~2^-r -- 1 in 4 billion for CRC-32, computed with nothing but shifts and XORs. This module does bit-at-a-time polynomial division for CRC-8/16/32, reproducing the published '123456789' check values and matching zlib.crc32 exactly. The detection companion to the Hamming code (which corrects).

CRC: a checksum from polynomial division over GF(2) the wire frame layout (top); miss probability ~2^-r shrinks with CRC width (bottom) D D D D D D D D D D D C C C C message data CRC-32 Receiver divides the whole frame by the generator: zero remainder = no error detected. CRC-8 CRC-16 CRC-32 1e0 1e-3 1e-6 1e-9 1e-12 8 16 32 CRC width r (check bits) miss probability 2^-r
the data-plus-CRC frame layout and the miss probability 2^-r shrinking with the number of check bits
CRC: divide the message polynomial by a generator, send the remainder

  check string '123456789':
             CRC-8 = 0xF4
      CRC-16-CCITT = 0x29B1
            CRC-32 = 0xCBF43926

  Frame = data + CRC. Receiver recomputes and compares:
    data   : b'GET /index.html'
    frame  : ...139492ad (4 CRC bytes appended), check = OK
    corrupt one bit -> check = ERROR CAUGHT

  Error-detection coverage (random multi-bit corruptions):
             CRC  width    caught   miss ~2^-r
           CRC-8      8    0.9960     3.91e-03
    CRC-16-CCITT     16    0.9988     1.53e-05
          CRC-32     32    0.9988     2.33e-10

  Every single-bit error, every burst shorter than the width, and all but a
  ~2^-r fraction of random corruptions are caught -- for 32 check bits that is a
  miss rate of 1 in 4 billion, with nothing but shifts and XORs. The checksum on
  Ethernet frames, ZIP files, and PNG chunks.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\crc.svg

LZ77: compression by pointing back at what you have seen

Lempel and Ziv's 1977 algorithm is the engine inside ZIP, gzip, and PNG. Scanning the data, whenever the next bytes have appeared recently it emits a back-reference -- a (distance, length) pair meaning 'copy length bytes from distance back' -- instead of repeating them; only genuinely new bytes are stored literally. A sliding window holds the recent history, and the decompressor replays the tokens from its own growing output, so an overlapping copy expands a whole run from one token. Repetitive data (text, code, logs) compresses enormously while random data cannot shrink at all -- Shannon's entropy limit showing through. This module implements the encoder and decoder with a guaranteed lossless round-trip; LZ77 plus Huffman together are DEFLATE.

LZ77: back-references replace repeats the token stream, literals vs copies (top); compression ratio vs repetition (bottom) a b c <-3,9 Y Z <-15,6 "abcabcabcabcXYZabcabc" -> each orange box copies bytes already sent literal byte back-reference (distance, length) ratio 1 (no gain) 1 20 40 60 1x 16x 33x number of repeated blocks -> compression ratio
the token stream of literals and back-references, and the compression ratio climbing with repetition
LZ77: replace repeats with (distance, length) back-references

  input (46 bytes): the cat sat on the mat, the cat sat on the hat

  token stream:
   the cat s[<-4,3]on [<-15,4]mat,[<-9,5]c[<-24,14]hat

  16 literals + 4 back-references, round-trip: OK
  compression ratio ~ 1.44x

  Ratio climbs with repetition, but incompressible data stays ~1 (Shannon's limit):
                        data  bytes   ratio
               one 'ab' pair      2    1.00
                    'ab' x 5     10    2.00
                   'ab' x 50    100   20.00
           English text x 20    400   14.81
         pseudo-random bytes    400    1.00

  LZ77 + Huffman together are DEFLATE -- the algorithm inside gzip, ZIP, and PNG.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\lz77.svg

Bloom filters: membership in a fraction of the space

A Bloom filter answers 'have I seen this?' with a bit array and a few hash functions, in a tiny fraction of the memory the items would take. The trade is one-sided: it may say 'possibly present' for something never added (a false positive) but NEVER says 'absent' for something you did add -- no false negatives. Add an item by setting its k bits; test by checking all k are set. After n items in m bits the false-positive rate is (1 - e^{-kn/m})^k, minimized at k = (m/n) ln 2, needing only ~1.44 log2(1/p) bits per item regardless of item size -- a million URLs at 1% error in about 1.2 MB. Web caches, spell checkers, and databases use one as a fast pre-filter. Verified here: zero false negatives and an observed false-positive rate matching theory.

Bloom filter: false positives rise with load, no false negatives false-positive rate vs items inserted (left); optimal number of hashes (right) design: 1000 @ 1% 0.0 0.5 1.0 0 2500 5000 items inserted theory observed optimal k = 7 1 7 15 number of hash functions k FP rate
the false-positive rate rising as the filter fills (observed tracking theory) and the optimal number of hash functions
Bloom filter: 'possibly present' or 'definitely absent', never a false negative

  1000 items at 1% target: 9586 bits (9.6 bits/item), 7 hash functions
  vs storing the items themselves: a fraction of the memory, item-size-independent

  after inserting 1000: false negatives = 0 (guaranteed 0)
  observed false-positive rate = 0.0103,  theory = 0.0100
  fill ratio = 0.513 (optimal load sets ~half the bits)

  False-positive rate as the filter fills (fixed m, k):
     items    theory  observed
       250    0.0000    0.0000
       500    0.0003    0.0011
      1000    0.0100    0.0076
      2000    0.1574    0.1104
      4000    0.6786    0.5940

  Web caches, spell checkers, and databases use one as a fast pre-filter: skip the
  expensive lookup for anything the filter says is definitely not there.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\bloom.svg

HyperLogLog: counting distinct items in kilobytes

How many DISTINCT items in a stream, when exact counting means storing every one? HyperLogLog estimates the cardinality to a percent or two in fixed tiny memory -- a billion distinct items in ~1.5 KB. Hash each item; the longest run of leading zeros seen hints at the count (k zeros suggests ~2^k items). To tame the noise, the first p bits pick one of m = 2^p registers each holding its max leading-zero rank, and the harmonic mean across registers gives E = alpha_m m^2 / sum 2^-M[j] with relative error ~1.04/sqrt(m). Small counts get a linear-counting correction, and two sketches merge by register-wise max, so counts are trivially distributed -- which is why Redis, Presto, and BigQuery all ship it. Verified against true cardinalities across four orders of magnitude.

HyperLogLog: distinct count in fixed tiny memory estimate tracks the truth over 4 orders of magnitude (left); relative error stays near 1.04/sqrt(m) (right) exact 100 10,000 100,000 true distinct (log) estimate (log) +1 SE (1.6%) -1 SE cardinality sample -> relative error
the estimate hugging the exact diagonal over four orders of magnitude, and the relative error staying within the standard-error band
HyperLogLog: distinct-count with 4096 registers = 4096 bytes, standard error 1.62%

   true distinct    estimate   rel error
             100          98     -0.0183
            1000         998     -0.0016
           10000        9760     -0.0240
          100000       99707     -0.0029
          500000      496746     -0.0065

  Memory stays fixed at ~4096 bytes whether you count 100 or 500,000 items --
  exact counting would need a set holding all of them. A billion distinct items
  fit in ~1.5 KB. Sketches merge by register-wise max, so counts are distributed.

  merge: |A|~59700 (true 60000), |B|~59599 (true 60000)
         |A union B|~98472 (true 100000) -- counted without a shared list.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\hyperloglog.svg

Fenwick trees: running sums that update in log time

Keeping an array while asking for prefix sums, a plain array gives instant updates but O(n) sums, and a prefix-sum array the reverse. Fenwick's binary indexed tree does both in O(log n) using the binary structure of the indices: node i stores the partial sum of the range ending at i whose length is its lowest set bit i & -i. A prefix sum strips the low bit each step (i -= i & -i); an update adds it (i += i & -i) -- each walk touches only one node per set bit. Range sums come by subtraction, and because cumulative sums are monotone you can binary-search the tree for the smallest index whose prefix reaches a target, an O(log n) 'select' for weighted sampling and rank queries. Every operation is cross-checked here against a brute-force array over thousands of mixed updates and queries.

Fenwick tree: each node covers a low-bit-sized range node i sums the range of length (i & -i) ending at i (top); update/query cost log n vs naive n (right) 3 idx 0 1 idx 1 4 idx 2 1 idx 3 5 idx 4 9 idx 5 2 idx 6 6 idx 7 node 1 (len 1) node 2 (len 2) node 3 (len 1) node 4 (len 4) node 5 (len 1) node 6 (len 2) node 7 (len 1) node 8 (len 8) A prefix sum to k adds the nodes covering [0,k): strip the low bit each step, log n adds. naive O(n) Fenwick O(log n) n (log) -> ops per query (log)
each node's low-bit-sized coverage range over the array, and the log n vs naive n cost per query
Fenwick tree (binary indexed tree): update AND prefix-sum in O(log n)

  values : [3, 1, 4, 1, 5, 9, 2, 6]
  prefix sums : [3, 4, 8, 9, 14, 23, 25, 31]
  total = 31,  range_sum[2,6) = 19 (= 19)

  after adding 10 at index 3: value there = 11, new total = 41

  cumulative search over weights [2, 0, 3, 1, 4, 0, 5] (prefix sums [2, 2, 5, 6, 10, 10, 15]):
    smallest index whose prefix sum >=  1: 0
    smallest index whose prefix sum >=  3: 2
    smallest index whose prefix sum >=  6: 3
    smallest index whose prefix sum >= 12: 6
  -- the O(log n) 'select' used for weighted sampling and rank queries.

           n  Fenwick log2 n  naive prefix-array n
          16               4                    16
       1,024              10                 1,024
   1,000,000              20             1,000,000
  1,000,000,000              30         1,000,000,000

  Each update or query touches one node per set bit of the index -- log n nodes --
  in a single array of n integers. It powers range queries, order statistics, and
  streaming quantiles.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\fenwick.svg

Union-Find: connectivity in near-constant time, and Kruskal's MST

Given a stream of 'these two are connected' facts, answer whether any two items are in the same group -- the disjoint-set problem. Union-Find solves a sequence of m operations in O(m alpha(n)) time, where the inverse Ackermann alpha(n) is at most 4 for any conceivable n: effectively constant. Each set is a tree with a representative root; union by rank hangs the shorter tree under the taller, and path compression repoints every node visited during a find straight at the root, so trees stay flat. A cycle in a graph is exactly two endpoints already in the same set, which makes Union-Find the whole of Kruskal's minimum-spanning-tree algorithm. It also drives image segmentation, percolation, and account-merging. Verified against a brute-force flood fill and known minimum spanning trees.

Union-Find & Kruskal: build a minimum spanning tree graph with all edges, MST edges highlighted (left); components merging as edges are added (right) 7 5 8 9 7 5 15 6 8 9 11 0 1 2 3 4 5 6 green = MST edges (cheapest, cycle-free) 1 (connected) 1 7 edges added -> component count
the MST edges chosen on a weighted graph, and the component count falling as edges are merged
Union-Find: merge groups and query connectivity in ~O(1) amortized

  start: 10 singletons
    connect 0-1:   merge -> 9 components
    connect 2-3:   merge -> 8 components
    connect 4-5:   merge -> 7 components
    connect 1-2:   merge -> 6 components
    connect 6-7:   merge -> 5 components
    connect 5-8:   merge -> 4 components
    connect 3-6:   merge -> 3 components

  final components: [[0, 1, 2, 3, 6, 7], [4, 5, 8], [9]]
  connected(0, 8)? False   connected(0, 9)? False
  size of 0's group: 6

  Kruskal's MST (add cheapest edge that joins two components):
   weight      edge
        5   0--3
        5   2--4
        6   3--5
        7   0--1
        7   1--4
        9   4--6
  total spanning-tree weight = 39  (6 edges for 7 nodes)

  A cycle is exactly two endpoints already in the same set, so Union-Find spots it
  in constant time -- which is the whole of Kruskal. It also drives image
  segmentation, percolation, and 'friend circle' / account-merge problems.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\union_find.svg

Dijkstra's algorithm: shortest paths, greedily

Given a graph with nonnegative edge weights, Dijkstra finds the cheapest route from a source to every node by a greedy rule: keep a tentative distance to each node, repeatedly settle the unsettled node with the smallest distance, and relax its edges. Once settled, a node's distance is final -- which holds precisely because no nonnegative detour can improve a shorter path. With a binary-heap priority queue it runs in O((V+E) log V). This module builds a weighted graph, runs Dijkstra (returning distances and a predecessor tree for path reconstruction), and includes a from-scratch binary min-heap and a Bellman-Ford implementation used to verify every distance. Add a goal heuristic to the priority and it becomes A*, the workhorse of map and game routing -- shown here solving a grid maze.

Dijkstra on a grid: distance flood and the shortest route cell shade = distance from S (blue near, red far); yellow = shortest path; gray = walls S G
the distance flood from the source colouring the grid, with the shortest S-to-G route picked out
Dijkstra: cheapest route from a source to every node

   node  distance  Bellman-Ford
      0         0             0
      1         3             3
      2         1             1
      3         4             4
      4         7             7

  shortest 0 -> 4: [0, 2, 1, 3, 4], cost 7  (Dijkstra == Bellman-Ford: True)

  Grid maze (10x10): shortest S->G path length = 32
  '#' walls, '*' the path Dijkstra found:
    S****#****
    .###*#*##*
    .#***#*#**
    .#*###*#*#
    .#*****#*#
    .#####.#*#
    .....#.#**
    ####.#.##*
    ...#...#.*
    .#.####..G

  wrote C:\Users\acwic\symplectic-nbody\examples\output\dijkstra.svg

k-d trees: fast nearest-neighbour search in space

"Which point is closest to this query?" is asked constantly in graphics, robotics, machine learning, and geographic search, and scanning every point is O(n). A k-d tree organizes the points by recursive median splitting -- the root splits on x, the next level on y, then z, cycling axes -- so a query descends to its leaf and then unwinds, only crossing a splitting plane into the far subtree when that hyper-rectangle could hold something nearer. Whole branches are pruned, giving O(log n) typical queries. The same descent-and-prune serves k-nearest-neighbours and radius queries. This module builds a balanced tree and does exact nearest, k-nearest, and radius search, each cross-checked against a brute-force scan in 2D and 3D. It powers k-NN classification, particle neighbour lists, and map search.

k-d tree: nearest, k-nearest, and radius queries query (yellow), nearest (green), 5-nearest (purple ring), radius query (orange circle) query query point nearest neighbour 5 nearest (ring) within radius 15 other points
a point cloud with the query, its nearest neighbour, its five nearest, and a radius query circle
k-d tree: nearest-neighbour search by recursive median splitting

  400 points in 2D, tree height 9 (~log2 n = 8.6)

  nearest to (50.0, 50.0): 49.2,47.8  (brute force agrees: True)
  5 nearest agree with brute force: True
  within radius 15: 32 points  (brute force agrees: True)

  nearest matches brute force on 500 random queries: True
  Each query descends to a leaf, then unwinds -- crossing the splitting plane only
  when the far side could hold something closer, so most branches are pruned.
  It powers k-NN classification, particle neighbour lists, and map/geographic search.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\kdtree.svg

Boyer-Moore: the string search that skips ahead

Finding a pattern in text is one of computing's most-run operations. The naive scan checks every position in O(n*m); Boyer-Moore -- the algorithm your editor's find actually uses -- matches the pattern right-to-left and, on a mismatch, jumps forward by more than one position, often by nearly the whole pattern. Two precomputed rules set the skip: the bad-character rule shifts so the last occurrence of the mismatched text character lines up (or past it entirely if absent), and the good-suffix rule realigns an already-matched suffix without undoing confirmed matches. Taking the larger shift keeps it safe and makes it sublinear on real text -- most characters are never examined. This module builds both tables and finds first/all/overlapping occurrences, proven correct exhaustively against a naive search over 5000 random cases.

Boyer-Moore: comparisons fall as the pattern grows character comparisons vs pattern length -- naive rises, Boyer-Moore drops (sublinear) naive O(nm) Boyer-Moore (sublinear) 0 2284 4569 2 12 24 pattern length -> character comparisons
character comparisons vs pattern length: the naive scan rises while Boyer-Moore falls
Boyer-Moore: match right-to-left and jump ahead on a mismatch

  text    : GCATCGCAGAGAGTATACAGTACG
  pattern : GCAGAGAG
  found at: [5]  (naive agrees: True)

  bad-character table (last index of each pattern character):
   G:7  C:1  A:6

  Character comparisons, Boyer-Moore vs naive (English text, longer patterns win):
                 pattern  BM comps    naive  speedup
                     fox      1079     2818     2.6x
                lazy dog       781     3113     4.0x
          jumps over the      1255     3467     2.8x
    the quick brown fox       1559     4352     2.8x

  A longer pattern with a rare mismatch character leaps forward by nearly its whole
  length, so most of the text is never even examined -- the search is sublinear.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\boyer_moore.svg

A* search: Dijkstra with a sense of direction

Dijkstra explores outward in every direction equally; A* keeps the same guarantee of an optimal path but adds a heuristic h(n) estimating the remaining distance to the goal, ordering its frontier by f(n) = g(n) + h(n) -- known cost so far plus the guess ahead -- so it pushes toward the goal instead of flooding. If the heuristic never overestimates the true remaining cost (it is admissible), the path is still guaranteed optimal; with h = 0 it degenerates exactly to Dijkstra. On a grid the Manhattan distance is admissible for 4-directional movement and the octile distance for 8-directional. This module runs A* on a weighted grid with obstacles, returns the path and the expanded-node set, and verifies A* finds the same optimal cost as Dijkstra while expanding no more nodes -- the standard for game and robot navigation.

A* vs Dijkstra: same path, far fewer nodes explored blue = cells the search expanded; yellow = the optimal path; gray = walls A* (Manhattan) (300 expanded) S G Dijkstra (360 expanded) S G
A* and Dijkstra side by side on the same maze: identical path, but A* explores far fewer cells
A*: guide the search with a heuristic h(n) = estimated distance to the goal

  grid 13x30, start (0, 0) -> goal (0, 29)
              path cost  nodes expanded
          A*         49             300
    Dijkstra         49             360

  same optimal cost: True   A* expanded 17% fewer nodes

  A* orders its frontier by f = g + h: known cost so far plus the guess to the goal,
  so it pushes toward the goal instead of flooding outward. With an admissible
  heuristic (never overestimating) the path it returns is still guaranteed optimal --
  which is why A* is the standard for game and robot navigation.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\astar.svg

Topological sort: ordering tasks so prerequisites come first

A directed acyclic graph encodes dependencies -- an edge u -> v means u must come before v -- and a topological order is a linear arrangement in which every edge points forward: the order you can build the modules, install the packages, or recompute the spreadsheet cells. It exists exactly when the graph has no cycle. Kahn's algorithm repeatedly emits a node with no remaining incoming edges (BFS on in-degrees); the DFS method reverses finish-times and spots a cycle as a back-edge to a node still on the stack. Both run in O(V+E). With a duration on each task, the longest path through the DAG is the critical path -- the minimum time to finish everything. This module implements both sorts, cycle detection, and the critical path, and verifies every order it returns respects all edges across hundreds of random DAGs.

Topological layers & the critical path each column is a dependency layer; green = the critical (longest-duration) path design 5d backend 8d frontend 6d docs 7d integrate 3d qa 4d release 2d
a dependency DAG laid out in topological layers with the critical (longest-duration) path highlighted
Topological sort: a build order where every dependency comes first

  Kahn:  fetch -> compile -> link -> test -> assets -> package -> deploy
  DFS:   assets -> fetch -> compile -> test -> link -> package -> deploy
  (both are valid orders; ties resolved differently)

  a -> b -> c -> a has a cycle: True (no order can exist)

  Project critical path (longest chain of durations = minimum completion time):
    design -> backend -> integrate -> qa -> release  =  22 days
    (total task-days = 35, but the critical path bounds the schedule)

  The same ordering runs package managers, build systems, spreadsheet recompute,
  and course prerequisites -- and a cycle is a dependency that can never be resolved.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\toposort.svg

Levenshtein edit distance: how far apart are two strings

The edit distance is the fewest single-character edits -- insert, delete, substitute -- that turn one string into another, the measure behind spell-checkers, fuzzy search, diff, and DNA alignment. Dynamic programming fills a table where d[i][j] is the distance between prefixes, each cell the cheapest of a match, substitution, insertion, or deletion, in O(mn); backtracing the choices recovers the actual alignment, not just the count. The distance is a true metric (symmetric, zero only for equal strings, triangle-inequality-respecting). This module computes the distance, a memory-lean two-row variant, the alignment operations, a similarity ratio, and the Damerau variant that treats an adjacent-character swap as one edit (the commonest typo), all verified against known values and the metric axioms.

Levenshtein DP table: cost to reach each corner cell shade = edit cost of the two prefixes; green trace = the minimal-edit alignment s i t t i n g k i t t e n 0 1 2 3 4 5 6 7 1 1 2 3 4 5 6 7 2 2 1 2 3 4 5 6 3 3 2 1 2 3 4 5 4 4 3 2 1 2 3 4 5 5 4 3 2 2 3 4 6 6 5 4 3 3 2 3 bottom-right = edit distance (3)
the dynamic-programming cost table with the backtrace path that spells out the minimal edits
Levenshtein edit distance: fewest insert/delete/substitute edits

  'kitten' -> 'sitting' : distance 3, similarity 0.57
  alignment:
    substitute k -> s
    keep       i
    keep       t
    keep       t
    substitute e -> i
    keep       n
    insert     g
  applying the ops reproduces 'sitting'

  spell-check 'recieve' against a small dictionary (nearest first):
        word  distance  damerau
     relieve         1        1
     believe         2        2
      recede         2        2
     receive         2        1
     deceive         3        2
    receiver         3        2
     receipt         4        3

  best guess: 'relieve' -- and Damerau sees 'recieve'->'receive' as a single
  adjacent-swap typo (distance 1), the commonest kind of mistake. The same DP powers
  fuzzy search, diff tools, and DNA sequence alignment.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\levenshtein.svg

The 0/1 knapsack: packing the most value under a weight limit

Items each have a weight and a value; which subset maximizes value without exceeding a capacity W, taking each item whole or not at all? Brute force checks 2^n subsets, but dynamic programming solves it in pseudo-polynomial O(nW): best[i][w] is the most value from the first i items within capacity w, each cell the better of skipping item i or taking it (freeing w - weight_i of room). Backtracing recovers which items to take; a rolling array (iterating capacity downward so each item is used once) cuts memory to O(W). The related subset-sum question and the unbounded knapsack (unlimited copies, iterate capacity upward) are the same table. This module solves all four, reconstructs the chosen items, and is verified exhaustively against a brute-force subset search -- the model for budget allocation, cargo loading, and portfolio selection under a hard cap.

0/1 knapsack DP table: best value for i items within capacity w rows = items considered, columns = capacity; shade = best value; bottom-right = the optimum 0 1 2 3 4 5 6 7 8 capacity w (none) 0 0 0 0 0 0 0 0 0 +item0 0 0 6 6 6 6 6 6 6 +item1 0 0 6 8 8 14 14 14 14 +item2 0 0 6 8 8 14 14 15 15 +item3 0 2 6 8 10 14 16 16 17 +item4 0 2 6 8 10 14 16 18 20 +item5 0 5 7 11 13 15 19 21 23 capacity -> optimal value (a staircase: value jumps as items become affordable)
the DP value table filling row by row to the optimum, and the optimal value climbing with capacity
0/1 knapsack: pick items to maximize value within 8 kg

        item  weight  value
      camera       2      6  <- take
      laptop       3      8
      whisky       4      7
        book       1      2
    gold bar       5     12  <- take
       phone       1      5  <- take

  optimal value 23 using 8 kg  (brute force agrees: True)

  Optimal value as the bag grows:
    capacity  best value
           0           0
           2           7
           4          13
           6          19
           8          23
          10          27
          12          33
          14          34
          16          40

  Each cell of the DP table is 'skip item i' vs 'take it and free up its weight' --
  O(nW) instead of the 2^n subsets brute force checks. It models budget allocation,
  cargo loading, and portfolio selection under a hard cap.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\knapsack.svg

Longest common subsequence: the engine behind diff

A subsequence keeps some elements in order but may skip others; the longest common subsequence of two sequences is the longest ordering appearing in both, and it is what diff, git, patch, and bioinformatics comparison are built on -- the complement of the LCS is exactly the lines to add or delete, so a bigger LCS means a smaller diff. The dynamic program fills L[i][j] = L[i-1][j-1]+1 on a match, else max(L[i-1][j], L[i][j-1]), in O(mn); backtracing recovers an actual longest subsequence, and turning the walk into keep/delete/insert steps yields the diff. The LCS length also gives the insert/delete edit distance, m + n - 2*LCS. This module computes the length, one subsequence, the diff edit-script, and that distance, all verified exhaustively against a brute-force subsequence search.

LCS DP table: longest shared subsequence length shade = LCS length of the two prefixes; yellow = match cells that build the LCS B D C A B A B C B D A B 0 0 0 0 0 0 0 0 0 0 1 1 0 1 1 1 1 2 0 1 1 2 2 2 0 1 1 2 2 3 0 1 2 2 2 3 0 1 2 2 3 3 0 1 2 2 3 4 bottom-right = LCS length (4); yellow diagonal spells "BCAB"
the DP length table with the match-cell diagonal that spells out the longest common subsequence
Longest common subsequence: the longest in-order shared thread

  a = ABCBDAB
  b = BDCAB
  LCS = 'BCAB' (length 4), indel edit distance 4

  A line-level diff (LCS of the two files' lines):
      import os
    + import sys
      def main():
    -     x = 1
    +     x = 2
          return x
    - main()
  patch reproduces v2: True

  The kept lines are the LCS; the +/- lines are its complement -- the smallest set
  of edits. A bigger LCS means a smaller diff. This runs git, patch, and
  bioinformatics sequence comparison.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\lcs.svg

Quickselect: the k-th smallest without sorting

Finding the median, a percentile, or the top-k threshold looks like it needs a full O(n log n) sort -- it does not. Quickselect partitions the array around a pivot and recurses only into the side containing rank k, discarding half the data each step for O(n) expected time; you never touch the elements you do not need. A bad pivot would degrade it to O(n^2), so the median-of-medians algorithm (medians of groups of five, recursively) picks a pivot provably better than 30% and worse than 30% of the data, guaranteeing worst-case linear time -- the classic proof that selection beats sorting. This module implements quickselect with a randomized pivot and with median-of-medians, plus median, k-th smallest/largest, and percentile wrappers, each verified against a full sort over 1000 random arrays.

Quickselect: comparisons grow linearly, not n log n element comparisons to find the median vs array size -- quickselect ~n, sorting ~n log n full sort ~n log n quickselect ~n 0 120422 240844 64 1024 16384 array size n (log axis) -> comparisons
comparisons to find the median: quickselect grows linearly while a full sort grows as n log n
Quickselect: order statistics in O(n), no full sort

  data: [37, 12, 91, 5, 68, 24, 50, 3, 79, 45, 18, 60]
  min           = 3
  median        = 41.0
  90th pct      = 79
  3rd largest   = 68
  (median-of-medians agrees: True)

  Comparisons to find the median: quickselect (~2n) vs a full sort (~n log n):
         n   quickselect   sort ~n log n   ratio
        64           238             384    1.6x
       256           825            2048    2.5x
      1024          3491           10240    2.9x
      4096         13248           49152    3.7x
     16384         61484          229376    3.7x

  Discarding one partition side each step gives O(n) expected time. The
  median-of-medians pivot (groups of five) guarantees it even in the worst case --
  the theoretical proof that selection beats sorting when you need just one rank.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\quickselect.svg

Aho-Corasick: finding many patterns in one pass

A spam filter or virus scanner must match hundreds of patterns at once; running a single-pattern search per pattern costs O(n * patterns). The Aho-Corasick automaton finds every occurrence of every pattern in a single left-to-right scan, in O(n + total pattern length + matches) -- independent of the pattern count. It builds a trie of the patterns, then adds failure links (fall back to the longest proper suffix that is also a pattern prefix, so no character is re-examined -- KMP generalized to many patterns) and output links (report every pattern ending at the current state, catching overlapping and nested matches like 'he', 'she', 'hers' in 'ushers'). Built once by breadth-first traversal, then one text pass reports all matches. This module builds the automaton and finds all matches, verified exhaustively against a brute-force per-pattern search.

Aho-Corasick: a trie of patterns with failure links solid = trie edges (a character); dashed red = failure links (fallback on a mismatch) h s e i h r s e s he his she,he hers root green node = a pattern ends here (an output)
the pattern trie with its failure links (dashed) and the nodes where a pattern ends
Aho-Corasick: match a whole dictionary of patterns in one left-to-right scan

  patterns ['he', 'she', 'his', 'hers'] in 'ushers':
    'he' at [2]
    'she' at [1]
    'hers' at [2]
  overlapping/nested matches all caught; brute force agrees: True

  scanning a log line for 6 patterns in one pass:
    'error' at [28]
    'fail' at [60]
    'warn' at [15]
    'timeout' at [35]
    'critical' at [50]
    'denied' at [73]
  total hits: 6, contains a blocked word: True

  The single scan finds every occurrence of every pattern in O(n + matches) time --
  independent of the pattern count, unlike running one search per pattern. It powers
  virus scanners, spam filters, and DNA motif search.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\aho_corasick.svg

Floyd-Warshall: shortest paths between every pair

Dijkstra gives shortest paths from one source; for a routing table or a road-network distance matrix you need them between ALL pairs. Floyd-Warshall does it in one elegant O(V^3) dynamic program that also handles negative edge weights (which Dijkstra cannot) and detects negative cycles. It allows ever-larger sets of intermediate nodes: dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j]) as k runs over every node -- three nested loops, no priority queue. Recording the next hop per pair reconstructs the routes; a negative value on the diagonal means a negative cycle; and swapping min for boolean OR gives the transitive closure (who can reach whom). This module computes the distance matrix, paths, cycle detection, and closure, verified against running Dijkstra from every source on 300 random graphs.

Floyd-Warshall all-pairs distance matrix cell [i][j] = shortest distance from i to j; darker = nearer, gray = unreachable to (destination) 0 1 2 3 4 5 from (source) 0 0 7 9 20 20 11 1 . 0 10 15 21 12 2 . . 0 11 11 2 3 . . . 0 6 . 4 . . . . 0 . 5 . . . . 9 0
the all-pairs distance matrix as a heatmap, nearer pairs darker and unreachable pairs gray
Floyd-Warshall: all-pairs shortest paths in one O(V^3) pass

  distance matrix (rows = from, cols = to):
          0    1    2    3    4    5
  0 :    0    7    9   20   20   11
  1 :    .    0   10   15   21   12
  2 :    .    .    0   11   11    2
  3 :    .    .    .    0    6    .
  4 :    .    .    .    .    0    .
  5 :    .    .    .    .    9    0

  shortest 0 -> 4: [0, 2, 5, 4]  (cost 20)
  agrees with all-pairs Dijkstra: True

  Negative weights are fine (Dijkstra can't do these), and a negative loop is flagged:
    0->1->2->0 with weights 1,-3,1 has a negative cycle: True

  transitive closure: node 0 can reach [0, 1, 2, 3, 4, 5]
  Three nested loops, no priority queue -- it also gives reachability (boolean OR)
  and detects negative cycles (a negative diagonal). Used for routing tables and
  network distance matrices.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\floyd_warshall.svg

Misra-Gries: frequent items of a stream in tiny memory

Which items appear more than n/k times in a stream, when a counter per distinct value is impossible for billions of distinct items? The Misra-Gries summary finds every such heavy hitter with only k-1 counters in a single pass. Its rule is a generalized vote: increment a tracked item, start tracking a new one if a slot is free, else decrement every counter (the incoming item cancels one of each). Any item over n/k is guaranteed to survive (no false negatives), and a cheap second pass counts the survivors exactly to drop the false positives; the carried counts underestimate by at most n/k. The special case k=2 is the Boyer-Moore majority vote -- the strict-majority element with one counter. This module builds the summary, verifies candidates, and does majority vote, all checked exhaustively against exact counting.

Misra-Gries: approximate counts track the true heavy hitters true count (blue) vs the summary's underestimate (green); dashed line = the n/k threshold n/k = 1250 A B C tail tail tail true count MG estimate 455 exact 3 Misra-Gries counters needed
the summary's underestimated counts against the true counts, and its fixed memory versus exact counting
Misra-Gries: heavy hitters (> n/k) of a 5000-item stream with 3 counters

  distinct items: 455 (exact counting would need 455 counters)
  Misra-Gries uses only k-1 = 3

      item  true count   approx   > n/k?
         A        1736      725      yes
         B        1240      230       no
         C         781        1       no

  heavy hitters (verified): {'A': 1736}
  matches exact: True

  Majority vote (Boyer-Moore, k=2, one counter): winner = A (A has 260/500)

  One pass, k-1 counters, no matter how many distinct items stream by: the real
  heavy hitters are never missed (a cheap second pass drops the false positives).
  Used for network traffic monitors, trending queries, and word-frequency counting.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\misra_gries.svg

Reservoir sampling: a uniform sample from an endless stream

Keep a uniform random sample of k items from a stream whose length you do not know and cannot store -- log lines, sensor readings, a file too big for memory. Vitter's Algorithm R does it in one pass with O(k) memory: fill the reservoir with the first k items, then keep the i-th item with probability k/i, evicting a random existing one. A short induction shows every item ever seen is in the final sample with probability exactly k/n, whatever n turns out to be (k=1 is the classic 'random line from a file'). The weighted Efraimidis-Spirakis variant gives each item a key u^(1/w) and keeps the largest, sampling in proportion to weight. This module implements both plus a streaming reservoir object, and verifies the uniformity with a chi-square test over tens of thousands of runs.

Reservoir sampling: uniform by construction, or weighted on demand selection frequency per element -- flat at k/n (left); weighted sampling tracks the weights (right) k/n * trials element index (uniform: bars hug the line) rare common dominant sampled fraction weight fraction
the flat selection-frequency histogram proving uniformity, and weighted sampling tracking the weights
Reservoir sampling: keep a uniform k-sample of a stream of unknown length

  one pass, O(k) memory; a 5-sample of a million-item stream: [668303, 839595, 938864, 958802, 959292]

  Uniformity check: sample 4 of 12 items, 60000 times.
  each element should be picked ~ trials*k/n = 20000 times:
    min 19942, max 20062, chi-square 0.78 (11 dof, 1% critical ~ 24.7)  -> uniform

  Weighted reservoir (Efraimidis-Spirakis): chance tracks weight.
        item  weight  sampled %  weight %
        rare       1       6.0%      6.2%
      common       3      18.8%     18.8%
    dominant      12      75.2%     75.0%

  Every item ever seen ends up in the sample with probability exactly k/n, no
  matter how long the stream. It powers log sampling, A/B test bucketing, and
  random line selection from a file too big to hold in memory.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\reservoir.svg

Count-Min sketch: frequency estimates in sublinear memory

How often has each item appeared, when a counter per distinct key is impossible? The Count-Min sketch estimates every item's count from a fixed d x w grid of counters with d hash functions. Add an item by incrementing one counter per row; query it by taking the MINIMUM of its d counters -- since collisions only inflate a counter, the smallest is the tightest overestimate and the true count is never above it. With width e/epsilon and depth ln(1/delta), the estimate exceeds the truth by more than epsilon * total with probability at most delta, so a few kilobytes track a stream of any size. Counts combine additively, so two sketches merge by element-wise addition -- counting is distributed. This module builds the sketch, queries and merges, and verifies it never underestimates and stays within the error bound across many skewed streams.

Count-Min sketch: estimates never fall below the truth estimate vs true count -- all points on/above the diagonal (left); max error shrinks with table width (right) exact true count (log) -> estimate (log) 64 512 4096 max error table width (log) -> overestimate
estimate-vs-true points all on or above the diagonal (never under), and the max error shrinking as the table widens
Count-Min sketch: 2815 distinct items counted in a 5 x 2719 grid (13595 counters)

      item    true  estimate  error
         A    5956      5956      0
         B    3565      3567      2
         C    2040      2040      0

  never underestimates: 0 of 2815 items fell below the truth
  error bound e/w * total = 20.0; max observed error = 6

  Wider tables mean smaller error (fixed depth 4, 20k-item stream):
     width  counters  max error
       128       512         87
       256      1024         48
       512      2048         23
      1024      4096         15
      2048      8192          9
      4096     16384          7

  It never undercounts (collisions can only inflate a counter, and the query takes
  the min of the d rows), overshoots by a bounded amount, and merges by addition --
  so counting is distributed. Powers network flow monitors and n-gram frequency tables.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\count_min.svg

The alias method: O(1) sampling from a weighted die

To draw outcome i with probability p_i, the obvious way builds a cumulative distribution and binary-searches a random number at O(log n) per draw. Walker's alias method does it in O(1) per draw after O(n) setup. It chops the distribution into n equal-area columns, each holding at most two outcomes -- a main and an alias -- by repeatedly pairing an under-full outcome with an over-full one until every column has area 1. A draw is one integer roll (pick a column) plus one float flip (main outcome or its alias): two operations, no search, however many outcomes there are. The result is exact -- long-run frequencies equal the weights. This module builds the table by Vose's algorithm and samples from it, verified against the target weights with chi-square tests. The standard for loot tables, particle spawning, and any hot loop sampling one categorical distribution.

Alias method: sampled frequencies match the weights target vs sampled probability (left); the equal-area alias columns (right) common someti rare uncomm target sampled col0 col1 col2 col3 each column: main (bottom) + alias (top), equal area
the sampled frequencies matching the target weights, and the equal-area alias columns each split main/alias
Alias method: O(n) setup, then O(1) per weighted draw

     outcome  weight   target  sampled
      common      12    0.571    0.569
   sometimes       5    0.238    0.239
        rare       1    0.048    0.048
    uncommon       3    0.143    0.144

  chi-square vs target over 100000 draws: 0.09 (3 dof, 5% critical ~ 7.8) -> matches

  The alias table (each column holds a main outcome + an alias, both equal-area):
    column  P(main)        main       alias
         0    1.000      common      common
         1    0.952   sometimes      common
         2    0.190        rare      common
         3    0.571    uncommon      common

  A draw is one integer roll (pick a column) plus one float (main or its alias) --
  two operations, no search, however many outcomes there are. It is the standard for
  loot tables, particle spawning, and any hot loop sampling the same distribution.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\alias_method.svg

Fisher-Yates: the only correct way to shuffle

Shuffling looks trivial and almost everyone gets it wrong: the naive 'swap each position with a random position anywhere' makes n^n equally likely swap sequences but only n! permutations, and since n^n is not divisible by n! some orderings come up more often. Fisher-Yates fixes it by shrinking the range -- to place position i, swap it with a random position in [i, n), only the unshuffled tail -- so each of the n! permutations results from exactly one choice sequence and every ordering is equally likely. The same sweep gives a partial-shuffle k-sample (uniform without replacement), and restricting swaps to strictly earlier positions (Sattolo) yields a uniform random single cycle. This module implements all of these and demonstrates the bias by enumerating every permutation: Fisher-Yates is flat, the naive shuffle measurably lumpy.

Fisher-Yates is flat; the naive shuffle is lumpy how often each of the 24 permutations appears -- Fisher-Yates (green) hugs the uniform line, naive (red) does not uniform = trials/n! the 24 permutations (each a bar pair) Fisher-Yates (uniform) naive (biased)
permutation frequencies: Fisher-Yates hugs the uniform line while the naive shuffle is visibly biased
Fisher-Yates: swap each position with a random one in the UNSHUFFLED tail

  ABCDEFGH  ->  EDGBFHCA

  Uniformity over all 24 permutations of 4 items, 60000 trials each:
    Fisher-Yates: 24/24 permutations seen, chi-square 4.9  -> uniform
    naive swap:   24/24 permutations seen, chi-square 1777.2  -> BIASED
    (23 dof, 5% critical ~ 35.2; the naive method fails by orders of magnitude)

  sample 5 without replacement from 0..19: [7, 9, 18, 16, 15]
  Sattolo cyclic shuffle of 0..7: [7, 6, 4, 0, 3, 1, 2, 5]  (single 8-cycle: True)

  The naive 'swap with any position' makes n^n equally likely swap sequences but
  only n! permutations -- and n^n isn't divisible by n!, so some orderings win. Only
  the shrinking range makes every permutation exactly equally likely.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\fisher_yates.svg

Box-Muller: turning uniform randomness into a bell curve

Random generators give uniform values, but almost every simulation -- Brownian motion, Monte-Carlo finance, noise models -- needs Gaussians. The Box-Muller transform converts a pair of uniforms into a pair of independent standard normals: z0 = sqrt(-2 ln u1) cos(2 pi u2), z1 = sqrt(-2 ln u1) sin(2 pi u2). It is exact -- the polar change of variables onto the 2D Gaussian, whose radius has r^2 exponentially distributed and whose angle is uniform. Marsaglia's polar method rejects to the unit disc and reuses its coordinates, skipping the trig. Scaling by sigma and shifting by mu gives any N(mu, sigma^2). This module implements both, and the tests verify the output's mean, variance, skewness (~0), kurtosis (~3), and the 68-95-99.7 rule over large samples.

Box-Muller: the sample histogram is the Gaussian 200000 transformed uniforms (bars) against the analytic N(0,1) density (curve); shaded 1/2/3-sigma bands -3 -2 -1 0 1 2 3 standard deviations from the mean sample histogram N(0,1) density
the transformed-uniform histogram landing exactly on the analytic Gaussian density with its 1/2/3-sigma bands
Box-Muller: two uniforms -> two independent standard normals

  sample of 200000 standard normals:
    mean     = +0.0028  (target 0)
    variance = 1.0033  (target 1)
    skewness = +0.0028  (target 0)
    kurtosis = 2.9973  (target 3)

  68-95-99.7 rule:
    within 1 sigma: 0.6821  (normal: 0.6827)
    within 2 sigma: 0.9546  (normal: 0.9545)
    within 3 sigma: 0.9971  (normal: 0.9973)

  Marsaglia polar method (no trig): mean -0.0044, var 1.0016, kurtosis 3.034 -- same distribution, faster.
  scaled to N(100, 15^2) (e.g. IQ scores): mean 100.0, sd 15.0

  The transform is exact: r = sqrt(-2 ln u1) makes r^2 exponential (the Gaussian
  radius), theta = 2 pi u2 is the uniform angle, and (r cos, r sin) is a 2D normal.
  Every simulation that needs noise -- Brownian motion, Monte-Carlo finance -- starts here.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\box_muller.svg

Rejection sampling: drawing from any density you can evaluate

You can compute a density f(x) but cannot sample it directly -- an unnormalized posterior, a physics distribution, a hand-drawn shape. Rejection sampling turns 'I can evaluate f' into 'I can sample f': draw a candidate from a simpler proposal g you can sample, and accept it with probability f(x)/(M g(x)) where M bounds f <= M g. Geometrically you throw darts uniformly under the envelope M g and keep those below f -- the kept points are distributed exactly as f, even when f is only known up to a constant (which is why it underlies Bayesian computation). The price is efficiency: the acceptance rate is the area ratio 1/M, so a loose envelope or a high dimension wastes darts. This module does box and general rejection sampling, and the tests verify the sampled moments, the acceptance-rate theory, and a histogram chi-square against the target.

Rejection sampling: darts under a curve green darts (below f) are kept, red (above f) rejected; the kept x-values form the target histogram envelope M accepted-sample histogram (bars) vs target density (curve)
accepted (green) and rejected (red) darts under a bimodal density, and the kept-sample histogram matching it
Rejection sampling: keep darts thrown under the envelope that land below f(x)

  target: a bimodal density on [-4.0, 4.0] (two unequal Gaussian bumps)
  drew 100000 samples; acceptance rate 0.307 (theory 0.307)
  sample mean -0.0136, variance 2.6092
  histogram chi-square vs target (12 bins): 12.73

  Acceptance = area under f / area of the box, so a tighter envelope is faster:
    envelope M  acceptance
          1.01       0.307
          2.00       0.155
          4.00       0.078

  It samples ANY density you can evaluate -- even unnormalized ones -- which is why
  it underlies Bayesian computation. The cost is the wasted darts: a loose envelope
  or a high dimension makes acceptance tiny, which is what MCMC methods work around.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\rejection_sampling.svg

Welford's algorithm: mean and variance in one stable pass

The textbook variance E[x^2] - E[x]^2 is a numerical disaster: it subtracts two large, nearly-equal numbers, so on offset data (temperatures near 1e6, timestamps, prices) catastrophic cancellation can even return a NEGATIVE variance. Welford's algorithm updates a running mean and the sum of squared deviations as each datum arrives -- delta = x - mean; mean += delta/n; M2 += delta*(x - new_mean) -- never forming those giant intermediates, so it is both online (no need to store the data) and numerically stable. Terriberry's extension carries M3 and M4 for skewness and kurtosis, and two accumulators merge by combining counts, means, and M2 with a correction -- so statistics over shards combine in parallel, exactly. This module provides the accumulator and merge, verified against a two-pass computation and shown staying exact where the naive formula collapses.

Welford: running mean and std converge as the stream flows running estimates settle onto the true mean (blue) and std (green) after a few hundred values true mean 49.9 true std 8.1 values seen -> running estimate
the running mean and standard deviation converging onto their true values as the stream flows
Welford: one-pass, online mean and variance, numerically stable

  5000 values ~ N(50, 8^2):
    mean     49.9322  (two-pass 49.9322)
    variance 65.2773  (two-pass 65.2773)
    std      8.0794,  skewness +0.0116,  kurtosis 2.8991

  The naive variance E[x^2]-E[x]^2 collapses when the data has a large offset:
        offset  true var     Welford           naive
         0e+00       2.0      2.0000          2.0000
         1e+03       2.0      2.0000          2.0000
         1e+06       2.0      2.0000          2.0000
         1e+09       2.0      2.0000          0.0000
         1e+12       2.0      2.0000 -134217728.0000

  Welford never forms the giant sum-of-squares, so it stays exact; the naive formula
  subtracts two nearly-equal huge numbers and loses everything to rounding -- even
  returning negative variances. Accumulators also merge, so shards combine in parallel.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\welford.svg

Kahan summation: adding floats without losing the small ones

Add a million small numbers naively and the answer drifts: once the total is large, each tiny addend has fewer mantissa bits to land in and its low-order part is rounded away, so the error grows with n. Kahan's compensated summation carries a correction term c for the bits lost on the previous addition -- y = x - c; t = sum + y; c = (t - sum) - y -- so the error stays bounded by a small constant, as if the sum were computed in twice the precision. Neumaier's variant also handles the case where the next addend exceeds the running total (catastrophic cancellation), and pairwise summation gives O(log n) error growth with no correction term. This module implements all of these plus a compensated dot product and running-mean accumulator, verified against Python's exact math.fsum on ill-conditioned inputs.

Kahan summation: naive error grows, compensated stays flat relative error vs number of 0.1 terms summed (both axes log) 1e-18 1e-16 1e-14 1e-12 1e-10 1e-8 naive ~ n eps Kahan (flat) 1e2 1e3 1e4 1e5 1e6 1e7 number of terms n (log) -> relative error (log)
the relative error versus the number of terms: naive climbs with n while Kahan stays flat at machine precision
Kahan summation: carry the low-order bits lost on each addition

  summing 0.1 repeatedly (exact answer = n/10):
           n     naive error   Kahan error      pairwise
        1000        1.41e-14      0.00e+00      2.27e-15
       10000        1.59e-13      0.00e+00      1.48e-15
      100000        1.88e-12      0.00e+00      1.82e-15
     1000000        1.33e-11      0.00e+00      2.33e-15
    10000000        1.61e-10      0.00e+00      1.40e-15

  Catastrophic cancellation, exact answer = 2.0:
    input [1.0, 1e+100, 1.0, -1e+100]
    naive     = 0.0   (both 1.0s vanished)
    Kahan     = 0.0
    Neumaier  = 2.0   <- recovers it
    math.fsum = 2.0

  The naive error grows like n * machine-epsilon; Kahan keeps a correction term so
  it stays bounded, as if the sum were done in double the precision. It matters for
  long dot products, running averages, and any accumulation over millions of terms.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\kahan.svg

Horner's method: evaluating a polynomial the fast, stable way

Evaluating a polynomial by computing each power separately costs ~2n multiplications and sums terms of wildly different sizes. Horner rewrites it as nested multiplication -- p(x) = (...(a_n x + a_{n-1})x + ...)x + a_0 -- evaluating in exactly n mults and n adds with far better rounding. The same sweep IS synthetic division: the intermediate values are the quotient of dividing by (x - r) and the final value is the remainder p(r) (the Remainder Theorem); a second sweep gives p'(r) for free, which makes Horner the engine of Newton's method for polynomial roots. This module evaluates by Horner, does synthetic division and derivatives, and finds real roots by Newton refinement plus deflation -- verified against direct power-sum evaluation and by substituting the roots back.

Horner: fewer multiplications, and Newton root convergence multiplications vs polynomial degree (left); |p(x)| shrinking each Newton step (right) direct ~2n Horner n 2 25 50 degree -> multiplications 1e-18 1e-12 1e-6 1e0 Newton step -> |p(x)| (log): quadratic convergence to sqrt(2)
the multiplication count (Horner n vs direct 2n) and Newton's quadratic convergence to a root
Horner: p(x) = ((2x - 6)x + 2)x - 1, evaluated in 3 mults instead of 6

  p(x) = 2x^3 - 6x^2 + 2x - 1
  p(3) = 5  (direct eval agrees: True)
  synthetic division by (x - 3): quotient [2, 0, 2], remainder 5 = p(3)
  p(3) = 5, p'(3) = 20  (both in two linear sweeps)

  roots of x^3 - 6x^2 + 11x - 6 (Newton + deflation): [1.0, 2.0, 3.0]
  each found by p(r)/p'(r) steps, then divided out to find the next.

  Multiplication count, Horner (n) vs direct powers (~2n):
    degree   Horner   direct
         3        3        6
         5        5       10
        10       10       20
        20       20       40
        50       50      100

  Horner is the standard polynomial evaluator: fewest operations, and summing
  left-to-right in one accumulator gives better rounding than adding separate powers.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\horner.svg

Bracketing root-finders: bisection, secant, false position, Brent

Newton is fast but can diverge; when you have a bracket [a, b] where f changes sign, bracketing methods guarantee convergence. Bisection halves the interval each step (foolproof, linear -- one bit per iteration); the secant method fits a line through the last two points (superlinear, order ~1.618, but not guaranteed); false position keeps the secant inside the bracket (safe and faster than bisection); and Brent's method combines bisection's safety with inverse quadratic interpolation's speed, falling back to bisection when the fast step misbehaves -- which is why it is the default root-finder in most numerical libraries. This module implements all four with a shared bracket interface plus a sign-change scanner, verified against roots of polynomials and transcendentals and checked to agree.

Convergence: |f(x)| per iteration on x^2 - 2 bisection is linear (steady slope); secant is superlinear (steepening) -- log-scale error 1e-16 1e-12 1e-8 1e-4 1e0 bisection false position secant iteration -> |f(x)|
the error per iteration on log scale: bisection's steady linear slope against the secant method's steepening superlinear one
Root-finding: four bracketing methods on x^2 - 2 = 0 (root = sqrt 2)

            method              root  iters       error
         bisection    1.414213562372     41     6.7e-13
    false position    1.414213562373     17     2.7e-13
             Brent    1.414213562373     26     0.0e+00
            secant    1.414213562373      8     2.2e-16

  Brent on transcendental equations (no closed form):
       cos x = x: x = 0.7390851332  (8 iters)
        x = e^-x: x = 0.5671432904  (6 iters)
      e^x = 3x+1: x = 1.9038136944  (11 iters)

  Locating every root by scanning for sign changes, then refining with Brent:
    x^3 - 6x^2 + 11x - 6  ->  [1.0, 2.0, 3.0]

  Bisection is foolproof but linear (one bit per step); secant is fast but can
  fail; Brent takes the fast interpolation step when it is safe and bisects when it
  is not -- guaranteed convergence at near-secant speed, the default in most libraries.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\rootfind.svg

Numerical quadrature: integrating what algebra cannot

Most integrals have no closed form, so you approximate the area under f by sampling it. The trapezoid rule joins samples with lines (error O(h^2)); Simpson fits parabolas (O(h^4), exact for cubics); Romberg applies Richardson extrapolation to a ladder of halved-step trapezoid estimates, cancelling error terms two orders at a time to reach machine precision in a handful of levels; adaptive Simpson subdivides only where the function is hard; and Gauss-Legendre places n nodes optimally to integrate polynomials of degree 2n-1 exactly. This module implements all five and verifies them against integrals with known values (polynomials, exp, trig, the Gaussian bell), confirming the convergence orders -- trapezoid error quarters and Simpson's sixteenths each time the step is halved.

Quadrature convergence: steeper slope = higher order error vs samples (log-log): trapezoid slope -2, Simpson -4, Gauss far steeper 1e-16 1e-12 1e-8 1e-4 1e0 trapezoid O(h^2) Simpson O(h^4) Gauss-Legendre 4 64 2048 samples n (log) -> absolute error (log)
error vs samples on log-log axes, where each method's slope is its convergence order
Numerical quadrature: integral of e^x cos x on [0, pi]

  exact value = -12.070346316390

              method            estimate       error
   trapezoid (n=100)    -12.072331874092     2.0e-03
     Simpson (n=100)    -12.070346055171     2.6e-07
  Gauss-Legendre (8p)    -12.070346316390     1.2e-14
    adaptive Simpson    -12.070346316389     3.6e-13
             Romberg    -12.070346316390     1.8e-15

  Convergence order (error as the sample count n doubles):
       n     trapezoid   ratio       Simpson   ratio
      16      7.77e-02       -      3.95e-04       -
      32      1.94e-02    4.0x      2.49e-05   15.9x
      64      4.85e-03    4.0x      1.56e-06   16.0x
     128      1.21e-03    4.0x      9.73e-08   16.0x
     256      3.03e-04    4.0x      6.08e-09   16.0x

  Trapezoid halves-the-step-quarters-the-error (O(h^2)); Simpson cuts it 16-fold
  (O(h^4)); Romberg and Gauss-Legendre reach machine precision in a few evaluations.
  It powers everything from physics simulations to option pricing to Bayesian evidence.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\quadrature.svg

Cubic spline interpolation: a smooth curve through every point

A single high-degree polynomial through many points oscillates wildly between them (Runge's phenomenon). A cubic spline instead fits a separate cubic to each interval and stitches them C^2 -- value, slope, AND curvature match at every join -- giving the smoothest interpolant, the shape a flexible draftsman's ruler naturally takes. The construction reduces to solving for the knot second derivatives, a tridiagonal system solved in O(n) by the Thomas algorithm; the natural spline sets zero end curvature, the clamped spline fixes end slopes. This module builds and evaluates the spline and its derivatives, verified to pass through every knot, be C^2, reproduce cubics exactly, and stay near the true Runge curve where a single polynomial explodes to ~1.9. The interpolation behind fonts, animation, and CAD.

Cubic spline vs single polynomial on Runge's function both pass through the 11 knots, but the polynomial (red) oscillates while the spline (green) stays smooth cubic spline single polynomial true 1/(1+25x^2) knots
spline (green) and single polynomial (red) through the same knots on Runge's function -- the polynomial oscillates, the spline stays smooth
Cubic spline: a separate cubic per interval, stitched C^2 smooth

  6 knots; spline passes through them all: True
  natural end curvatures: M0 = 0.0, Mn = 0.0
  value/slope/curvature at interior knot 3: 0.100 / -1.034 / -0.141

  Runge's function 1/(1+25x^2), 11 equally spaced knots -- spline vs one polynomial:
       x      true    spline   polynomial
    0.70    0.0755    0.0747      -0.2262
    0.80    0.0588    0.0588       0.0588
    0.90    0.0471    0.0476       1.5787
    0.95    0.0424    0.0429       1.9236

  The single polynomial oscillates to ~1.6 near the edges (Runge's phenomenon)
  while the spline hugs the true curve. The spline minimizes total curvature -- the
  shape a flexible ruler takes. It's the interpolation behind fonts, animation, and CAD.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\spline.svg

The Fast Fourier Transform: O(n log n) instead of O(n^2)

The discrete Fourier transform turns n samples into their n frequency components -- the recipe behind audio and image compression, spectrum analysis, and fast polynomial multiplication. Computed directly it costs O(n^2); the Cooley-Tukey FFT does the same transform in O(n log n) by splitting the samples into even- and odd-indexed halves, transforming each recursively, and combining them with twiddle-factor butterflies -- collapsing a million-sample transform from 10^12 operations to ~2x10^7, ~50,000x faster. The same butterfly runs backwards for the inverse, and the convolution theorem turns an O(n^2) convolution into three FFTs. This module implements the radix-2 FFT, inverse, naive DFT check, and FFT convolution using only built-in complex numbers, verified to match the DFT, round-trip exactly, recover known frequencies, and satisfy Parseval's identity.

FFT: a two-tone signal and its frequency spectrum the signal in time (top) decomposes into two sharp peaks in frequency (bottom) time domain 4 Hz 12 Hz 0 16 32 frequency bin -> magnitude
a two-tone signal in time decomposing into two sharp peaks in its frequency spectrum
FFT: decompose a signal into its frequency components

  signal = cos(2pi*4t) + 0.5 cos(2pi*12t), 64 samples
  FFT matches the naive DFT: True
  round-trip ifft(fft(x)) == x: True

  strongest frequency bins (0..n/2):
    bin   4 (4 Hz): magnitude 32.00
    bin  12 (12 Hz): magnitude 16.00
    bin  24 (24 Hz): magnitude 0.00
  -> peaks at 4 Hz and 12 Hz, amplitude ratio 2:1, exactly the input.

  Convolution via the FFT (multiply spectra): (1+2x)(3+4x+5x^2) =
    [3.0, 10.0, 13.0, 10.0]  (= 3 + 10x + 13x^2 + 10x^3)

  Operation count, FFT (n log2 n) vs naive DFT (n^2):
           n           FFT               DFT   speedup
        1024        10,240         1,048,576      102x
       32768       491,520     1,073,741,824    2,184x
     1048576    20,971,520 1,099,511,627,776   52,428x

  For a million samples the FFT is ~50,000x faster -- the difference between
  instant and infeasible. It is why digital audio, JPEG, MP3, and MRI all exist.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\fft.svg

Gaussian elimination and LU decomposition: solving A x = b

A x = b -- n equations in n unknowns -- is the most-solved problem in computation: circuit analysis, structural mechanics, least squares, the linearized step of every nonlinear solver. Gaussian elimination row-reduces to triangular form and back-substitutes in O(n^3), and done once it factors A = L U into lower- and upper-triangular matrices, after which each new right-hand side is solved in O(n^2) by two triangular sweeps. Partial pivoting swaps in the largest pivot at each step to keep it numerically stable, recorded as a permutation P so P A = L U. The determinant is the product of U's diagonal times the permutation sign, and the inverse comes from solving against each unit column. This module builds the factorization, solves systems, and computes determinants and inverses, verified by residuals and P A = L U over hundreds of random systems.

LU decomposition: A splits into triangular L and U L unit-lower-triangular (left), U upper-triangular (right); together they factor the pivoted A L 1.00 0.00 0.00 0.67 1.00 0.00 -0.67 0.20 1.00 U -3.00 -1.00 2.00 0.00 1.67 0.67 0.00 0.00 0.20
the L and U triangular factors that a pivoted matrix splits into
Gaussian elimination / LU: solve A x = b, factor once, reuse

  A = [[2, 1, -1], [-3, -1, 2], [-2, 1, 2]],  b = [8, -11, -3]
  solution x = [2.0, 3.0, -1.0]   residual ||Ax-b|| = 4.4e-16

  LU factorization (P A = L U), row permutation [1, 2, 0]:
    L (unit lower): [[1.0, 0.0, 0.0], [0.667, 1.0, 0.0], [-0.667, 0.2, 1.0]]
    U (upper):      [[-3.0, -1.0, 2.0], [0.0, 1.667, 0.667], [0.0, 0.0, 0.2]]
    check P A == L U: True

  Reusing the factorization for several right-hand sides (each O(n^2)):
    b = [8, -11, -3]  ->  x = [2.0, 3.0, -1.0]
    b = [1, 0, 0]  ->  x = [4.0, -2.0, 5.0]
    b = [0, 5, 5]  ->  x = [10.0, -5.0, 15.0]

  determinant = -1  (sign 1 x product of U's diagonal)
  inverse row 0 = [4.0, 3.0, -1.0]
  A * inverse = identity: True

  Factor once in O(n^3), then every new b costs O(n^2): the reason LU beats
  re-eliminating from scratch. Partial pivoting (swap in the largest pivot) keeps it
  numerically stable -- it underlies circuit analysis, FEM, and every linear solve.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\linsolve.svg

QR decomposition and least squares: fitting more data than parameters

Any matrix A (m >= n) factors as A = Q R with Q orthonormal (Q^T Q = I) and R upper-triangular. It is the workhorse of overdetermined systems: to fit a model to more data points than parameters, solving the least-squares problem min ||A x - b|| by R x = Q^T b is far more stable than the normal equations A^T A x = A^T b (which square the condition number). The construction is the Gram-Schmidt process -- subtract from each column the components along the earlier orthonormal directions, then normalize -- in its modified form, which subtracts each projection immediately to stay orthogonal under rounding. QR also drives eigenvalue iteration and orthogonal regression. This module builds the thin QR by modified Gram-Schmidt, solves least-squares and square systems, and verifies Q^T Q = I, Q R = A, and that the residual is orthogonal to the column space.

QR least squares: the best-fit line through scattered data residuals (gray) minimized in the sum-of-squares sense; the fit line is orthogonal to them y = 1.72x + 3.18 x -> y
the least-squares best-fit line through scattered points, with the residuals it minimizes
QR decomposition: A = Q R, Q orthonormal, R upper-triangular

  Q^T Q = I (orthonormal columns): True
  Q R reconstructs A: True
  R (upper-triangular): [[14.0, 21.0, -14.0], [0.0, 175.0, -70.0], [0.0, 0.0, 35.0]] 

  Least-squares line fit to 25 noisy points (true slope 1.7, intercept 3.0):
    fitted y = 1.724 x + 3.178
    residual ||Ax - b|| = 7.408

  Least-squares quadratic fit (exact data): 3.00 x^2 + -2.00 x + 5.00  (true 3, -2, 5)

  QR solves least squares via R x = Q^T b -- more stable than the normal equations
  A^T A x = A^T b (which squares the condition number). It also powers eigenvalue
  iteration and orthogonal regression. Modified Gram-Schmidt keeps Q orthogonal.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\qr.svg

Power iteration: eigenvalues without the characteristic polynomial

An eigenvector is a direction a matrix only stretches: A v = lambda v. They govern vibration modes, Markov stationary distributions, PCA axes, and PageRank -- and for anything beyond 2x2 the characteristic polynomial is a poor way to find them. Power iteration is the simplest alternative: start from a random vector and repeatedly multiply by A and normalize; each multiply amplifies the largest-|eigenvalue| direction most, so the vector converges to the dominant eigenvector and the Rayleigh quotient v^T A v / v^T v gives its eigenvalue. Shifted inverse iteration power-iterates (A - mu I)^-1 to target the eigenvalue nearest mu, and deflation peels off found eigenpairs to recover a symmetric matrix's whole spectrum. This module implements all three, verified by A v = lambda v, the trace/determinant identities, and analytic cases.

Power iteration: the Rayleigh quotient climbs to the eigenvalue estimate converging to the dominant eigenvalue (left); the full spectrum by deflation (right) lambda = 4.745 iteration -> Rayleigh quotient 4.75 3.18 1.82 0.25 eigenvalues on the real line
the Rayleigh quotient converging to the dominant eigenvalue, and the full spectrum recovered by deflation
Power iteration: multiply by A and normalize -> dominant eigenvector

  dominant eigenvalue = 4.745281  (converged in 30 iterations)
  eigenvector = [0.7779, 0.5798, 0.234, 0.0625]
  residual ||A v - lambda v|| = 2.51e-06

  Shifted inverse iteration targets any eigenvalue (the one nearest the shift):
    nearest 0.5:  lambda = 0.254719
    nearest 2.0:  lambda = 1.822717
    nearest 3.5:  lambda = 3.177283

  Full spectrum by deflation: [4.7453, 3.1773, 1.8227, 0.2547]
  sum of eigenvalues = 10.0000  (trace = 10) -- they match

  No characteristic polynomial, no root-finding: each multiply amplifies the
  largest-|lambda| direction, so the vector aligns with it. It is how PageRank ranks
  the web, PCA finds data axes, and vibration modes of a structure are computed.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\eigen.svg

Conjugate gradient: huge sparse SPD systems without a factorization

For a symmetric positive-definite A, solving A x = b by LU costs O(n^3) and stores the whole factorization -- impossible at millions of rows (finite-element meshes, image operators, graph Laplacians). Conjugate gradient solves it with nothing but matrix-vector products, so a sparse A costs O(nnz) per step and O(n) memory. It minimizes the energy (1/2)x^T A x - b^T x, choosing each search direction A-conjugate to all previous ones so it never undoes earlier progress -- converging in at most n steps exactly, far fewer in practice at a rate set by sqrt(kappa), which is why preconditioning (here the Jacobi diagonal) is the whole game. This module implements CG and preconditioned CG, verified against a dense LU solve, the <= n step guarantee, and the monotone residual decay, and shown beating steepest descent's zig-zag.

Conjugate gradient converges in <= n steps; steepest descent crawls residual norm vs iteration (log scale): CG plunges, steepest descent zig-zags slowly 1e-12 1e-9 1e-6 1e-3 1e0 conjugate gradient steepest descent iteration -> residual norm
the residual plunging to machine precision under CG while steepest descent crawls (log scale)
Conjugate gradient: minimize (1/2)x^T A x - b^T x with mat-vecs only

  20x20 SPD system:
    CG converged in 12 iterations (<= n = 20)
    residual ||A x - b|| = 1.96e-09
    matches LU: True

  Steepest descent on the same system: 22 iterations to the same tol
    -- CG picks A-conjugate directions so it never undoes earlier progress;
    steepest descent zig-zags: 1.8x as many steps.

  Jacobi-preconditioned CG: 12 iterations (residual 2.7e-09)

  No factorization, O(nnz) per step, O(n) memory: CG (and its preconditioned
  cousins) solve the million-unknown SPD systems of finite elements, image
  reconstruction, and graph Laplacians that a dense LU could never store.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\conjugate_gradient.svg

SVD and PCA: the axes a matrix acts along

Every matrix A factors as A = U S V^T: orthonormal input directions V, orthonormal outputs U, and non-negative singular values S saying how much A stretches each -- geometrically, A sends the unit sphere to an ellipsoid whose semi-axes are the singular values. It is the most informative factorization: the rank, the 2-norm and condition number, the best low-rank approximation (Eckart-Young, the basis of image compression), and the pseudo-inverse all read off it. Here it is built via the symmetric eigendecomposition of A^T A (reusing the power-iteration eigensolver). Principal component analysis is SVD of mean-centred data: the top singular vectors are the directions of greatest variance. This module computes the thin SVD, low-rank reconstruction, and PCA with explained variance, verified by A = U S V^T, orthonormality, and the variance ordering.

PCA: the principal axes of a tilted data cloud points (blue) with the 1st (green) and 2nd (orange) principal components; singular values (right) 66.8 s1 13.5 s2 singular values
a tilted data cloud with its PCA principal axes, and the singular-value spectrum
SVD: A = U S V^T -- orthonormal axes and the stretches between them

  singular values: [6.491, 3.4449]
  reconstruction A = U S V^T holds: True
  rank 2, spectral norm 6.4910, condition number 1.8842

  Low-rank approximation (Eckart-Young) of a 6x6 nearly-rank-1 matrix:
    rank k   approximation error
         1                0.0841
         2                0.0617
         3                0.0449
         6                0.0000
  -- rank 1 already captures almost everything: the basis of image/data compression.

  PCA of a tilted 2D cloud (120 points):
    component 1 [0.881, 0.474], explains 96.1% of variance
    component 2 [-0.474, 0.881], explains 3.9%

  SVD gives the rank, the 2-norm, the condition number, the best low-rank fit, and
  the pseudo-inverse -- and PCA (SVD of centred data) finds the directions of greatest
  variance. It underlies image compression, recommender systems, and latent semantics.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\svd.svg

k-means clustering: finding groups in unlabelled data

Given points and a number k, k-means partitions them so each belongs to the nearest centroid, minimizing the total within-cluster squared distance (the inertia). Lloyd's algorithm alternates two steps -- assign each point to its nearest centroid, then move each centroid to its members' mean -- each of which can only lower the inertia, so it converges (to a local minimum). k-means++ seeding spreads the initial centres by picking each with probability proportional to its squared distance from the nearest chosen one, avoiding bad starts, and a few restarts keep the best. The inertia elbow and the silhouette score both flag the natural number of clusters. This module implements Lloyd's with random and k-means++ init, multi-restart selection, and the silhouette, verified to recover well-separated blobs with monotone inertia.

k-means: points coloured by cluster, X marks the centroids four recovered clusters (left); the inertia elbow at the true k (right) X X X X 1 2 3 4 5 6 7 elbow at k=4 number of clusters k -> inertia
points coloured by recovered cluster with their centroids, and the inertia elbow marking the true k
k-means: partition points so each is nearest its cluster's centroid

  160 points, k=4: converged in 3 iterations
  cluster sizes [40, 40, 40, 40], inertia 102.3, silhouette 0.851
  centroids: [[11.9, 10.0], [2.0, 2.0], [9.0, 3.0], [4.9, 9.1]]

  Inertia falls every Lloyd iteration (assign, then re-centre):
    after 1 iter(s): inertia 227.2
    after 2 iter(s): inertia 102.3  (-124.9)
    after 3 iter(s): inertia 102.3  (-0.0)
    after 4 iter(s): inertia 102.3  (-0.0)
    after 5 iter(s): inertia 102.3  (-0.0)
    after 6 iter(s): inertia 102.3  (-0.0)

  The 'elbow': inertia vs k drops sharply until the true cluster count, then flattens.
     k     inertia  silhouette
     1      4438.1       0.000
     2      2118.0       0.523
     3      1105.1       0.652
     4       102.3       0.851
     5        89.9       0.729
     6        80.4       0.597
     7        69.9       0.483

  The elbow at k=4 and the peak silhouette both flag the true four groups. k-means
  powers customer segmentation, color quantization, and vector quantization -- and
  k-means++ seeding spreads the initial centres to avoid Lloyd's bad local minima.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\kmeans.svg

Linear and logistic regression: fitting a line and a decision boundary

The two workhorses of supervised learning. Linear regression fits y = w.x + b by minimizing squared error -- solved in closed form by QR least squares (avoiding the normal equations' condition-number squaring) or by gradient descent for large data. Logistic regression predicts a probability sigmoid(w.x + b) for binary labels, fit by gradient descent on the convex log-loss, and its decision boundary w.x + b = 0 is a separating hyperplane. Both are linear models; logistic just squashes through the sigmoid to stay a probability, and an L2 penalty shrinks the weights for generalization. This module fits linear regression by both QR and gradient descent, logistic by gradient descent with optional L2, and reports R^2 for regression and accuracy / log-loss for classification, verified against exact fits and separable data.

Regression: a fitted line (left) and a logistic decision boundary (right) least-squares line through noisy points; sigmoid probability crossing 0.5 at the boundary linear least squares boundary x=5.2 logistic: P(class 1) vs x
the least-squares line through noisy points, and the logistic sigmoid crossing 0.5 at the decision boundary
Linear regression: least-squares line through noisy data

  QR fit:   y = 1.442 x + 2.452  (true slope 1.5, intercept 2.0)
  GD fit:   y = 1.442 x + 2.452  (matches QR)
  R^2 = 0.7831

Logistic regression: probability of class 1 vs x (boundary where p = 0.5)
  accuracy 100.0%, log-loss 0.0314
  decision boundary at x = 5.23  (data splits at 5)

  Log-loss falls as gradient descent proceeds:
       10 epochs: log-loss 0.5806
      100 epochs: log-loss 0.1399
     1000 epochs: log-loss 0.0638
     6000 epochs: log-loss 0.0314

  Linear regression fits a line by minimizing squared error (closed form or GD);
  logistic squashes w.x+b through the sigmoid to a probability and minimizes log-loss,
  a convex objective with one global minimum. Both are the base of modern ML pipelines.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\regression.svg

Decision trees: classification by the best yes/no questions

The most interpretable model, and the base learner of random forests and gradient boosting -- the workhorses of tabular ML. CART grows a tree greedily: at each node it tries every feature and every threshold and keeps the split that most reduces the IMPURITY of the children, where Gini impurity is 1 - sum p^2 (the chance two random draws differ) and entropy is -sum p log2 p (bits of surprise), both zero for a pure node. Recursing carves the plane into axis-aligned rectangles, each a leaf that votes the majority class; a max-depth or minimum-node-size cap fights the overfitting a fully grown tree invites. The path from root to leaf reads as a plain if/else rule, and no feature scaling is needed. This module builds the classifier with Gini or entropy, exposes the learned rules and feature importances, and is checked on separable blobs, a train/test split, and a known single split.

Decision tree: axis-aligned regions carved by best-split recursion each rectangle is a leaf; colour = predicted class; dots = training points learned rules if x[0] <= 5.11: if x[1] <= 4.95: predict 0 {0: 30} else: predict 2 {2: 30} else: predict 1 {1: 30}
the axis-aligned decision regions the tree carves out, with training points and the learned rules
Decision tree (CART): recursive best-split classification

  90 points, 3 classes, 2 features (x, y)
  training accuracy 100.0%, depth 2, 3 leaves

  Learned rules (Gini impurity):
    if x[0] <= 5.11:
      if x[1] <= 4.95:
        predict 0  {0: 30}
      else:
        predict 2  {2: 30}
    else:
      predict 1  {1: 30}

  Feature importances:  x = 0.50,  y = 0.50

  Depth cap trades fit for simplicity:
     max_depth=1:  2 leaves, accuracy 66.7%
     max_depth=2:  3 leaves, accuracy 100.0%
     max_depth=3:  3 leaves, accuracy 100.0%
       unlimited:  3 leaves, accuracy 100.0%

  Entropy criterion: accuracy 100.0%, 3 leaves
  (a pure node has gini 0 and entropy 0; a 50/50 node has gini 0.50, entropy 1.00)

  A tree asks the single threshold question that most purifies its samples,
  then recurses. The result is a set of axis-aligned rectangles tiling the plane,
  each labelled by majority vote -- readable rules, the base learner of forests.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\decision_tree.svg

Random forests: a committee of decorrelated trees

A single decision tree overfits -- it memorizes noise by growing pure leaves. A random forest averages many trees deliberately weakened to disagree, so their errors cancel while their signal adds. Two randomizations decorrelate them: BAGGING trains each tree on a bootstrap sample (n rows drawn with replacement, ~63% distinct), and FEATURE SUBSAMPLING lets each split consider only a random sqrt(d) subset of features so no one strong feature dominates every tree. Prediction is a majority vote. Because ~37% of rows are out-of-bag for each tree -- never seen by it -- voting each row over only its out-of-bag trees gives a free, honest validation estimate needing no held-out set. This module builds a bagged forest of the CART learner with per-node feature sampling, majority-vote prediction, out-of-bag scoring, and averaged feature importances, verified to beat an overfit single tree on a noisy problem with its OOB estimate tracking true test error.

Random forest vs single tree: voting smooths the decision boundary noisy circular rule; the single tree (left) memorizes noise, the forest (right) generalizes single tree (overfit) random forest (41 trees)
the jagged single-tree boundary beside the smoother forest boundary on the same noisy data
Random forest: a committee of decorrelated decision trees

  350 train / 150 test points, 2 real + 3 noise features, 10% label flips

  single tree (unlimited depth):  train 100.0%, test 70.0%,  64 leaves (memorized noise)
  random forest (41 trees):       train 95.4%, test 84.7%,  out-of-bag 81.4%

  Out-of-bag error is a free validation estimate -- each row is scored only by the
  ~37% of trees that never trained on it. Here OOB tracks true test error closely.

  Feature importances (real features 0,1 should dominate the 3 noise features):
          x:  0.30 ############
          y:  0.34 ##############
     noise1:  0.11 ####
     noise2:  0.11 #####
     noise3:  0.13 #####

  More trees only help -- test accuracy stabilizes as the ensemble grows:
      1 trees: test 72.7%, oob 78.0%
      3 trees: test 80.0%, oob 71.1%
      9 trees: test 83.3%, oob 74.6%
     21 trees: test 84.7%, oob 78.0%
     41 trees: test 84.7%, oob 81.4%

  Bagging (bootstrap rows) plus per-split feature subsampling decorrelate the trees
  so their errors cancel while signal adds. The forest boundary is smoother and more
  robust than any single tree -- the strong default for tabular data.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\random_forest.svg

Gaussian mixtures & EM: soft, probabilistic clustering

k-means assigns each point hard to its nearest centroid; a Gaussian mixture instead models the data as drawn from k Gaussians and asks, for each point, the PROBABILITY it came from each -- a soft assignment that lets clusters differ in size, weight, and spread, and that comes with a likelihood. The fit is Expectation-Maximization: the E-step computes each point's responsibility (posterior over components) with the parameters fixed, and the M-step re-estimates each component as the responsibility-weighted mean, variance, and weight of the data. Each round provably cannot decrease the log-likelihood -- that monotone climb is the standard correctness check -- and EM converges to a local optimum, so it is run from several inits and the best kept. This module fits a diagonal-covariance mixture in any dimension with log-sum-exp numerics, gives soft responsibilities and hard labels, and reports AIC/BIC for choosing k -- verified to recover known mixture parameters, climb the log-likelihood every iteration, and let BIC select the true number of components.

Gaussian mixture (EM): points tinted by soft responsibility, and BIC picking k colour blends the P(component) of each point; ellipses are the fitted 2-sigma components soft clusters + 2-sigma ellipses k=1 k=2 k=3 k=4 k=5 min BIC BIC vs number of components
points tinted by their soft responsibilities with the fitted 2-sigma component ellipses, and the BIC curve dipping at the true k
Gaussian mixture model (EM): soft, probabilistic clustering

  360 points, 3 clusters of unequal spread, 2-D

  converged in 25 iterations, log-likelihood -1259.0

  Recovered components (true means (2,2) sd .5, (8,8) sd 1.4, (2,8) sd .9):
    mean ( 1.89, 8.04)  sd (0.80,0.80)  weight 0.33
    mean ( 2.06, 2.01)  sd (0.48,0.46)  weight 0.33
    mean ( 7.81, 7.90)  sd (1.53,1.24)  weight 0.34

  EM climbs the log-likelihood monotonically (first / last 4 iterations):
    iter  0: -2065.9
    iter  1: -1642.4
    iter  2: -1514.4
    iter  3: -1496.8
    iter 21: -1259.0
    iter 22: -1259.0
    iter 23: -1259.0
    iter 24: -1259.0

  BIC selects the number of components (lower is better):
    k=1: BIC   3622.7  AIC   3607.2
    k=2: BIC   2775.7  AIC   2740.7
    k=3: BIC   2600.4  AIC   2546.0
    k=4: BIC   2613.4  AIC   2539.6
    k=5: BIC   2623.0  AIC   2529.8
    BIC minimized at k=3 (true is 3)

  Unlike k-means' hard nearest-centroid assignment, a mixture gives each point a
  probability of belonging to each component, so clusters can differ in size, weight,
  and spread -- and the likelihood lets BIC/AIC choose k in a principled way.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\gmm.svg

Hidden Markov models: decoding sequences with hidden state

A hidden Markov model describes a sequence you can see (emissions) generated by a chain of states you cannot (the hidden path) -- the weather driving whether you carry an umbrella, or a dealer switching between a fair and a loaded die. Three questions have three exact dynamic-programming answers over the trellis: the FORWARD algorithm sums the probability of every consistent path in O(T k^2) to evaluate the sequence (a naive sum is O(k^T)); VITERBI is the same recursion with max for sum plus backpointers, giving the single most-likely hidden path; and BAUM-WELCH is EM -- forward-backward gives the posterior of each state and transition at each step, and those soft counts re-estimate the matrices, the likelihood provably climbing each round. It all runs in log space with log-sum-exp so long sequences never underflow. This module implements forward, Viterbi, forward-backward posteriors, and Baum-Welch training, verified that Viterbi recovers a planted path, forward and backward agree on the likelihood, and Baum-Welch relearns a known loaded-die model with monotone log-likelihood.

Hidden Markov model: decoding the dishonest casino first 120 rolls: true hidden state, Viterbi decode, and the posterior P(loaded) ribbon true viterbi P(loaded) 1.0 learned emissions (loaded=red, fair=blue): die faces 1-6 1 2 3 4 5 6
the true hidden fair/loaded path, the Viterbi decode, the posterior P(loaded) ribbon, and the relearned emission distributions
Hidden Markov model: the occasionally-dishonest casino

  260 die rolls; hidden state is fair (0) or loaded (1)

  forward log-likelihood of the rolls: -442.96
  Viterbi decode accuracy vs the true hidden path: 76.9%
  fraction of time loaded -- true 0.31, Viterbi 0.13

  forward-backward posterior: 24 rolls flagged loaded with >90% confidence

  Baum-Welch relearns the model from the rolls alone (no hidden states given):
    learned P(roll a six | loaded state) = 0.67  (true 0.60)
    learned P(roll a six | fair state)   = 0.20  (true 0.17)
    learned P(stay loaded)               = 0.76  (true 0.88)
    trained log-likelihood -434.83  vs true-model -442.96

  Three exact dynamic-programming algorithms over the trellis: forward sums all paths
  (evaluate), Viterbi maxes over them (decode), Baum-Welch uses forward-backward soft
  counts to relearn the matrices (EM). All in log space so long sequences never underflow.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\hmm.svg

The Kalman filter: optimal tracking of a hidden state

Where a hidden Markov model tracks a DISCRETE hidden state, the Kalman filter tracks a CONTINUOUS one -- a position and velocity, a trajectory -- from noisy measurements in real time, with no growing history. It is the optimal estimator for a linear system with Gaussian noise, and the math behind GPS, guidance, and sensor fusion. The world is a linear-Gaussian state space: the true state evolves as x <- F x + process noise, the sensor reports z = H x + measurement noise. The filter carries a Gaussian belief (mean, covariance) and alternates PREDICT (push through the dynamics, uncertainty grows) and UPDATE (fold in a measurement weighted by the Kalman gain K = P H' (H P H' + R)^-1, uncertainty shrinks). Because both model and sensor are noisy, the fused estimate beats either alone -- its variance provably below the sensor's -- and a backward RTS smoother, using future data, beats the causal filter. This module implements the multivariate filter and smoother with self-contained matrix helpers, verified on constant-velocity tracking: error and variance fall below the raw measurements', a steady-state gain is reached, a perfect sensor is trusted exactly and a useless one ignored, and the smoother improves on the filter.

Kalman filter: fusing a motion model with a noisy sensor left: truth vs noisy measurements vs filtered vs smoothed track; right: estimate variance collapsing truth measured filtered smoothed position-estimate variance vs step 24 0
the true track, noisy measurements, filtered and smoothed estimates, and the estimate variance collapsing to a steady state
Kalman filter: optimal tracking of a hidden state from noisy measurements

  80 steps, position sensor with noise sd = 6.0

  RMSE vs truth:  raw measurements  6.40
                  Kalman filter     3.52   (45% better than raw)
                  RTS smoother      2.18   (66% better than raw)

  Estimate variance collapses from 24.0 to a steady 8.60 (measurement variance is 36):
    step  0: var  24.00 ##############################
    step  1: var  24.01 ##############################
    step  2: var  22.51 ############################
    step  5: var  16.39 ####################
    step 10: var  11.05 ##############
    step 40: var   8.60 ###########
    step 79: var   8.60 ###########

  Velocity is never measured, only inferred: final estimate 3.64 (truth ~3.60).

  Predict pushes the belief through the motion model (variance grows); update folds in
  a measurement weighted by the Kalman gain (variance shrinks). The fused estimate beats
  either the model or the sensor alone -- and the backward smoother, using future data,
  beats the causal filter. This is the math behind GPS, guidance, and sensor fusion.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\kalman.svg

PageRank: ranking a graph by its random walk

The algorithm that launched Google, and a clean application of Markov chains and the dominant eigenvector. PageRank scores every node of a directed graph by one recursive idea -- a node is important if important nodes link to it -- formalized as a random surfer who with probability d follows a random out-link and with probability 1-d teleports to a uniformly random page. The score is the fraction of time the surfer spends on each page: the STATIONARY DISTRIBUTION of that chain, equivalently the dominant eigenvector of the Google matrix G = d M + (1-d)/N 11'. The teleport makes G strictly positive, so Perron-Frobenius guarantees a unique positive stationary vector and POWER ITERATION converges to it geometrically at rate d. Dangling nodes (no out-links) would leak probability, so their mass is redistributed by teleport, and it is all done sparsely without forming the dense NxN matrix. This module computes PageRank by sparse power iteration with damping and correct dangling handling, plus the personalized variant, verified against the analytic stationary distribution of small chains, ring symmetry, and the fixed-point property.

PageRank: node size is stationary probability; edges are links importance flows from important linkers; right: power-iteration convergence about 0.11 ad 0.02 blog 0.21 home 0.33 orphan 0.02 post1 0.08 post2 0.08 shop 0.15 iteration -> L1 change (log scale) 1e0 1e-2
a small web graph with each node sized by its PageRank, and the power-iteration convergence curve on a log scale
PageRank: ranking a small web by the random surfer's stationary distribution

  8 pages, 13 links, damping 0.85

  Rank (share of surfer's time), highest first:
       home: 0.3313  in-links 5  ########################################
       blog: 0.2137  in-links 3  ##########################
       shop: 0.1463  in-links 2  ##################
      about: 0.1126  in-links 1  ##############
      post1: 0.0793  in-links 1  ##########
      post2: 0.0793  in-links 1  ##########
         ad: 0.0188  in-links 0  ##
     orphan: 0.0188  in-links 0  ##

  Scores sum to 1.000000 (a probability distribution).
  Note 'home' wins not just on in-link COUNT but because important pages link to it --
  that recursive definition is the whole point; raw in-degree would tie several pages.

  Personalized PageRank (teleport home = 'shop') re-weights the graph:
       shop: uniform 0.1463 -> shop-personalized 0.2736
       home: uniform 0.3313 -> shop-personalized 0.3672
       blog: uniform 0.2137 -> shop-personalized 0.1629

  Power iteration converges geometrically (rate ~ damping = 0.85):
    step  0: L1 change 8.85e-01
    step  1: L1 change 4.62e-01
    step  2: L1 change 3.07e-01
    step  4: L1 change 2.22e-01
    step  8: L1 change 1.16e-01
    step 16: L1 change 3.16e-02

  The teleport term makes the Google matrix strictly positive, so Perron-Frobenius
  guarantees a unique stationary vector and power iteration converges at rate d.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\pagerank.svg

LU & Cholesky: factoring a matrix to solve, invert, and take determinants

Solving A x = b once is easy; solving it for many right-hand sides, or getting det(A) or A^-1, is where FACTORIZATION pays. LU decomposition writes any square matrix as P A = L U -- a permutation of row swaps (partial pivoting, for stability), a unit-lower-triangular L, and an upper-triangular U -- by Gaussian elimination in O(n^3) once; afterwards each solve is two O(n^2) triangular sweeps, the determinant is the signed product of U's diagonal, and the inverse is n solves. For a symmetric positive-definite matrix, CHOLESKY A = L L' is the smaller, stabler special case: half the work, no pivoting, and it succeeds if and only if the matrix is positive definite -- so attempting it IS the standard SPD test, the backbone of least squares and Kalman filters. This module implements LU with partial pivoting, Cholesky, triangular and general solves, determinant, and inverse, verified by reconstructing P A = L U and A = L L', cross-checking determinants against cofactors, confirming Cholesky rejects non-positive-definite matrices, and round-tripping A A^-1 = I.

LU and Cholesky: a matrix as a product of triangular factors cells shaded by magnitude (blue negative, red positive); zeros show the triangular structure A original 2.0 1.0 1.0 4.0 -6.0 0.0 -2.0 7.0 2.0 L unit lower 1.0 0.0 0.0 0.5 1.0 0.0 -0.5 1.0 1.0 U upper 4.0 -6.0 0.0 0.0 4.0 1.0 0.0 0.0 1.0 = x A (SPD) symmetric pos-def 4.0 2.0 2.0 2.0 5.0 3.0 2.0 3.0 6.0 L Cholesky 2.0 0.0 0.0 1.0 2.0 0.0 1.0 1.0 2.0 L' transpose 2.0 1.0 1.0 0.0 2.0 1.0 0.0 0.0 2.0 = x
the L and U factors of A and the L, L' factors of an SPD matrix, shaded by magnitude so the triangular zero-structure shows
LU decomposition: P A = L U by Gaussian elimination with partial pivoting

  A =
    [  2.00    1.00    1.00]
    [  4.00   -6.00    0.00]
    [ -2.00    7.00    2.00]

  L (unit lower triangular) =
    [  1.00    0.00    0.00]
    [  0.50    1.00    0.00]
    [ -0.50    1.00    1.00]

  U (upper triangular) =
    [  4.00   -6.00    0.00]
    [  0.00    4.00    1.00]
    [  0.00    0.00    1.00]

  pivot row order [1, 0, 2], permutation sign -1
  reconstruction max|PA - LU| = 0.00e+00

  det(A) = sign * prod(diag U) = -16.0  (== determinant() -16.0)

  Factor once, then solve for many right-hand sides cheaply (O(n^2) each):
    A x = [5.0, -2.0, 9.0]  ->  x = [1.000, 1.000, 2.000]  (A x back = [5.0, -2.0, 9.0])
    A x = [1.0, 0.0, 0.0]  ->  x = [0.750, 0.500, -1.000]  (A x back = [1.0, 0.0, 0.0])
    A x = [0.0, 1.0, 1.0]  ->  x = [-0.688, -0.625, 2.000]  (A x back = [0.0, 1.0, 1.0])

Cholesky: A = L L' for a symmetric positive-definite A (half the work, no pivoting)

  A =
    [  4.00    2.00    2.00]
    [  2.00    5.00    3.00]
    [  2.00    3.00    6.00]

  L =
    [  2.00    0.00    0.00]
    [  1.00    2.00    0.00]
    [  1.00    1.00    2.00]

  reconstruction max|L L' - A| = 0.00e+00

  Attempting Cholesky IS the positive-definiteness test:
             SPD [[4,2,2],...]: positive definite = True
      indefinite [[1,2],[2,1]]: positive definite = False
       negative [[-1,0],[0,1]]: positive definite = False

  LU factors any square matrix and powers solves, determinants, and inverses;
  Cholesky is the smaller, stabler SPD special case behind least squares and Kalman.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\lu.svg

Gaussian process regression: prediction with honest error bars

Where linear regression fits fixed coefficients, a Gaussian process fits a distribution over FUNCTIONS and returns a calibrated error bar that widens where there is no data. It assumes any finite set of function values is jointly Gaussian with covariance set by a KERNEL -- the RBF kernel k(x,x') = sigma^2 exp(-||x-x'||^2/2l^2) encoding 'smooth, with length scale l'. Conditioning that joint Gaussian on the observations gives the posterior in closed form: mean = k*'(K+sigma_n^2 I)^-1 y and variance = k(x*,x*) - k*'(K+sigma_n^2 I)^-1 k*, the single linear solve done by a Cholesky factorization of the SPD matrix (K + noise) and reused for the log MARGINAL LIKELIHOOD that scores hyperparameters. Noise-free, the posterior interpolates the data exactly with zero variance there; far from data the variance rises back to the prior. This module builds GP regression with the RBF kernel, posterior mean and variance, and marginal-likelihood length-scale selection -- verified to interpolate noise-free data exactly, grow uncertainty away from data, recover a known smooth function, and peak the marginal likelihood near the true length scale.

Gaussian process: posterior mean and 2-sigma confidence band the band pinches shut at observations and flares in the gap and beyond -- calibrated uncertainty posterior mean true function observations 2-sigma band
the posterior mean tracking the true function with a 2-sigma band that pinches shut at observations and flares wide in the gap and beyond
Gaussian process regression: prediction with honest error bars

  8 noisy observations of a smooth function, with a gap in the middle

  length scale chosen by max marginal likelihood: l = 0.7 (log ML -7.12)
  marginal likelihood across the grid:
    l =  0.2: log ML   -9.98
    l = 0.35: log ML   -9.37
    l =  0.5: log ML   -8.38
    l =  0.7: log ML   -7.12  <- selected
    l =  1.0: log ML   -7.48
    l =  1.4: log ML  -12.20
    l =  2.0: log ML  -22.94
    l =  3.0: log ML  -50.10

  Posterior 2-sigma band: ~0.16 at the data, ~1.99 in the middle gap -- uncertainty tracks where data is.
  100% of the true curve lies inside the 2-sigma band (well-calibrated).

  A GP places a prior over smooth functions via a kernel, then conditions on the data
  in closed form -- one Cholesky solve gives both the mean and the variance. The band
  pinches to zero at noise-free data and flares back to the prior far away: uncertainty
  you can actually trust, which is why GPs drive Bayesian optimization and active learning.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\gaussian_process.svg

Bayesian optimization: minimizing an expensive black box

Some objectives are costly to evaluate -- a hyperparameter sweep that trains a model each time, a physical experiment, a slow simulation -- and grid or random search wastes most of the budget on uninteresting regions. Bayesian optimization builds a cheap probabilistic surrogate (a Gaussian process) of the objective and spends each expensive evaluation where an ACQUISITION FUNCTION says the expected payoff is highest, balancing EXPLOITATION (sample where the surrogate predicts a low value) against EXPLORATION (sample where it is uncertain). The classic acquisition is EXPECTED IMPROVEMENT: with current best f_best and posterior (mu, sigma), EI = (f_best - mu) Phi(z) + sigma phi(z) where z = (f_best - mu)/sigma -- zero at observed points, large where the surrogate is both promising and unsure, so maximizing it trades the two off automatically. This module implements EI and the full loop over a bounded domain, verified to locate the minima of a 1-D multimodal function (to within 0.001 of optimum in 20 evaluations) and the 2-D Branin function, and to beat random search at equal budget.

Bayesian optimization: GP surrogate + Expected Improvement left: surrogate (blue) and 2-sigma band over the true function (green), sampled points; EI below Expected Improvement (next sample at its peak) best-so-far vs evaluation BO best-so-far random runs
the GP surrogate and 2-sigma band over the true function with sampled points and the EI curve, beside the best-so-far convergence outpacing random search
Bayesian optimization: minimize an expensive black box in few evaluations

  1-D multimodal target on [2.7, 7.5], 20 total evaluations

  found minimum at x = 5.136, f = -1.8990
  true minimum   at x = 5.146, f = -1.8996
  gap to optimum: 0.0006

  Best-so-far value as evaluations accrue:
    after  1 evals: -0.4240
    after  4 evals: -1.8958
    after  6 evals: -1.8958
    after  9 evals: -1.8970
    after 13 evals: -1.8983
    after 20 evals: -1.8990

  On 2-D Branin (global min 0.398), BO vs random search at equal budget:
    Bayesian opt : mean 0.446, best 0.407 (all runs within 0.5 of optimum)
    random search: mean 3.339, best 0.826 (erratic)

  The GP surrogate turns a few expensive samples into a full belief over the objective;
  Expected Improvement then spends each new evaluation where the payoff is highest,
  balancing exploiting the current best against exploring the uncertain regions. This is
  how modern hyperparameter tuners and experiment optimizers work.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\bayes_opt.svg

DBSCAN: density clustering of arbitrary shapes

k-means and Gaussian mixtures need you to pick k and assume blobby clusters. DBSCAN assumes neither: it finds clusters as connected regions of high point density, discovers their number automatically, handles arbitrary shapes (interlocking moons, concentric rings), and explicitly labels outliers as NOISE instead of forcing every point into a cluster. Two parameters set 'dense enough' -- a radius EPS and a count MIN_PTS: a CORE point has at least min_pts neighbours within eps, a BORDER point is within eps of a core but not itself core, and everything else is NOISE. A cluster grows by starting at a core point and flood-filling through core-to-core neighbourhoods, so an S-curve is recovered whole where k-means would slice it. This module implements DBSCAN with the core/border/noise classification and a k-distance helper for choosing eps, verified to separate two interlocking half-moons that k-means cannot, flag sparse outliers as noise, discover the cluster count on its own, and degenerate sensibly at extreme parameters.

DBSCAN: two moons recovered by density; outliers flagged as noise left: points coloured by cluster (grey x = noise); right: k-distance elbow for choosing eps eps = 0.22 sorted 4th-neighbour distance (elbow = eps)
two moons coloured by discovered cluster with noise points marked as grey crosses, and the k-distance graph whose elbow suggests eps
DBSCAN: density-based clustering of arbitrary shapes with noise

  232 points (220 on two interlocking moons + 12 outliers), eps = 0.22, min_pts = 5

  clusters discovered (no k given): 2
  points flagged as noise:          4

  Point classification:
      core: 224
    border: 4
     noise: 4

  Cluster sizes:
    cluster 0: 115 points
    cluster 1: 113 points

  The k-distance graph (sorted distance to the 4th neighbour) -- its elbow marks eps:
     10th pct: 0.056 ##
     30th pct: 0.072 ###
     50th pct: 0.084 ###
     70th pct: 0.103 ####
     90th pct: 0.140 ######
     97th pct: 0.234 #########

  Because it grows clusters by local density instead of distance to a centroid, DBSCAN
  recovers the crescents as single clusters (k-means would cut each in half), needs no k,
  and calls sparse points noise instead of forcing them into a cluster.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\dbscan.svg

Hierarchical clustering: a tree of nested groupings

k-means and DBSCAN give one flat partition; hierarchical clustering gives the whole family at once, as a tree. Agglomerative clustering starts with every point its own cluster and repeatedly merges the two closest, recording each merge and the distance at which it happened -- the DENDROGRAM. Cut it at any height to read off a flat clustering, so the number of clusters comes from where you cut, often the biggest gap in merge heights, rather than being fixed in advance. What 'closest' means is the LINKAGE: SINGLE (nearest points, tends to chain along filaments), COMPLETE (farthest points, compact clusters), AVERAGE (mean pairwise, UPGMA), and WARD (least increase in within-cluster variance, tight and spherical). Merge heights are monotone for these, so the tree has no crossings. This module builds the full dendrogram by the Lance-Williams update and cuts it into k clusters, verified to recover well-separated blobs with all linkages, climb merge heights monotonically, and show single-linkage chaining where complete linkage stays compact.

Hierarchical clustering: points cut into 3 (left), dendrogram (right) the dendrogram branch heights are merge distances; cutting at a height gives a clustering cut -> 3 clusters 18.2 0
points coloured by the three-cluster cut beside the dendrogram whose branch heights are merge distances, with the cut line marked
Hierarchical agglomerative clustering: a tree of nested groupings

  24 points, Ward linkage, 23 merges to one cluster

  Merge heights climb monotonically (no dendrogram inversions):
    merge  0: height 0.113
    merge  1: height 0.181
    merge  2: height 0.210
    merge 11: height 0.598
    merge 21: height 16.961
    merge 22: height 18.230
  monotone: True

  Largest jump in merge height is before the final 3 merges -> suggests 3 clusters.

  Cutting into 3 clusters gives sizes {0: 8, 1: 8, 2: 8}
  clusters recover the true blobs: True

  Linkage changes cluster shape -- on a long chain of 24 evenly-spaced points, cut in 2:
       single: split sizes [5, 19]  chains (peels an end)
     complete: split sizes [10, 14]  compact (even split)
      average: split sizes [10, 14]  
         ward: split sizes [10, 14]  

  Every merge is recorded with the distance at which it happened, so one run yields
  the entire family of clusterings: cut the tree low for many tight groups, high for a
  few broad ones. No k needed up front -- read it off the biggest gap in merge heights.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\hierarchical.svg

Naive Bayes: fast probabilistic classification

Naive Bayes applies Bayes' theorem with one bold simplification -- it assumes the features are conditionally independent given the class. That is 'naive' (words and pixels are not independent), but it collapses a joint distribution into a product of per-feature terms, so training is a single counting pass and prediction a sum of logs. The posterior is P(c | x) proportional to P(c) prod_i P(x_i | c); in log space we pick the class maximizing log P(c) + sum_i log P(x_i | c). Two flavours differ only in P(x_i | c): GAUSSIAN models each continuous feature as a per-class Normal (estimate mean and variance), while MULTINOMIAL models word counts with Laplace add-alpha smoothing so an unseen word never zeroes the product -- the workhorse of spam filters. This module implements both entirely in log space, verified that the Gaussian model separates blobs and matches a hand-computed posterior exactly, that the multinomial model classifies documents and its smoothing prevents zero probabilities, and that the predicted class-probabilities are normalized. Despite the crude assumption it is the strong baseline every fancier classifier must beat.

Naive Bayes: Gaussian decision regions (left), spam word-evidence (right) left: regions coloured by predicted class with the training points; right: each word’s log-odds free money offer meeting report project 0 (spam right, ham left)
the Gaussian decision regions with per-class means, and the multinomial spam filter's per-word log-odds as a diverging bar chart
Gaussian naive Bayes: continuous features, one Gaussian per class per feature

  105 points, 3 classes, training accuracy 100.0%

  Learned per-class feature statistics:
    class 0: mean (-0.16, 0.10)  sd (0.52,0.52)  prior 0.33
    class 1: mean ( 6.06, 1.07)  sd (0.87,0.97)  prior 0.33
    class 2: mean ( 2.10, 6.05)  sd (0.47,0.46)  prior 0.33

  Posterior class probabilities at a few points:
    [0.0, 0.0]: c0=1.00, c1=0.00, c2=0.00
    [3.0, 3.0]: c0=0.00, c1=1.00, c2=0.00
    [6.0, 1.0]: c0=0.00, c1=1.00, c2=0.00

Multinomial naive Bayes: word counts, a classic spam filter

  8 documents, vocab ['free', 'money', 'offer', 'meeting', 'report', 'project'], training accuracy 100.0%

  Per-word evidence, log P(word|spam) - log P(word|ham) (positive => spammy):
        free: +2.54 +++++++++++++++
       money: +1.48 ++++++++
       offer: +1.48 ++++++++
     meeting: -2.51 ---------------
      report: -1.53 ---------
     project: -1.53 ---------

  Classifying new documents:
    [free, money, offer] -> spam (P(spam)=1.00)
    [meeting, report, project] -> ham (P(spam)=0.00)
    [free, meeting, report] -> ham (P(spam)=0.18)

  The independence assumption is false -- words co-occur -- yet summing per-feature
  log-evidence gives a fast, strong classifier trained in a single counting pass. It is
  the baseline every fancier text or tabular model has to beat.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\naive_bayes.svg

k-nearest-neighbours: lazy, instance-based learning

k-NN is the ultimate lazy learner: it builds no model. To predict a point it finds the k nearest training points and lets them VOTE (classification) or AVERAGES their values (regression) -- all the work at query time, a non-parametric decision boundary that traces the data. The number of neighbours k trades variance for bias: k=1 fits every point exactly (a jagged, noise-fitting boundary) while large k averages over a wide region (smooth but blurred), and the vote can be uniform or DISTANCE-WEIGHTED (weight = 1/distance, so closer neighbours count more). Because it compares raw coordinates it is sensitive to feature scaling, so standardization is included. A brute-force search is O(n) per query; a k-d tree cuts that to O(log n) in low dimensions. This module implements k-NN classification and regression with both weightings and leave-one-out cross-validation, verified that 1-NN memorizes the training labels, that it recovers separable classes and a smooth regression target, that distance-weighting follows the closest neighbour, that LOO selects k>1 under label noise, and that its neighbours agree with the k-d tree.

k-NN: the decision boundary smooths as k grows k=1 traces every noisy point (jagged); larger k averages over neighbours (smooth) k = 1 (overfits) k = 15 (LOO-selected)
the jagged k=1 decision boundary beside the smoother cross-validation-selected k on the same noisy two-class data
k-nearest-neighbours: lazy, non-parametric classification

  120 points, 2 classes split by a diagonal with 15% label noise

  Training accuracy vs k (k=1 memorizes, larger k smooths):
    k =  1: train accuracy 100.0%
    k =  3: train accuracy 89.2%
    k =  7: train accuracy 88.3%
    k = 15: train accuracy 88.3%
    k = 31: train accuracy 88.3%

  Leave-one-out cross-validation (honest accuracy) picks k:
    k =  1: LOO 0.750 ##############################
    k =  3: LOO 0.825 #################################
    k =  5: LOO 0.825 #################################
    k =  7: LOO 0.850 ##################################
    k =  9: LOO 0.867 ###################################
    k = 11: LOO 0.850 ##################################
    k = 15: LOO 0.875 ###################################  <- best
    k = 21: LOO 0.875 ###################################
    k = 31: LOO 0.858 ##################################

  Best k by LOO = 15: k=1 overfits the noise, very large k oversmooths.

  k-NN regression on a noisy sine (k=5): R^2 uniform 0.952, distance-weighted 1.000

  k-NN builds no model at all -- it just stores the data and votes among the nearest
  neighbours at query time. Small k means low bias but high variance (jagged, noise-
  fitting); large k means the opposite. Cross-validation finds the balance.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\knn.svg

Gradient boosting: shallow trees that correct each other

A random forest averages independent deep trees; gradient boosting grows trees in SEQUENCE, each correcting the errors of those before it. It is gradient descent in FUNCTION space: start with a constant, then repeatedly fit a small tree to the negative gradient of the loss and add a shrunken step of it to the running model. For squared-error regression that gradient is exactly the RESIDUAL y - F(x), so each tree learns what the ensemble still gets wrong; for logistic classification it is y - sigmoid(F(x)). Three knobs trade bias for variance: the number of trees adds capacity, the LEARNING RATE shrinks each tree's contribution (small rates need more trees but generalize better -- shrinkage is regularization), and tree DEPTH caps the feature interactions each weak learner captures. This module implements boosting for squared-error regression and log-loss binary classification over self-contained regression trees, with staged predictions, verified that training loss decreases monotonically as trees are added, that the ensemble beats a single tree by ~250x on a noisy target, that a smaller learning rate needs more trees, and that it separates a circular class boundary. This is the method that wins most tabular-data competitions.

Gradient boosting: the fit sharpens as trees accumulate left: data with the ensemble fit after 1, 5, and 120 trees; right: training loss falling final (120 trees) 5 trees 1 tree trees added -> training MSE (log scale) 1e0 1e-3
the regression fit sharpening from 1 to 120 trees over the noisy data, beside the training loss falling monotonically on a log scale
Gradient boosting: shallow trees added in sequence, each fixing the last's errors

  regression on 70 noisy points of sin(x) + 0.3x

  final R^2 0.9997, MSE 0.0003

  Training loss falls monotonically as trees are added:
    after   1 trees: MSE 0.7882
    after   2 trees: MSE 0.6447
    after   5 trees: MSE 0.3595
    after  10 trees: MSE 0.1474
    after  30 trees: MSE 0.0083
    after  60 trees: MSE 0.0017
    after 120 trees: MSE 0.0003

  A single depth-3 tree: MSE 0.0826 -- boosting is 252x better.

  Learning rate vs number of trees (shrinkage regularizes):
    lr=0.02 trees= 10: MSE 0.6537
    lr=0.02 trees= 40: MSE 0.2198
    lr=0.02 trees=120: MSE 0.0181
    lr=0.1  trees= 10: MSE 0.1474
    lr=0.1  trees= 40: MSE 0.0035
    lr=0.1  trees=120: MSE 0.0003
    lr=0.5  trees= 10: MSE 0.0031
    lr=0.5  trees= 40: MSE 0.0001
    lr=0.5  trees=120: MSE 0.0000

  Classification on a circular boundary (a nonlinear problem for a linear model):
    accuracy 99.5%, log-loss 0.113

  Boosting is gradient descent in function space: each tree fits the residual (the
  negative gradient of the loss), and a shrunken step of it is added to the model. Small
  learning rates plus many shallow trees is the recipe that wins tabular competitions.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\gradient_boosting.svg

Spectral clustering: cutting a graph by its Laplacian

k-means splits space by distance to a centroid, so it fails on non-convex shapes -- concentric rings, interlocking moons. Spectral clustering escapes that by working on a GRAPH: connect nearby points with weighted edges, then cut the graph into pieces dense inside and sparse between. Remarkably, that combinatorial cut is solved (relaxed) by linear algebra -- the eigenvectors of the graph LAPLACIAN. Build a Gaussian affinity matrix, form the normalized Laplacian L = I - D^-1/2 W D^-1/2, take the eigenvectors of its k smallest eigenvalues (the number near zero equals the number of connected components), embed each point by its coordinates there, and run k-means in that space -- where the tangled shapes become tight, linearly separable blobs. This module builds the affinity graph and Laplacians, extracts the low eigenvectors by reusing a symmetric eigensolver on cI - L (turning smallest into largest), and clusters the embedding, verified to separate concentric rings and two moons that k-means cannot, and that the Laplacian's zero-eigenvalue multiplicity counts the graph's connected components. Built on the eigen and k-means modules.

Spectral clustering: rings split correctly (left), the embedding that does it (right) left: original points coloured by spectral cluster; right: the two Laplacian eigenvectors -- tangled rings become separable blobs rings, spectral labels eigenvector embedding
two concentric rings correctly split by spectral clustering, beside the two-eigenvector embedding in which the tangled rings become separable point clouds
Spectral clustering: cutting a graph by its Laplacian eigenvectors

  44 points on two concentric rings (inner radius 1, outer 3)

  spectral clustering separates the rings: True
  plain k-means separates the rings:       False  (it can't -- centroids can't wrap a ring)

  Laplacian's smallest eigenvalues: [0.0, 0.0007]
  Two near-zero values => two clusters; the theorem: zero-eigenvalue multiplicity
  equals the number of connected components of the affinity graph.

  In the 2-eigenvector embedding the rings collapse to two tight point clouds --
  k-means in that space is trivial. That is the whole trick: solve a hard geometric
  clustering by an easy one after a spectral change of coordinates.

  Sanity check: 3 far-apart blobs -> 3 graph components (and 3 zero Laplacian eigenvalues).

  wrote C:\Users\acwic\symplectic-nbody\examples\output\spectral_clustering.svg

Particle filters: nonlinear, non-Gaussian tracking

The Kalman filter is optimal but only for LINEAR dynamics with GAUSSIAN noise. When the motion or sensor is nonlinear, or the belief is multimodal, its single Gaussian breaks. A particle filter drops that assumption: it represents the belief as a CLOUD of weighted samples and propagates them through the true, arbitrary dynamics -- sequential Monte Carlo. Each step PREDICTS (push every particle through the motion model plus noise), WEIGHTS (reweight by the likelihood of the actual measurement), and RESAMPLES (draw a new equal-weight set in proportion to the weights, killing unlikely particles and duplicating likely ones). Resampling is the crux: without it a few particles hoard all the weight (DEGENERACY) and the cloud stops representing the posterior. The EFFECTIVE SAMPLE SIZE 1/sum(w^2) measures that, and we resample only when it drops below N/2, using low-variance systematic resampling. This module implements a generic bootstrap filter with adaptive systematic resampling, verified on a nonlinear tracking problem: its estimate beats the raw sensor by ~60%, resampling keeps the effective sample size high where a weight-only filter collapses to a single particle, and more particles reduce the error. Built with its own Gaussian sampler.

Particle filter: nonlinear tracking (left), resampling saves the cloud (right) left: truth vs noisy sensor vs particle-filter estimate; right: effective sample size with and without resampling truth measured PF estimate resample threshold N/2 with resampling without (collapses) effective sample size vs step
the particle-filter estimate tracking the nonlinear truth below the noisy sensor, beside the effective sample size staying healthy with resampling and collapsing without it
Particle filter: sequential Monte-Carlo estimation of a nonlinear system

  50 steps, nonlinear motion x <- x + 0.3 sin(x) + 0.5, noisy position sensor

  mean absolute error vs truth:  raw measurements 0.754
                                 particle filter  0.293  (61% better)

  Effective sample size (of 500) -- resampling is what stops degeneracy:
    adaptive resampling:  min ESS 212, 10 resamples
    NO resampling:        min ESS 1.1 (cloud collapses to ~1 particle)

  More particles, lower error (mean over 3 seeds):
     20 particles: error 0.333
    100 particles: error 0.303
    500 particles: error 0.297

  Unlike the Kalman filter, a particle filter needs no linearity or Gaussian noise --
  it just pushes a cloud of samples through the true dynamics, reweights by the
  measurement likelihood, and resamples to concentrate on where the posterior lives.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\particle_filter.svg

Simulated annealing: escaping local minima by cooling

Greedy search only moves downhill and gets trapped in the first local minimum. Simulated annealing escapes by sometimes moving UPHILL, with a probability that shrinks over time -- the metallurgical analogy: heat a metal and cool it slowly so its atoms settle into a low-energy crystal, not a brittle freeze. At each step it proposes a random neighbour and applies the METROPOLIS criterion: a move lowering the cost is always taken; one raising it by delta is taken with probability exp(-delta/T). High T (early) accepts almost anything and roams freely out of local basins; as T cools only improving moves survive. The COOLING SCHEDULE is the key knob -- cool too fast and you quench into a poor minimum; cool slowly (geometric T <- alpha T) and you approach the global optimum. This module implements generic annealing over any state plus a travelling-salesman solver with 2-opt segment-reversal moves, verified to find the global minimum of a multimodal function that greedy descent misses, converge a square TSP tour to its exact optimal perimeter, beat nearest-neighbour greedy on random tours, and shrink its acceptance rate as it cools.

Simulated annealing: a shorter TSP tour (left), cost cooling (right) left: greedy nearest-neighbour (grey) vs annealed tour (blue); right: tour length as temperature falls greedy 53.5 annealed 45.6 final 45.6 iterations (cooling) -> tour length
the greedy nearest-neighbour tour beside the shorter annealed tour, and the tour length falling as the temperature cools with early uphill excursions
Simulated annealing: global optimization by cooling a fictitious temperature

  Multimodal 1-D function (many local minima):
    SA found x = -0.761, cost -4.650
    true global x = -0.760, cost -4.650
    acceptance rate over the run: 24.6% (high early, near zero once cold)

  Travelling salesman, 25 random cities:
    nearest-neighbour greedy tour: length 53.49
    simulated-annealing tour:      length 45.61  (15% shorter)

  Tour length as the system cools (early rises = escaping local minima):
      0% through: length 148.13
      5% through: length 105.82
     15% through: length 56.30
     40% through: length 45.61
     70% through: length 45.61
    100% through: length 45.61

  Annealing accepts uphill moves with probability exp(-delta/T): at high T it roams
  freely and hops out of local basins; as T cools only improving moves survive and it
  settles. Cool slowly enough and it approaches the global optimum -- here a far shorter
  tour than greedy, and the true minimum of a function that traps hill-climbing.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\simulated_annealing.svg

Genetic algorithms: optimization by simulated evolution

Where simulated annealing perturbs a single state, a genetic algorithm evolves a whole POPULATION, letting good solutions breed -- a direct metaphor for natural selection. Each generation SELECTS parents biased toward high fitness (tournament: the best of k random individuals), applies CROSSOVER to splice two parents' genes into offspring, MUTATES to inject new variation, and keeps the best few via ELITISM so the best-so-far never regresses. Being population-based it explores many basins at once and needs no gradients, making it strong on rugged, discrete, or black-box landscapes. This module implements a generic GA (tournament selection, one-point crossover, elitism) over both binary and real-valued genomes plus a 0/1 knapsack solver, verified to solve the OneMax bit problem to all-ones, maximize a multimodal real function, match the brute-force optimum of a small knapsack, keep the best fitness monotone under elitism, and beat random search at equal budget.

Genetic algorithm: best and mean fitness climb each generation left: OneMax (fraction of 1-bits); right: bumpy real-function fitness -- both with elitism OneMax fitness best mean bumpy-function fitness best mean
the best and mean population fitness climbing each generation on the OneMax bit problem and the bumpy real-function optimization
Genetic algorithm: optimization by simulated evolution

  OneMax (maximize 1-bits in a 60-bit genome):
    best fitness 60/60, reached all-ones: True
    population mean fitness climbed 29.8 -> 58.5

  Bumpy real function (many local optima):
    GA found x = -0.761, fitness 4.650
    true global x = -0.760, fitness 4.650

  0/1 knapsack (capacity 20):
    chose items [1, 2, 3, 5, 6], total value 28, total weight 20

  Selection biases reproduction toward fit individuals; crossover recombines their
  genes; mutation adds new variation; elitism preserves the best. The population
  explores many basins at once -- no gradients, works on discrete or black-box problems.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\genetic_algorithm.svg

Particle swarm optimization: a flock homing on the optimum

A third metaheuristic beside simulated annealing (one state) and genetic algorithms (breeding a population): a SWARM of candidate solutions flies through the search space, each remembering its own best spot and pulled toward the best any member has found -- inspired by flocking birds. Each particle carries a position and a VELOCITY updated by three pulls: INERTIA (coast on the old velocity, exploring), COGNITIVE (toward this particle's own best, individual memory), and SOCIAL (toward the swarm's global best, shared knowledge), with fresh randoms keeping it stochastic. High inertia explores, low inertia exploits, so it is often decayed over the run. No gradients, just local rules, and the swarm balances exploration against convergence. This module implements PSO over a bounded box with velocity clamping and linearly-decaying inertia, verified to find the global minimum of the Sphere, Rastrigin, and Rosenbrock benchmarks, drive the global best down monotonically, beat random search at equal budget, and converge faster when inertia decays.

Particle swarm: the flock converges on the Rastrigin optimum left: final particle positions clustered at the origin; right: convergence with decaying vs fixed inertia final swarm (green + = optimum) decaying inertia fixed inertia iterations -> global best cost (log)
the final swarm clustered at the Rastrigin optimum, beside the global-best convergence with decaying vs fixed inertia on a log scale
Particle swarm optimization: a flock of solutions homing on the optimum

      Sphere (min at origin): cost 0.00000 at (0.000, 0.000)
   Rastrigin (min at origin): cost 0.00000 at (0.000, -0.000)
  Rosenbrock (min at (1,1)): cost 0.00000 at (1.000, 1.000)

  PSO vs random search at equal budget (40x300 = 12000 evaluations):
        Sphere: PSO 0.00000   random 0.00001
     Rastrigin: PSO 0.00000   random 0.00166

  Rastrigin convergence (global best cost) -- decaying inertia converges faster:
    iter   0: decaying-w   7.507   fixed-w   7.507
    iter  10: decaying-w   0.485   fixed-w   0.010
    iter  30: decaying-w   0.161   fixed-w   0.002
    iter  60: decaying-w   0.001   fixed-w   0.002
    iter 120: decaying-w   0.000   fixed-w   0.001
    iter 199: decaying-w   0.000   fixed-w   0.000

  Each particle coasts on inertia, is pulled toward its own best spot (cognitive) and
  the swarm's best (social); r1,r2 keep it stochastic. High inertia explores, low
  inertia exploits -- decaying it does both in turn. No gradients, just local rules.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\particle_swarm.svg

Reed-Solomon codes: recovering data from errors

The error-correcting code behind QR codes, CDs, DVDs, and deep-space probes. Reed-Solomon treats a message as the coefficients of a polynomial over the finite field GF(256) and appends 2t parity symbols so the whole codeword is divisible by a fixed generator polynomial. Any corruption breaks that divisibility in a way that pinpoints both WHERE the errors are and WHAT they should have been -- correcting up to t byte-errors per block no matter how they are distributed, which is why it survives a scratch on a disc or a fading radio link. The field is GF(2^8): bytes with XOR as addition and multiplication modulo 0x11d, so every nonzero byte is a power of the generator and multiplication is log-table addition. Decoding is the classic pipeline: SYNDROMES (evaluate at the code roots), BERLEKAMP-MASSEY (the error-locator polynomial), a CHIEN search (its roots = error positions), and FORNEY's formula (the error magnitudes). This module implements GF(256) arithmetic, encoding, and full syndrome decoding, verified that a clean codeword is unchanged, that up to t corrupted bytes anywhere (including in the parity) are corrected exactly across dozens of random trials, and that one error past the limit is flagged rather than mis-corrected.

Reed-Solomon: corrupted bytes (red) located and repaired (green) top: received codeword with errors; bottom: after decoding -- message bytes | parity bytes received 52 45 1f bb 2d 53 4f 4c 4f 4c 4f 4e b2 5a 3b b8 39 e9 27 8a decoded 52 45 45 44 2d 53 4f 4c 4f 4d 4f 4e b2 24 3b b8 39 e9 27 8a yellow line = message | parity boundary; red = injected error; green = corrected
the received codeword with injected errors in red, and the decoded codeword with the same positions repaired in green, split at the message/parity boundary
Reed-Solomon error correction over GF(256)

  message: 'REED-SOLOMON'  (12 bytes)
  parity : 8 bytes  ->  corrects up to t = 4 byte-errors per block
  codeword (20 bytes): 52 45 45 44 2d 53 4f 4c 4f 4d 4f 4e b2 24 3b b8 39 e9 27 8a

  corrupting 4 bytes at positions [2, 3, 9, 13] (a scratch / noise burst):
  received (20 bytes): 52 45 1f bb 2d 53 4f 4c 4f 4c 4f 4e b2 5a 3b b8 39 e9 27 8a
  syndromes (nonzero => error detected): nonzero -> [218, 230, 113, 0]...

  decoded: corrected 4 byte-errors
  recovered codeword matches original: True
  recovered message: 'REED-SOLOMON'  (correct: True)

  12+8 block with 5 errors (one past the limit): correctly flagged as uncorrectable.

  A message is the coefficients of a polynomial over GF(256); parity makes the codeword
  divisible by the generator. Corruption breaks that divisibility, and the syndromes ->
  Berlekamp-Massey -> Chien -> Forney pipeline recovers both the error positions and
  their values. This is the code in QR codes, CDs, and deep-space telemetry.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\reed_solomon.svg

Mutual information: measuring dependence between variables

Entropy measures the uncertainty in one variable; MUTUAL INFORMATION I(X;Y) measures how much knowing one reduces the uncertainty in the other -- the shared information. Unlike correlation it captures ANY relationship, linear or not: I = 0 exactly when X and Y are independent, and it equals H(X) for a deterministic copy. Everything is built from Shannon entropy: I(X;Y) = H(X) + H(Y) - H(X,Y) = H(X) - H(X|Y). The Kullback-Leibler divergence D(p||q) = sum p log2(p/q) -- the extra bits to code p-samples with a q-code -- is the asymmetric distance from which MI is the divergence of the joint from the product of marginals, and INFORMATION GAIN (the decision-tree split criterion) is exactly the mutual information between a feature and the label. This module estimates entropies and mutual information from samples or a joint distribution with KL divergence and normalized MI, verified that independent variables have zero MI, a copy has I = H (maximal), the H(X)+H(Y)-H(X,Y) and conditional-entropy identities hold, KL is nonnegative and zero only for equal distributions, and against hand-computed values -- and it catches a nonlinear dependence that correlation reports as ~0.

Mutual information vs channel noise Y = X flipped with probability f; I(X;Y) falls from 1 bit (perfect copy) to 0 (pure noise at f=0.5) 0.5 1.0 0.00 0.05 0.10 0.20 0.30 0.40 0.50 flip probability f theory I = 1 - H(f) measured (8000 samples)
mutual information falling from 1 bit to 0 as a binary channel's flip probability rises, matching the theoretical 1 - H(f) curve
Mutual information: how much one variable tells you about another

  Binary symmetric channel, Y = X flipped with probability f:
  (I falls from H(X)=1 bit at f=0 to 0 at f=0.5, the fully-noisy channel)

    f = 0.00: I(X;Y) = 1.000 bits ########################################
    f = 0.05: I(X;Y) = 0.712 bits ############################
    f = 0.10: I(X;Y) = 0.542 bits ######################
    f = 0.20: I(X;Y) = 0.287 bits ###########
    f = 0.30: I(X;Y) = 0.123 bits #####
    f = 0.40: I(X;Y) = 0.027 bits #
    f = 0.50: I(X;Y) = 0.000 bits 

  MI sees nonlinear dependence that (linear) correlation misses:
    Y = X^2 (deterministic): correlation -0.002 (near 0, looks 'unrelated')
                             mutual information 1.522 bits (= H(X), fully dependent)

  Feature selection -- rank features by information gain about the label:
    informative: information gain 0.5054 bits
           weak: information gain 0.0702 bits
          noise: information gain 0.0003 bits

  KL divergence D(p||q) -- extra bits to code p-samples with a q-code:
    D([0.5,0.50] || fair) = 0.0000 bits
    D([0.7,0.30] || fair) = 0.1187 bits
    D([0.9,0.10] || fair) = 0.5310 bits
    D([0.99,0.01] || fair) = 0.9192 bits

  Mutual information is the shared information I(X;Y) = H(X)+H(Y)-H(X,Y): zero iff
  independent, maximal for a deterministic relationship, and blind to nothing -- it
  catches any dependency, which is why it drives feature selection and tree splits.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\mutual_information.svg

LRU & LFU caches: O(1) eviction policies

A cache holds a fixed number of items and must EVICT one when full; which one decides the hit rate. LRU (Least Recently Used) evicts the item untouched longest, betting on temporal locality -- the default in CPU caches, page tables, and web caches. LFU (Least Frequently Used) evicts the least-accessed, betting popularity persists. The craft is doing it in O(1): a naive LRU scans for the oldest item on every eviction, but a HASH MAP (key -> node) for lookup plus a DOUBLY-LINKED LIST ordered by recency makes touch-and-promote and tail-eviction O(1); LFU groups keys into frequency buckets so increments and min-frequency eviction are O(1) amortized. This module implements both with hit/miss statistics, verified that LRU evicts in true least-recently-used order (checked against a brute-force reference over 60 random workloads), that touching an item spares it, that LFU evicts the least-frequent breaking ties by recency, that capacity is never exceeded, and that a skewed hot-key workload gives LFU a higher hit rate than LRU.

Cache hit rate: LRU vs LFU by workload the best eviction policy depends on the access pattern (cache holds 10 of 200 keys) 25% 50% 75% 100% 5% 5% uniform random 23% 32% skewed (hot keys) 0% 0% looping sweep LRU LFU
LRU and LFU hit rates side by side across uniform, skewed, and looping workloads, showing no single policy wins everywhere
LRU / LFU caching: which item to evict when memory is full

  LRU trace (capacity 3), MRU-to-LRU order after each op:
    put A        -> [A]
    put B        -> [B, A]
    put C        -> [C, B, A]
    get A (hit)  -> [A, C, B]
    put D        -> [D, A, C]
    get C (hit)  -> [C, D, A]
    put E        -> [E, C, D]
    (D's insertion evicted B, the least-recently-used; E evicted A)

  Hit rate by workload (cache holds 10 of 200 keys):
    uniform random    : LRU  4.5%   LFU  5.0%
    skewed (hot keys) : LRU 22.7%   LFU 31.5%
    looping sweep     : LRU  0.0%   LFU  0.0%

  No policy wins everywhere: LFU shines when a few keys are truly hot (it remembers
  popularity), LRU adapts faster to shifting working sets. Both do get/put in O(1) --
  LRU via a hash map plus a recency-ordered doubly-linked list, LFU via frequency buckets.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\lru_cache.svg

Tries: the prefix tree behind autocomplete

A trie stores strings as a tree where each edge is a character and each root-to-node path spells a prefix. Words sharing a prefix share its path, so the structure is naturally compressed by common beginnings and every operation runs in O(length of the key) -- INDEPENDENT of how many keys are stored, unlike a hash set's collisions or a balanced tree's O(log n) whole-string comparisons. That per-character walk is exactly what powers autocomplete (every word under a prefix), longest-prefix matching (IP routers), and dictionary spell-check. Deletion unmarks a word and prunes now-childless non-terminal nodes on the way back up, and building a trie over all SUFFIXES of a text turns it into a substring index -- any substring is a prefix of some suffix. This module implements insert, search, prefix membership, autocomplete (alphabetical or frequency-ranked), deletion with pruning, longest-prefix matching, and a suffix-trie substring index, verified that it distinguishes a stored word from a mere prefix, autocompletes exactly the words under a prefix, deletes without disturbing siblings or shared prefixes, and detects substrings and their positions.

Trie: each path spells a prefix; filled nodes end a word words sharing a prefix share its path -- O(length) lookup, natural prefix compression n e r i m e r y e h o p o t word end prefix node
the trie drawn as a character-labelled tree with word-ending nodes filled green and interior prefix nodes outlined
Trie: prefix tree for autocomplete and O(len) lookup

  dictionary: 10 distinct words, 39 total insertions

  Autocomplete 'the' (alphabetical):
    ['the', 'their', 'them', 'there', 'they']
  Autocomplete 't' by insertion frequency (what a search box would rank):
    the      (typed 9x)
    to       (typed 8x)
    there    (typed 5x)
    their    (typed 4x)
    they     (typed 3x)

  membership: 'the' stored = True, 'th' stored = False (prefix only)
  starts_with 'thei' = True, 'xyz' = False
  longest stored prefix of 'thereafter' = 'there'

  Deletion prunes dead branches but spares shared prefixes:
    after deleting 'tea': autocomplete 'te' -> ['ten'] (ten kept)

  Suffix-trie substring index of 'abracadabra':
     'abra': present at [0, 7]
      'cad': present at [4]
      'bra': present at [1, 8]
      'xyz': not found

  Every operation is O(length of the key), independent of how many words are stored --
  words sharing a prefix share its path. That per-character walk is what powers
  autocomplete, spell-check, and longest-prefix IP routing.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\trie.svg

Comparison sorts: the classic four and their trade-offs

Sorting is computing's most-studied problem, and the classic comparison algorithms each make a different trade among speed, memory, stability, and worst case -- all bounded below by the O(n log n) decision-tree limit. INSERTION sort is O(n^2) but fast on nearly-sorted data and the base case big sorts fall back to; MERGE sort is O(n log n) ALWAYS and stable but needs O(n) scratch; QUICK sort is usually fastest and in-place but O(n^2) on adversarial input unless the pivot is chosen well (here median-of-three plus an insertion cutoff); HEAP sort is O(n log n) worst-case AND in-place, built on a binary heap that doubles as a priority queue. This module implements all four plus the heap with a comparison counter and a key function, verified that every sort matches Python's built-in on random, sorted, reverse, and duplicate-heavy inputs, that merge and insertion are stable while quick and heap are not, that comparison counts scale as O(n log n) for the good sorts and O(n^2) for insertion, that median-of-three keeps quick sort fast on its classic sorted/reverse adversaries, and that the heap is a correct priority queue.

Sort comparison counts vs input size (log-log) steeper slope = worse scaling: insertion O(n^2) pulls away from the O(n log n) trio 100 200 400 800 1600 insertion merge quick heap input size n (log)
comparison counts vs input size on a log-log plot, insertion's O(n^2) slope pulling away from the parallel O(n log n) lines of merge, quick, and heap
Comparison sorts: speed, stability, and worst-case trade-offs

  All four match Python's sorted() exactly; they differ in HOW they get there:

    algorithm  time           stable  in-place  note
    insertion  O(n^2)         yes     yes       great on nearly-sorted / tiny arrays
    merge      O(n log n)     yes     no        O(n) scratch; never degrades
    quick      O(n log n)*    no      yes       *O(n^2) worst; median-of-3 avoids it
    heap       O(n log n)     no      yes       worst-case AND in-place

  Comparisons vs input size (random data):
    n       insertion      merge      quick       heap
    100          2554        544        642       1016
    200          9545       1277       1552       2460
    400         42183       2958       3784       5728
    800        159812       6725       8665      13033
    1600       647433      15071      17964      29259
    (insertion's column grows ~4x when n doubles = O(n^2); the rest ~2x = O(n log n))

  Stability -- sort (key, original-index) pairs by key; do equal keys keep their order?
    insertion : stable (0 reorderings)
    merge     : stable (0 reorderings)
    quick     : UNstable (10 reorderings)
    heap      : UNstable (16 reorderings)

  The binary heap behind heap sort is also a priority queue:
    popped by priority: ['fire', 'bug', 'email', 'meeting', 'lunch']

  Every comparison sort is bounded below by O(n log n); the interesting differences are
  the constants, the memory (merge needs scratch, heap and quick don't), stability, and
  the worst case (merge and heap never degrade; quick can without a good pivot).

  wrote C:\Users\acwic\symplectic-nbody\examples\output\sorting.svg

Newton's method in n dimensions: solving nonlinear systems

One-dimensional Newton iterates x <- x - f/f'; in n dimensions the derivative becomes the JACOBIAN matrix and the division becomes solving a linear system J(x) delta = -F(x), then x <- x + delta. Each step linearizes the system at the current point, jumps to that linear model's root, and repeats -- and near a solution it converges QUADRATICALLY, the number of correct digits roughly doubling each step. It is the engine inside circuit simulation, inverse kinematics, chemical equilibrium, and optimization. When the analytic Jacobian is unavailable it is approximated by finite differences; because plain Newton can overshoot and diverge far from a root, a damped line search backtracks the step until the residual actually decreases for global robustness; and a Broyden quasi-Newton mode updates a Jacobian approximation instead of recomputing it. This module implements all three, each solving the linear step via LU with partial pivoting, verified on a circle-line intersection and the Rosenbrock stationary point, that convergence is quadratic near the root, that the finite-difference Jacobian matches an analytic one, that damping rescues a start where plain Newton diverges (arctan from far out), and that Broyden converges too.

Newton vs Broyden: residual norm per step (log scale) Newton's near-vertical drop is quadratic convergence (digits double); Broyden takes a few more steps 1e-16 1e-13 1e-10 1e-7 1e-4 1e-1 Broyden Newton (quadratic) iteration
the residual norm plunging near-vertically for Newton (quadratic convergence) and a few steps slower for Broyden, on a log scale
Newton's method in n dimensions: J(x) delta = -F(x), x <- x + delta

  System: x^2 + y^2 = 4 and y = x  ->  root (sqrt2, sqrt2) = (1.414214, 1.414214)

  Newton from (1.6, 2.4): converged in 5 iterations to (1.41421356, 1.41421356)
  residual norm per step (note the digits roughly DOUBLING -- quadratic convergence):
    step 0: |F| = 4.39e+00
    step 1: |F| = 7.43e-01
    step 2: |F| = 2.91e-02
    step 3: |F| = 5.26e-05
    step 4: |F| = 1.73e-10
    step 5: |F| = 8.88e-16

  Broyden quasi-Newton from the same start: 7 iterations (no Jacobian recompute per step; a bit slower to converge)

  Global robustness -- solving arctan(x) = 0 from a far start x0 = 5:
    plain Newton:  diverged (overshoots, |x| explodes)
    damped Newton: converged to 0.000000 in 7 iterations (backtracks until the residual drops)

  3-variable system (sum 6, sq-sum 14, product 6): root (1.0000, 2.0000, 3.0000) = (1, 2, 3)

  Each step linearizes the system at the current point and jumps to that linear model's
  root; near a solution the error squares each iteration. Far away it can overshoot, so a
  line search that backtracks until the residual falls buys global robustness. This is
  the solver inside circuit simulation, inverse kinematics, and chemical equilibrium.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\newton_nd.svg

Differential evolution: optimization by vector differences

A population optimizer for continuous spaces that, unlike genetic algorithms (mutating bits) or particle swarms (tracking velocities), mutates by ADDING SCALED DIFFERENCES between population members. That self-referential step is its signature: the spread of the population itself sets the mutation scale, so the search takes large steps while dispersed (early, exploring) and small ones as it converges (late, refining) -- no cooling schedule or velocity tuning. The classic DE/rand/1/bin per target: pick three other members and form a donor v = a + F*(b - c); build a trial by taking each coordinate from the donor with probability CR (else from the target); keep whichever of trial and target has the lower cost (greedy selection, so the best never worsens). This module implements DE/rand/1/bin over a bounded box with bound reflection and a random-search baseline, verified that it finds the global minimum of the Sphere, Rastrigin, and Rosenbrock benchmarks, that the best cost is monotone, that it beats random search at equal budget, that the solution stays in bounds, and that it scales to 10-20 dimensions. One of the most robust black-box optimizers for continuous problems.

Differential evolution: best cost per generation (log scale) greedy selection makes the best-so-far monotone; all three benchmarks driven to their global minimum 1e-12 1e-9 1e-6 1e-3 1e0 Sphere Rastrigin Rosenbrock generation
the best cost of all three benchmarks falling monotonically to their global minima on a log scale
Differential evolution: DE/rand/1/bin -- donor = a + F*(b - c)

      Sphere (min at origin): cost 0.000000 at (-0.000, 0.000)
   Rastrigin (min at origin): cost 0.000000 at (-0.000, -0.000)
  Rosenbrock (min at (1,1)): cost 0.000000 at (1.000, 1.000)

  DE vs random search at equal budget (40x200 = 8000 evaluations):
        Sphere: DE 0.000000   random 0.004577
     Rastrigin: DE 0.000000   random 0.938405
    Rosenbrock: DE 0.000000   random 0.005118

  Differential weight F trades exploration for refinement (Rastrigin, 60 iters):
    F = 0.3: best cost after 60 iters = 0.0000
    F = 0.5: best cost after 60 iters = 0.0000
    F = 0.7: best cost after 60 iters = 0.0015
    F = 0.9: best cost after 60 iters = 0.0001

  Scales to higher dimensions (Sphere):
     2-D: cost 0.000000
     5-D: cost 0.000000
    10-D: cost 0.000000
    20-D: cost 0.930333

  DE mutates by adding a SCALED DIFFERENCE between population members, so the
  population's own spread sets the step size -- large while dispersed (exploring), small
  as it converges (refining), with no schedule to tune. Greedy selection keeps the best
  monotone. It is one of the most robust black-box optimizers for continuous problems.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\differential_evolution.svg

Nelder-Mead: derivative-free optimization by a crawling simplex

Newton needs a Jacobian and gradient descent a gradient; Nelder-Mead needs NEITHER. It minimizes using only function VALUES, maintaining a simplex of n+1 points (a triangle in 2-D, a tetrahedron in 3-D) that tumbles and shrinks downhill -- the workhorse behind 'just minimize this black box' (SciPy's gradient-free default, MATLAB's fminsearch). Each iteration reflects the worst vertex through the centroid of the others and, by how good that reflection is, EXPANDS further in a promising direction, CONTRACTS back toward the centroid, or SHRINKS the whole simplex toward the best vertex. The simplex crawls like an amoeba, stretching down valleys and squeezing through the curved Rosenbrock banana. This module implements the standard algorithm with the classic coefficients, value- and size-based convergence, and restarts, verified that it finds the minimum of the Sphere, Rosenbrock, and Beale benchmarks from several starts with no gradient, that the best vertex improves monotonically, that it even minimizes a non-smooth objective, and that restarting refines the result.

Nelder-Mead on Rosenbrock: best-vertex value (log scale) the simplex crawls the curved banana valley using only function values -- no gradient 1e-14 1e-11 1e-8 1e-5 1e-2 iteration
the best-vertex value plunging as the simplex crawls the Rosenbrock valley, on a log scale, using function values alone
Nelder-Mead: derivative-free minimization by a crawling simplex

  Uses ONLY function values -- no gradient, no Jacobian. A simplex of n+1 points
  reflects, expands, contracts, and shrinks its way downhill.

      Sphere from [3.0, -2.0]: min 1.35e-21 at (0.0000, 0.0000) [true min at (0,0)]  80 iters, 156 evals
  Rosenbrock from [-1.2, 1.0]: min 7.65e-22 at (1.0000, 1.0000) [true min at (1,1)]  135 iters, 261 evals
       Beale from [1.0, 1.0]: min 1.44e-22 at (3.0000, 0.5000) [true min at (3,0.5)]  89 iters, 170 evals

  Rosenbrock best-vertex value as the simplex crawls the banana valley:
    iter   0: 5.200e+00
    iter   5: 4.604e+00
    iter  20: 1.947e+00
    iter  50: 1.850e-01
    iter 100: 1.666e-12
    iter 134: 7.647e-22

  Restarting the simplex refines the result: single run 7.89e-22 -> 3 restarts 7.89e-22

  Cost of no derivatives -- function evaluations grow with dimension (Sphere):
     2-D:   144 evaluations to converge
     4-D:   361 evaluations to converge
     8-D:  1143 evaluations to converge
    16-D:  2848 evaluations to converge

  The simplex tumbles and stretches like an amoeba: reflecting the worst vertex
  through the others, expanding into promising directions, contracting when it
  overshoots, shrinking when stuck. Robust and gradient-free -- the default when all
  you can do is evaluate the function.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\nelder_mead.svg

HITS: hubs and authorities

PageRank's contemporary rival, and it splits importance into TWO complementary scores. An AUTHORITY is a page many good hubs point to (a definitive source); a HUB is a page that points to many good authorities (a good list of links). The definitions are mutually recursive -- a good authority is linked by good hubs, a good hub links to good authorities -- resolved by iterating to a fixed point: authority(p) = sum of hub scores linking to p, hub(p) = sum of authority scores p links to, normalized each round. Written with the adjacency matrix A the update is a = A'h, h = Aa, so authorities are the dominant eigenvector of A'A and hubs of AA' -- HITS is power iteration on those matrices. Unlike PageRank's single query-independent score, HITS yields the two roles separately, so a curated link list and the source everyone cites rank differently. This module computes HITS by power iteration with normalization plus the eigenvector check, verified that on a hub-and-spoke graph the hub score flags the linker and the authority score the linked-to targets, that scores converge and are unit-normalized, that a pure authority has zero hub score and vice versa, and that the results match the dominant eigenvectors of A'A and AA'.

HITS: node size = hub score (left) vs authority score (right) the biggest hub (a link list) and the biggest authority (a cited source) are different nodes blog fan journal news portal reader student wiki hubs (link lists) blog fan journal news portal reader student wiki authorities (cited sources)
the same web graph drawn twice, node size by hub score (link lists) then by authority score (cited sources), showing the two roles fall on different nodes
HITS: hubs and authorities (two scores per node)

  8 pages, 14 links

  Top HUBS (good lists of links -- point to many authorities):
      portal: 0.630
      reader: 0.536
     student: 0.367
         fan: 0.263

  Top AUTHORITIES (definitive sources -- linked by many good hubs):
        wiki: 0.631
        news: 0.519
     journal: 0.498
        blog: 0.290

  Hubs and authorities are DIFFERENT roles -- compare the two rankings and PageRank:
        page     hub  authority  pagerank
        wiki   0.000      0.631     0.369
        news   0.205      0.519     0.192
     journal   0.205      0.498     0.111
        blog   0.169      0.290     0.095
         fan   0.263      0.000     0.058
      portal   0.630      0.000     0.058
      reader   0.536      0.000     0.058
     student   0.367      0.000     0.058

  Best hub is 'portal' (a link directory); best authority is 'wiki' (what everyone cites).
  PageRank collapses this into one score; HITS keeps the two roles distinct.

  A good authority is pointed to by good hubs; a good hub points to good authorities.
  That mutual recursion is resolved by power iteration -- authorities are the dominant
  eigenvector of A'A, hubs of AA'. Kleinberg's HITS ran per query; PageRank runs once
  globally. Both turn the link graph into a ranking by linear algebra.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\hits.svg

Classical MDS: a map from a table of distances

Given only the pairwise DISTANCES between objects -- cities on a map, dissimilar survey responses, aligned sequences -- multidimensional scaling reconstructs COORDINATES whose distances match, answering 'where do these sit relative to each other?' from distances alone. Classical (Torgerson) MDS solves it in closed form by DOUBLE CENTERING: from the squared-distance matrix, B = -1/2 J D2 J turns distances into the centered inner-product (Gram) matrix B = X X', and eigendecomposing B = V L V' gives the coordinates X = V L^{1/2} -- the top k eigenvectors scaled by the square roots of their eigenvalues, with the eigenvalues themselves reporting how much shape each dimension carries. The map is unique only up to rotation, reflection, and translation. This module builds the squared-distance and double-centered matrices, extracts the embedding by eigendecomposition, reports the eigenvalue spectrum, and Procrustes-aligns a reconstruction to a known map, verified that it recovers a square, a line, and random point sets so their reconstructed distances match, that a flat configuration has exactly two positive eigenvalues, and that the stress is essentially zero for Euclidean inputs. Built on the eigen module.

Classical MDS: map recovered from distances alone left: true cities (green) vs MDS reconstruction (blue), Procrustes-aligned; right: eigenvalue scree A B C D E F true (green) vs recovered (blue) 1 2 3 4 5 6 eigenvalue index (2 positive = 2-D)
true city positions and the MDS reconstruction Procrustes-aligned on top of them, beside the eigenvalue scree showing two positive dimensions
Classical MDS: reconstructing a map from a distance table

  6 cities; input is ONLY the 6x6 distance matrix.

  distance table (rounded):
             A     B     C     D     E     F
      A   0.00  4.03  6.10  4.47  2.55  3.20
      B   4.03  0.00  3.16  4.03  4.03  2.12
      C   6.10  3.16  0.00  3.04  4.61  2.92
      D   4.47  4.03  3.04  0.00  2.12  2.06
      E   2.55  4.03  4.61  2.12  0.00  2.06
      F   3.20  2.12  2.92  2.06  2.06  0.00

  Recovered a 2-D embedding; stress (distance mismatch) = 2.43e-23
  max pairwise-distance error after reconstruction = 2.86e-12

  Eigenvalue scree (how much 'shape' each axis carries -- flat map => 2 positive):
    axis 1:   21.283 ##############################
    axis 2:   10.258 ##############
    axis 3:    0.000 
    axis 4:    0.000 
    axis 5:    0.000 
    axis 6:   -0.000 
  -> 2 clearly-positive eigenvalues = intrinsic dimensionality 2

  Procrustes-aligned recovered coordinates vs the true map:
     city       true (x,y)    recovered (x,y)
        A  ( 0.00, 0.00)    (-0.00, 0.00)
        B  ( 4.00, 0.50)    ( 4.00, 0.50)
        C  ( 5.00, 3.50)    ( 5.00, 3.50)
        D  ( 2.00, 4.00)    ( 2.00, 4.00)
        E  ( 0.50, 2.50)    ( 0.50, 2.50)
        F  ( 2.50, 2.00)    ( 2.50, 2.00)

  Double-centering the squared-distance matrix turns distances into inner products;
  its top eigenvectors, scaled by sqrt(eigenvalue), are the coordinates. The map is
  recovered up to rotation, reflection, and translation -- distances fix shape, not
  orientation -- so Procrustes rotates the reconstruction back onto the known layout.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\mds.svg

Skip lists: a probabilistic ordered dictionary

A balanced binary search tree gives O(log n) search, insert, and delete but needs intricate rotations; a SKIP LIST reaches the same expected bounds with almost no bookkeeping, using RANDOMNESS instead. It is an ordered linked list with EXPRESS LANES: each node is promoted to the next level up with probability p (~1/2), so level 0 holds every element, level 1 about half, level 2 a quarter -- a tower of sparser and sparser shortcut lists. A search drops down from the top lane, skipping far along each level before descending, covering the list in O(log n) expected hops. Because promotion is a coin flip there are no rotations: insert picks a random height and splices in, delete unlinks, and the structure stays probabilistically balanced on its own (Redis sorted sets use it). This module implements a skip-list ordered map with insert, search, delete, ordered iteration, range queries, and min/max, verified against a brute-force sorted dictionary over 4000 random operations (every search, deletion, and traversal agrees), that keys iterate sorted, that duplicates update rather than duplicate, that range queries return exactly the in-range keys, and that the level distribution is geometric as designed.

Skip list: express lanes over a sorted linked list higher lanes skip more keys; a search drops down from the top, covering the list in O(log n) hops L0 L1 L2 L3 L4 3 6 7 9 12 17 19 21 25 26
the skip list drawn as stacked express lanes, taller towers skipping more keys, over the fully-populated level-0 sorted list
Skip list: an ordered map with express lanes for O(log n) search

  10 keys: [3, 6, 7, 9, 12, 17, 19, 21, 25, 26]

  The tower of shortcut lanes (level 0 has every key; higher lanes are sparser):
    L4:  9
    L3:  9  12
    L2:  9  12  19
    L1:  3   6   9  12  17  19  21  25
    L0:  3   6   7   9  12  17  19  21  25  26

  Searching for 21 (drop down from the top, skip far, descend):
    L4: advanced to 9
    L3: advanced to 12
    L2: advanced to 19
    found 21: True in 3 forward hops (a level-0 scan would take up to 7)

  Level distribution over 4000 keys (geometric: ~half promoted each rung):
    level 0: 1975 (49.4%) #########################
    level 1: 1019 (25.5%) #############
    level 2:  516 (12.9%) ######
    level 3:  238 ( 5.9%) ###
    level 4:  125 ( 3.1%) ##
    level 5:   64 ( 1.6%) #
    level 6:   30 ( 0.8%) 
    level 7:   14 ( 0.4%) 
    level 8:   11 ( 0.3%) 
    level 9:    5 ( 0.1%) 
    level 10:    1 ( 0.0%) 
    level 11:    1 ( 0.0%) 
    level 12:    1 ( 0.0%) 

  Each node is promoted to the next lane by a coin flip, so the lanes thin out
  geometrically and a search covers the list in O(log n) expected hops -- the same
  bound as a balanced tree, but with random splices instead of rotations. No
  rebalancing: insert picks a random height and links in; delete unlinks. (Redis uses it.)

  wrote C:\Users\acwic\symplectic-nbody\examples\output\skiplist.svg

Ant colony optimization: pheromone trails for the travelling salesman

Real ants find short paths without a map: each lays a PHEROMONE trail, shorter paths get traversed sooner so their trails are reinforced first, and other ants prefer strongly-scented edges -- positive feedback that converges the colony onto good routes. Ant colony optimization turns that into a TSP solver. Each iteration a swarm of artificial ants each builds a tour, at every step choosing the next city with probability proportional to tau^alpha * eta^beta, where tau is the learned pheromone on an edge and eta = 1/distance is the greedy heuristic (alpha weights experience, beta greed). Then pheromone EVAPORATES (forgetting stale trails) and each ant DEPOSITS an amount inversely proportional to its tour length, so shorter tours leave stronger trails and the pheromone concentrates on good edges. This module implements ant system for the symmetric TSP with the standard transition rule, evaporation, length-weighted deposit, and elitist reinforcement, verified that it recovers the optimal perimeter of a square and a circle's polygon, beats the nearest-neighbour greedy tour on random cities, drives the best length down monotonically, and concentrates pheromone on short edges.

Ant colony: the converged tour and pheromone (left), length over time (right) left: strong pheromone edges glow; the best tour is drawn bold; right: best length falling tour (blue) over pheromone (yellow glow) 42.8 iterations -> best tour length
the converged tour drawn bold over the pheromone field (strong edges glowing yellow), beside the best-tour-length convergence curve
Ant colony optimization: pheromone trails solving the travelling salesman

  22 cities, 120 iterations, colony of 22 ants

  nearest-neighbour greedy tour: length 52.94
  ant colony optimization:       length 42.85  (19% shorter)

  Best tour length as pheromone accumulates (early rounds explore, then converge):
    iter   0: 57.21
    iter   5: 43.48
    iter  15: 43.48
    iter  40: 43.23
    iter  80: 42.85
    iter 119: 42.85

  Pheromone on the 22 best-tour edges holds 79% of all pheromone (of 231 possible edges) -- the colony has converged.

  Pheromone (alpha) vs greedy heuristic (beta) balance -- final tour length:
      pheromone only: 76.06
        a=1.0, b=2.0: 42.85
        a=1.0, b=5.0: 42.85
         greedy only: 44.77

  Each ant builds a tour choosing the next city by pheromone^alpha * (1/dist)^beta;
  pheromone evaporates then is redeposited inversely to tour length, so short tours
  reinforce their edges and the colony converges -- swarm intelligence, no central plan.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\ant_colony.svg

AVL trees: a self-balancing binary search tree

A plain binary search tree degrades to a linked list -- O(n) operations -- if keys arrive sorted. An AVL tree (the first self-balancing BST) prevents that by keeping every node HEIGHT-BALANCED: its two subtrees' heights differ by at most 1. After each insert or delete it checks the balance factor up the path to the root and, wherever it exceeds the bound, restores it with a local ROTATION -- a constant-time pointer rewiring that shortens the tall side. Four cases cover every imbalance: left-left and right-right take a single rotation, left-right and right-left a double. The strict invariant makes AVL the most rigidly balanced classic BST (shorter than a red-black tree), so lookups are fast at the cost of a little more rotation on updates -- where a skip list stays balanced probabilistically, an AVL tree does so deterministically. This module implements an AVL ordered map with insert, delete, search, ordered traversal, range queries, and min/max, verified against a brute-force sorted dictionary over 5000 random operations, that the height-balance invariant holds throughout, that the height stays O(log n) even for sorted insertions (where a naive BST would be linear), and that all four rotation cases trigger.

AVL tree: height-balanced, every subtree within 1 level in-order left-to-right, depth top-to-bottom; the tree stays log-deep no matter the insert order 10 20 25 30 35 40 50 60 70 80
a balanced AVL tree drawn in-order left-to-right and by depth top-to-bottom, staying log-deep regardless of insertion order
AVL tree: a binary search tree that rotates itself back into balance

  Inserting SORTED keys (the naive-BST worst case that degrades to a linked list):
         n  AVL height   naive BST   log2(n)
        15           4          15       3.9
        63           6          63       6.0
       255           8         255       8.0
      1023          10        1023      10.0
      4095          12        4095      12.0
    -> AVL height tracks log2(n); a naive BST would be n (a linear chain).

  The four rotation cases, each fixing one imbalance shape:
    insert [3, 2, 1] (LL, left-left  -> right rotation): root rebalances to 2, balanced=True
    insert [1, 2, 3] (RR, right-right-> left rotation): root rebalances to 2, balanced=True
    insert [3, 1, 2] (LR, left-right -> left then right): root rebalances to 2, balanced=True
    insert [1, 3, 2] (RL, right-left -> right then left): root rebalances to 2, balanced=True

  Example tree of 10 keys: height 4, balanced=True, sorted keys [10, 20, 25, 30, 35, 40, 50, 60, 70, 80]
  range [25, 60]: [25, 30, 35, 40, 50, 60]

  After every insert or delete, the tree checks the balance factor up the path and,
  where |left height - right height| > 1, rotates -- a constant-time pointer rewiring --
  to restore balance. Deterministic O(log n) worst case, where a skip list gets there
  probabilistically with coin flips instead of rotations.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\avl_tree.svg

Segment trees with lazy propagation: range query and range update

A Fenwick tree answers prefix sums with point updates; a SEGMENT TREE handles arbitrary RANGE queries (sum, min, max, ...) AND RANGE updates -- add a value to every element in [l, r] -- all in O(log n). Each node stores the aggregate of a contiguous segment; the root covers everything and each node splits its range in half between two children, so a query descends only into the O(log n) nodes whose segments tile the range. The trick for range UPDATES is LAZY PROPAGATION: rather than touch every leaf (O(n)), a node records a pending update as a lazy tag, applies it to itself immediately, and pushes it down to its children only when a later operation actually visits them -- so a full-array update touches ~2 log n nodes, not n. This module implements a segment tree parameterized by the aggregate (sum, min, or max) with lazy range-add updates, point updates, and range queries, verified against a brute-force array over 3000 random mixed operations for all three aggregates, that overlapping range-adds accumulate correctly, that point updates match a plain list, and that it handles single-element and full-array edge ranges.

Segment tree: each node is the sum of its segment root covers the whole array; children split the range in half; a query tiles its range with O(log n) nodes 3 1 [0,1]=4 4 1 [2,3]=5 [0,3]=9 5 9 [4,5]=14 2 6 [6,7]=8 [4,7]=22 [0,7]=31
the segment tree drawn as a binary tree of segment sums, the root covering the whole array and each level halving the range
Segment tree with lazy propagation: O(log n) range query AND range update

  array: [3, 1, 4, 1, 5, 9, 2, 6]

  Range-SUM queries:
    sum[0..7] = 31
    sum[2..5] = 19
    sum[0..0] = 3
    sum[6..7] = 8

  Range-ADD updates (lazy -- a full-array add touches only O(log n) nodes):
    after +10 to [2..5]: array = [3, 1, 14, 11, 15, 19, 2, 6]
    sum[0..7] = 71,  sum[2..5] = 59
    after +100 to [0..7]: sum[0..7] = 871

  The same array under MIN and MAX aggregates:
    [0..7]  min = 1   max = 9
    [1..3]  min = 1   max = 4
    [4..6]  min = 2   max = 9
    min[0..7] after +5 to [0..3]: 2

  Lazy propagation keeps cost logarithmic -- a range-add over the WHOLE array:
    array size     16: a full-range update visits ~10 nodes (not 16) -- O(log n)
    array size    256: a full-range update visits ~18 nodes (not 256) -- O(log n)
    array size   4096: a full-range update visits ~26 nodes (not 4096) -- O(log n)
    array size  65536: a full-range update visits ~34 nodes (not 65536) -- O(log n)

  Each node holds the aggregate of a segment; a query descends only into the O(log n)
  nodes whose segments tile the range. A range update marks a 'lazy' tag on the covering
  nodes and pushes it to children only when a later operation visits them -- so add-to-a-
  window and query-a-window are both O(log n), which a Fenwick tree (point-update) can't do.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\segment_tree.svg

Convex hull: the tightest polygon enclosing points

The convex hull is the smallest convex polygon containing a point set -- the shape a rubber band snaps to around a scatter of pins, and the foundation of computational geometry (collision detection, shape analysis, path-planning boundaries). This module builds it by ANDREW'S MONOTONE CHAIN in O(n log n): sort the points, then sweep left-to-right building the lower hull and right-to-left the upper, keeping only left turns. The engine is the CROSS PRODUCT, whose sign gives orientation -- (b-a) x (c-a) > 0 is a counter-clockwise turn -- so the chain pops any vertex that would make a non-left turn, leaving only the outer boundary. From the hull come the enclosed AREA (shoelace formula), perimeter, whether an arbitrary point lies inside (orientation tests against each edge), and the DIAMETER (farthest pair, which always lies on the hull). This module implements the hull, area, perimeter, point-in-hull, and diameter, verified that a square's hull is its four corners (interior points dropped), that collinear and duplicate points are handled, that the hull is convex and counter-clockwise, that its area matches an independent shoelace value, that every input point lies inside it, and that the diameter is the true farthest pair.

Convex hull: the rubber-band boundary of a point set hull vertices in blue, interior points grey, the diameter (farthest pair) dashed yellow
a scatter of points with the convex-hull boundary outlined in blue, interior points grey, and the diameter (farthest pair) marked with a dashed line
Convex hull: the tightest polygon enclosing a set of points

  60 random points -> hull of 11 vertices (18% of the points are on the boundary)

  hull is convex & counter-clockwise: True
  every input point inside or on the hull: True

  enclosed area:  8310.0
  perimeter:      345.0
  diameter (farthest pair): 123.9  between (8.2,5.2) and (94.3,94.2)

  Hull vertices in counter-clockwise order:
    (   0.2,   60.0)
    (   8.2,    5.2)
    (  21.2,    2.6)
    (  64.2,    2.0)
    (  85.9,    5.9)
    (  96.1,   19.5)
    (  94.3,   94.2)
    (  91.7,   95.4)
    (  18.6,   96.3)
    (  14.5,   95.6)
    (   7.9,   89.1)

  Interior points are dropped -- only the extreme points survive:
       square + centre: 5 points -> 4 hull vertices
             collinear: 4 points -> 2 hull vertices
     triangle + inside: 4 points -> 3 hull vertices

  Andrew's monotone chain sorts the points, then sweeps building the lower and upper
  hulls, keeping only left turns (positive cross product) and popping any vertex that
  would make a right turn. O(n log n), dominated by the sort. The farthest pair always
  lies on the hull, so the diameter needs only the hull vertices.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\convex_hull.svg

Closest pair of points: divide and conquer beats O(n^2)

Finding the two closest of n points is trivially O(n^2); the elegant 1975 result is that divide and conquer does it in O(n log n) -- one of the first proofs that geometry could beat the brute-force quadratic. Sort by x, split into halves, recursively find the closest pair in each, and let d be the smaller distance. The only remaining candidates straddle the split line and must lie within a vertical STRIP of width 2d around it -- and there, sorted by y, each point can be closer than d to at most a constant number (7) of following points, because a d x 2d rectangle holds only so many points that are all >= d apart. So the merge is linear and T(n) = 2T(n/2) + O(n) = O(n log n). This module implements the divide-and-conquer closest pair with the strip merge plus the brute-force reference, verified that the two agree exactly on random sets of many sizes, that it finds a planted near-coincident pair, handles duplicate points (distance 0), collinear and grid inputs, small n, and survives a crowded strip that would trap a naive merge.

Closest pair: the two nearest points among 80 the closest pair (yellow) found in O(n log n) by divide and conquer d = 0.94
a scatter of points with the single closest pair ringed and connected in yellow, its distance labelled
Closest pair of points: O(n log n) divide and conquer

  80 random points in a 100x100 square

  closest pair: (37.39, 56.14) and (38.08, 56.79)
  distance: 0.9444
  brute-force agrees: True (distance 0.9444)

  Distance computations vs input size (divide-and-conquer beats O(n^2)):
         n   brute O(n^2)   D&C (approx)
        16            120             64
        64           2016            384
       256          32640           2048
      1024         523776          10240
      4096        8386560          49152
    -> at n=4096, brute does ~8.4M comparisons; D&C ~49K, a ~170x saving.

  Divide-and-conquer matches brute force exactly:
    n=  10: closest 92.7770  (brute 92.7770, match True)
    n=  50: closest 19.1854  (brute 19.1854, match True)
    n= 200: closest 5.5590  (brute 5.5590, match True)
    n= 800: closest 1.1317  (brute 1.1317, match True)

  Split the x-sorted points in half, recurse, take the smaller distance d, then check
  only the pairs inside the width-2d strip around the split line -- where, sorted by y,
  each point can beat d against at most a constant number of neighbours. Linear merge,
  so T(n) = 2T(n/2) + O(n) = O(n log n): geometry beating the quadratic.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\closest_pair.svg

Line-segment intersection: do two segments cross, and where?

Deciding whether two segments intersect -- and finding the point -- is the atom of computational geometry: collision detection, map overlays, clipping. The robust test avoids slopes (which blow up for vertical lines) and uses the ORIENTATION of point triples from the sign of a cross product: orient(a,b,c) is +1 counter-clockwise, -1 clockwise, 0 collinear. Two segments PROPERLY cross when each straddles the other's line (its endpoints have opposite orientations); the fiddly COLLINEAR/TOUCHING cases give a zero orientation and are settled by an on-segment bounding-box check -- handling them is what separates a toy from a usable predicate. The crossing point comes from the 2x2 parametric system. From this atom the module builds the SIMPLE-POLYGON test (no non-adjacent edges cross, the correctness precondition for area and point-in-polygon) and pairwise intersection counting, verified on crossing, touching, collinear-overlap, parallel, and disjoint segments, that the intersection point is correct and lies on both segments, that a convex polygon is simple while a figure-eight is not, and that a 5x5 grid has exactly 25 crossings.

Simple vs self-intersecting polygons green = simple (no non-adjacent edges cross); red = self-intersecting square (simple) convex pentagon (simple) arrow (non-convex) (simple) figure-eight (self-crossing) (self-intersecting)
four polygons, the simple ones outlined green and the self-intersecting figure-eight red
Line-segment intersection via the orientation predicate

                            case  intersect?  crossing point
               proper X-crossing        True  (2.0, 2.0)
      T-touch (endpoint on edge)        True  (2.0, 0.0)
                 shared endpoint        True  (2.0, 2.0)
               collinear overlap        True  -- (none / not a single point)
                 parallel, apart       False  -- (none / not a single point)
                        disjoint       False  -- (none / not a single point)

  Simple-polygon test (no non-adjacent edges cross -- the precondition for area/PIP):
                            square: simple = True
                   convex pentagon: simple = True
                arrow (non-convex): simple = True
      figure-eight (self-crossing): simple = False

  A 4x6 grid of horizontal/vertical segments has 24 crossings (4*6).

  orient(a,b,c) = sign of the cross product tells which side of line ab point c is on.
  Two segments properly cross iff each straddles the other's line (opposite orientations
  at its endpoints); zero orientations mean collinear/touching, settled by a bounding-box
  check. No slopes, so vertical segments are no trouble -- the robust geometric predicate.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\segment_intersection.svg

Point-in-polygon: ray casting vs winding number

Testing whether a point lies inside a polygon is the workhorse of hit-testing, GIS, and rendering. For a convex polygon a side-of-each-edge test suffices, but general (concave, star-shaped) polygons need one of two classic methods. RAY CASTING shoots a ray to infinity and counts edge crossings -- odd is inside; simple and fast, but blind to winding, so it uses the even-odd rule. WINDING NUMBER sums the signed turns the polygon makes around the point; nonzero is inside, and it correctly handles self-intersecting polygons where parity would disagree. The delicate part is boundary and vertex-graze cases: this module uses a half-open edge convention so a ray grazing a vertex counts exactly once, plus an explicit on-boundary test, and also gives the signed area (orientation) and centroid. Verified that both methods agree on convex, concave, and star polygons and across a grid, that boundary points are detected, that the signed area's sign tracks vertex orientation, and -- the textbook case -- that on a PENTAGRAM's doubly-wound centre the two rules DISAGREE: even-odd says outside, nonzero-winding says inside (winding count 2).

Point-in-polygon: concave (agree) vs self-intersecting (disagree) left: grid points green=inside, grey=outside a concave arrow; right: pentagram centre (the rules split) concave arrow (ray = winding) centre: winding=in, even-odd=out pentagram (rules disagree)
a concave arrow with grid points coloured by membership (both rules agree), and a pentagram whose centre the two rules classify differently
Point-in-polygon: ray casting vs winding number

  Concave arrow polygon, area 12.0, centroid (2.67, 3.00), orientation CCW

  Over a 7x7 grid: 12/49 points inside; ray casting and winding
  number agree on all of them (0 disagreements) -- as they must for a
  simple polygon.

  Self-intersecting PENTAGRAM (a 5-point star drawn in one stroke):
  its centre pentagon is enclosed by TWO loops, so the two rules disagree --
    ray casting (even-odd):   centre is OUTSIDE
    winding number (nonzero): centre is INSIDE
    winding count at centre:  2 (wrapped twice)

  Ray casting counts edge crossings (odd = in); winding sums the signed turns of the
  polygon around the point (nonzero = in). They agree on any simple polygon; on a
  self-overlapping one, even-odd cancels a doubly-wrapped region to 'out' while winding
  keeps it 'in'. Both handle concavity that a convex side-test cannot.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\point_in_polygon.svg

Polygon clipping: intersecting a polygon with a window

Clipping a polygon to a rectangular viewport -- or any convex region -- is fundamental to rendering (nothing off-screen is drawn), CAD, and GIS overlay. The Sutherland-Hodgman algorithm clips the SUBJECT polygon against each edge of the CONVEX CLIP polygon in turn, feeding the output of one edge as input to the next; whatever survives all clip edges is exactly the intersection. Each single-edge clip is a linear scan: for every subject edge s->e, keep e if it is inside, add the crossing point when the edge leaves or enters, drop it when both ends are outside -- 'inside' decided by the same cross-product orientation predicate as everywhere in geometry. O(n*k) for an n-vertex subject and k-edge window; the clip must be convex but the subject may be concave. This module implements clipping against an arbitrary convex clip polygon plus a rectangle-window convenience, verified that a polygon fully inside is unchanged, one fully outside clips to empty, two overlapping squares clip to their analytic 2x2 overlap, a square clips to a triangular or diamond window at the right area, a concave subject stays within the window bounds, and the clipped area never exceeds the original.

Polygon clipping: subject (grey) clipped to a window (yellow) -> result (green) Sutherland-Hodgman keeps the intersection of the subject polygon and the convex window
a concave subject polygon (grey dashed) intersected with a rectangular window (yellow dashed) to give the clipped result (green)
Sutherland-Hodgman polygon clipping: subject INTERSECT convex window

  subject polygon: 7 vertices, area 37.00

  clip to rectangle (2, 2, 8, 8): 13 vertices, area 30.25
    retained 82% of the subject's area; clipped area <= original: True

  Clipping the same subject to different convex windows:
     rectangle [2,8]^2: clipped area  30.25 (13 vertices)
              triangle: clipped area  29.85 (9 vertices)
               diamond: clipped area  30.10 (11 vertices)
       tiny centre box: clipped area   4.00 (4 vertices)

  Edge cases:
    fully inside the window -> unchanged (area 4.0)
    fully outside -> empty ([])

  The algorithm clips the subject against each clip edge in turn, feeding the output
  of one edge into the next. Per edge it keeps vertices on the inside and adds the
  crossing point wherever an edge exits or enters -- O(n*k) for an n-gon and k-edge
  convex window. This is how a renderer discards geometry outside the viewport.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\polygon_clip.svg

Marching squares: contour lines from a scalar field

Given a 2-D grid of scalar values -- a height map, a temperature field, a signed-distance function -- marching squares finds the ISO-CONTOUR at a chosen level: the curve where the field equals it (a topographic contour, an isotherm, a blob boundary). It is the 2-D sibling of marching cubes (the isosurface method behind medical imaging) and the workhorse behind every contour plot. The idea is local: at each grid cell the four corners are above or below the level, giving a 4-bit CASE INDEX (16 possibilities) that says which cell edges the contour crosses, and LINEAR INTERPOLATION between corner values places each crossing exactly where the field equals the level -- so the curve is smooth, not blocky. The two ambiguous saddle cases (opposite corners high) are resolved consistently by the cell-center average. This module builds the 16-case lookup, extracts contour segments from a grid or a sampled function, and sums contour length, verified that a radial field contours to circles of the right radius and circumference, a linear ramp gives straight contours, a level outside the field range yields nothing, a diagonal saddle gives two segments, and a closed blob's contour forms closed loops with no loose ends.

Marching squares: iso-contours of two scalar fields left: circles from a radial field; right: nested iso-lines of a Gaussian terrain f = x^2 + y^2 (circles) Gaussian terrain (iso-lines)
concentric circular contours of a radial field beside the nested iso-lines of a Gaussian terrain
Marching squares: iso-contours of a scalar field

  Radial field f = x^2 + y^2 -- contours should be circles:
       level   radius  segments    length    2*pi*r
           1     1.00        96     6.278     6.283
           4     2.00       192    12.564    12.566
           9     3.00       288    18.848    18.850
          16     4.00       384    25.131    25.133

  Gaussian terrain -- contour-line count at several heights:
    height  -0.5:   98 contour segments, total length 7.85
    height   0.0:  128 contour segments, total length 10.37
    height   0.5:  330 contour segments, total length 26.34
    height   1.0:  278 contour segments, total length 22.18
    height   2.0:  100 contour segments, total length 8.00

  Each grid cell's four corners are above or below the level -> a 4-bit case index
  selecting which edges the contour crosses; linear interpolation places the crossing
  exactly where the field equals the level, so the curve is smooth. Two ambiguous saddle
  cases are resolved by the cell-center average. This is how every contour plot is drawn.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\marching_squares.svg

Bresenham rasterization: lines and circles from integer math

Turning a mathematical line into pixels sounds trivial -- step x, round y -- but that needs slow floating point per pixel and mis-steps on steep lines. Bresenham's algorithm draws a line using ONLY INTEGER additions and comparisons: it tracks an error term measuring how far the true line has drifted from the chosen pixel, and whenever that error crosses a threshold it steps the minor axis and corrects. No division, no floating point, no rounding -- which is why it ran on the first plotters and still underlies GPU line rasterizers and grid games. All eight octants are handled uniformly with absolute deltas and step signs; the companion MIDPOINT CIRCLE draws one octant and mirrors it into the other seven by symmetry. This module implements line drawing, the midpoint circle, and a filled disk, verified that a line hits both endpoints, is 8-connected, stays within half a pixel of the true line, is symmetric under reversal, and handles every octant; and that a circle's pixels all lie within half a pixel of the true radius with 8-fold symmetry and a count tracking the circumference.

Bresenham: integer-pixel lines and a midpoint circle each square is one pixel; lines (blue) and circle (green) rasterized with integer math only
a fan of integer-pixel lines beside a midpoint-circle rasterization, each cell one pixel
Bresenham rasterization: lines and circles from integer arithmetic only

  Lines fanning from the bottom-left corner (integer pixels):
    |#     #        #       #|
    |#     #       #      ## |
    |#    #       #     ##   |
    |#    #     ##    ##     |
    |#   #     #     #       |
    |#   #    #    ##        |
    |#  #    #   ##          |
    |#  #   #  ##          ##|
    |# #   # ##        ####  |
    |# #  # #      ####      |
    |## ####   ####          |
    |##### ####              |
    |######                  |
    |########################|

  Every rasterized line is connected and within half a pixel of the true line:
    (0, 0)->(10, 3): 11 pixels, connected=True, max error 0.479
    (0, 0)->(3, 10): 11 pixels, connected=True, max error 0.479
    (0, 0)->(10, 10): 11 pixels, connected=True, max error 0.000
    (0, 0)->(-8, 5):  9 pixels, connected=True, max error 0.424

  Midpoint circle, radius 8:
    |                   |
    |       ooooo       |
    |     oo     oo     |
    |    o         o    |
    |   o           o   |
    |  o             o  |
    |  o             o  |
    | o               o |
    | o               o |
    | o               o |
    | o               o |
    | o               o |
    |  o             o  |
    |  o             o  |
    |   o           o   |
    |    o         o    |
    |     oo     oo     |
    |       ooooo       |
    |                   |

    44 pixels, max deviation from the true radius: 0.384 (< 0.5, sub-pixel)

  Circle pixel count tracks the circumference 2*pi*r (integer steps ~ r):
    r =  5:   28 pixels  (2*pi*r = 31)
    r = 10:   56 pixels  (2*pi*r = 63)
    r = 20:  112 pixels  (2*pi*r = 126)
    r = 40:  228 pixels  (2*pi*r = 251)

  Bresenham tracks an integer error term: the signed distance the true line has
  drifted from the current pixel. When it crosses a threshold, step the minor axis and
  correct the error -- no floating point, no division, no rounding. The midpoint circle
  does the same with a decision variable and draws one octant, mirroring it eight ways.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\bresenham.svg

Flood fill: paint bucket, region growing, connected components

Flood fill answers 'which cells are reachable from here through a region of the same value?' -- the paint-bucket tool, the region-grow of image segmentation, and the reachability query behind maze solving. From a seed it spreads to same-valued neighbours until it hits a boundary. Three strategies are here: a QUEUE/STACK fill (BFS/DFS visiting each cell once), a SCANLINE fill (painting whole horizontal runs and seeding only the rows above and below -- the classic optimization for big flat regions), and CONNECTED-COMPONENT LABELING (fill from every unlabeled cell to partition the grid into maximal same-valued regions). CONNECTIVITY is a parameter: 4-connected (orthogonal) or 8-connected (including diagonals), which changes what counts as one region. This module implements all three fills (4- and 8-connected) plus component labeling and sizing, verified that the strategies produce identical results, that fills respect barriers and the grid edge, that 8-connectivity merges diagonal regions 4-connectivity separates, that a bounded region leaves the rest untouched, and that a checkerboard has 9 components under 4-connectivity but 2 under 8.

Flood fill: paint bucket (left) and connected components (right) left: interior filled inside a wall; right: cells coloured by component label 8 components (4-connectivity) interior filled (blue), wall grey
an interior region filled inside a wall (barrier respected), and a grid coloured by connected-component label
Flood fill: paint bucket, region growing, connected components

  Canvas (# = wall, . = empty):
    ........
    .#####..
    .#...#..
    .#...##.
    .###..#.
    ...####.
    ........

  Paint-bucket from inside the wall (8 cells filled, ~ = paint):
    ........
    .#####..
    .#~~~#..
    .#~~~##.
    .###~~#.
    ...####.
    ........
  The fill stays inside the wall -- the barrier is respected.

  Filling the OUTER region by three strategies: queue 30, stack 30, scanline 30 cells -- all identical: True

  Connected components of a value grid:
    4-connectivity: 8 components, sizes [1, 2, 3, 3, 3, 3, 3, 7]
    8-connectivity: 6 components (diagonal touches merge some)

  A diagonal chain of 1s:
    #..
    .#.
    ..#
    region size from a corner: 4-conn = 1 (isolated), 8-conn = 3 (all three joined)

  Flood fill spreads from a seed to same-valued neighbours until it meets a boundary.
  The scanline variant paints whole horizontal runs at once, seeding only the rows above
  and below -- far fewer stack operations on big flat regions. Run it from every
  unlabeled cell and you get connected-component labeling, the atom of image analysis.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\flood_fill.svg

Bezier curves: control-point curves via de Casteljau

A Bezier curve is defined not by points it passes through but by CONTROL POINTS that pull it into shape -- it starts at the first, ends at the last, and is tugged toward the ones between. That handles behaviour is why Bezier curves are the primitive of SVG and PDF paths, TrueType fonts, and animation easing. A degree-n curve blends its n+1 control points by the Bernstein polynomials, but the elegant, numerically stable way to evaluate it is DE CASTELJAU's algorithm: repeatedly take pairwise linear interpolations of the control points at parameter t until one point remains -- that is B(t). The same construction, kept rather than discarded, SPLITS the curve at t into two Bezier curves (subdivision, the basis of adaptive rendering). This module implements de Casteljau evaluation, the derivative (tangents), subdivision, degree elevation, and arc length by adaptive subdivision, verified that the curve hits its first and last control points, that de Casteljau matches the Bernstein sum, that a linear curve is exactly the straight segment, that the curve stays within the convex hull of its control points, that subdivision reproduces the original and degree elevation preserves the shape, and that a linear curve's arc length is the endpoint distance.

Bezier curves: control polygons (dashed) pull the curves (solid) the curve touches only its first and last control points; the middle ones tug it, and the de Casteljau point at t=0.5 is marked quadratic cubic B(0.5)
a quadratic and a cubic Bezier curve drawn solid with their dashed control polygons and the de Casteljau midpoint marked
Bezier curves: shaped by control points, evaluated by de Casteljau

  Quadratic Bezier, control points [(0, 0), (2, 4), (4, 0)]
    B(0) = (0, 0)  (first control point)
    B(0.5) = (2.0, 2.0)
    B(1) = (4, 0)  (last control point)
    arc length 5.9158, tangent at 0.5 = (4.0, 0.0)

  Cubic Bezier, control points [(0, 0), (1, 4), (3, -2), (4, 2)]
    arc length 5.9769
    every sampled point inside the control hull: True

  Subdivision at t=0.5 splits into two cubics that reproduce the curve: True
    left control points:  [(0, 0), (0.5, 2.0), (1.25, 1.5), (2.0, 1.0)]
    right control points: [(2.0, 1.0), (2.75, 0.5), (3.5, 0.0), (4, 2)]

  Degree elevation: the quadratic rewritten as a cubic (same shape: True)
    new control points: [(0, 0), (1.33, 2.67), (2.67, 2.67), (4, 0)]

  de Casteljau evaluates B(t) by repeated linear interpolation of the control points:
  interpolate adjacent pairs at t, then interpolate the results, until one point remains
  -- that point is on the curve. Keeping the triangle's outer edges splits the curve in
  two. It is numerically stable and the basis of every vector-graphics renderer.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\bezier.svg

Burrows-Wheeler transform: the heart of bzip2

The Burrows-Wheeler transform is the clever core of bzip2. On its own it compresses NOTHING -- it is a reversible PERMUTATION -- but it rearranges bytes so characters sharing a context cluster into long runs that later stages squeeze. It sorts all rotations of a sentinel-terminated string and takes the last column; the inverse reconstructs the original by the LF-mapping. The bzip2 pipeline chains three reversible stages: BWT (cluster like-context bytes into runs), MOVE-TO-FRONT (recode each byte as its index in a running alphabet, so a run becomes a run of zeros), and RUN-LENGTH encoding (collapse those runs into value/count pairs). This module implements BWT and its inverse (with a sentinel so any input works), move-to-front, run-length coding, and the full forward/backward pipeline, verified that BWT round-trips any string, that it genuinely increases the mean run length on structured text (DNA-like input becomes ~15x runnier), that MTF and RLE round-trip, that the whole pipeline is lossless across 50 random strings, and that it yields far fewer tokens than the input length on repetitive data.

Burrows-Wheeler: mean run length before (grey) vs after (green) the transform a runnier string compresses better; structured text gains most, random text little 1.0 6.1 repeated phrase 1.0 14.6 DNA-like 1.1 4.6 English 1.0 3.6 random
the mean run length of several inputs before and after the transform, structured text gaining the most, random text barely
Burrows-Wheeler transform + the bzip2-style pipeline

  BWT rearranges text so characters sharing a context cluster into runs:
          banana -> BWT 'annb$aa'  (reversible: True)
     mississippi -> BWT 'ipssm$pissii'  (reversible: True)
     abracadabra -> BWT 'ard$rcaaaabb'  (reversible: True)

  On structured text the mean run length jumps (more compressible):
     repeated phrase: mean run 1.00 -> 6.11  (6.1x runnier)
            DNA-like: mean run 1.00 -> 14.60  (14.6x runnier)
         English-ish: mean run 1.06 -> 4.60  (4.4x runnier)

  Full pipeline (BWT -> move-to-front -> run-length) on repetitive input:
    input  100 chars ->    5 RLE pairs (20.0x fewer tokens), lossless: True
    input  120 chars ->   11 RLE pairs (10.9x fewer tokens), lossless: True
    input  120 chars ->   33 RLE pairs (3.6x fewer tokens), lossless: True

  Worked example on 'banana':
    BWT:            'annb$aa'
    move-to-front:  [1, 3, 0, 3, 3, 3, 0]
    run-length:     [(1, 1), (3, 1), (0, 1), (3, 3), (0, 1)]
    decode:         'banana'

  BWT alone compresses nothing -- it is a reversible permutation -- but it makes the
  data 'runnier' by clustering same-context bytes. Move-to-front then turns runs into
  small numbers (mostly zeros), and run-length collapses those into (value, count) pairs.
  Every stage inverts exactly, so the pipeline is lossless: this is bzip2's core.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\bwt.svg

Arithmetic coding: entropy compression past the Huffman limit

Huffman coding assigns each symbol a whole number of bits, so it wastes up to nearly a bit per symbol -- disastrous when one symbol has probability 0.9 and 'deserves' 0.15 bits. ARITHMETIC CODING escapes that: it encodes the ENTIRE message as a single number in [0, 1), progressively narrowing an interval by each symbol's probability, so a symbol costing 0.15 bits really adds only 0.15 bits. It gets within a fraction of a bit of the Shannon entropy regardless of the distribution -- which is why it sits inside JPEG and H.264. Naively it needs unbounded-precision reals; the practical trick is integer RANGE CODING with RENORMALIZATION, emitting settled top bits and shifting, with an underflow counter for the straddle-the-middle case. This module implements an integer arithmetic coder and decoder driven by a frequency model, verified that encode/decode round-trips arbitrary messages (60 random ones included), that the code length approaches the entropy (within a couple of bits over the whole message), that it beats Huffman's one-bit-per-symbol floor on skewed data (~49% smaller at 90% skew), and that it handles single-symbol and uniform alphabets.

Bits per symbol: entropy (floor) vs arithmetic vs Huffman arithmetic coding (blue) hugs the entropy (green line); Huffman (orange) rounds up, wasting most on skewed data 0.56 1.10 very skewed 1.37 1.40 skewed 1.57 1.60 mild skew 1.60 1.66 near uniform 2.02 2.00 uniform-4 4.35 4.41 English-ish arithmetic Huffman
bits per symbol for several distributions: arithmetic coding hugging the entropy floor while Huffman rounds up, the gap widest on skewed data
Arithmetic coding vs Huffman vs entropy

  Arithmetic coding encodes the whole message as one number, so it is not limited to
  whole-bit codewords -- it hugs the entropy where Huffman must round up.

      distribution  entropy  arithmetic  Huffman  AC saves
       very skewed    0.557       0.560    1.100     49.1%
            skewed    1.353       1.370    1.400      2.1%
         mild skew    1.559       1.570    1.600      1.9%
      near uniform    1.585       1.600    1.660      3.6%
         uniform-4    2.000       2.017    2.000     -0.8%
       English-ish    4.339       4.348    4.409      1.4%

  The bigger the skew, the more Huffman's whole-bit codewords waste and the more
  arithmetic coding wins: at 90% one symbol, entropy is ~0.56 bits but Huffman is
  forced to spend at least 1 bit on the rarer symbols. All encodings round-trip exactly.

  How it works: start with the interval [0,1); each symbol narrows it to its
  probability-weighted sub-interval; after the whole message, any number in the final
  tiny interval names the message. Integer range coding with renormalization keeps it
  exact without unbounded-precision reals -- the entropy coder inside JPEG and H.264.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\arithmetic_coding.svg

Sequence alignment: global and local dynamic programming

Aligning two sequences -- lining them up to maximize matching symbols by inserting gaps -- is the central operation of bioinformatics, spell-check, and diff tools. Where edit distance just counts operations, alignment produces the actual correspondence and a SCORE weighting matches, mismatches, and gaps. Both algorithms fill a DP matrix where cell (i,j) is the best score aligning the first i and j symbols via three moves (align, gap in a, gap in b). NEEDLEMAN-WUNSCH aligns the sequences end to end (global): the borders are cumulative gap penalties and the answer is the corner cell. SMITH-WATERMAN finds the best-matching SUBSEQUENCES (local): scores are floored at zero so a bad stretch resets, and the answer traces back from the maximum cell -- ideal for a conserved motif inside dissimilar sequences. Traceback reconstructs the two gapped strings. This module implements both with configurable match/mismatch/gap scoring, verified that identical sequences align perfectly, that the global score matches recomputing it from the alignment, that a local alignment isolates an embedded motif the global one drags flanks into, that the gap penalty steers whether gaps or mismatches are used, and that the score is symmetric.

Needleman-Wunsch DP matrix with the traceback path each cell is the best alignment score to that prefix pair; the yellow path is the optimal alignment C C G A T T A C A A A G G G G G A T T A C A T T T T T 0 -1 -2 -3 -4 -5 -6 -7 -8 -9 -10 -11 -1 -1 -2 -1 -2 -3 -4 -5 -6 -7 -8 -9 -2 -2 -2 -1 -2 -3 -4 -5 -6 -7 -8 -9 -3 -3 -3 -1 -2 -3 -4 -5 -6 -7 -8 -9 -4 -4 -4 -2 -2 -3 -4 -5 -6 -7 -8 -9 -5 -5 -5 -3 -3 -3 -4 -5 -6 -7 -8 -9 -6 -6 -6 -4 -2 -3 -4 -3 -4 -5 -6 -7 -7 -7 -7 -5 -3 -1 -2 -3 -4 -5 -6 -7 -8 -8 -8 -6 -4 -2 0 -1 -2 -3 -4 -5 -9 -9 -9 -7 -5 -3 -1 1 0 -1 -2 -3 -10 -8 -8 -8 -6 -4 -2 0 2 1 0 -1 -11 -9 -9 -9 -7 -5 -3 -1 1 3 2 1 -12 -10 -10 -10 -8 -6 -4 -2 0 2 2 1 -13 -11 -11 -11 -9 -7 -5 -3 -1 1 1 1 -14 -12 -12 -12 -10 -8 -6 -4 -2 0 0 0 -15 -13 -13 -13 -11 -9 -7 -5 -3 -1 -1 -1 -16 -14 -14 -14 -12 -10 -8 -6 -4 -2 -2 -2
the Needleman-Wunsch score matrix as a heatmap with the optimal-alignment traceback path highlighted in yellow
Sequence alignment: global (Needleman-Wunsch) vs local (Smith-Waterman)

  Global alignment of two DNA-like sequences (aligned end to end):
    GCA-TGCU
    | | |.|.
    G-ATTACA
    score 0, identity 67% (| match, . mismatch, space gap)

  Global alignment of two words (KITTEN vs SITTING):
    KITTEN-
    .|||.| 
    SITTING
    score 1

  A shared motif GATTACA buried in different flanks:
    seq A = GGGGGATTACATTTTT
    seq B = CCGATTACAAA

  GLOBAL alignment drags in the mismatched flanks:
    GGGGGATTACATTTTT
      ..|||||||   ..
    --CCGATTACA---AA
    global score -2

  LOCAL alignment isolates the conserved motif:
    GATTACA
    |||||||
    GATTACA
    local score 14, identity 100%

  Gap penalty steers the alignment (AAAA vs ATAA):
    gap=-1, mismatch=-10: A-AAA / AT-AA  (cheap gaps -> insert a gap)
    gap=-10, mismatch=-1: AAAA / ATAA  (costly gaps -> keep the mismatch)

  Both fill a DP matrix where cell (i,j) is the best score aligning the first i and j
  symbols via align / gap-in-a / gap-in-b. Needleman-Wunsch seeds the borders with
  cumulative gap penalties and reads the corner (global); Smith-Waterman floors scores
  at zero and starts from the max cell (local). Traceback reconstructs the gapped strings.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\sequence_alignment.svg

Linear-time string matching: KMP, Z-algorithm, Manacher

Finding a pattern in a text is the most basic string operation, and the naive try-every-position approach is O(n*m). Three classics do fundamental string tasks in LINEAR time by precomputing self-overlap so the search never re-examines a character. KMP builds the PREFIX FUNCTION -- for each pattern position, the longest proper prefix that is also a suffix -- so a mismatch shifts the pattern by more than one without rescanning the text. The Z-ALGORITHM computes, for each position, the longest substring there matching a prefix of the whole string; running it on pattern + separator + text turns matching into reading off Z-values. MANACHER finds the longest PALINDROMIC substring in O(n) by reusing mirror information where the naive approach is O(n^2). This module implements the prefix function and KMP search, the Z-array and Z-based search, and Manacher's palindrome, verified that KMP and the Z-search find exactly the same (overlapping) occurrences as a brute-force scan across 400 random strings, that the prefix function matches its definition, and that Manacher's longest palindrome matches brute force in length across 100 strings and finds the known cases (racecar, geeksskeeg).

KMP: the prefix function (top) and pattern matches in the text (bottom) prefix-function bars show self-overlap; highlighted spans are pattern occurrences a 0 b 0 a 1 b 2 a 3 c 0 a 1 pi a b a b a b a c a b a b a b a c a b a text pattern 'ababaca' found at [2, 10]
the KMP prefix-function bars over a pattern and the pattern's occurrences highlighted within the text
Linear-time string matching: KMP, Z-algorithm, Manacher

  KMP prefix function of 'ababaca':
    index:  0 1 2 3 4 5 6
    char:   a b a b a c a
    pi:     0 0 1 2 3 0 1
    (pi[i] = longest proper prefix that is also a suffix of the first i+1 chars --
     on a mismatch the pattern shifts by more than one without rescanning the text)

  Searching for 'ababaca' in 'abababacabababacaba':
    KMP occurrences:      [2, 10]
    Z-algorithm:          [2, 10]
    brute-force (check):  [2, 10]
    all three agree: True

  Overlapping matches -- 'aa' in 'aaaaa': [0, 1, 2, 3] (4 occurrences)

  Z-array of 'aabxaabxcaabxaab' (z[i] = match length with the prefix):
    0 1 0 0 4 1 0 0 0 7 1 0 0 3 1 0

  Longest palindromic substring (Manacher, O(n)):
               babad -> 'bab'
    forgeeksskeegfor -> 'geeksskeeg'
        abacdfgdcaba -> 'aba'
             racecar -> 'racecar'
              banana -> 'anana'

  All three exploit precomputed self-overlap to skip redundant comparisons: KMP's
  prefix function, Z's prefix-match array, and Manacher's mirror reuse. Naive matching
  is O(n*m) and naive palindrome search O(n^2); these are all O(n) -- the difference
  between scanning a genome once and scanning it thousands of times.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\string_matching.svg

Suffix arrays: a compact string index

A suffix array is the sorted order of all SUFFIXES of a string, stored as their start indices -- packing almost everything a suffix TREE offers (fast substring search, longest-repeated-substring, longest-common-substring) into a single length-n integer array, which is why it underlies full-text search, bioinformatics indices, and the bzip2 family. Once built, 'does pattern P occur?' is a BINARY SEARCH over the sorted suffixes in O(m log n), and every occurrence is a contiguous run. It is built here by PREFIX DOUBLING (sort by the first 1, 2, 4, ... characters using previous ranks as keys, O(n log^2 n)), and the companion LCP ARRAY (longest common prefix of adjacent sorted suffixes) is built in O(n) by KASAI's algorithm -- the largest LCP value is the longest repeated substring. This module builds the suffix array, the LCP array, substring search, longest repeated substring, and longest common substring, verified that the suffix array is the true sorted order (checked against a brute-force sort over 150 strings), that search finds exactly the same occurrences as a scan, that the LCP array matches the direct prefix computation, and that the longest repeated and common substrings match brute force.

Suffix array of 'mississippi': sorted suffixes with LCP bars each row is a suffix in sorted order; the orange bar is its common-prefix length with the row above (the largest = longest repeated substring) 0 i 1 i p p i 1 i s s i p p i 4 i s s i s s i p p i 0 m i s s i s s i p p i 0 p i 1 p p i 0 s i p p i 2 s i s s i p p i 1 s s i p p i 3 s s i s s i p p i LCP
the sorted suffixes of a string with their LCP bars, the largest bar marking the longest repeated substring
Suffix array: the sorted order of all suffixes, as an integer index

  string: 'mississippi'

    rank  SA  LCP  suffix
       0  10    0  i
       1   7    1  ippi
       2   4    1  issippi
       3   1    4  ississippi
       4   0    0  mississippi
       5   9    0  pi
       6   8    1  ppi
       7   6    0  sippi
       8   3    2  sissippi
       9   5    1  ssippi
      10   2    3  ssissippi

  Substring search is a binary search over the sorted suffixes (O(m log n)):
     'issi': occurs at [1, 4]
       'ss': occurs at [2, 5]
      'ppi': occurs at [8]
      'xyz': not found

  Longest repeated substring (the largest LCP value): 'issi'

  Longest repeated substring of a few strings:
                        'banana' -> 'ana'
                   'abracadabra' -> 'abra'
     'the theme of these theses' -> ' these'
                          'aaaa' -> 'aaa'

  Longest common substring of two strings (via a combined suffix array):
    'dogandcat' & 'thecatsat' -> 'cat'
    'bioinformatics' & 'informant' -> 'informa'
    'GATTACAGG' & 'TTGATTACA' -> 'GATTACA'

  The suffix array packs a suffix tree's power into one length-n array: sorted
  suffixes make substring search a binary search, every occurrence is a contiguous
  run, and the LCP array turns tree queries into array scans -- the largest LCP is the
  longest repeated substring. Built by prefix doubling; LCP by Kasai in linear time.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\suffix_array.svg

Quadtrees: recursive spatial partition for region queries

A quadtree indexes 2-D points by recursively splitting a square region into four QUADRANTS whenever a node exceeds a small CAPACITY, so empty regions stay shallow and crowded regions subdivide deeply -- the tree adapts to the data's density. It is the 2-D workhorse for spatial queries (which points are in this rectangle?), collision broad-phase, image compression, and the Barnes-Hut n-body approximation, answering a range query by visiting only the nodes whose square OVERLAPS the query and pruning whole branches that fall outside. Where a k-d tree splits alternately on one coordinate (a binary tree), a quadtree splits on both at once (a 4-way tree tied to axis-aligned squares), making rectangle and circle range queries especially natural. This module implements a point-region quadtree with insert (and a max-depth guard so coincident points do not subdivide forever), rectangle and circular range queries, and nearest-neighbour, verified that rectangle and radius queries return exactly the same points as a linear scan across 80 random queries, that nearest matches the brute-force nearest, that out-of-bounds points are rejected, that a dense cluster subdivides deeply while spread data stays shallow, and that duplicate points are all stored.

Quadtree: cell boundaries (grey) adapt to point density; query rectangle (yellow) 180 points, depth 6; yellow = inside the query rectangle
a point cloud with the quadtree's cell boundaries subdividing where points crowd, and a query rectangle with its contained points highlighted
Quadtree: recursive 2-D spatial partition for region queries

  180 points over a 100x100 area, node capacity 4
  tree depth 6 (denser regions subdivide deeper)

  Rectangle query [30,20]-[70,60]: 24 points (matches brute force: True)
  Circle query centre (22,82) r=12 (in the dense cluster): 66 points (matches brute force: True)
  Nearest point to (50,50): (55.5, 47.3) (matches brute force: True)

  A quadtree splits a square into four quadrants whenever a node exceeds its capacity,
  so empty regions stay shallow and crowded ones subdivide deeply. A range query visits
  only the cells whose square overlaps the query, pruning whole branches -- the basis of
  collision broad-phase and the Barnes-Hut n-body approximation.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\quadtree.svg

Maximum flow: augmenting paths, the min-cut theorem, and matching

How much can flow from a SOURCE to a SINK through a network of capacitated pipes? Ford-Fulkerson repeatedly finds an AUGMENTING PATH with spare capacity and pushes flow along it, maintaining a RESIDUAL graph where each used edge gains a reverse edge so later paths can cancel and reroute earlier flow -- the trick that lets a greedy-looking method reach the true optimum. Edmonds-Karp always takes the SHORTEST augmenting path (by BFS), guaranteeing O(V E^2) termination independent of the capacities. The celebrated MAX-FLOW MIN-CUT THEOREM says the maximum flow equals the minimum CUT -- the smallest total capacity of edges whose removal severs source from sink -- and after the flow saturates, the vertices still reachable from the source in the residual graph reveal exactly those bottleneck edges. A classic reduction turns BIPARTITE MATCHING into flow: a super-source into every left vertex, a super-sink from every right vertex, all capacities one, and the max flow is the matching size. This module implements Edmonds-Karp, min-cut extraction, and bipartite matching, verified that it hits the textbook flow of 23, that the min-cut capacity equals the flow, that flow conservation and capacity constraints hold, that residual rerouting achieves the true optimum on the anti-greedy network, and that matching-by-flow equals a direct augmenting-path matching over 40 random graphs.

Max flow = 23: network with capacities; min-cut edges in red source = node 0 (left), sink = node 5 (right); red edges are the bottleneck cut 16 13 10 4 12 9 14 7 20 4 0 1 2 3 4 5
the six-node network with edge capacities; the minimum-cut edges (the bottleneck that equals the max flow) drawn in red
Maximum flow: Edmonds-Karp, min-cut, and bipartite matching

  A 6-node network (source 0, sink 5):
    maximum flow: 23
    minimum cut edges: [(1, 3), (4, 3), (4, 5)]
    min-cut capacity: 23  == max flow: True
    (the max-flow min-cut theorem: the bottleneck edges limit the whole network)

  Small networks:
    two parallel pipes (5, 3):        8
    series with a bottleneck (10, 3): 3
    anti-greedy (needs rerouting):    2 (residual reverse edges reroute flow)

  Bipartite matching by reduction to max flow (jobs to workers):
    8 allowed assignments -> maximum matching of 4:
       Ann -> cook
       Bob -> clean
        Cy -> drive
       Dot -> shop

  Ford-Fulkerson pushes flow along augmenting paths and updates a residual graph where
  each used edge gains a reverse edge (so later paths can cancel flow); Edmonds-Karp
  always takes the shortest such path (BFS) for O(V E^2) time. When no augmenting path
  remains, the source-reachable residual set defines the minimum cut = the maximum flow.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\max_flow.svg

De Bruijn sequences: every window exactly once, via Eulerian circuits

A DE BRUIJN SEQUENCE B(k, n) is a cyclic string over a k-symbol alphabet in which every one of the k**n possible length-n strings appears EXACTLY ONCE as a wrapped substring -- a single string of length k**n that packs all k**n windows with zero waste. They are the mathematics behind absolute rotary shaft encoders (read n adjacent tracks to know the angle), the shortest string that brute-forces every PIN, card tricks, and de novo genome assembly from overlapping k-mers. The construction is a gem: build the DE BRUIJN GRAPH whose vertices are the k**(n-1) shorter strings and whose edges are the k**n windows, then find an EULERIAN CIRCUIT -- a closed walk using every edge once. Because every vertex has equal in- and out-degree, one always exists, and HIERHOLZER'S ALGORITHM finds it in linear time by splicing detour cycles wherever unused edges remain. Reading the appended symbol along the circuit yields the sequence. This module builds B(k, n) for any parameters, offers the greedy prefer-largest 'Ford' construction, and exposes a general Eulerian path/circuit finder for arbitrary directed multigraphs, verified that every window appears exactly once (a bijection onto all k**n strings), that lengths are exactly k**n, that B(2,3) matches the classic example, that the greedy sequence is also valid, and that the Eulerian finder recovers a circuit using each edge once and rejects graphs where none exists.

De Bruijn graph B(2,3): 01011100 vertices = 2-bit strings, edges = 3-bit strings; an Eulerian circuit reads off the sequence 000 001 010 011 100 101 110 111 00 01 10 11 blue edges append 0 yellow edges append 1
the De Bruijn graph for B(2,3): four two-bit vertices, edges labelled by three-bit strings; an Eulerian circuit that uses every edge once reads off the sequence
De Bruijn sequences: every length-n window exactly once

  B(2,3) = 01011100   (length 8 = 2^3)
  its 8 cyclic windows (each binary triple exactly once):
    010
    101
    011
    111
    110
    100
    000
    001
  is a valid De Bruijn sequence: True

  B(2,4) = 0100110101111000   (length 16 = 2^4)
  all 16 nibbles appear once: True

  B(10,4): the shortest cyclic string containing all 10000 four-digit PINs
    length 10000 (vs 40000 keypresses to type each PIN separately)
    first 40 digits: 0100020003000400050006000700080009001100...
    every PIN appears exactly once: True

  B(2,6): a 64-position absolute rotary encoder track
    reading any 6 adjacent bits gives a unique angle: True

  Each sequence is an Eulerian circuit of the De Bruijn graph: vertices are the
  (n-1)-length strings, edges are the n-length strings, and every vertex has equal in-
  and out-degree, so Hierholzer's cycle-splicing finds a walk using every edge once.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\de_bruijn.svg

Rotating calipers: diameter, width, and the minimum-area bounding box

Once a point set's CONVEX HULL is known, a whole family of extremal measurements can be read off in a single sweep instead of the naive all-pairs comparison. The ROTATING CALIPERS technique imagines parallel lines pinching the polygon and rotating in lockstep around it; the vertices they touch enumerate exactly the ANTIPODAL PAIRS -- pairs with parallel supporting lines -- and since the farthest pair (the DIAMETER) is always antipodal, one rotation finds it in O(h). The same structure yields the WIDTH (the thinnest slab of parallel lines that still contains everything) and, by the Freeman-Shapira theorem, the MINIMUM-AREA enclosing RECTANGLE, which must have one side flush with a hull edge -- so trying each edge orientation and measuring the extent parallel and perpendicular to it gives the optimum. These power collision bounding volumes, shape metrology, and part orientation for packing and machining. This module computes the hull (monotone chain), the diameter, the width, and the minimum-area (and -perimeter) rectangle with its corners, verified against brute force: the calipers diameter equals the O(n^2) farthest pair, the width equals the brute minimum over hull directions, and the minimum rectangle contains every point and never beats the axis-aligned box -- across many random and structured sets, with exact values on squares and triangles.

Rotating calipers: hull, diameter, and minimum-area bounding box purple = min-area rectangle, red = diameter (farthest pair), green = convex hull min-area box area 18455 vs axis-aligned box (rotated to fit the cloud)
a point cloud with its convex hull (green), the diameter as a dashed red farthest-pair line, and the minimum-area bounding rectangle (purple) rotated to hug the cloud
Rotating calipers: diameter, width, and minimum-area bounding box

  40 points, hull has 8 vertices
  diameter (farthest pair): 277.31
    between (46.4, 58.3) and (276.9, 212.4)
  width (thinnest slab): 66.70
  minimum-area bounding box:
    area 18454.9, 276.7 x 66.7, perimeter 686.7
    axis-aligned box area 37895.0 -> the rotated box saves 51.3%

  A single rotation of the calipers enumerates every antipodal pair (the diameter is
  always among them), the thinnest slab gives the width, and the minimum-area rectangle
  must have a side flush with a hull edge -- so all three fall out of one hull sweep.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\rotating_calipers.svg

Ternary search trees: a trie's prefix power at a BST's space

A TERNARY SEARCH TREE gives every node one character and THREE children: LEFT for characters that sort before it, RIGHT for those after, and a MIDDLE child that advances to the next character of the key. Following the middle links spells out a key just like a trie, but the alternatives at each position live in a little binary search tree rather than a k-way array -- so a TST keeps a trie's prefix structure and sorted traversal while spending BST-like space, with no wasted arrays for large alphabets and graceful behaviour on sparse key sets. Championed by Bentley and Sedgewick, they are a classic backing store for spell-checkers, autocomplete, and prefix routing. A single three-way comparison drives every operation: insert and lookup walk left/right on the comparison and drop into the middle child on a match; PREFIX COMPLETION enumerates the subtree below a prefix in sorted order; and -- the trick hash maps cannot do cheaply -- PARTIAL-MATCH search with '.' wildcards recurses into all three children at a wildcard position, so 'c.t' finds cat, cot, cut in one pass. This module implements insert with values, lookup, deletion, sorted iteration, autocomplete, longest-prefix-of, and wildcard search, verified against a plain dict and brute force: exact keys and values, truly sorted iteration, prefix completion matching a startswith scan, wildcard search matching a regex scan over hundreds of random queries, and dict-like deletion.

Ternary search tree green = middle link (advance to next character), gray = left/right BST link; gold ring = end of a key a p e p c a r d e t s d a y o d g e g t
the TST for a small dictionary: green middle links advance to the next character (spelling keys), gray left/right links are the per-position BST, and gold rings mark where a key ends
Ternary search tree: trie prefix power with BST space

  inserted 12 words: cat, cats, car, card, care, dog, do, dodge, dot, day, ape, app
  stored (sorted): ape, app, car, card, care, cat, cats, day, do, dodge, dog, dot

  Autocomplete (keys_with_prefix):
    'ca' -> ['car', 'card', 'care', 'cat', 'cats']
    'do' -> ['do', 'dodge', 'dog', 'dot']
    'app' -> ['app']
    'd' -> ['day', 'do', 'dodge', 'dog', 'dot']

  Longest prefix of a query (longest_prefix_of):
    'cards' -> 'card'
    'doghouse' -> 'dog'
    'dotted' -> 'dot'
    'apex' -> 'ape'

  Wildcard search ('.' matches any one letter):
    'c.r' -> ['car']
    'do.' -> ['dog', 'dot']
    'ca..' -> ['card', 'care', 'cats']
    '..t' -> ['cat', 'dot']

  Each node holds one character and three children: left/right for the BST of
  alternatives at this position, and a middle link that advances to the next character
  (spelling a key, like a trie). One three-way comparison drives every operation.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\ternary_search_tree.svg

Delaunay triangulation and the Voronoi diagram: two faces of proximity

Given a scatter of points, two dual structures capture who is near what. The VORONOI DIAGRAM cuts the plane into one cell per site -- the region closer to that site than to any other -- so a single point location answers a nearest-neighbour query. Its straight-line dual is the DELAUNAY TRIANGULATION, connecting two sites whenever their Voronoi cells touch. The Delaunay triangulation is the 'roundest' triangulation: it maximizes the minimum angle (avoiding slivers) and is characterized by the EMPTY-CIRCUMCIRCLE property -- the circle through any triangle's three vertices contains no other site. Both are workhorses of mesh generation, terrain modelling, interpolation, and spatial statistics. The construction here is BOWYER-WATSON: enclose the sites in a super-triangle, then insert points one by one, deleting every triangle whose circumcircle contains the new point and re-triangulating the star-shaped cavity that opens up. The in-circle test is evaluated in EXACT rational arithmetic so adjacent triangles never disagree and the mesh stays a valid triangulation. The Voronoi diagram falls out for free -- its vertices are the triangles' circumcentres. Verified that every output triangle has an empty circumcircle against all sites, that the triangle count obeys Euler's 2n-2-h, that the triangle areas exactly fill the convex hull (no gaps or overlaps), that Voronoi vertices are equidistant from their three sites, and that each site's nearest neighbour is always a Delaunay edge.

Delaunay triangulation (blue) and its dual Voronoi diagram (orange) sites in green; Voronoi edges connect the circumcentres of adjacent Delaunay triangles 24 sites, 37 triangles -- every triangle circumcircle is empty
a point set's Delaunay triangulation (blue) overlaid with its dual Voronoi diagram (orange); Voronoi edges join the circumcentres of adjacent triangles
Delaunay triangulation and the dual Voronoi diagram

  24 sites
  Delaunay triangles: 37  (Euler: 2n-2-h)
  Voronoi edges (finite): 51
  empty-circumcircle property holds: True
  average Delaunay degree: 5.00
  every site's nearest neighbour is a Delaunay edge: True

  The Voronoi diagram partitions the plane into 'nearest-site' cells; its straight-line
  dual is the Delaunay triangulation, whose triangles have empty circumcircles and
  maximize the minimum angle. Voronoi vertices are the triangle circumcentres.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\delaunay.svg

The simplex method: linear programming by walking polytope vertices

LINEAR PROGRAMMING maximizes or minimizes a linear objective under linear constraints -- the backbone of operations research, from diet and blending problems to scheduling, network flows, and the LP relaxations inside integer-programming solvers. The feasible region is a convex POLYTOPE, and a fundamental theorem guarantees that if an optimum exists it is attained at a VERTEX. The SIMPLEX METHOD, Dantzig's 1947 invention, starts at a vertex and repeatedly slides along an edge to an adjacent vertex that improves the objective, stopping when no improving edge remains -- at which point the vertex is provably optimal. It works on a TABLEAU: slack variables turn inequalities into equalities, and each PIVOT picks an entering variable by reduced cost and a leaving one by the minimum-ratio test, then does Gaussian elimination. BLAND'S RULE (smallest index) prevents cycling on degenerate problems, and a TWO-PHASE approach with artificial variables finds a starting vertex for >= and = constraints. This module solves LPs in general form (mixed <=, >=, = constraints, maximize or minimize) and reports optimal, unbounded, or infeasible, verified against hand-solved textbook LPs, against a brute-force solver that enumerates every basic vertex (60 random LPs), against the LP-duality theorem (primal optimum equals dual optimum), and on degenerate, unbounded, and infeasible instances.

Simplex: feasible polytope and the optimal vertex maximize 3x + 5y; green = feasible region, red = optimum, arrows = objective gradient optimum (2,6) = 36
a 2-D production LP: the green feasible polytope bounded by dashed constraint lines, the yellow objective gradient, and the red optimal vertex the simplex walk terminates at
The simplex method: linear programming by walking polytope vertices

  Factory problem: maximize profit 3x + 5y
    subject to  x <= 4,  2y <= 12,  3x + 2y <= 18,  x,y >= 0
    -> status optimal, optimal profit 36.0 at x = 2.00, y = 6.00

  Dual problem (minimize resource prices b.y): optimum 36.0
    LP duality: primal 36.0 == dual 36.0  -> True

  Diet problem: minimize cost 2x + 3y s.t. x+y>=10, x+3y>=18
    -> minimum cost 24.0 at x = 6.00, y = 4.00

  Detects unbounded LPs: unbounded
  Detects infeasible LPs: infeasible

  Simplex starts at a vertex of the feasible polytope and slides to an adjacent vertex
  that improves the objective, until none does -- then that vertex is provably optimal.
  Bland's rule (smallest-index pivot) prevents cycling on degenerate problems.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\simplex.svg

MinHash and LSH: estimating set similarity at scale

How similar are two sets? The JACCARD SIMILARITY -- intersection over union -- is the natural measure, but comparing every pair among millions of sets is hopeless if each comparison touches the full sets. MINHASH, Broder's trick for AltaVista's near-duplicate web-page detection, compresses each set into a short SIGNATURE of k numbers such that the probability two signatures agree in any position equals exactly the Jaccard similarity of the sets -- so the fraction of matching positions is an unbiased estimate computed from k small integers instead of the whole sets. The mechanism is pure probability: under a random permutation of the universe, a set's minimum element is equally likely to be any of its members, so two sets share that minimum precisely when the overall minimum lies in their intersection, an event of probability |A n B| / |A u B|. Using k hash functions as permutations gives a signature whose error shrinks like 1/sqrt(k). To actually FIND similar pairs without comparing all pairs, LOCALITY-SENSITIVE HASHING splits each signature into bands and hashes each band; items that collide in any band become candidates, and tuning the bands shapes an S-curve that makes near-duplicates collide while keeping dissimilar pairs apart. This module implements MinHash signatures with universal hashing, the Jaccard estimator, and banded LSH, verified that the estimate converges to the true Jaccard as k grows (error tracking 1/sqrt(k)), that identical sets estimate 1 and disjoint ~0, and that LSH recalls every high-Jaccard pair while filtering dissimilar ones.

MinHash accuracy: mean Jaccard error vs number of hashes blue = measured mean error, yellow dashed = the 1/sqrt(k) theory curve 8 16 32 64 128 256 512 number of hashes k (log scale)
the mean Jaccard-estimation error falling as the number of hashes k grows, the measured curve (blue) hugging the 1/sqrt(k) theory curve (yellow dashed)
MinHash + LSH: estimating set similarity at scale

  Documents (as character 3-shingle sets):
    A: "the quick brown fox jumps over the lazy dog"
    B: "the quick brown fox jumped over a lazy dog"
    C: "the quick brown fox jumps over the lazy dog!"
    D: "a completely different sentence about cats"
    E: "cats are completely different from that sentence"

  Pairwise similarity (MinHash estimate vs true Jaccard, k=200):
    A-B: estimate 0.740  true 0.717  (err 0.023)
    A-C: estimate 0.980  true 0.975  (err 0.005)
    A-D: estimate 0.005  true 0.013  (err 0.008)
    A-E: estimate 0.020  true 0.024  (err 0.004)
    B-C: estimate 0.730  true 0.702  (err 0.028)
    B-D: estimate 0.000  true 0.013  (err 0.013)
    B-E: estimate 0.000  true 0.012  (err 0.012)
    C-D: estimate 0.005  true 0.013  (err 0.008)
    C-E: estimate 0.020  true 0.024  (err 0.004)
    D-E: estimate 0.530  true 0.527  (err 0.003)

  Estimate error shrinks like 1/sqrt(k):
    k=   8  mean error 0.1301
    k=  16  mean error 0.1023
    k=  32  mean error 0.0701
    k=  64  mean error 0.0477
    k= 128  mean error 0.0341
    k= 256  mean error 0.0245
    k= 512  mean error 0.0179

  LSH near-duplicate search (bands=50, rows=4, k=200):
    candidate near-duplicate pairs: [('A', 'B'), ('A', 'C'), ('B', 'C'), ('D', 'E')]
    S-curve 50% threshold at Jaccard ~ 0.376

  Two signatures agree in a position with probability equal to the Jaccard similarity,
  so matching-position fraction is an unbiased estimate. LSH bands make similar items
  collide in a hash table, turning all-pairs search into a few lookups.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\minhash.svg

CMA-ES: covariance matrix adaptation for black-box optimization

For minimizing a function with no gradient -- possibly noisy, non-convex, or badly scaled -- the COVARIANCE MATRIX ADAPTATION EVOLUTION STRATEGY is among the most powerful derivative-free optimizers known, the de-facto standard for hard continuous problems. It samples candidate solutions from a MULTIVARIATE NORMAL distribution and, generation by generation, reshapes that distribution toward better regions by adapting three things: the MEAN (moved to the weighted average of the best samples), the STEP SIZE (grown or shrunk by comparing the length of the path actually taken to the length expected under pure randomness), and the full COVARIANCE MATRIX (bent to align with the directions of recent progress, so the search ellipsoid learns the local curvature -- much like a second-order method learns the inverse Hessian, but with no derivatives). That self-adaptation is what lets it crack badly-conditioned and rotated problems that defeat coordinate-wise methods. This module implements a faithful (mu/mu_w, lambda)-CMA-ES with the standard strategy parameters and a self-contained Jacobi eigensolver for the covariance decomposition, verified that it converges to the global optimum of the sphere, Rosenbrock, ill-conditioned ellipsoid (condition 1e6), and shifted problems to near machine precision, that it beats random search by many orders of magnitude under an equal budget, that it handles a rotated anisotropic bowl (rotation invariance), and that it is fully reproducible from a seed.

CMA-ES convergence: best fitness (log scale) vs evaluations each curve is a benchmark function; steep drops show the covariance learning the landscape 1e-16 1e-12 1e-8 1e-4 1e0 1e4 function evaluations sphere (4D) Rosenbrock (2D banana) Rastrigin (2D, multimodal) ellipsoid (3D, cond 1e6)
log-scale convergence curves: best fitness plunging toward machine precision as CMA-ES adapts its covariance to each benchmark landscape, versus the near-flat progress of undirected search
CMA-ES: covariance matrix adaptation for black-box minimization

  sphere (4D)                   : fx = 6.376e-13 in 952 evals, x ~ [-0.000, -0.000, 0.000...]
  Rosenbrock (2D banana)        : fx = 7.890e-13 in 870 evals, x ~ [1.000, 1.000]
  Rastrigin (2D, multimodal)    : fx = 9.950e-01 in 12000 evals, x ~ [-0.995, -0.000]
  ellipsoid (3D, cond 1e6)      : fx = 5.062e-13 in 1540 evals, x ~ [-0.000, -0.000, -0.000]

  On the sphere, 952 evaluations of random search reach only 2.687e+00,
  while CMA-ES reaches 6.376e-13 -- a difference of many orders.

  CMA-ES samples from a Gaussian and, each generation, moves its mean to the best
  samples, adapts the step size from the length of its cumulative path, and bends the
  covariance toward recent progress -- learning the landscape's curvature without gradients.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\cma_es.svg

L-BFGS: limited-memory quasi-Newton optimization

When a function is smooth and its GRADIENT is available, QUASI-NEWTON methods are the fastest general-purpose minimizers. Newton's method rescales the gradient by the inverse HESSIAN to account for curvature and converge superlinearly, but forming an n x n Hessian costs O(n^2) memory and O(n^3) time. BFGS approximates the inverse Hessian from successive gradient differences with no second derivatives; L-BFGS ('limited memory') never stores the matrix at all -- it keeps only the last m pairs of (step, gradient-change) vectors and reconstructs the inverse-Hessian action on the gradient through the elegant TWO-LOOP RECURSION, so memory is O(m n) and each step is O(m n). It is the workhorse behind training logistic regression and conditional random fields, large-scale maximum likelihood, and countless scientific fits. A line search satisfying the Wolfe conditions picks a step that decreases the objective enough without overshooting, which also keeps the curvature pairs positive-definite. This module implements L-BFGS with the two-loop recursion, a Wolfe line search, and an automatic finite-difference gradient fallback, verified on the quadratic bowl, Rosenbrock (2D and 4D), a shifted optimum, and an ill-conditioned quadratic where it beats gradient descent by eighteen orders of magnitude, plus a logistic-regression fit that recovers the generating weights -- and its finite-difference gradients match the analytic ones.

L-BFGS vs gradient descent on an ill-conditioned quadratic objective (log scale) vs iteration; L-BFGS uses curvature, GD crawls the stiff axis 1e-16 1e-12 1e-8 1e-4 1e0 iteration L-BFGS gradient descent
L-BFGS (blue) plunging to machine precision in a few dozen iterations while gradient descent (red) barely dents an ill-conditioned quadratic in the same span
L-BFGS: limited-memory quasi-Newton optimization

  Rosenbrock (2D banana):
    L-BFGS: fx = 2.745e-17 at (1.00000, 1.00000) in 34 iterations

  Ill-conditioned quadratic (condition number 1000):
    L-BFGS after 26 evals:          1.343e-18
    gradient descent after 100 steps:  2.187e+00
    -> L-BFGS uses curvature to rescale each direction; GD crawls along the stiff axis.

  Logistic regression (300 points):
    fitted weights [1.55, -1.77, 0.66] vs true [1.5, -2.0, 0.5]
    training accuracy 0.85 in 10 iterations

  L-BFGS reconstructs the action of the inverse Hessian from the last few (step,
  gradient-change) pairs via the two-loop recursion -- O(m n) memory, no matrix stored --
  giving Newton-like convergence on smooth problems that cripple first-order methods.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\lbfgs.svg

t-SNE: nonlinear dimensionality reduction that preserves neighborhoods

High-dimensional data usually lives on a low-dimensional manifold, and we want to SEE it: a 2-D map where points near in the original space stay near. Raw projection preserves large distances but smears local structure; t-SNE, van der Maaten and Hinton's method, preserves NEIGHBORHOODS instead, which is why its maps reveal clusters so vividly, and it is the default visualization for high-dimensional data across machine learning and computational biology. It is a probabilistic matching: in high-D, the similarity of point j to i is the probability i picks j as a neighbor under a Gaussian whose width is tuned per point so the effective neighbor count equals a target PERPLEXITY (a soft k adapting to local density); these are symmetrized into a joint distribution P. In the 2-D map, similarities use a heavy-tailed STUDENT-t kernel (1/(1+d^2)) -- the crucial trick that lets moderate-distance points spread out and cures the 'crowding problem'. t-SNE then moves the map points by gradient descent to minimize KL(P||Q). This module implements perplexity calibration by binary search on each point's bandwidth, the symmetric joint P, the Student-t affinities, and KL-gradient descent with momentum and early exaggeration, verified that P is a valid symmetric distribution, that the perplexity search hits its target, that the KL divergence falls over training, that well-separated clusters map to well-separated 2-D groups, and that a neighbourhood-preservation (trustworthiness) score is high.

t-SNE map of 8-D clusters (colour = true cluster) left: 2-D embedding; right: KL(P||Q) falling over training KL divergence iterations
left: the 2-D t-SNE map of 8-D clusters, each true cluster its own tight island of colour; right: the KL divergence falling as the embedding organizes itself
t-SNE: nonlinear dimensionality reduction that preserves neighborhoods

  80 points in 8 dimensions, 4 true clusters
  KL divergence: 1.639 (start) -> 0.095 (end)
  trustworthiness (k=8): 0.989  (1.0 = perfect neighbor preservation)
  2-D separation: inter-cluster distance is 28.0x the intra-cluster distance

  t-SNE matches per-point Gaussian neighbor probabilities in high-D to a heavy-tailed
  Student-t kernel in 2-D, minimizing KL(P||Q) by gradient descent. The heavy tail cures
  the crowding problem, letting clusters breathe apart -- which is why the map is so clear.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\tsne.svg

Wavelet trees: rank, select, and quantile over a sequence

A WAVELET TREE is a succinct data structure that turns a sequence over an alphabet into a balanced tree of bit vectors, answering a remarkable range of queries in O(log sigma) time with essentially the space of the sequence itself -- the machinery behind FM-indexes, compressed suffix arrays, and range-search structures in geometry and bioinformatics. It recursively partitions the ALPHABET: at each node the value range is split at its midpoint, and a bit vector marks whether each element falls in the upper half (1) or lower half (0); elements going left and right form the children's subsequences, which recurse on their half of the alphabet, so a value is encoded by its root-to-leaf bit path. Every query walks that O(log sigma)-deep tree using RANK on the bit vectors to map an index into the correct child. From this one structure fall RANK (occurrences of a value in a prefix), SELECT (position of the j-th occurrence, by walking back up), QUANTILE (the k-th smallest value in a range -- a range-median generalization array scans cannot do in sublinear time), and RANGE_COUNT (values in a positional range that fall in a value window). This module builds a wavelet tree over an integer sequence and implements all five, verified exhaustively against brute force: access reproduces the sequence, rank matches a prefix count, select matches an occurrence scan, quantile matches a sorted-slice lookup, and range-count matches a filtered scan, across hundreds of random sequences and queries.

Wavelet tree: recursive alphabet partition with per-node bit vectors each node shows its value range and bits (0 = lower half / goes left, 1 = upper half / right) [0,7] 001010110101011 [0,3] 1001101 [4,7] 00100110 [0,1] 110 [2,3] 1010 [4,5] 01110 [6,7] 010 =0 x1 =1 x2 =2 x2 =3 x2 =4 x2 =5 x3 =6 x2 =7 x1
the wavelet tree's recursive alphabet partition: each node shows its value range and per-element bit vector, narrowing to single-value leaves at the bottom
Wavelet tree: rank, select, quantile, and range-count in O(log sigma)

  sequence: [3, 1, 4, 1, 5, 2, 6, 5, 3, 5, 0, 7, 2, 6, 4]
  alphabet range: [0, 7]

  RANK -- how many times a value appears in a prefix:
    rank(5, 15) = 3   (brute 3)
    rank(5, 8) = 2   (brute 2)
    rank(2, 15) = 2   (brute 2)

  SELECT -- position of the j-th occurrence (0-indexed):
    select(5, 0) = 4   (seq[4] = 5)
    select(5, 2) = 9   (seq[9] = 5)
    select(3, 1) = 8   (seq[8] = 3)

  QUANTILE -- k-th smallest value in a range (range median and friends):
    quantile([0,15), k=7) = 4   (sorted slice [7] = 4)
    quantile([2,9), k=0) = 1   (sorted slice [0] = 1)
    quantile([2,9), k=6) = 6   (sorted slice [6] = 6)
    quantile([4,11), k=3) = 5   (sorted slice [3] = 5)

  RANGE_COUNT -- values in a positional range that fall in a value window:
    range_count([0,15), 3..5) = 7   (brute 7)
    range_count([0,8), 0..2) = 3   (brute 3)
    range_count([5,15), 4..7) = 6   (brute 6)

  The tree splits the alphabet at its midpoint level by level; each node stores one bit
  per element (upper half = 1, lower half = 0). Every query walks the O(log sigma)-deep
  tree, using bit-rank to map an index into the correct child -- succinct and fast.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\wavelet_tree.svg

Fibonacci heaps: O(1) amortized decrease-key

A priority queue with fast DECREASE-KEY is the engine behind Dijkstra's shortest paths and Prim's minimum spanning tree, which do O(m) decrease-keys and O(n) extract-mins. A binary heap does every operation in O(log n); the FIBONACCI HEAP of Fredman and Tarjan improves the bound to O(m + n log n) by making INSERT, MERGE, FIND-MIN, and DECREASE-KEY run in O(1) AMORTIZED time, paying the logarithmic cost only at EXTRACT-MIN. The trick is LAZINESS: the heap is a forest of heap-ordered trees in a circular root list, so insert just drops in a node and merge just concatenates two lists. Cleanup happens only at extract-min, which promotes the minimum's children to roots and CONSOLIDATES trees of equal degree (like binary addition) until all root degrees are distinct. DECREASE-KEY lowers a key and, if heap order breaks, CUTS the node to the root list; a MARK bit triggers a CASCADING CUT the second time a node loses a child, which keeps trees bushy enough that degrees obey Fibonacci-number bounds -- hence the name. This module implements the full heap (insert, find-min, extract-min, decrease-key, delete, merge) with node handles, plus a Dijkstra built on it, verified against a binary heap over a 2000-operation random stream, that a drained heap yields sorted order, that merge preserves all elements, that the maximum root degree stays within the O(log n) Fibonacci bound, and that Dijkstra on the Fibonacci heap matches a binary-heap Dijkstra across 40 random graphs.

Fibonacci heap: max root degree stays within the log_phi(n) bound blue = measured max root degree after consolidation, yellow dashed = the Fibonacci bound 10 50 100 500 1000 5000 heap size n (log scale)
the maximum root degree (blue) growing far slower than the heap size and staying under the log_phi(n) Fibonacci bound (yellow dashed) -- what keeps the tree count logarithmic
Fibonacci heap: O(1) amortized insert, merge, and decrease-key

  inserted [27, 25, 60, 33, 36, 64, 1, 33, 87, 26, 71, 53]
  extract-min order: [1, 25, 26, 27, 33, 33, 36, 53, 60, 64, 71, 87]  (sorted: True)

  decrease-key: min starts at 30; after decreasing d(90)->5, min is 5
  merge two heaps -> size 6, min 1 (O(1) list concatenation)

  Maximum root degree vs heap size (should stay below the Fibonacci bound log_phi(n)):
    n=   10: max degree  3  (bound 4.6)
    n=   50: max degree  5  (bound 8.1)
    n=  100: max degree  6  (bound 9.5)
    n=  500: max degree  8  (bound 12.9)
    n= 1000: max degree  9  (bound 14.4)
    n= 5000: max degree 12  (bound 17.7)

  Dijkstra shortest paths from node 0 (classic 6-node graph): [0, 7, 9, 20, 20, 11]
    (0->2->5->4 gives 9+2+9 = 20 to node 4)

  A Fibonacci heap stays lazy: insert and merge just splice into a circular root list,
  and decrease-key cuts a node to the roots (cascading up via mark bits). Only extract-min
  consolidates equal-degree trees -- which is what keeps decrease-key O(1) amortized and
  improves Dijkstra to O(m + n log n).

  wrote C:\Users\acwic\symplectic-nbody\examples\output\fibonacci_heap.svg

Treaps: balanced search trees by randomization

A binary search tree is fast only while balanced, and the classic balancers (AVL, red-black) achieve that with intricate rotation cases. A TREAP earns balance almost for free: give every key a random PRIORITY and keep the tree a binary-search-tree on the KEYS and a heap on the PRIORITIES (tree + heap = treap). Because priorities are random, the treap's shape equals that of a BST built by inserting the keys in random order -- balanced with high probability, O(log n) expected height, and none of the case analysis. The structure rests on two primitives: SPLIT cuts a treap into keys < k and keys >= k, and MERGE joins two treaps where all of one's keys precede the other's, taking the higher-priority root to preserve heap order. Insert is split-then-merge-in; delete is merge-around. That split/merge pair -- which AVL and red-black trees do not expose naturally -- makes treaps the tree of choice for slicing and splicing ordered sequences. Augmenting each node with its subtree SIZE turns it into an ORDER-STATISTICS tree (k-th smallest and rank in O(log n)). This module implements insert, delete, membership, select, rank, split, and merge with a seeded RNG, verified against a sorted list: in-order traversal is always sorted, a 3000-operation random insert/delete stream keeps the contents equal to a reference set, select and rank match a sorted array, split produces the correct partition, merge reassembles the original, and even under adversarial sorted insertion the height stays near 2 log2(n) where a plain BST would degrade to n-1.

Treap height vs size (keys inserted in sorted order) blue = actual treap height, yellow dashed = 2 log2(n); a plain BST here would be height n-1 100 1000 10000 50000 number of keys n (log scale)
the treap's height (blue) tracking 2 log2(n) even when keys are inserted in sorted order -- the case that degrades an unbalanced BST to a linear chain of height n-1
Treap: a balanced BST from random priorities (tree + heap)

  inserted [50, 30, 70, 20, 40, 60, 80, 10, 25, 65]
  in-order (sorted): [10, 20, 25, 30, 40, 50, 60, 65, 70, 80]
  height 6 for 10 keys

  Order statistics:
    0-th smallest = 10
    4-th smallest = 40
    9-th smallest = 80
    rank of 40 = 4 (keys smaller than it)
    rank of 65 = 7 (keys smaller than it)

  split at 50 -> lower [10, 20, 25, 30, 40]
              upper [50, 60, 65, 70, 80]
  merge back  -> [10, 20, 25, 30, 40, 50, 60, 65, 70, 80]

  Expected height stays near 2*log2(n) (random priorities = random insertion order):
  (an adversarial sorted insertion into a plain BST would give height n-1)
    n=   100 (sorted insert): treap height  12  vs  2log2(n)=13.3  vs  unbalanced BST would be 99
    n=  1000 (sorted insert): treap height  26  vs  2log2(n)=19.9  vs  unbalanced BST would be 999
    n= 10000 (sorted insert): treap height  32  vs  2log2(n)=26.6  vs  unbalanced BST would be 9999
    n= 50000 (sorted insert): treap height  39  vs  2log2(n)=31.2  vs  unbalanced BST would be 49999

  Every key gets a random priority; the treap is a BST on keys and a heap on priorities,
  so its shape equals a BST built from a random insertion order -- balanced with high
  probability. Split and merge (which AVL/red-black trees don't expose) fall out for free.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\treap.svg

Splay trees: self-adjusting search trees that keep hot keys near the root

Most balanced trees maintain an explicit invariant; a SPLAY TREE, from Sleator and Tarjan, keeps NO balance information at all, yet achieves O(log n) AMORTIZED time through one move called SPLAYING. Every access, insert, or delete rotates the touched node all the way to the root, in careful pairs -- ZIG-ZIG when node and parent lean the same way, ZIG-ZAG when opposite, a single ZIG at the top -- that lift the node while roughly halving the depth of everything on its path, so long paths pay for themselves by becoming short. This buys properties fixed-balance trees lack: the WORKING-SET property (recently used keys sit near the root, so temporally-local workloads run far faster than log n per access) and STATIC OPTIMALITY (on any access sequence a splay tree is within a constant factor of the best static tree built with full knowledge of the frequencies -- with no tuning). This module implements insert, delete, membership, find-min/max, predecessor/successor, and ordered traversal, splaying on every access, verified against a sorted set: in-order traversal stays sorted through a 3000-operation random stream, the BST and parent-pointer invariants hold throughout, predecessor/successor match a sorted array, the most-recently-accessed key is always at the root, and hammering a small hot set drives the average access depth well below log2(n) -- the working-set property in action.

Splay tree working-set property: skew lowers average access depth blue = splay average access depth, yellow dashed = a balanced tree's fixed log2(n) balanced ~log2(n)=12.0 1.00 0.50 0.20 0.10 0.05 0.02 0.01 hot-set fraction (smaller = more skewed access) ->
average access depth (blue) sinking below a balanced tree's fixed log2(n) (yellow dashed) as the access pattern grows more skewed -- recently touched keys stay near the root
Splay tree: self-adjusting BST, hot keys rise to the root

  inserted 7 keys; in-order: [20, 30, 40, 50, 60, 70, 80]
    access 20 -> root is now 20
    access 80 -> root is now 80
    access 40 -> root is now 40

  Working-set property: with skewed access, splay beats fixed O(log n).
  Build a tree of 4000 keys, then draw accesses from a Zipf-like skewed distribution;
  measure the average depth of the accessed node BEFORE it is splayed.

    hot fraction  1.00 (|hot|=4000): avg access depth 16.49   (balanced tree ~ 12.0)
    hot fraction  0.50 (|hot|=2000): avg access depth 15.48   (balanced tree ~ 12.0)
    hot fraction  0.20 (|hot|= 800): avg access depth 14.08   (balanced tree ~ 12.0)
    hot fraction  0.10 (|hot|= 400): avg access depth 12.64   (balanced tree ~ 12.0)
    hot fraction  0.05 (|hot|= 200): avg access depth 11.72   (balanced tree ~ 12.0)
    hot fraction  0.02 (|hot|=  80): avg access depth 10.32   (balanced tree ~ 12.0)
    hot fraction  0.01 (|hot|=  40): avg access depth  8.67   (balanced tree ~ 12.0)

  As the access pattern concentrates, the splay tree's average access depth drops far
  below log2(n): hot keys live near the root. A balanced tree pays log2(n) every time,
  regardless of how skewed the workload is. Splaying is O(log n) amortized either way.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\splay_tree.svg

Van Emde Boas trees: integer sets with O(log log u) successor

A balanced search tree does membership, insert, and SUCCESSOR/PREDECESSOR in O(log n). But when the keys are integers from a bounded universe {0, ..., u-1}, the VAN EMDE BOAS TREE does all of them in O(log log u) -- exponentially better in the universe size. For u = 2^32 that is about five steps per operation regardless of how many keys are stored, which makes it the theoretical champion for the predecessor problem and the basis of fast integer priority queues, IP routing tables, and integer sorting. The structure recursively divides the universe by its SQUARE ROOT: each key splits into a HIGH half (which of sqrt(u) clusters) and a LOW half (position within it); a node holds sqrt(u) child vEB trees plus a SUMMARY vEB tree over sqrt(u) recording which clusters are non-empty. The genius is storing each node's MIN and MAX directly and NOT recursing on the min -- that caps the work at one recursive call per level, so T(u) = T(sqrt u) + O(1) = O(log log u). Successor checks the current cluster, and if the answer is not there consults the summary to jump to the next non-empty cluster in a single step. This module implements insert, delete, membership, min, max, successor, and predecessor over a universe rounded to a power of two, verified against a reference sorted set: membership after a 4000-operation random stream, correct min and max, successor and predecessor matching a linear scan at every point in the universe, and the successor-walk from the minimum reproducing the sorted key list.

Van Emde Boas: O(log log u) depth vs a BST's O(log u) blue = vEB recursion depth (log log u), red = a BST's depth over the same universe (log u) 2^4 2^8 2^16 2^24 2^32 2^48 2^64 universe size u BST ~ log u vEB ~ log log u
the vEB per-operation recursion depth (blue, ~log log u) staying almost flat as the universe grows, against a BST's depth (red, ~log u) climbing linearly in the exponent
Van Emde Boas tree: integer sets with O(log log u) successor/predecessor

  universe u=64, inserted keys, sorted: [5, 9, 17, 21, 33, 42, 58]
  min 5, max 58
  successor(21) = 33, successor(42) = 58
  predecessor(33) = 21, predecessor(5) = None
  member(17)? True   member(18)? False

  Recursion depth per operation grows like log2(log2(u)) -- barely at all:
  (a balanced BST would pay log2(n); vEB pays log2 log2 u regardless of n)
    u = 2^ 4: vEB recursion depth 2   (log2 log2 u = 2.00), BST would be up to 4 levels deep
    u = 2^ 8: vEB recursion depth 3   (log2 log2 u = 3.00), BST would be up to 8 levels deep
    u = 2^16: vEB recursion depth 4   (log2 log2 u = 4.00), BST would be up to 16 levels deep
    u = 2^24: vEB recursion depth 4   (log2 log2 u = 4.58), BST would be up to 24 levels deep
    u = 2^32: vEB recursion depth 5   (log2 log2 u = 5.00), BST would be up to 32 levels deep
    u = 2^48: vEB recursion depth 5   (log2 log2 u = 5.58), BST would be up to 48 levels deep
    u = 2^64: vEB recursion depth 6   (log2 log2 u = 6.00), BST would be up to 64 levels deep

  In a universe of 2^24 = 16777216 values with 5 keys:
    successor(123456) = 5000000
    predecessor(9999999) = 5000000
    sorted keys: [10, 123456, 5000000, 9999999, 16000000]

  Each key splits into a high half (which cluster) and low half (position in it); the
  tree recurses on the SQUARE ROOT of the universe, and storing min/max directly caps the
  recursion at one call per level -- so T(u) = T(sqrt u) + O(1) = O(log log u).

  wrote C:\Users\acwic\symplectic-nbody\examples\output\van_emde_boas.svg

Sparse tables and binary lifting: O(1) range minimum, O(log n) LCA

Some queries deserve constant time. The RANGE MINIMUM QUERY -- the minimum of any subrange of a fixed array -- is answered by a SPARSE TABLE in O(1) after O(n log n) preprocessing, exploiting that min is IDEMPOTENT: overlapping ranges combine without double-counting, so any interval is covered by just TWO precomputed power-of-two blocks. The table stores the minimum of every block [i, i+2^k); a query takes the length, finds the largest fitting power 2^k, and returns min(table[l][k], table[r-2^k][k]) -- two lookups, always. The same doubling idea on trees gives BINARY LIFTING for the LOWEST COMMON ANCESTOR: store each node's 2^k-th ancestor, then to find LCA(u,v) lift the deeper node to the other's depth and lift both by the largest jumps that keep them apart until their parents coincide -- O(log n) per query, which also yields the tree distance for free. These are the standard tools for static range and tree-ancestor queries in competitive programming, compilers, and phylogenetics. This module implements a generic sparse table for any idempotent operation (min, max, gcd), a specialized RMQ, and a binary-lifting LCA with depth and distance, verified against brute force: the sparse table matches a direct scan over every subrange, the LCA matches a naive ancestor-walk for every pair of nodes on many random trees, and the tree distance matches a BFS shortest path.

Sparse table RMQ (top) and binary-lifting LCA (bottom) array with min[3,9) = 1 highlighted (green range, yellow minimum) 5 2 8 1 9 3 7 4 6 0 11 2 tree: LCA(7,8) = 0 (red), query nodes in blue 0 1 2 3 4 5 6 7 8
top: an array with a range-minimum query, the range in green and its minimum in yellow; bottom: a tree with two query nodes in blue and their lowest common ancestor in red
Sparse table: O(1) range minimum, and binary-lifting LCA

  array: [5, 2, 8, 1, 9, 3, 7, 4, 6, 0, 11, 2]
  Range minimum queries (each answered by exactly two table lookups):
    min[1,5) = 1   (brute 1)
    min[3,9) = 1   (brute 1)
    min[0,12) = 0   (brute 0)
    min[6,8) = 4   (brute 4)

  Same table idea for max: max[3,9) = 9   (brute 9)

  Lowest common ancestor (binary lifting), on a 9-node tree:
    LCA(7, 4) = 1, distance = 3
    LCA(7, 8) = 0, distance = 6
    LCA(3, 4) = 1, distance = 2
    LCA(8, 6) = 2, distance = 3

  RMQ works because min is idempotent: any range is covered by two overlapping
  power-of-two blocks, so a query is min(table[l][k], table[r-2^k][k]) -- always O(1).
  LCA lifts the deeper node to the other's depth, then both jump up by shrinking
  powers of two until their parents meet -- O(log n) using the 2^k-ancestor table.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\sparse_table.svg

Pollard's rho: factoring integers in sqrt(p) steps

Multiplying two large primes is easy; recovering them from the product is the FACTORIZATION problem that RSA's security rests on. Trial division works only up to tiny factors -- factoring a 40-digit number that way would outlast the universe. POLLARD'S RHO finds a non-trivial factor of a composite n in expected O(n^{1/4}) time and O(1) space; for a factor p that is about sqrt(p) iterations, versus the p that trial division needs. The idea is a probabilistic collision: iterate a pseudo-random x -> x^2 + c (mod n), and by the BIRTHDAY PARADOX the sequence cycles after about sqrt(p) steps MODULO a prime factor p, so when two iterates collide mod p but not mod n their difference shares p with n and gcd(|x_i - x_j|, n) reveals it. Brent's variant (used here) batches the gcd computations to run faster; POLLARD'S p-1 is a complementary trick that shatters RSA primes whose p-1 is smooth. This module implements deterministic Miller-Rabin primality, Brent's Pollard rho, Pollard p-1, and a full recursive prime factorization, plus Euler's totient and divisor count from the factors, verified against brute-force trial division and a sieve: the product of the returned factors equals the input and every factor is prime, it recovers both primes of random semiprimes (the RSA case) including a 17-digit one, primality matches a sieve, and totient and divisor counts match brute-force enumeration.

Pollard rho: iterations to find factor p scale with sqrt(p) blue points = measured rho iterations; yellow dashed = the sqrt(p) trend line sqrt(smallest prime factor)
the measured number of Pollard-rho iterations to find a factor (blue) tracking the sqrt(p) trend line (yellow dashed) -- the birthday-paradox speedup over trial division's linear p
Pollard's rho: factoring integers in ~sqrt(smallest factor) steps

  600851475143 = 71 * 839 * 1471 * 6857
  1000000016000000063 = 1000000007 * 1000000009
  720720 = 2^4 * 3^2 * 5 * 7 * 11 * 13
  254803968 = 2^20 * 3^5
  1000036000099 = 1000003 * 1000033

  RSA-style semiprimes (product of two primes) -- the case RSA relies on being hard:
                     10403 = 101 * 103
                 100160063 = 10007 * 10009
             1000036000099 = 1000003 * 1000033
         10000004400000259 = 100000007 * 100000037

  Pollard's rho finds a factor p in about sqrt(p) iterations (birthday paradox),
  where trial division would need p/2. For a 15-digit semiprime that is the
  difference between a few thousand steps and a hundred million.

  Number-theoretic functions from the factorization:
    n=36: phi(n)=12, number of divisors=9
    n=100: phi(n)=40, number of divisors=9
    n=720720: phi(n)=138240, number of divisors=240

  wrote C:\Users\acwic\symplectic-nbody\examples\output\pollard_rho.svg

Perlin noise: smooth gradient fields for procedural generation

Random numbers are jagged; nature is smooth. PERLIN NOISE, Ken Perlin's 1983 invention (and an Academy Award winner for its use in film), produces a random-looking but CONTINUOUS field -- values that vary smoothly across space with no visible grid -- the foundation of procedurally generated terrain, clouds, textures, and fire. Unlike white noise, it has controllable feature size and looks organic because it is differentiable. The construction is GRADIENT noise: lay an integer lattice over space and give each lattice point a pseudo-random unit gradient (deterministically, from a hashed permutation table, so the field is reproducible and infinite); to evaluate a point, take at each surrounding corner the dot product of that corner's gradient with the vector to the point, and interpolate with the SMOOTHSTEP fade 6t^5 - 15t^4 + 10t^3 (whose first and second derivatives vanish at the ends, which is what makes it seamless). The noise passes through zero on the grid and undulates between. Layering copies at doubling frequency and halving amplitude -- FRACTAL BROWNIAN MOTION -- adds detail at every scale, the standard terrain recipe. This module implements reproducible 1-D and 2-D Perlin noise and fBm, verified that the noise is exactly zero at integer lattice points (the defining property), stays within bounds, is deterministic per seed and continuous (a 1e-3 step moves the output by under 0.003), has near-zero mean over a large region, and that fBm with more octaves adds high-frequency detail while staying bounded.

Perlin fractal noise: a 2-D heightfield and a 1-D terrain slice left: fBm heightfield (dark=low, bright=high); right: a 1-D fBm cross-section 1-D fBm terrain (6 octaves)
left: a 2-D fractal-Brownian-motion heightfield with a terrain palette (blue lows through green and tan to white peaks); right: a 1-D fBm cross-section, the organic undulation of layered gradient noise
Perlin noise: smooth pseudo-random gradient fields

  Gradient noise is exactly zero at integer lattice points:
    noise2(3,5) = 0.00e+00, noise2(0,0) = 0.00e+00
    but noise2(3.5, 5.5) = 0.3535 (smooth between)

  Over a 100x100 sample grid:
    range [-0.861, 0.861], mean -0.0085

  Fractal Brownian motion (summing octaves at doubling frequency, halving amplitude):
    1 octave(s): sample spread 0.962 (more octaves -> more fine detail)
    2 octave(s): sample spread 0.746 (more octaves -> more fine detail)
    4 octave(s): sample spread 0.550 (more octaves -> more fine detail)
    6 octave(s): sample spread 0.527 (more octaves -> more fine detail)

  Each lattice point holds a random gradient vector; the noise at a point is the
  smoothstep-interpolated dot product of the surrounding gradients with the offset
  vectors. Layering octaves (fBm) is the standard recipe for terrain, clouds, and fire.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\perlin.svg

Wave function collapse: procedural generation by constraint propagation

WAVE FUNCTION COLLAPSE fills a grid with tiles so that every adjacency obeys local rules, producing outputs that look hand-authored -- coherent maps, textures, levels -- rather than random. The name is a quantum metaphor: each cell starts in a SUPERPOSITION of every tile, and the algorithm repeatedly OBSERVES (collapses) the most-constrained cell to one tile, then PROPAGATES that choice, deleting any option a neighbour can no longer support -- the discrete constraint-satisfaction loop of arc consistency. Observation picks the lowest-ENTROPY cell (fewest remaining options) and collapses it by weight; propagation runs a worklist, shrinking neighbour option sets until the grid is arc-consistent, and if a cell's options ever hit zero the attempt is a CONTRADICTION and restarts. The adjacency rules -- which tiles may sit beside which, per direction -- are the whole specification, turning a tiny rule set into endless consistent worlds. This module implements tiled WFC with per-direction rules, weighted collapse, lowest-entropy observation, full propagation, contradiction detection with restart, and a seeded RNG, verified that every generated grid strictly satisfies the rules (no forbidden neighbour anywhere, across 20 seeds), that a seed reproduces its grid, that an over-constrained rule set is reported unsatisfiable rather than producing garbage, and that a rule set forcing a unique tiling produces exactly it (a perfect checkerboard).

Wave function collapse: a coastline map from adjacency rules green = land, sand = coast, blue = sea; land and sea never touch (coast always between)
a coastline map generated by WFC: green land, sand coast, blue sea, where land and sea never touch because the coast tile is the only legal bridge -- every adjacency obeys the rules
Wave function collapse: coherent maps from local adjacency rules

  Coastline map (rule: land 'L' and sea 'S' may never touch; coast 'C' between them):
    .~~.~~~~~~~.~.~~..~~~~~~~~~~
    ~.~.~.~~~~~~~~...~.~~~~~~~~~
    .#.~~~~.~~.~.~~...~~~~~~~~~.
    ~.~~~~~.~~~~~~.~~~..~~~.~~.~
    ~~~~.~~~~~..~~~~.~..~~..~~~~
    .~~~~~~.~~.~~.~.~~~~~~~~~.~.
    ~~~~~~.~~~~~~~~.~~~~~~~~.#.#
    ~.~~~~~~~~..~~~~~~~~~~.~~.~.
    ~~~~~~~.~.~~~~~~~~~~.~~.~~~~
    ~~~~~~~.~~~.~.~~..~~~.~~~~~.
    .~~.~~~~~~.~~~~~..~..~.....~
    .~~~~~~~~~~~~~~~.~.~~~~~~~~~
    ~~~~~...~~.~~~~~.~.~~~~~.~~.
    ~...~~~~.~~~~.~~~~~~~.~~~~~~
    .~~~~~~.~.~..~.~~~~~~.....~~
    #..~~~~~~~~.~..~~~.~.~~~~~~~

  satisfies all adjacency rules: True
  land-sea direct adjacencies (should be 0): 0

  Forced checkerboard rules -> a perfect 2-coloring: True

  Each cell begins as a superposition of all tiles. WFC repeatedly collapses the
  lowest-entropy (most-constrained) cell to one tile, then propagates: neighbours lose
  any option the new choice forbids, cascading until the grid is arc-consistent. A
  contradiction (a cell with no options left) triggers a restart.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\wave_function_collapse.svg

The Hungarian algorithm: optimal assignment in O(n^3)

Given n workers, n jobs, and a COST for each pairing, which one-to-one assignment minimizes the total? This ASSIGNMENT PROBLEM appears everywhere -- taxis to riders, tasks to machines, tracked objects to detections between video frames. Brute force tries all n! permutations; the HUNGARIAN ALGORITHM (Kuhn-Munkres, 1955) finds the exact optimum in O(n^3), one of the first polynomial algorithms for a combinatorial problem. It rests on an invariant: subtracting a constant from a full row or column does not change WHICH assignment is optimal, only shifts every total equally. So the algorithm reduces rows and columns to expose zeros, selects n INDEPENDENT zeros (one per row and column -- a valid assignment), and when fewer than n exist, covers all zeros with a minimum set of lines (Konig's theorem) and subtracts the smallest uncovered value to create new zeros without breaking the old, until n independent zeros -- the optimum -- appear. This module implements the O(n^3) potential/augmenting-path form for rectangular matrices (padded to square), for both minimization and maximization, verified against brute-force search over all permutations (matching the exact optimum over 60 random square matrices and up to n=8), the row/column reduction invariant, identity and hand-worked matrices, and rectangular padding.

Hungarian algorithm: optimal assignment (green cells) cost matrix shaded by value (darker = cheaper); the chosen minimum-cost cells outlined green cook clean drive shop Ann Bob Cy Dot 9 11 14 11 6 15 13 10 12 13 6 8 11 9 10 12 optimal total cost = 32
a worker-job cost matrix shaded by value with the optimal minimum-cost assignment outlined in green -- one cell per row and column, the total no greedy pick can beat
The Hungarian algorithm: optimal assignment in O(n^3)

  Cost matrix (hours for each worker to do each job):
             cook   clean   drive    shop
     Ann       9      11      14      11
     Bob       6      15      13      10
      Cy      12      13       6       8
     Dot      11       9      10      12

  Optimal assignment (total 32.0 hours):
     Ann ->   shop (11 h)
     Bob ->   cook (6 h)
      Cy ->  drive (6 h)
     Dot ->  clean (9 h)

  Brute force over all 4! = 24 permutations: 32  (matches: True)
  Greedy (pick each row's cheapest free job): 34  -> 2.0 hours worse than optimal

  The algorithm reduces rows and columns to expose zeros, selects n independent zeros
  (one per row and column), and when fewer than n exist, covers the zeros with a minimum
  set of lines and shifts the smallest uncovered value to create new ones -- until an
  optimal set of independent zeros appears. O(n^3), versus n! for brute force.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\hungarian.svg

Dynamic time warping: aligning time series that vary in speed

Two recordings of the same spoken word, two gait cycles, two heartbeats trace the same shape at different, non-uniformly varying speeds -- so a point-by-point Euclidean comparison badly mismatches them. DYNAMIC TIME WARPING finds the optimal NON-LINEAR alignment: it stretches and compresses the time axis of one series to match the other, returning both the minimal alignment cost and the WARPING PATH. DTW was the backbone of speech recognition before deep learning and remains central to gesture and signature recognition and time-series clustering. It is dynamic programming over an n x m cost grid: cell (i, j) is the cheapest cost to align the first i and first j points, equal to the local distance between points i and j plus the minimum of three neighbours (the match, insert, and delete moves). The corner cell is the DTW distance, and backtracking the minimizing choices recovers the monotone warping path pairing each point of one series with one or more of the other. A SAKOE-CHIBA BAND confines the path near the diagonal, speeding it to O(n*w) and forbidding pathological warps. This module implements DTW distance, warping-path recovery, an optional band, and a multi-dimensional variant, verified against an independent DP and known properties: identical series have zero distance, DTW is symmetric and non-negative, it is invariant to time stretching (duplicating points changes nothing), it crushes the Euclidean distance on shifted signals, and the recovered path is monotone with unit steps and its summed cost equals the distance.

Dynamic time warping: two speed-varying signals aligned blue = series A (top), green = series B (bottom), gray = warping-path correspondences series A series B (time-warped)
two signals of the same shape at different speeds (blue above, green below) with the gray warping path connecting each matched pair of points -- the time axis stretched to align the peaks
Dynamic time warping: aligning series that vary in speed

  two 30-point signals of the same shape at different speeds:
    DTW distance:       2.731
    Euclidean distance: 17.900   (naive point-by-point)
    -> DTW is 6.6x smaller: it warps the time axis to match the shapes
    warping path length: 37 matched index pairs

  Stretch invariance: DTW(base, 3x-stretched-base) = 0.0 (exactly zero)

  Sakoe-Chiba band (limits the warp window):
    unconstrained DTW: 2.731
    band=5 DTW:        3.517   (band restricts the path, so distance >= unconstrained)

  DTW fills a cost grid where cell (i,j) is the local distance plus the cheapest of its
  three predecessors (match / insert / delete). The bottom-right cell is the DTW distance,
  and backtracking the minimizing moves recovers the monotone warping path.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\dtw.svg

The P-square algorithm: streaming quantiles in constant memory

Computing a QUANTILE the exact way -- the median, the 99th percentile -- requires storing and sorting all the data, impossible for a stream of billions or a sensor reporting forever. The P-SQUARE ALGORITHM (Jain and Chlamtac, 1985) estimates any quantile using just FIVE markers and O(1) memory, never storing the samples, with accuracy that improves as more data arrives -- the classic tool for latency percentiles (p50/p95/p99) in monitoring without keeping every request time. It tracks five markers along the data: the running minimum and maximum, the current quantile estimate, and two markers halfway between; each has a height and a position, plus a DESIRED position that grows linearly with the sample count so the markers stay spread at the target quantile. As each value arrives it is slotted into a marker cell, positions increment, and any marker that drifts more than one step from its desired position is nudged back -- its height adjusted by PARABOLIC interpolation through its neighbours, falling back to linear if the parabola would break the ordering. The middle marker is the estimate. This module implements the single-quantile estimator and a multi-quantile histogram, verified against exact quantiles from the full sorted data: on uniform, normal, and exponential streams the estimate is within a small error of the true quantile (a p99 within 0.12% using 20 floats instead of storing 200000 samples), the median of a symmetric stream is near its centre, the min and max markers are exact, and a constant stream returns the constant.

P-square: the p95 estimate converging with stream size blue = P-square estimate, yellow dashed = the true final p95 (constant memory throughout) true p95 = 134.5 ms 100 1000 10000 50000 200000 samples seen (log scale)
the P-square p95 latency estimate (blue) converging to the true final percentile (yellow dashed) as the stream grows -- computed in constant memory the whole time
P-square: streaming quantiles (latency percentiles) in constant memory

  streamed 200000 latency samples (heavy-tailed, in ms):
    percentile    P-square       exact     error
           p50       49.96       49.97     0.02%
           p90      106.51      106.50     0.01%
           p95      134.39      134.51     0.09%
           p99      212.30      212.05     0.12%

  Memory: P-square keeps 5 markers per quantile = 20 floats total,
  versus 200000 samples (1562 KB) that exact computation would need to store and sort.

  The p95 estimate converges to the true value as more data arrives:
    after    100 samples: p95 estimate  144.61  (true so far  144.10)
    after   1000 samples: p95 estimate  142.98  (true so far  138.02)
    after  10000 samples: p95 estimate  133.94  (true so far  134.04)
    after  50000 samples: p95 estimate  135.24  (true so far  135.27)
    after 200000 samples: p95 estimate  134.39  (true so far  134.51)

  Five markers track the min, max, the target quantile, and two midpoints; each new
  sample nudges the markers toward their desired positions using parabolic interpolation.
  No samples are stored -- ideal for p99 latency monitoring on a firehose of requests.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\p2_quantile.svg

Tarjan's strongly connected components and the condensation DAG

In a directed graph, a STRONGLY CONNECTED COMPONENT is a maximal set of vertices where every vertex can reach every other -- the graph's cycles collapsed into equivalence classes. Shrinking each SCC to a single node yields the CONDENSATION, which is always a DAG, so every directed graph is a directed-acyclic graph of its cycles -- the structure behind dependency and dead-code analysis, deadlock detection, 2-SAT, and the web graph's block model. TARJAN'S ALGORITHM finds all SCCs in a single depth-first search in O(V + E). It gives each vertex a DISCOVERY INDEX and a LOW-LINK (the smallest index reachable from its subtree via at most one back-edge to a vertex still on the DFS stack); a vertex whose low-link equals its own index is the ROOT of an SCC, and everything pushed onto an auxiliary stack after it forms that component. Because the low-link propagates cycle reachability, the roots partition the vertices into exactly the SCCs, discovered in reverse topological order of the condensation. This module implements Tarjan's SCC iteratively (no recursion-depth limit), the condensation DAG, a Kahn topological sort, and a cycle test, verified against a brute-force mutual-reachability check (two vertices share an SCC iff each reaches the other) over 100 random graphs, that the condensation is always acyclic, that SCCs come in reverse topological order, that a cycle is one component and a DAG is all singletons, and on a 5000-cycle that the iterative DFS survives without hitting Python's recursion limit.

Tarjan SCC: cycles colored (left), condensation DAG (right) each colour is one strongly connected component; the condensation is always acyclic 0 1 2 3 4 5 6 7 0,1,2 3,4 5,6,7
left: a directed graph with each strongly connected component in its own colour; right: the condensation, each cycle collapsed to one node, forming an acyclic dependency chain
Tarjan's SCC: collapsing a directed graph's cycles into a DAG

  8 vertices, 11 edges
  strongly connected components (reverse topological order):
    component 0: [5, 6, 7]
    component 1: [3, 4]
    component 2: [0, 1, 2]

  condensation DAG super-edges: [(1, 0), (2, 1)]
  condensation topological order: [2, 1, 0]
  condensation is acyclic: True

  original graph has a cycle: True
  condensation has a cycle:   False

  A single depth-first search assigns each vertex a discovery index and a low-link (the
  smallest index reachable via one back-edge from its subtree). A vertex whose low-link
  equals its index is an SCC root; the stack above it forms the component. O(V+E).

  wrote C:\Users\acwic\symplectic-nbody\examples\output\tarjan_scc.svg

2-SAT: satisfying two-literal clauses in linear time

Boolean SATISFIABILITY is NP-complete in general, but the restricted case where every clause has at most TWO literals is solvable in LINEAR time -- a rare island of tractability inside an intractable problem, and a beautiful application of strongly connected components. The trick is the IMPLICATION GRAPH: a clause (a OR b) is equivalent to two implications, (not a implies b) and (not b implies a), so build a directed graph with two vertices per variable (the literal and its negation) and an edge for each implication. A truth assignment is consistent exactly when no variable x has x and NOT x forced together -- which happens iff x and NOT x lie in the SAME strongly connected component (each would then imply the other). So the formula is SATISFIABLE iff no variable shares an SCC with its negation, checkable by Tarjan's SCC in O(V + E); and when satisfiable, an assignment reads straight off the SCC order because the condensation is a DAG. This module (built on this project's Tarjan SCC) adds clauses and implications, tests satisfiability, and extracts a satisfying assignment, verified against brute force over all 2^n assignments: the verdict always matches whether any assignment satisfies the formula, every returned assignment actually satisfies every clause (over 300 random formulas), and the canonical contradiction (x) AND (not x) and the four-clause unsatisfiable formula are classified correctly.

2-SAT implication graph (nodes colored by SCC) top row = literals true, bottom = negations; edges are clause implications F1 ~F1 F2 ~F2 F3 ~F3
the implication graph of a 2-SAT instance: literals on top, negations below, clause implications as edges, each strongly connected component in its own colour -- no variable shares a component with its negation, so the formula is satisfiable
2-SAT: satisfying two-literal clauses in linear time via SCCs

  Formula: (F1 v F2) AND (~F1 v F3) AND (~F2 v ~F3)
  SATISFIABLE. One assignment: F1=T, F2=F, F3=T
  verifies against every clause: True
  brute force agrees it is satisfiable: True

  Unsatisfiable formula: (a v b)(a v ~b)(~a v b)(~a v ~b)
  solver verdict: UNSATISFIABLE
  reason: some variable and its negation land in the same SCC of the implication graph

  Each clause (a v b) becomes two implications (~a -> b) and (~b -> a). The formula is
  satisfiable iff no variable shares a strongly connected component with its own negation;
  when it does, x -> ~x -> x forces a contradiction. Assignment reads off the SCC order.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\two_sat.svg

Dormand-Prince RK45: adaptive-step ODE integration

Solving an ODE y' = f(t, y) numerically hinges on STEP SIZE: too large loses accuracy or stability, too small wastes work where the solution is smooth. ADAPTIVE methods choose the step at every point to keep the local error near a target tolerance -- big steps through calm regions, tiny steps through rapid change. The DORMAND-PRINCE method (RK45, the engine of MATLAB's ode45 and SciPy's solve_ivp) pairs a 5th- and a 4th-order Runge-Kutta formula that SHARE their stage evaluations, so their difference is a cheap estimate of the local truncation error -- used to accept or reject each step and rescale it by (tol/error)^(1/5). Seven stages per step (six new plus one reused via the FSAL property) make it efficient. This module implements adaptive Dormand-Prince RK45 for scalar and vector ODEs, forward or backward in time, with optional sampling at requested times, verified against closed-form solutions: exponential decay and growth to the requested tolerance, a harmonic oscillator conserving energy to 2e-9 over ten periods and returning exactly to its start, a logistic curve, and a 2-frequency oscillator matching cos(2t) -- with tightening the tolerance shrinking the error (1.6e-5 at tol=1e-4 down to 2e-11 at tol=1e-10) and faster dynamics correctly demanding more steps.

Adaptive RK45: Van der Pol oscillator, with step markers blue = solution x(t); ticks below = accepted steps (dense where the solution swings fast) accepted steps (450 total) -- clustered at the sharp transitions
the Van der Pol relaxation oscillator solved by RK45; the orange step ticks below cluster tightly at the sharp switch-backs and spread out across the smooth stretches -- adaptive control at work
Dormand-Prince RK45: adaptive-step ODE solving

  y' = -y, y(0)=1:  y(5) = 0.00673795  (exact e^-5 = 0.00673795)
    error 1.98e-10 in 54 adaptive steps

  Harmonic oscillator over 10 periods:
    energy drift 2.54e-09 (should be ~0), 1469 steps

  Tolerance controls accuracy AND effort:
    tol=1e-04:   8 steps, error 1.60e-05
    tol=1e-06:  16 steps, error 1.63e-07
    tol=1e-08:  35 steps, error 1.79e-09
    tol=1e-10:  83 steps, error 1.97e-11

  Van der Pol oscillator (mu=5.0, nonlinear relaxation oscillation):
    450 adaptive steps; step size ranges 0.0124 to 0.1089
    -> the solver takes 9x bigger steps in the smooth stretches
       than through the sharp switch-backs -- exactly where a fixed step would waste work.

  Each step computes a 5th- and a 4th-order estimate from shared stage evaluations; their
  difference estimates the local error, which accepts/rejects the step and rescales it by
  (tol/error)^(1/5). Big steps in calm regions, tiny steps through rapid change.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\rk45.svg

CORDIC: trigonometry and logarithms with only shifts and adds

How does a calculator or an FPGA with no hardware multiplier compute sin, cos, or a logarithm? CORDIC (COordinate Rotation DIgital Computer, Volder 1959) evaluates a whole family of transcendental functions using nothing but ADDITION, SUBTRACTION, and BIT SHIFTS -- no multiplication, no division, no lookup of the function itself. It powered the first scientific calculators and still lives in FPGAs and DSPs wherever a multiplier is scarce. The trick is iterative ROTATION by ever-smaller angles whose tangents are exact powers of two, so 'multiplying' by the rotation is just a bit shift. To get cos and sin, start at (1, 0) and rotate toward the target angle in steps of arctan(2^-i), adding or subtracting the shifted coordinates depending on whether the running angle is short of or past the target; after n steps the point is (K cos, K sin) for a fixed gain K divided out at the end. This is CIRCULAR mode; flipping a sign gives HYPERBOLIC mode (exp, ln, sqrt), and a VECTORING variant computes atan2 and hypot. This module implements all of them on shift-and-add updates with precomputed angle and gain tables, verified against the math library across their ranges: cos/sin to ~1e-9 over [-2pi, 2pi], atan2 in all four quadrants, hypot, and exp/ln/sqrt to high precision (with range reduction), the Pythagorean identity everywhere, and the circular gain matching its analytic product.

CORDIC: iterative rotation homing in on cos/sin each step rotates by +/- arctan(2^-i) (a bit shift); the path spirals to the target angle target 1.1 rad Only shift + add: cos = 0.453596 sin = 0.891207 no multiply, no divide, no function lookup
the CORDIC rotation spiralling around the unit circle toward a target angle -- each step a shift-add rotation of decreasing size -- with the final point's coordinates being cos and sin
CORDIC: sin, cos, exp, ln, sqrt using only shifts and additions

  Circular functions (vs math library):
    cos(0.5000) = 0.8775825619  (math 0.8775825619)
    sin(0.5000) = 0.4794255386  (math 0.4794255386)
    cos(1.0000) = 0.5403023059  (math 0.5403023059)
    sin(1.0000) = 0.8414709848  (math 0.8414709848)
    cos(1.0472) = 0.5000000000  (math 0.5000000000)
    sin(1.0472) = 0.8660254038  (math 0.8660254038)

  Vectoring mode (rectangular -> polar):
    atan2(1, 1) = 0.7853981634  (pi/4 = 0.7853981634)
    hypot(3, 4) = 5.0000000000  (= 5)

  Hyperbolic mode (exp, ln, sqrt):
    exp(2)   = 7.3890560989  (math 7.3890560989)
    ln(10)   = 2.3025850930  (math 2.3025850930)
    sqrt(50) = 7.0710678119  (math 7.0710678119)

  Rotation converging on angle 1.1 rad (each step is a shift-add):
    after  1 steps: angle 0.785398  (error 3.15e-01)
    after  3 steps: angle 1.004067  (error 9.59e-02)
    after  5 steps: angle 1.066003  (error 3.40e-02)
    after  7 steps: angle 1.112867  (error 1.29e-02)
    after  9 steps: angle 1.101148  (error 1.15e-03)
    after 11 steps: angle 1.100172  (error 1.72e-04)

  Each iteration rotates by +/- arctan(2^-i); because tan of the step is a power of two,
  the rotation is a bit shift, not a multiply. The running angle homes in on the target,
  and the final x,y are cos and sin (after dividing out the fixed CORDIC gain).

  wrote C:\Users\acwic\symplectic-nbody\examples\output\cordic.svg

Savitzky-Golay: smoothing that preserves peaks, and noisy differentiation

Smoothing noisy data usually blunts its features -- a moving average flattens peaks and shifts edges. The SAVITZKY-GOLAY filter, the standard in analytical chemistry, smooths by fitting a low-degree POLYNOMIAL to a sliding window by least squares and taking the fitted value at the centre. Because a polynomial follows a peak's curvature, it removes noise while preserving peak height and width far better than a moving average -- the default for spectroscopy and chromatography, where SHAPE matters. The elegant fact is that for evenly-spaced points the least-squares fit reduces to fixed CONVOLUTION COEFFICIENTS depending only on window size and degree, so the whole filter is one convolution. Taking the DERIVATIVE of the fitted polynomial at the centre yields a smoothed estimate of the signal's derivative -- differentiating noisy data, normally a disaster, becomes stable, invaluable for finding inflection points and rates. This module computes coefficients for any odd window, degree, and derivative order and applies the filter with edge handling, verified against exact references: a polynomial of degree <= the filter degree passes through unchanged (the defining property), the derivative mode recovers the analytic derivative, smoothing a noisy sine cuts the MSE to the clean signal, the coefficients have unit DC gain (sum 1) for smoothing and zero for derivatives, and it beats a moving average at preserving a Gaussian peak's height (0.996 vs 0.842).

Savitzky-Golay vs moving average on a noisy peak signal gray = noisy input, green = Savitzky-Golay (peaks kept), red = moving average (peaks blunted) noisy Savitzky-Golay moving avg
a noisy two-peak signal (gray) smoothed by Savitzky-Golay (green, peaks intact) versus a moving average (red, peaks flattened) -- the polynomial fit follows the curvature
Savitzky-Golay: smoothing that keeps peak shapes, and stable differentiation

  degree-2, window-5 smoothing coefficients: [-0.0857, 0.3429, 0.4857, 0.3429, -0.0857]
    (sum = 1.0000, the classic -3 12 17 12 -3 / 35)

  MSE to the clean signal:
    noisy input:            0.0048
    moving average:         0.0028
    Savitzky-Golay:         0.0008
  peak-2 height (true 1.450): SG 1.434, moving avg 1.264
    -> the moving average flattens the sharp peak; SG keeps it

  Smoothed 1st derivative finds peak locations (zero crossings): [61, 100, 131, 148, 163, 196]
    (true peaks near 60 and 130 -- differentiating raw noisy data would be hopeless)

  Savitzky-Golay fits a low-degree polynomial to a sliding window by least squares and
  takes the centre value -- a fixed convolution. The polynomial follows a peak's
  curvature, so unlike a moving average it smooths without blunting features, and its
  derivative gives a stable estimate of the signal's slope.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\savitzky_golay.svg

LZW: adaptive dictionary compression

LEMPEL-ZIV-WELCH is the dictionary compressor behind GIF, early TIFF, and Unix `compress`. Its charm is that it needs NO explicit dictionary in the output and NO two passes: encoder and decoder build the SAME dictionary independently as they go, so the compressed stream is just a sequence of integer codes. It replaces repeated SUBSTRINGS with single codes, and the longer and more repetitive the input, the longer the matched substrings become -- so its ratio improves as it learns the data. The encoder starts with every single byte (codes 0..255), reads the longest string w already in the dictionary, and on the next character c either extends (if w+c is known) or outputs the code for w, adds w+c as a new entry, and restarts at c. The decoder mirrors this one step behind, with the classic KwKwK edge case (a code referring to the entry about to be built) resolved by the rule that the entry is the previous string plus its own first character. This module implements byte-oriented LZW compression and decompression with an optional capped code width (GIF-style dictionary reset), verified by exhaustive round-tripping: decompress(compress(x)) == x for 300 random byte strings, repetitive data, text, all-same and all-distinct inputs, and the empty string; that repetitive input yields far fewer codes than its length; that the KwKwK case decodes correctly; and that the capped-width reset round-trips with codes staying in range.

LZW compression ratio improves with repetition codes/bytes falls as the same phrase repeats -- the dictionary learns longer substrings ratio = 1 (no compression) 1x 2x 5x 10x 25x 50x 100x number of phrase repetitions (log scale)
the LZW compression ratio (codes per byte) plunging below the no-compression line as a phrase repeats more -- the dictionary learns longer substrings and encodes each in one code
LZW: adaptive dictionary compression (GIF / Unix compress)

  input : TOBEORNOTTOBEORTOBEORNOT  (24 bytes)
  codes : [84, 79, 66, 69, 79, 82, 78, 79, 84, 256, 258, 260, 265, 259, 261, 263]  (16 codes)
  decompresses exactly: True
  Codes > 255 are dictionary entries the encoder built from repeated substrings;
  the decoder rebuilds the identical dictionary with no side channel.

  Compression ratio (codes / bytes) vs repetition:
      1 repetitions (   20 bytes): ratio 1.000
      2 repetitions (   40 bytes): ratio 0.750
      5 repetitions (  100 bytes): ratio 0.540
     10 repetitions (  200 bytes): ratio 0.400
     25 repetitions (  500 bytes): ratio 0.264
     50 repetitions ( 1000 bytes): ratio 0.190
    100 repetitions ( 2000 bytes): ratio 0.137

  Repetitive 2700 bytes: ratio 0.047 (great)
  Random     2700 bytes: ratio 0.983 (near 1 -- incompressible)

  Dictionary entries learned from 'the quick brown fox' x50: 189

  LZW replaces repeated substrings with single codes, and because both sides build the
  same dictionary as they read, the compressed stream is just integer codes -- no
  dictionary transmitted, one pass, and the ratio improves the longer the patterns run.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\lzw.svg

Convolutional codes and the Viterbi decoder: error correction for noisy channels

A CONVOLUTIONAL CODE protects a bit stream by feeding it through a shift register and emitting, each step, several output bits that are XORs of the current and recent inputs. Unlike a block code it has MEMORY -- each output depends on a sliding window -- spreading each bit's information across many transmitted bits, so a burst of channel errors can be undone. Convolutional codes carried the Voyager images home and run in every satellite and GSM modem. Decoding a noisy stream means finding the input whose encoded output is CLOSEST (minimum Hamming distance) to what arrived; brute force over 2^n inputs is hopeless, but the VITERBI ALGORITHM does it in linear time by dynamic programming on the TRELLIS -- at each step keeping, for every register state, the single survivor path with least accumulated error, then tracing back the survivors to recover the maximum-likelihood sequence. This module implements a rate-1/n encoder for arbitrary generator polynomials and a Viterbi decoder with zero-tail termination, verified that a clean channel decodes exactly, that it corrects every single-bit error and most well-separated double errors, that Viterbi achieves the same minimum distance as a brute-force search over 60 noisy trials, and that the classic (7,5) code shows a clear coding gain -- 95% decode success at a 5% channel error rate where uncoded transmission manages only 41%.

Convolutional coding gain: decode success vs channel error rate green = coded + Viterbi (errors corrected), red = uncoded (any flip corrupts the message) 0.00 0.25 0.50 0.75 1.00 channel bit-error rate 0.00 0.02 0.05 0.10 0.15 0.20 0.30
the coding gain: decode success staying high for the coded+Viterbi channel (green) as the bit-error rate rises, while uncoded transmission (red) collapses -- error correction buying reliability
Convolutional coding + Viterbi decoding over a noisy channel

  rate-1/2 code, constraint length 3, 4 trellis states

  message (10 bits): 1011001011
  encoded (24 bits): 111000010111111000010111
  received (2 bit errors at [3, 11]):
           111100010110111000010111
  decoded  (10 bits): 1011001011
  recovered the original exactly: True

  Decode success vs channel bit-error rate (200 messages of 16 bits each):
    error rate    coded   uncoded
          0.00     1.00      1.00
          0.02     0.99      0.73
          0.05     0.95      0.41
          0.10     0.74      0.16
          0.15     0.51      0.07
          0.20     0.22      0.04
          0.30     0.02      0.01

  The encoder runs the message through a shift register, emitting XOR combinations that
  spread each bit across several outputs. Viterbi finds the maximum-likelihood path
  through the trellis -- the transmitted sequence closest to what arrived -- in linear time.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\convolutional_code.svg

Ear-clipping: triangulating any simple polygon

Triangulating a polygon -- splitting it into non-overlapping triangles that exactly tile its interior -- is the first step of almost all polygon processing: rendering (GPUs draw only triangles), finite-element meshing, area and centroid computation, and collision geometry. The TWO EARS THEOREM guarantees every simple polygon with more than three vertices has at least two EARS -- a vertex whose neighbours can be joined by a diagonal lying entirely inside, cutting off a triangle that contains no other vertex. EAR CLIPPING finds an ear, snips it as a triangle, and repeats on the smaller polygon until a triangle remains -- the simplest robust triangulation, O(n^2). A vertex is an ear when it is CONVEX (the polygon turns the right way, so the diagonal is interior) and no other vertex lies INSIDE the candidate triangle; the algorithm fixes the winding from the signed area so 'convex' is consistent, then clips and rescans. An n-vertex polygon yields exactly n-2 triangles. This module triangulates convex or concave polygons in either winding order, verified against exact references: the triangle count is always n-2, the triangle areas sum exactly to the polygon's area (shoelace) with no gaps or overlaps, every triangle's centroid lies inside the polygon, and stars, L-shapes, arrows, and a deeply non-convex comb all triangulate correctly.

Ear-clipping triangulation of concave polygons each polygon split into n-2 triangles (alternating fills); the outline is drawn in white star: 8 triangles L-shape: 4 triangles arrow: 5 triangles
a star, an L-shape, and an arrow each split into n-2 triangles by ear clipping (alternating fills), the white outline showing they tile the interior exactly
Ear-clipping triangulation: any simple polygon into n-2 triangles

  star: 10 vertices -> 8 triangles (n-2 = 8)
    polygon area 4.7023, triangle sum 4.7023, match True
  L-shape: 6 vertices -> 4 triangles (n-2 = 4)
    polygon area 5.0000, triangle sum 5.0000, match True
  arrow: 7 vertices -> 5 triangles (n-2 = 5)
    polygon area 8.0000, triangle sum 8.0000, match True

  The two-ears theorem guarantees every simple polygon with >3 vertices has an EAR --
  a convex vertex whose diagonal stays inside and whose triangle holds no other vertex.
  Clip the ear, repeat on the smaller polygon; n-2 triangles later, done. O(n^2).

  wrote C:\Users\acwic\symplectic-nbody\examples\output\ear_clipping.svg

Multi-layer perceptron and backpropagation

A single linear classifier cannot learn XOR -- no straight line separates its classes. The MULTI-LAYER PERCEPTRON stacks layers of neurons with NONLINEAR activations, and with even one hidden layer becomes a UNIVERSAL APPROXIMATOR that can represent any continuous function -- the foundation of deep learning. It learns by BACKPROPAGATION, the chain rule applied systematically: a forward pass gives the prediction and loss, then the loss's gradient flows BACKWARD layer by layer, each layer computing its weights' contribution to the error from the gradient handed down from above. Because each layer reuses the downstream gradient, the whole gradient is computed in one backward pass costing the same as the forward pass -- the efficiency that makes training deep networks feasible. Gradient descent then nudges every weight down its gradient. This module implements a feedforward MLP with configurable layers and activations (sigmoid, tanh, ReLU), full backprop, and mini-batch gradient descent with momentum, verified that its analytic gradients match finite-difference gradients to 8e-11 (the definitive backprop-correctness test), that it learns the XOR a linear model cannot (loss to 4e-5, all four points correct), that it fits a nonlinear regression, that it separates blobs to 100% and a circular decision boundary to 99%, and that training is reproducible from a seed.

MLP: XOR loss curve (left) and a learned circular boundary (right) left: training loss falling; right: the network output over the plane (blue=inside, dark=outside) XOR loss (log scale) vs epoch learned output over the plane (yellow = true circle)
left: the XOR training loss plunging on a log scale; right: the network's learned output over the plane forming a circular decision boundary (yellow = the true circle) that no linear model could draw
Multi-layer perceptron + backpropagation

  XOR (not linearly separable):
    [0, 0] -> 0.004  (target 0)
    [0, 1] -> 0.991  (target 1)
    [1, 0] -> 0.991  (target 1)
    [1, 1] -> 0.011  (target 0)
    loss 0.1269 -> 0.000038 over 3000 epochs
    a linear model gets only 3/4 right (XOR needs a hidden layer)

  Circular decision boundary (inside a disk): accuracy 0.993
    a linear boundary could never separate a disk from its surroundings

  Backpropagation is the chain rule run backward: a forward pass computes the loss, then
  the gradient flows back layer by layer, each reusing the gradient handed down from
  above, so the whole gradient costs one backward pass. Gradient descent does the rest.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\mlp.svg

Markov decision processes: value iteration and policy iteration

A MARKOV DECISION PROCESS frames sequential decision-making under uncertainty -- a robot navigating, inventory being restocked -- with STATES, ACTIONS, stochastic TRANSITIONS, REWARDS, and a DISCOUNT that trades immediate against future reward. The goal is a POLICY that maximizes expected discounted return, and MDPs are the foundation of reinforcement learning. VALUE ITERATION repeatedly applies the BELLMAN OPTIMALITY backup V(s) <- max_a sum_s' P(s'|s,a)[R + gamma V(s')]; because this is a contraction (it shrinks the gap to the true values by gamma each sweep) it converges geometrically to the optimal value function, whose greedy policy is optimal. POLICY ITERATION alternates exact policy evaluation with greedy improvement, converging in a handful of rounds. This module implements both for a finite MDP plus a stochastic gridworld builder, verified that value and policy iteration reach the SAME optimal policy and values, that the result satisfies the Bellman optimality equation (zero residual), that a corridor yields a straight-to-goal policy, that a higher discount values distant rewards more, that a hand-built MDP gives the exact expected value, that stochastic slip is handled, and that value iteration's error contracts by exactly the discount factor each sweep on a self-looping MDP.

Gridworld MDP: optimal value (color) and policy (arrows) brighter = higher value (closer to goal); arrows = optimal action; gold = goal, dark = obstacle +0.29 +0.37 +0.47 +0.57 +0.69 +0.36 +0.58 +0.83 +0.46 +0.70 +0.83 +0.98 +0.56 +0.69 +0.82 +0.98 G
a gridworld solved to optimality: cell brightness is the state value (rising toward the gold goal), the arrows are the optimal action in each state, routing around the dark obstacles
Markov decision process: value iteration and policy iteration

  5x4 gridworld, goal at (4, 3), obstacles [(1, 1), (1, 2), (3, 1)], 10% slip:
    value iteration converged in 26 sweeps
    policy iteration converged in 3 rounds
    same policy: True, same values: True
    Bellman residual: 1.22e-11 (zero = optimal fixed point)

  Optimal policy (G = goal, # = obstacle):
    > > > > G
    ^ # > > ^
    ^ # ^ # ^
    > > ^ > ^

  State values (higher = closer to the goal along the optimal path):
    +0.56  +0.69  +0.82  +0.98  +0.00 
    +0.46    ##   +0.70  +0.83  +0.98 
    +0.36    ##   +0.58    ##   +0.83 
    +0.29  +0.37  +0.47  +0.57  +0.69 

  Value iteration applies the Bellman optimality backup until the value function stops
  changing (a contraction, converging geometrically). Policy iteration alternates exact
  policy evaluation with greedy improvement, converging in a handful of rounds. Both find
  the same optimal policy -- the action in each state that maximizes expected return.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\mdp.svg

Multi-armed bandits: the exploration-exploitation tradeoff

A MULTI-ARMED BANDIT is the purest model of decision under uncertainty: a row of slot machines paying from unknown distributions, and a gambler choosing which to pull each round to maximize reward. The tension is EXPLORATION versus EXPLOITATION -- pull the arm that looks best, or try an under-sampled one that might be better? Bandits model A/B testing, ad selection, and clinical trials. Performance is REGRET: reward lost by not always pulling the truly best arm; a good policy drives average regret toward zero. This module implements three classics: EPSILON-GREEDY (exploit the best-so-far, explore randomly with probability epsilon), UCB1 (optimism under uncertainty -- pull the arm maximizing its mean plus a confidence bonus sqrt(2 ln t / n) that shrinks with sampling, giving provably logarithmic regret with no tuning), and THOMPSON SAMPLING (keep a Beta posterior per arm, sample one value from each, pull the argmax -- Bayesian probability-matching). Verified against exact references: every learning policy vastly outperforms random selection, UCB1 and Thompson achieve sublinear regret (average regret falls toward zero as rounds grow), all policies identify the best arm as the most-pulled, UCB1's regret grows logarithmically (far slower than linearly), decaying epsilon beats fixed, and a dominant arm is found fast.

Multi-armed bandit: cumulative regret by policy lower and flatter is better; learning policies bend away from the linear random baseline round random epsilon-greedy UCB1 Thompson
cumulative regret by policy: random selection climbs linearly (red) while UCB1 (blue) and Thompson (green) bend flat as they learn the best arm -- the payoff of principled exploration
Multi-armed bandit: exploration vs exploitation

  5 arms with payout probabilities [0.2, 0.5, 0.75, 0.4, 0.6]
  best arm is #2 (p = 0.75), over 3000 rounds:

              policy  total reward    regret  best-arm %
              random          1449     758.5       20.9%
      epsilon-greedy          2151      92.4       90.4%
                UCB1          2092     150.9       78.1%
            Thompson          2210      35.6       94.3%

  Regret is reward lost versus always pulling the best arm. Random selection accrues
  regret linearly; UCB1 and Thompson learn which arm is best and their regret flattens
  (grows only logarithmically), driving the average regret per round toward zero.

  UCB1 after 500 rounds (mean + confidence bonus per arm):
    arm 0: pulled  26, mean 0.269, UCB 0.961  (true 0.2)
    arm 1: pulled  59, mean 0.508, UCB 0.967  (true 0.5)
    arm 2: pulled 269, mean 0.762, UCB 0.977  (true 0.75)
    arm 3: pulled  38, mean 0.395, UCB 0.967  (true 0.4)
    arm 4: pulled 108, mean 0.630, UCB 0.969  (true 0.6)

  wrote C:\Users\acwic\symplectic-nbody\examples\output\bandit.svg

Q-learning: model-free reinforcement learning from experience

Value iteration solves an MDP when you KNOW its transitions and rewards. But an agent dropped into an unknown world can only ACT and observe. MODEL-FREE reinforcement learning learns to act optimally from raw experience alone. Q-LEARNING learns the ACTION-VALUE Q(s, a) -- the expected return of taking action a in state s then acting optimally -- purely from sampled transitions (s, a, r, s'), and its greedy policy converges to the optimum. The update is TEMPORAL-DIFFERENCE learning: nudge Q(s,a) toward r + gamma max_a' Q(s',a'), the reward plus the discounted value of the best next action; the bracketed TD ERROR is the surprise between prediction and outcome. Q-learning is OFF-POLICY -- it learns the optimal Q while exploring epsilon-greedily. SARSA is its on-policy cousin, bootstrapping from the action actually taken. This module implements tabular Q-learning and SARSA over a step-based gridworld (the agent never sees the transition model), verified against value iteration as ground truth: the learned greedy policy matches the optimal policy, reaches the goal in the optimal number of steps (on 4x3 and 6x6 gridworlds), the learned Q-values approach the MDP optimum, a learned agent reaches the goal far faster than a random walker (5 vs 57 steps), returns improve over training, a higher learning rate speeds early learning, and SARSA also solves the task.

Q-learning: learning curve (left) and learned policy (right) left: episode return rising as the agent learns; right: learned value (color) + policy (arrows) episode return (smoothed): blue Q-learning, green SARSA
left: the episode return rising as Q-learning (blue) and SARSA (green) learn from experience; right: the learned value function (color) and greedy policy (arrows) routing to the gold goal
Q-learning: model-free reinforcement learning from experience

  6x5 gridworld, 4 obstacles, goal (5, 4)
  Q-learning saw only (state, action, reward, next-state) samples -- never the model.
  after 3000 episodes: policy matches value iteration on 21/25 states
  greedy rollout: Q-learning 9 steps vs value-iteration optimum 9
  SARSA (on-policy) rollout: 9 steps

  Learned policy (G = goal, # = obstacle):
    > > > > > G
    ^ ^ ^ # > ^
    ^ ^ ^ < # ^
    ^ # # > > ^
    ^ > > > > ^

  The TD update nudges Q(s,a) toward r + gamma max_a' Q(s',a') after each step -- learning
  the optimal action-values from raw experience, off-policy, while exploring epsilon-greedily.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\q_learning.svg

Reverse-mode automatic differentiation

How does a deep-learning framework compute the gradient of a loss with respect to millions of parameters? Not by hand-derived formulas and not by finite differences (slow and inexact), but by REVERSE-MODE AUTOMATIC DIFFERENTIATION -- the engine behind PyTorch's autograd and JAX's grad -- which computes exact derivatives of any function built from elementary operations at the cost of about one extra evaluation, regardless of the number of inputs. It records operations as they run: each numeric VALUE remembers the operation that produced it and its parents, so an expression becomes a computation graph; the forward pass computes the result, and the BACKWARD pass walks the graph in reverse topological order, applying each node's LOCAL DERIVATIVE to accumulate the gradient flowing back from its children -- backpropagation generalized from neural-net layers to any composition. Because it sweeps from the single scalar output back to all inputs, one pass yields the entire gradient vector, which is why it dominates machine learning. This module implements a Value type with +, -, *, /, **, and exp/log/sin/cos/tanh/relu/sqrt, each recording its local derivative, plus a topological backward pass, verified that gradients match finite differences across a nonlinear function suite and hand-derived symbolic derivatives, that they accumulate correctly when a value is reused (graph diamonds), that a gradient-descent loop using autodiff reaches the analytic optimum, and that a 3-parameter a*sin(bx)+c model is trained to recover its true parameters exactly.

Autodiff-trained model: loss curve (left), fit (right) gradients computed by reverse-mode autodiff -- no derivative formulas written by hand training loss (log scale) gray = data, green = learned a sin(bx)+c
a model trained purely by autodiff gradients: the loss plunging on a log scale (left) and the learned a*sin(bx)+c curve landing exactly on the data (right) -- no derivative formulas written by hand
Reverse-mode automatic differentiation: exact gradients through any expression

  f(x,y,z) = sin(xy) + e^(z^2)*x - y/z  at [0.8, 1.2, 0.5]
    value: -0.553588
    autodiff gradient:      [+1.97225, -1.54118, +5.82722]
    finite-diff gradient:   [+1.97225, -1.54118, +5.82722]
    max difference: 1.75e-10 (autodiff is exact)

  One backward pass computes the WHOLE gradient vector, regardless of the number of
  inputs -- the property that makes it the engine of deep learning.

  Training a 3-parameter model y = a*sin(b*x) + c by autodiff gradient descent:
    true params:    a=2.0, b=1.5, c=0.5
    learned params: a=2.000, b=1.500, c=0.500
    loss 3.0001 -> 0.000000

  wrote C:\Users\acwic\symplectic-nbody\examples\output\autodiff.svg

Shamir's secret sharing: any k of n pieces reconstruct the secret

How do you store a secret -- a master key, a launch code -- so no single person holds it, yet any sufficiently large group can recover it? SHAMIR'S SECRET SHARING solves this with geometry: a polynomial of degree k-1 is uniquely determined by any k of its points, but k-1 points reveal NOTHING. Hide the secret as the constant term of a random degree-(k-1) polynomial over a finite field GF(p), hand each participant one point (x, f(x)) as their SHARE, and any k shares interpolate the polynomial and read off the secret f(0) -- while any k-1 shares leave every possible secret equally likely, information-theoretic security, not merely computational. The arithmetic lives in integers mod a prime so division is exact; reconstruction is LAGRANGE INTERPOLATION at x=0 with modular inverses. The scheme is (k, n)-THRESHOLD: any k of the n shares suffice, any fewer are useless. This module implements splitting a secret integer (or byte string) into shares over a 256-bit prime field and reconstructing it, verified that any k of the n shares reconstruct the secret exactly, that every k-subset gives the same answer, that no k-1 subset recovers it, that the shares span the field, that a byte-string secret round-trips, and on the boundary cases k=1 and k=n.

Shamir secret sharing over GF(97): shares on a degree-2 polynomial blue = the 5 shares (points); gold = the secret at f(0) = the y-intercept; any 3 fix the curve (1,77) (2,90) (3,44) (4,36) (5,66) secret = f(0) = 5 share index x (secret hidden at x=0)
over GF(97): five shares as blue points on a degree-2 polynomial whose gold y-intercept f(0) is the secret -- any three points fix the curve, two leave it undetermined
Shamir's secret sharing: any k of n shares reconstruct the secret

  secret: 1234567890
  (3, 5) threshold -> 5 shares, any 3 reconstruct:
    share 1: 4151667051347653500082243584139699646936...
    share 2: 6054314218818774026761417985512402722793...
    share 3: 5707941502413361580037523204118109227568...
    share 4: 3112548902131416159910559239956819161262...
    share 5: 9847345341704557308737624593897323309203...

  reconstruct from shares 1,2,3: 1234567890
  reconstruct from shares 3,4,5: 1234567890
  reconstruct from just 2 shares: 224901988387653297340306918276... (wrong -- reveals nothing)

  byte secret b'attack at dawn' split (2,4):
    reconstruct from 2 shares: b'attack at dawn'

  The geometry: the secret is the y-intercept f(0) of a degree-(k-1) polynomial. Each
  share is a point on it. k points fix the polynomial uniquely (Lagrange interpolation
  recovers f(0)); k-1 points leave infinitely many polynomials, so the secret is hidden.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\shamir.svg

Merkle trees: compact proofs of membership in a dataset

A MERKLE TREE hashes a list of data blocks into a single ROOT such that any change to any block changes the root, and any single block can be proven to belong with a proof of only O(log n) hashes -- without revealing the other blocks. It is the structure behind blockchain transaction commitments, Git's content addressing, certificate transparency logs, and peer-to-peer file verification, turning 'is this item in the set?' from an O(n) download into an O(log n) proof. The leaves are the hashes of the blocks; each internal node hashes its two children; the root fingerprints the entire ordered dataset (flip one bit anywhere and it changes). To prove block i is included, supply the AUTHENTICATION PATH -- the sibling hash at each level from leaf to root -- and a verifier who trusts only the root recomputes the path and checks it lands on the root. This module builds a tree over byte blocks (SHA-256 with domain-separated leaf and node prefixes, the standard second-preimage defence), generates inclusion proofs, and verifies them, checked that a valid proof for every block verifies, that tampering with the block, the proof, or the root all fail, that the proof length is logarithmic (a million blocks need a 20-hash proof), that changing or reordering any block changes the root, and on odd block counts where the last node is promoted.

Merkle tree: the authentication path for one leaf green = the proven leaf and its path to the root; gold = the sibling hashes in the proof leaves (block hashes) at the bottom, root at the top
the Merkle tree with one leaf's authentication path highlighted: green is the proven leaf up to the root, gold the sibling hashes the proof supplies -- log(n) hashes to prove membership
Merkle tree: O(log n) proofs that a block belongs to a dataset

  8 transaction blocks -> Merkle root 51483b2847f13ec776c5681c...

  Inclusion proof for block 3 ('tx: Dot->Eve 3'):
    3 sibling hashes (log2(8) = 3):
      left  sibling: 72836eadad4c42f34826586e...
      left  sibling: 3674ff0918216c59cf6644d7...
      right sibling: b308c557eb8da518dba34f35...
    verifies against the root: True
    (a verifier who trusts only the root confirms membership without the other blocks)

  Tamper detection:
    altered block verifies: False (rejected)
    forged proof verifies:  False (rejected)

  Proof size grows logarithmically (download shrinks from O(n) to O(log n)):
            8 blocks: proof ~ 3 hashes (96 bytes) vs 8 blocks to download
           64 blocks: proof ~ 6 hashes (192 bytes) vs 64 blocks to download
         1024 blocks: proof ~ 10 hashes (320 bytes) vs 1024 blocks to download
      1000000 blocks: proof ~ 20 hashes (640 bytes) vs 1000000 blocks to download

  Each leaf is a block's hash; each parent hashes its two children; the root fingerprints
  the whole ordered set. An inclusion proof is the sibling hash at each level up to the
  root -- recompute the path, check it lands on the trusted root. Flip any bit and it won't.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\merkle.svg

The traveling salesman problem: exact Held-Karp and 2-opt

The TRAVELING SALESMAN PROBLEM asks for the shortest tour visiting every city once and returning to the start -- the archetypal NP-hard optimization problem (the tour count grows as (n-1)!/2), yet the model for vehicle routing, circuit drilling, and logistics everywhere. Two complementary approaches: an EXACT dynamic program for small instances and fast HEURISTICS for large ones. HELD-KARP builds, for every SUBSET of cities and every possible last city, the shortest path from the start through that subset ending there -- reusing subproblems for O(n^2 2^n) instead of O(n!), making ~20 cities exactly solvable. The 2-OPT heuristic starts from any tour and repeatedly removes two edges and reconnects them the other way (reversing the segment between) whenever that shortens the tour, a local search that removes the self-crossings a good tour never has and lands within a few percent of optimal. This module implements Held-Karp, nearest-neighbour construction, and 2-opt over an arbitrary distance matrix, verified against brute-force permutation search that Held-Karp returns the true optimum on small instances, that 2-opt never worsens a tour and averages within ~1% of the Held-Karp optimum, that every tour is a valid permutation whose reported length matches its edges, that the Euclidean helper satisfies the triangle inequality, and on hand-checked squares and collinear points.

TSP: nearest-neighbour (left, crossings) vs 2-opt (right, untangled) 2-opt reverses segments to remove crossings; the tour shrinks from 778 to 684 nearest-neighbour (778) 2-opt (684)
the same 60 cities toured two ways: the nearest-neighbour tour (left, red) riddled with crossings, and the 2-opt tour (right, green) with the crossings untangled and 12% shorter
Traveling salesman: exact Held-Karp DP and the 2-opt heuristic

  11 cities (Held-Karp exact, examining subsets not the 3.6M tours):
    nearest-neighbour tour: 370.9
    Held-Karp optimum:      349.0
    2-opt from NN:          350.1

  60 cities ((n-1)!/2 = astronomically many tours; 2-opt local search):
    nearest-neighbour: 778.1
    after 2-opt:       683.6  (12.1% shorter)
    2-opt removes the self-crossings a good tour never has

  Held-Karp builds shortest paths over every (subset, last-city) pair, reusing
  subproblems for O(n^2 2^n) instead of O(n!). 2-opt repeatedly reverses a segment
  between two edges whenever that shortens the tour -- fast, and within a few percent.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\tsp.svg

The Poisson equation by relaxation: fields, potentials, and steady heat

The POISSON equation laplacian(u) = f governs the electrostatic potential of a charge, the steady-state temperature of a heated plate, and the pressure in incompressible flow; with zero source it is LAPLACE'S equation, whose solutions are HARMONIC -- every point the average of its surroundings, no interior hot spots. On a grid with fixed boundary values, the solution is found by RELAXATION: sweep the grid replacing each cell with the source-adjusted average of its four neighbours until the field stops changing. Three schemes trade simplicity for speed: JACOBI updates from the old values (simple, slow), GAUSS-SEIDEL uses freshly-updated values (about twice as fast), and SUCCESSIVE OVER-RELAXATION overshoots each correction by a factor omega in (1,2) -- with the optimal omega it converges an order of magnitude faster. This module solves the 2-D Poisson/Laplace equation with Dirichlet boundaries by all three methods, verified against exact references: Laplace with linear boundary data reproduces the exact linear harmonic solution, the discrete solution satisfies the mean-value property and the maximum principle (no interior extrema), a separable analytic harmonic solution sinh(kx)sin(ky) is matched to grid accuracy, a point charge gives a symmetric monotone potential, all three methods converge to the same field, and SOR beats Jacobi by 30x (158 vs 5261 sweeps on a 40x40 grid).

Laplace: steady-state temperature on a plate (hot left, cold right) each interior cell is the average of its neighbours; isotherms curve smoothly across the plate 100 (hot) 0 (cold)
the steady-state temperature on a plate held hot on the left and cold on the right, solved by relaxation -- a smooth harmonic field whose isotherms curve gently, every interior cell the average of its neighbours
Poisson/Laplace by relaxation: steady-state fields on a grid

  Steady-state heat on a 40x40 plate (hot left 100, cold right 0):
    converged in 158 SOR sweeps; harmonic (mean-value error 4.02e-09)
    center temperature: 48.7 (halfway between the edges)

  Convergence speed (sweeps to tol=1e-8, 40x40 grid):
    jacobi        :  5261 sweeps
    gauss_seidel  :  2733 sweeps
    sor           :   158 sweeps
    SOR over-relaxes each correction by omega in (1,2), converging an order faster.

  Poisson for a point charge (grounded box): peak potential 37.10
    residual |laplacian(u) - f|: 9.21e-09

  Laplace's equation makes every interior point the average of its neighbours -- a
  harmonic field, smooth with no interior hot spots. Relaxation just sweeps the grid
  averaging until it settles; SOR accelerates by overshooting each averaging step.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\poisson.svg

Manacher's algorithm: every palindrome in linear time

Finding the LONGEST PALINDROMIC SUBSTRING naively takes O(n^2) or worse. MANACHER'S ALGORITHM does it in O(n) by computing, for every position, the RADIUS of the longest palindrome centered there, reusing the symmetry of already-found palindromes so no character is examined more than a constant number of times -- the definitive linear-time palindrome algorithm, used in bioinformatics and text processing. It handles even- and odd-length palindromes uniformly by inserting separators so every palindrome becomes odd-length with one center; a running scan keeps the RIGHTMOST palindrome found (center C, right edge R), and a new center i inside R inherits its MIRROR position's radius for free, expanding beyond it only when possible -- and each expansion advances R, so total work is linear. This module returns the per-center radii, the longest palindromic substring, the count of all palindromic substrings, and the odd/even radius profiles, verified against brute force: the longest palindrome matches an O(n^2) center-expansion reference and the total count matches a full substring enumeration over 500 random strings, the returned substring is always a genuine palindrome, and edge cases (empty, single char, all-same, all-distinct, a 2000-char worst case) all work.

Manacher: odd-palindrome radius at each character center taller bar = longer palindrome centered on that character; the peak is the longest palindrome a 1 b 2 a 1 c 4 a 1 b 2 a 1 d 8 a 1 b 2 a 1 c 4 a 1 b 2 a 1
the palindrome radius at each character center of 'abacabadabacaba' -- the self-similar 1,2,1,4,1,2,1,8 profile peaking at the center where the whole string reads as one palindrome
Manacher's algorithm: all palindromes in O(n)

  'babad'         : longest 'bab'        (3 chars), 7 palindromic substrings
  'racecar'       : longest 'racecar'    (7 chars), 10 palindromic substrings
  'abacaba'       : longest 'abacaba'    (7 chars), 12 palindromic substrings
  'mississippi'   : longest 'ississi'    (7 chars), 20 palindromic substrings
  'aabaaacaaab'   : longest 'baaacaaab'  (9 chars), 24 palindromic substrings

  Verify 'aabaaacaaab': Manacher 24 == brute 24

  Odd-length palindrome radius at each center of 'abacabadabacaba':
    a b a c a b a d a b a c a b a
    1 2 1 4 1 2 1 8 1 2 1 4 1 2 1
    peak radius 8 at center 'd' (index 7) -> the whole string is a palindrome

  The string is padded with separators so every palindrome is odd-length with one
  center. A running scan keeps the rightmost palindrome found; a new center inherits its
  mirror's radius for free and only expands beyond it -- so total work is linear.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\manacher.svg

Stoer-Wagner: the global minimum cut of a weighted graph

A minimum CUT splits a graph's vertices into two groups so the total weight of edges crossing between them is smallest. Unlike the s-t min cut (which fixes which side two vertices land on, solved by max-flow), the GLOBAL minimum cut asks for the cheapest cut over ALL ways of splitting -- the graph's weakest link, measuring network reliability and driving clustering and segmentation. The STOER-WAGNER algorithm finds it in O(V^3) with no flow computation, by a strikingly simple idea: MINIMUM CUT PHASES. Each phase grows a set from an arbitrary vertex, repeatedly adding the vertex most tightly connected to the current set (maximum-adjacency order); the last two vertices added, s and t, give a CUT-OF-THE-PHASE that is provably the minimum s-t cut for that pair, and the phase then MERGES s and t into one vertex (summing parallel weights) and repeats. After V-1 phases every pair has been implicitly considered, and the smallest cut-of-the-phase is the global minimum -- no augmenting paths, just orderings and merges. This module implements it, returning the cut weight and partition, verified against brute force over all vertex bipartitions of small graphs and on known graphs (the textbook example gives 4, a bridge gives its single edge, K_n gives n-1, a cycle gives two edges), that the partition achieves the reported weight, that parallel edges are summed, and that a disconnected graph gives a zero cut.

Stoer-Wagner global minimum cut blue and green vertices are the two sides; red edges cross the cut (the weakest partition) 2 3 3 2 2 4 2 2 2 3 1 3 0 1 2 3 4 5 6 7 minimum cut weight = 4
a weighted graph with its global minimum cut: the two sides in blue and green, the red edges (the weakest partition) crossing between them -- the cheapest way to sever the network
Stoer-Wagner: the global minimum cut of a weighted graph

  Classic 8-vertex graph, 12 weighted edges:
    global minimum cut weight: 4
    partition: [2, 3, 6, 7] | [0, 1, 4, 5]
    brute force confirms: 4

  Two dense clusters joined by 2 weak links:
    min cut 2 -> partition [4, 5, 6, 7] | [0, 1, 2, 3]
    the cut finds exactly the weak bridges between the clusters

  Each phase grows a set by repeatedly adding the most tightly-connected vertex; the last
  vertex added gives a provable s-t min cut, then s and t merge. V-1 phases later, the
  smallest cut seen is the global minimum -- no max-flow, no augmenting paths.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\stoer_wagner.svg

Chebyshev approximation: near-optimal fits that dodge the Runge phenomenon

Approximating a function by a polynomial sounds simple -- sample and interpolate -- but sampling at EQUALLY SPACED points is a trap: for many smooth functions the interpolation error EXPLODES near the interval ends as the degree rises (the RUNGE PHENOMENON). The fix is to sample at CHEBYSHEV POINTS, the projections of equally spaced points on a circle onto the interval, clustered toward the ends. Chebyshev interpolation converges for every continuous function and is NEAR-OPTIMAL -- its maximum error is within a small factor of the best possible polynomial of that degree, converging geometrically for analytic functions. A function is expanded as a sum of Chebyshev polynomials T_n = cos(n arccos x) with coefficients from samples at the Chebyshev nodes, evaluated stably by CLENSHAW'S RECURRENCE. Because T_n has equal-ripple extrema, the truncated series spreads its error evenly (equioscillation, the hallmark of minimax approximation) -- the foundation of function-approximation libraries and spectral methods. This module builds Chebyshev interpolants on any interval, verified that smooth functions (exp, sin, rationals) are matched to near machine precision, that error shrinks geometrically with degree, that on Runge's function Chebyshev stays bounded (error 0.007 at degree 24) where equispaced interpolation blows up (error 257), that a low-degree polynomial is recovered exactly, and that the nodes cluster at the ends.

Runge phenomenon: Chebyshev (green) vs equispaced (red), degree 16 gray = the true function; equispaced oscillates wildly at the ends, Chebyshev hugs the curve true Chebyshev equispaced
Runge's function fit at degree 16: the equispaced interpolant (red) oscillating wildly near the ends while the Chebyshev interpolant (green) hugs the true curve -- the nodes marked below cluster at the edges
Chebyshev approximation: near-optimal polynomial fits

  Approximating exp(x) on [-1,1] -- error falls geometrically with degree:
    degree  2: max error 5.65e-02
    degree  4: max error 6.40e-04
    degree  6: max error 3.62e-06
    degree  8: max error 1.22e-08
    degree 12: max error 4.26e-14
    degree 16: max error 1.78e-15

  Runge's function 1/(1+25x^2) -- Chebyshev vs equally-spaced nodes:
     degree     Chebyshev    equispaced
          8        0.1708          1.05
         12        0.0692          3.66
         16        0.0326         14.39
         20        0.0153         59.77
         24        0.0069        257.21
    equispaced interpolation EXPLODES near the ends; Chebyshev nodes (clustered there)
    tame it -- error spreads evenly across the interval (equioscillation).

  Chebyshev nodes are the projections of equally spaced points on a circle onto the
  interval, clustered at the ends. Expanding in Chebyshev polynomials T_n and evaluating
  by Clenshaw recurrence gives a fit within a small factor of the best possible polynomial.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\chebyshev.svg

Gibbs sampling: drawing a joint distribution via its conditionals

Sampling from a high-dimensional joint distribution is hard, but sampling from its one-dimensional CONDITIONALS -- the distribution of one variable given the others fixed -- is often easy. GIBBS SAMPLING cycles through the variables, resampling each from its conditional given the current values of the rest; the sequence is a Markov chain whose stationary distribution is the target joint, so after a burn-in the samples are draws from it. It is the workhorse of Bayesian statistics (hierarchical models, LDA, image restoration) because the conditionals stay simple even when the joint is intractable. It is a special case of Metropolis-Hastings where every proposal is ACCEPTED (the ratio is exactly 1, since we propose from the true conditional), so no step is wasted. For a multivariate Gaussian each conditional is a 1-D Gaussian with a mean linear in the other coordinates and a variance from the precision matrix. This module implements Gibbs sampling for bivariate and general multivariate Gaussians (closed-form conditionals) and a generic user-conditional sampler, verified against exact references: the sampled mean and covariance of a correlated bivariate Gaussian converge to the true parameters, the correlation matches the target (0.735 recovered exactly), a 3-D Gaussian's full covariance is recovered, negative and zero correlations are handled, and a generic discrete conditional sampler reproduces a known joint distribution.

Gibbs samples of a correlated bivariate Gaussian blue dots = samples; yellow = target covariance ellipse (2 sigma); the cloud tilts with the correlation
the Gibbs sample cloud of a correlated bivariate Gaussian (blue) tilted along its correlation, with the target 2-sigma covariance ellipse (yellow) that the samples fill -- coordinate-wise sampling reproducing the full joint
Gibbs sampling: draw a joint distribution via its conditionals

  Correlated bivariate Gaussian (target mean [2.0, -1.0], cov [[3.0, 1.8], [1.8, 2.0]]):
    sampled mean:       [1.979, -1.013]
    sampled covariance: [[3.01, 1.80], [1.80, 2.00]]
    correlation: 0.735 (target 0.735)
    Each step resamples x | y then y | x from their 1-D Gaussian conditionals -- every
    proposal accepted (acceptance ratio exactly 1), so no step is wasted.

  4-D Gaussian: sampled mean [0.01, 1.00, 2.00, 3.00] (target [0, 1, 2, 3])
    diagonal variances: [1.97, 1.48, 1.00, 1.20] (target [2.0, 1.5, 1.0, 1.2])

  Gibbs sampling is the engine of Bayesian computation: even when the joint is
  intractable, the one-dimensional conditionals are usually simple, and cycling through
  them builds a Markov chain that converges to the joint after burn-in.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\gibbs.svg

Louvain community detection: the natural clusters in a network

Real networks break into COMMUNITIES -- groups of nodes more densely connected inside than between -- and finding them reveals structure without being told how many groups to look for. The quality measure is MODULARITY: how much denser the within-community edges are than a random graph with the same degrees. Maximizing it is NP-hard, but the LOUVAIN METHOD is a fast greedy heuristic that is the de-facto standard for community detection at scale. It alternates two phases until modularity stops improving: LOCAL MOVING, where each node joins whichever neighbour's community gives the largest modularity gain (computable in O(degree)), and AGGREGATION, where each community collapses into a super-node and the process recurses on the smaller graph. Each phase only raises modularity and the graph shrinks each round, so it converges quickly to a hierarchical partition. This module implements Louvain on a weighted undirected graph, returning the community assignment and achieved modularity, verified against exact references: the reported modularity matches a direct computation, on graphs with planted communities (dense cliques joined by sparse bridges) it recovers exactly those communities (four groups, modularity 0.66), a single clique stays one community, a complete graph gives low modularity, the found partition beats both trivial partitions, weighted edges are respected, and a ring of triangles is split correctly.

Louvain communities: 4 clusters found by modularity each colour is a community; dense within-group edges, thin bridges between -- the natural clustering red edges bridge communities; gray edges stay within one
a network of four dense groups joined by thin bridges, each community coloured by Louvain -- the red bridging edges are the sparse links between clusters, the gray edges the dense within-community ties
Louvain community detection: finding a network's natural clusters

  20 nodes, 44 edges (4 planted groups + 4 bridges)
  Louvain found 4 communities, modularity 0.6591
    planted group 0 ([0, 1, 2, 3, 4]): community label(s) {0}  <- recovered
    planted group 1 ([5, 6, 7, 8, 9]): community label(s) {1}  <- recovered
    planted group 2 ([10, 11, 12, 13, 14]): community label(s) {2}  <- recovered
    planted group 3 ([15, 16, 17, 18, 19]): community label(s) {3}  <- recovered

  Modularity of the trivial partitions (for comparison):
    all in one community: 0.0000
    every node its own:   -0.0506
    Louvain:              0.6591  <- highest

  Louvain greedily moves each node to the neighbouring community that most increases
  modularity, then collapses communities into super-nodes and recurses. Each phase only
  raises modularity, so it converges fast to a strong hierarchical partition.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\louvain.svg

Matrix-chain multiplication: the optimal order by dynamic programming

Matrix multiplication is associative -- (AB)C = A(BC) -- but the WORK is not: multiplying a p x q matrix by q x r costs p*q*r operations, so the order of products can change the total cost by orders of magnitude. Given the dimensions, which PARENTHESIZATION minimizes the scalar multiplications? The number of orderings is a Catalan number (exponential), but DYNAMIC PROGRAMMING solves it in O(n^3) -- the textbook example of optimal substructure. The insight: the best way to multiply matrices i..j splits at some k into (i..k)(k+1..j) with both halves themselves optimal, so m[i][j] = min over k of m[i][k] + m[k+1][j] + p_{i-1} p_k p_j, filled by increasing chain length, with the base case m[i][i] = 0. Recording the minimizing split reconstructs the parenthesization. This is the canonical interval DP, shared with optimal binary search trees and polygon triangulation. This module computes the minimum cost and optimal parenthesization, verified against brute-force search over all Catalan-many orderings for short chains that the DP finds the true minimum, that the reconstructed order achieves that cost, that it beats the naive left-to-right order on skewed dimensions (94% saved on one example), and on the classic CLRS instance (15125).

Matrix-chain DP cost table m[i][j] m[i][j] = min cost to multiply matrices i..j; brighter = costlier; the corner m[1][n] is the answer 0 15750 7875 9375 11875 15125 0 2625 4375 7125 10500 0 750 2500 5375 0 1000 3500 0 5000 0 answer = m[1][6] = 15,125 1 1 2 2 3 3 4 4 5 5 6 6
the DP cost table m[i][j] filled diagonal by diagonal (min cost to multiply matrices i through j), the gold-boxed top-right corner holding the answer for the whole chain
Matrix-chain multiplication: the cheapest order to multiply a chain

  6 matrices with dimensions:
    A1: 30 x 35
    A2: 35 x 15
    A3: 15 x 5
    A4: 5 x 10
    A5: 10 x 20
    A6: 20 x 25

  optimal parenthesization: ((A1(A2A3))((A4A5)A6))
    optimal cost:        15,125 scalar multiplications
    left-to-right cost:  40,500
    savings: 62.7%

  A skewed chain where order matters even more:
    dims [50, 5, 100, 5, 100, 5]: optimal 6,375 vs left-to-right 100,000 (94% saved)
    optimal order: (A1((A2A3)(A4A5)))

  The number of parenthesizations grows as a Catalan number (exponential), but the
  DP fills an n x n cost table in O(n^3): m[i][j] = min over split k of the cost of the
  two halves plus multiplying them. Recording each split reconstructs the optimal order.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\matrix_chain.svg

Longest increasing subsequence: patience sorting in O(n log n)

The LONGEST INCREASING SUBSEQUENCE is the longest set of elements, in their original order, that strictly increases -- not necessarily contiguous. It measures how sorted a sequence is and appears in card games, computational biology (the longest consistently ordered gene run between genomes), and the analysis of permutations. The naive DP is O(n^2); PATIENCE SORTING does it in O(n log n) by dealing the sequence like solitaire: each number is placed on the leftmost PILE whose top is greater-or-equal, or starts a new pile. The number of piles at the end EQUALS the LIS length, and because the pile tops stay sorted, the right pile is found by BINARY SEARCH -- the log factor. Back-pointers to the top of the pile to the left at placement time reconstruct the actual subsequence. This module computes the length and a witnessing subsequence, with strict, non-decreasing, and longest-decreasing variants, verified against a brute-force O(2^n) search that the length is optimal and the returned subsequence is genuinely increasing and a real subsequence, against the O(n^2) DP over hundreds of random sequences, and on known cases (sorted gives n, reversed gives 1, all-equal gives 1 strict but n non-decreasing).

Longest increasing subsequence (green bars, connected) each bar is a sequence element; the green ones form the longest strictly-increasing run in order 3 1 4 1 5 9 2 6 5 3 5 8 9 7 9 LIS length 6: [1, 2, 3, 5, 7, 9]
a sequence as bars with its longest increasing subsequence in green, connected in order by the gold line -- the longest run that climbs left to right, found by patience sorting
Longest increasing subsequence: patience sorting in O(n log n)

  sequence: [3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5, 8, 9, 7, 9]
  longest increasing subsequence (length 6): [1, 2, 3, 5, 7, 9]

  patience-sorting piles (leftmost pile whose top >= card):
    pile 0: [3, 1, 1]
    pile 1: [4, 2]
    pile 2: [5, 5, 3]
    pile 3: [9, 6, 5]
    pile 4: [8, 7]
    pile 5: [9, 9]
    number of piles = 6 = LIS length 6

  strict vs non-decreasing on [1,3,3,5,2,4,4,6]:
    strict:         length 4
    non-decreasing: length 6 (equal elements allowed)

  longest DECREASING subsequence: length 4, [9, 6, 5, 3]

  Each card is placed on the leftmost pile whose top is >= it (binary search), or starts
  a new pile. The pile count equals the LIS length; back-pointers to the previous pile's
  top at placement time reconstruct the actual subsequence. O(n log n).

  wrote C:\Users\acwic\symplectic-nbody\examples\output\lis.svg

Continued fractions: the best rational approximations of a real

Every real number expands as a CONTINUED FRACTION x = a0 + 1/(a1 + 1/(a2 + ...)), found greedily: take the integer part, invert the remainder, repeat. Truncating gives the CONVERGENTS p_k/q_k, which are the BEST RATIONAL APPROXIMATIONS -- no fraction with a smaller denominator is closer. This is why 22/7 and 355/113 are the famous approximations of pi, how gear ratios and calendar leap-years are chosen, and how the eventually-periodic expansion of a quadratic irrational solves Pell's equation. The convergents follow the recurrence p_k = a_k p_{k-1} + p_{k-2}, q_k = a_k q_{k-1} + q_{k-2}, always in lowest terms, straddling x and closing in with error bounded by 1/(q_k q_{k+1}) -- so a large partial quotient (pi's 292) makes the previous convergent (355/113, good to 7 digits) exceptional. This module computes the expansion of a real or exact fraction, its convergents, and the best rational within a denominator bound, verified that pi's convergents are 3, 22/7, 333/106, 355/113, that a finite expansion recovers its rational exactly, that each convergent is the best approximation for its denominator (checked against all smaller ones), that the golden ratio is all ones and sqrt(2) is [1;2,2,...], that convergents alternate around and converge to the target, and that best-approximation matches brute force within a denominator bound.

Continued-fraction convergents: approximation error vs denominator log-log: error falls steeply with denominator; the golden ratio (worst-approximable) falls slowest log10(denominator) pi e golden ratio sqrt(2)
log-log convergent error vs denominator for pi, e, sqrt(2), and the golden ratio -- error plunges with denominator, and the golden ratio (the 'most irrational' number, all-ones expansion) is the slowest to approximate
Continued fractions: the best rational approximations of a real

  pi             = [3; 7, 15, 1, 292, 1, 1, 1, ...]
  e              = [2; 1, 2, 1, 1, 4, 1, 1, ...]
  golden ratio   = [1; 1, 1, 1, 1, 1, 1, 1, ...]
  sqrt(2)        = [1; 2, 2, 2, 2, 2, 2, 2, ...]

  Convergents of pi (each the best rational for its denominator):
                   3  = 3.0000000000   error 1.42e-01
                22/7  = 3.1428571429   error 1.26e-03
             333/106  = 3.1415094340   error 8.32e-05
             355/113  = 3.1415929204   error 2.67e-07
        103993/33102  = 3.1415926530   error 5.78e-10
        104348/33215  = 3.1415926539   error 3.32e-10
    355/113 is accurate to 7 digits because pi's next quotient (292) is huge --
    a large partial quotient means the preceding convergent is exceptionally good.

  Convergent error falls roughly as 1/denominator^2:
    pi: 7:1.3e-03  106:8.3e-05  113:2.7e-07  33102:5.8e-10  33215:3.3e-10
    e: 1:2.8e-01  3:5.2e-02  4:3.2e-02  7:4.0e-03  32:4.7e-04

  wrote C:\Users\acwic\symplectic-nbody\examples\output\continued_fraction.svg

The Chinese Remainder Theorem: a number from its remainders

If you know a number's remainders modulo several pairwise-coprime moduli, the CHINESE REMAINDER THEOREM says there is a UNIQUE value modulo their product matching all of them, and constructs it. This ancient result (Sunzi, 3rd century) is the backbone of modern computing: RSA decryption speeds up about 4x by working modulo the prime factors separately and recombining with CRT; big-integer and polynomial arithmetic run in parallel residue systems; and it underlies secret sharing and error-correcting codes. It rests on the EXTENDED EUCLIDEAN ALGORITHM, which finds the Bezout identity a*x + b*y = gcd(a,b) and hence modular inverses. Given x = r_i (mod m_i) with coprime moduli, CRT builds x = sum r_i * M_i * (M_i^-1 mod m_i) mod M, each term hitting r_i in its own modulus and 0 in the others. When the moduli are not coprime the system is solvable only if the congruences agree on each pairwise gcd -- a generalized CRT that merges congruences and detects contradictions. This module implements extended Euclid, modular inverse, and both CRTs, verified against brute force: the solution satisfies every congruence and is the smallest non-negative one (matching exhaustive search over 200 systems), the Bezout identity is correct over 300 random pairs, non-coprime systems are solved when consistent and rejected when contradictory, and the classic Sunzi puzzle gives 23.

CRT: the unique value where all congruence stripes align each row shades the values matching one congruence; they coincide only at x = 23 x%3=2 x%5=3 x%7=2 x = 23
the Sunzi puzzle visualized: each coloured row shades the numbers satisfying one congruence, and the red line marks x=23, the single value where all three stripes overlap
Chinese Remainder Theorem: a number from its remainders

  Sunzi's classic puzzle (3rd century): a number leaves remainder
    2 mod 3, 3 mod 5, 2 mod 7 -- what is it?
    -> x = 23 (mod 105); check: 23%3=2, 23%5=3, 23%7=2

  Reconstruct 8675309 from its residues mod [101, 103, 107, 109, 113]:
    residues: [15, 31, 70, 108, 73]
    CRT gives 8675309 (mod 13710311357); matches the secret: True

  RSA speedup idea: instead of one exponentiation mod n = p*q,
  do two mod p and mod q (each ~half the size, ~4x faster) and recombine with CRT.
    value 123456789 recovered from (mod 1000003, mod 1000033): True

  Non-coprime moduli (generalized CRT):
    x = 3 mod 4 and x = 5 mod 6 -> 11 mod 12
    x = 1 mod 2 and x = 0 mod 4 -> None (contradiction)

  For pairwise-coprime moduli there's a unique answer mod their product, built as
  sum(r_i * M_i * (M_i^-1 mod m_i)) where M_i = M/m_i -- each term hits r_i in its own
  modulus and 0 in the others. The extended Euclidean algorithm supplies the inverses.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\crt.svg

Tonelli-Shanks: square roots modulo a prime

Solving x^2 = n (mod p) -- a MODULAR SQUARE ROOT -- is basic to number theory and cryptography: it decompresses elliptic-curve points (recovering y from x), appears in quadratic-sieve factoring and primality proving, and underlies the Rabin cryptosystem. There is no general formula, but the TONELLI-SHANKS algorithm finds a root in expected polynomial time whenever one exists. First, EULER'S CRITERION decides existence: n is a QUADRATIC RESIDUE mod p iff n^((p-1)/2) = 1, the LEGENDRE SYMBOL. If it is, and p = 3 mod 4, the root is simply n^((p+1)/4); otherwise Tonelli-Shanks writes p-1 = q*2^s, finds a quadratic non-residue, and iteratively squares and corrects a running candidate -- each step halving the order of the error -- until it squares to n. The two roots are r and p-r. This module implements the Legendre symbol, a residue test, and the modular square root, verified against brute force: the returned root squares back to n, a root is returned iff n is a genuine square (checked against the actual set of squares for every residue of 40 primes), both roots are r and p-r, the Legendre symbol matches a residue count with exactly (p-1)/2 residues per prime, and both the p = 3 mod 4 fast path and the general 1 mod 4 path give correct roots, up to million-scale primes.

Quadratic residues mod 37 (green) and the square map x -> x^2 top row: each value 1..36 coloured as residue (green) or non-residue (gray); curve: x^2 mod 37 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 x (each x maps to x^2 mod 37; two x share each residue)
top: which values mod 37 are quadratic residues (green) versus non-residues (gray); below: the square map x -> x^2 mod 37, where each residue is hit by exactly two x -- the two roots Tonelli-Shanks recovers
Tonelli-Shanks: square roots modulo a prime

  Modular square roots (x^2 = n mod p):
    sqrt(10) mod 13: (6, 7)  (check: 6^2 = 10 mod 13)
    sqrt(2) mod 7: (3, 4)  (check: 3^2 = 2 mod 7)
    sqrt(5) mod 7: none (5 is a non-residue)
    sqrt(123456) mod 1000033: (450092, 549941)  (check: 450092^2 = 123456 mod 1000033)

  Modulo 13: 6 residues [1, 3, 4, 9, 10, 12], 6 non-residues [2, 5, 6, 7, 8, 11]
    (exactly (p-1)/2 = 6 of each, per the Legendre symbol)

  Elliptic-curve point decompression on y^2 = x^3 + 2x + 3 (mod 1000003):
    x = 5: no point (rhs 138 is a non-residue) -- would try another x

  Euler's criterion (n^((p-1)/2) mod p) tells us if a root exists; if so, Tonelli-Shanks
  finds it: for p = 3 mod 4 it's just n^((p+1)/4), otherwise it iteratively corrects a
  candidate using a quadratic non-residue until the error's order drops to zero.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\tonelli_shanks.svg

Baby-step giant-step: the discrete logarithm in O(sqrt(n))

The DISCRETE LOGARITHM problem -- find x with g^x = h (mod m) -- is the hard problem underpinning Diffie-Hellman, ElGamal, and DSA, whose security rests on the belief that no fast general algorithm exists. Naive search is O(n) in the order of g; BABY-STEP GIANT-STEP cuts that to O(sqrt(n)) by meet-in-the-middle. Write x = i*N + j with N = ceil(sqrt(n)); then g^x = h becomes g^j = h*(g^{-N})^i. Precompute a hash table of the BABY STEPS g^j for all j, then take GIANT STEPS multiplying h by g^{-N} and looking each up -- a hit gives x = i*N + j. Both loops run sqrt(n) times, exponentially faster than brute force yet still exponential in the bit-length, which is why real cryptographic groups (256-bit and up) stay secure. This module implements BSGS modulo a prime and a multiplicative-order helper, verified against brute force: the returned x satisfies g^x = h, existence and validity agree with exhaustive search over dozens of primes, no-solution cases are reported, a toy Diffie-Hellman exchange is broken by recovering the secret exponent (and reconstructing the shared key), and the order helper matches known values.

Discrete log: baby-step giant-step vs brute force (log-log) green = O(sqrt(n)) BSGS steps, red = O(n) brute force; the gap widens as the group grows 100 1000 10000 100000 1000000 group order n (log scale)
the work to solve a discrete log: baby-step giant-step (green, ~sqrt(n)) versus brute force (red, n) on a log-log scale -- the gap that both enables toy attacks and forces real crypto to use enormous groups
Baby-step giant-step: discrete logarithm in O(sqrt(n))

  Solving g^x = h (mod p):
    3^x = 13 (mod 17)  ->  x = 4  (check: 3^4 = 13)
    2^x = 22 (mod 29)  ->  x = 26  (check: 2^26 = 22)
    7^x = 6 (mod 41)  ->  x = 39  (check: 7^39 = 6)

  Breaking a toy Diffie-Hellman key exchange:
    public: p=7919, g=7, A=1434, B=7138
    (Alice and Bob's shared secret: 2002)
    eavesdropper solves g^x = A -> x = 5555 (Alice's secret was 5555)
    -> reconstructs the shared secret 2002: True
    (this works only because p is tiny; real DH uses 2048+ bit primes)

  Baby-step giant-step vs brute force (steps to solve):
    p=    101: BSGS ~ 2*sqrt(n) =   22 steps   vs brute force n = 100 steps
    p=   1009: BSGS ~ 2*sqrt(n) =   64 steps   vs brute force n = 1008 steps
    p=  10007: BSGS ~ 2*sqrt(n) =  202 steps   vs brute force n = 10006 steps
    p= 100003: BSGS ~ 2*sqrt(n) =  634 steps   vs brute force n = 100002 steps

  Write x = i*N + j with N = ceil(sqrt(n)). Precompute the baby steps g^j in a hash map,
  then take giant steps h*(g^-N)^i and look each up. A hit gives x = i*N + j. Both loops
  run sqrt(n) times -- exponentially faster than brute force, but still exponential in bits.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\discrete_log.svg

Welzl's algorithm: the smallest enclosing circle

Given a point cloud, the SMALLEST ENCLOSING CIRCLE is the circle of least radius containing all of them -- the tightest 'where is everything?' summary, the optimal facility placement minimizing the worst-case distance, and the bounding volume for collision culling. A key fact makes it tractable: the smallest circle is determined by at most THREE boundary points (two as a diameter, or three on a circumcircle). WELZL'S ALGORITHM finds it in EXPECTED LINEAR time by randomized incremental construction: process points in random order maintaining the current smallest circle; a point already inside is skipped, but a point outside MUST lie on the boundary of the new circle, so rebuild from the earlier points with it forced onto the boundary. With one, two, then three boundary points fixed, the circle is pinned down. This module implements the practical iterative (move-to-front) variant, returning the centre and radius, verified against brute force and exact references: every point lies inside, the radius matches an O(n^4) all-triples minimum over 60 random sets, shrinking the radius excludes a point (true minimality), interior points don't change it, and known cases hold (two points give a diameter, a square its circumscribed circle, points on a circle recover that circle) -- up to 1000-point sets.

Smallest enclosing circle (Welzl) blue = points, green = the minimum circle, red = the 2-3 support points that pin it down radius 170.6, 2 boundary points
a point cloud with its smallest enclosing circle (green) and the two or three red support points on the boundary that alone determine it -- the tightest circle containing everything
Welzl's algorithm: the smallest circle enclosing a point cloud

  50 points
  smallest enclosing circle: centre (193.8, 164.2), radius 170.56
  all points inside: True
  support points (on the boundary): 2 -- the circle is pinned by 2 (a diameter)

  For comparison, the bounding-box circumscribed circle has radius 186.03
    -> the true minimum circle is 8% smaller in radius

  The smallest circle is determined by at most 3 boundary points. Welzl processes points
  in random order; a point inside the current circle is skipped, but one outside must lie
  on the new circle's boundary, so the circle is rebuilt with it fixed there -- O(n) expected.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\welzl.svg

Cuckoo filters: approximate membership with deletion

A BLOOM FILTER answers 'have I seen this?' in tiny space but cannot DELETE items. The CUCKOO FILTER matches Bloom's space and false-positive rate, is often faster, AND supports deletion -- which is why it backs routers, databases, and deduplication that must remove items. Like Bloom it never gives a FALSE NEGATIVE; it only occasionally gives a false positive. It stores a small FINGERPRINT of each item in a table of buckets, with two candidate buckets per item found by CUCKOO HASHING: i2 = i1 XOR hash(fingerprint), the trick that lets either bucket recover the other from just the stored fingerprint, so lookups and deletes need only the fingerprint. Insertion places the fingerprint in either candidate; if both are full it evicts a random resident to ITS alternate bucket and repeats -- the 'cuckoo' kicking-out that reaches ~95% load. This module implements add, contains, and delete with configurable bucket size and fingerprint bits, verified that it never reports a false negative (every added, not-deleted item tests present), that the false-positive rate is small and shrinks with fingerprint size (0.067 at 4 bits down to ~0 at 16), that deletion removes an item while leaving others, that deleting one of several duplicates leaves the rest, that it packs to a ~95% load factor, and that it is reproducible from a seed.

Cuckoo filter: false-positive rate vs fingerprint bits more fingerprint bits -> exponentially fewer false positives (log scale); no false negatives ever 4 6 8 10 12 16 fingerprint bits
the false-positive rate falling exponentially as the fingerprint grows (log scale) -- a few more bits per item buys orders-of-magnitude fewer false hits, with deletion Bloom filters can't offer
Cuckoo filter: approximate membership with deletion

  added 2000 items, load factor 0.24
  no false negatives: True
  false positives on 50000 absent items: 1 (rate 0.00002)

  Deletion (impossible with a Bloom filter):
    'user-500' present: True
    after delete:        False
    'user-501' unaffected: True

  False-positive rate shrinks as the fingerprint grows:
     4 bits: false-positive rate 0.06656
     6 bits: false-positive rate 0.03904
     8 bits: false-positive rate 0.00082
    10 bits: false-positive rate 0.00004
    12 bits: false-positive rate 0.00004
    16 bits: false-positive rate 0.00000

  Each item stores a small fingerprint in one of two candidate buckets, the second found
  by XORing the first with hash(fingerprint) -- so either bucket recovers the other from
  the fingerprint alone. Full buckets kick out a resident to its alternate (the 'cuckoo').

  wrote C:\Users\acwic\symplectic-nbody\examples\output\cuckoo_filter.svg

Rollback disjoint-set union: undoable connectivity

Ordinary UNION-FIND is nearly constant-time thanks to PATH COMPRESSION, but that same compression rewrites the tree unpredictably, so unions cannot be UNDONE. Offline dynamic connectivity needs exactly that: process edge additions and queries, then roll back to an earlier state. The trick is to drop path compression and use only UNION BY RANK, which changes O(1) state per union (one parent pointer, maybe one rank), recorded on a stack; rollback pops the stack and restores the saved values. This ROLLBACK DSU underlies the offline dynamic-connectivity structure (a segment tree over time), Kruskal-style reconnection, and 'components after each edge, later retracted' problems. Find is O(log n) (rank keeps trees shallow) rather than inverse-Ackermann, but every operation is reversible: a SNAPSHOT is just the stack length. This module implements union by rank, find, a component counter, and snapshot/rollback, verified against a brute-force recompute: connectivity queries always match a fresh union-find over the live edges (50 random graphs), rolling back to a snapshot exactly restores connectivity and the component count, nested snapshots roll back correctly, a full add-then-rollback returns to all-singletons, redundant unions roll back cleanly, and a dynamic-connectivity scenario with edge retraction behaves correctly.

Rollback DSU: component count as edges are added and retracted each edge merges components (count drops); the rollback jumps back up -- the undo Bloom-free union-find enables start 8 +01 7 +23 6 +45 5 +12 4 +67 3 +34 2 rollback 5 reset 8
the component count dropping as edges merge groups, then jumping back up at the red rollback points -- the undo that path-compressed union-find cannot provide
Rollback disjoint-set union: undoable connectivity

  8 isolated nodes -> 8 components

  Adding edges (recording a snapshot before each):
    edge 0-1: merged -> 7 components
    edge 2-3: merged -> 6 components
    edge 4-5: merged -> 5 components
    edge 1-2: merged -> 4 components
    edge 6-7: merged -> 3 components
    edge 3-4: merged -> 2 components

  Now 2 components; 0-5 chained together, 6-7 a separate pair

  Roll back 3 edges (undo 1-2, 6-7, 3-4):
    components: 5
    0 and 3 connected: False (retracted -- the 1-2 bridge is gone)
    0 and 1 connected: True (kept -- it was before the snapshot)

  Full rollback to the start: 8 components (back to all singletons: True)

  Ordinary union-find uses path compression and cannot be undone. Rollback DSU uses only
  union by rank -- each union changes O(1) state (one parent, maybe one rank) -- and records
  those changes on a stack, so a snapshot is a stack length and rollback replays it in reverse.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\dsu_rollback.svg

Weighted interval scheduling: the most valuable non-overlapping jobs

Given jobs with a start, finish, and WEIGHT, pick a subset of mutually non-overlapping jobs maximizing total weight -- booking a resource for the most valuable non-conflicting requests. With equal weights this is the classic activity-selection, solved by an earliest-finish greedy, but arbitrary weights defeat greed and need DYNAMIC PROGRAMMING. Sort jobs by finish time; for job i let p(i) be the latest job finishing at or before i starts (binary search); then opt(i) = max(opt(i-1), weight_i + opt(p(i))) -- skip job i or take it plus the best schedule among jobs finishing before it begins. Filling opt is O(n log n) and tracing back the max choices recovers the chosen set. This module implements the weighted DP (with the selected jobs) and the greedy count-maximizing version, verified against brute force over all 2^n subsets that the DP finds the true maximum weight, the returned jobs are genuinely non-overlapping and sum to that weight, the greedy count matches the maximum independent set of intervals, equal weights make the DP agree with the greedy count, and a high-value long job correctly beats several small ones. In the demo the DP's optimum of 60 beats both a count-maximizing greedy (50) and a highest-value-first greedy (40).

Weighted interval scheduling: chosen jobs (green) maximize total value each bar is a job on the time axis; height/label shows value; green = in the optimal set A v20 B v5 C v10 D v25 E v30 F v8 G v12 H v40 optimal total value = 60
a Gantt chart of job requests on the time axis, bar height showing value, with the optimal non-overlapping set highlighted green -- the maximum-value schedule no greedy heuristic finds
Weighted interval scheduling: the most valuable non-conflicting jobs

  Requests (start, finish, value):
    A: [0, 3) value 20
    B: [1, 4) value 5
    C: [3, 5) value 10
    D: [4, 7) value 25
    E: [5, 9) value 30
    F: [6, 8) value 8
    G: [8, 10) value 12
    H: [2, 9) value 40

  Optimal schedule (weighted DP): value 60.0, jobs ['A', 'C', 'E']
  Count-maximizing greedy: 4 jobs ['A', 'C', 'F', 'G'], value 50
  Greedy highest-value-first: value 40 (greedy is not optimal in general)

  -> the DP's 60.0 beats both greedies; only dynamic programming guarantees the optimum.

  Sort by finish time; for each job find p(i), the latest job finishing before it starts
  (binary search). Then opt(i) = max(skip job i, value_i + opt(p(i))). O(n log n), and
  tracing back the max choices recovers the chosen set. Equal weights reduce to the greedy.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\interval_scheduling.svg

Coin change: fewest coins, ways to make change, and the greedy trap

Given coin denominations and a target, two classic questions: the MINIMUM number of coins summing to the amount, and the NUMBER OF WAYS to make it. Both are textbook DYNAMIC PROGRAMMING, and both expose a trap: the obvious GREEDY (take the largest coin that fits) is right for canonical currency like US coins but FAILS for many denomination sets -- with {1, 3, 4} making 6, greedy gives 4+1+1 (3 coins) while the optimum is 3+3 (2 coins). Only DP is guaranteed correct. The minimum-coins DP builds min[a] = 1 + min over coins c of min[a-c], with recorded choices reconstructing the coin multiset. The counting DP is subtler: iterating coins in the OUTER loop counts order-independent COMBINATIONS, while the amount outer counts ordered SEQUENCES. This module computes the minimum coins (with the coins used), makeability, and both counts, verified against brute force: the minimum is truly minimal and the reconstructed coins sum to the amount (150 random instances), unmakeable amounts are detected, the combination count matches a brute enumeration, greedy-defeating sets are handled ({1,3,4}->6 gives 2, {1,15,25}->30 gives 2 vs greedy's 6), and canonical currency agrees with greedy.

Minimum coins per amount, by denomination system fewer coins is better; denser systems (US) stay flat, sparse ones climb -- the DP handles all US {1,5,10,25} {1,3,4} {1,7,10} amount
the minimum coins needed for each amount under three denomination systems -- dense sets (US) stay flat, sparse ones climb, and the DP finds the true optimum where greedy would stumble
Coin change: fewest coins, number of ways, and the greedy trap

  US currency {1, 5, 10, 25} (greedy works here):
    63c -> 6 coins [1, 1, 1, 10, 25, 25]
    99c -> 9 coins [1, 1, 1, 1, 10, 10, 25, 25, 25]
    41c -> 4 coins [1, 5, 10, 25]

  The greedy trap -- coins {1, 3, 4}, make 6:
    DP optimum: 2 coins [3, 3]
    greedy (largest-first): 3 coins (4 + 1 + 1) -- WRONG, one more coin than needed

  Another greedy failure -- coins {1, 15, 25}, make 30:
    DP: 2 coins [15, 15]; greedy: 6 coins (25 + five 1s)

  Number of ways to make change (combinations vs ordered sequences):
    5 with {1,2,5}: 4 combinations, 9 ordered sequences
    10 with {1,2,5}: 10 combinations, 128 ordered sequences

  The minimum-coins DP builds min[a] = 1 + min over coins c of min[a-c]. The counting DP
  puts coins in the OUTER loop (combinations) or the amount outer (sequences). Greedy is
  only correct for 'canonical' coin systems; DP is always right.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\coin_change.svg

Combinatorial ranking: objects as integers

Every finite combinatorial object -- a permutation of n items, a k-subset of n, a bit-string -- can be given a unique integer RANK from 0 to (count-1), and recovered from it by UNRANKING. This bijection is the engine of combinatorial generation: store a permutation as one integer, draw a uniformly random one by ranking a random integer, or split an enumeration across machines by rank range -- all without ever building the (often astronomically large) full set. Permutations use the FACTORIAL NUMBER SYSTEM: the Lehmer code counts, at each position, how many not-yet-used smaller elements remain, and those digits times descending factorials give the lexicographic rank. Combinations use the COMBINATORIAL NUMBER SYSTEM, a mixed-radix representation in binomial coefficients. Gray code ranks bit-strings so consecutive ranks differ in exactly one bit (rank = n XOR n>>1), the reflected binary code used in rotary encoders to avoid transition glitches. All exact integer arithmetic, so unranking jumps straight to the trillionth permutation of 15 elements. Verified against brute force: ranking then unranking is the identity, ranks form a contiguous 0..N-1 bijection with no gaps or collisions, permutation ranks match Python's lexicographic itertools order, combination ranks match itertools.combinations, and consecutive Gray codes differ in exactly one bit.

5-bit Gray code: each row flips one bit from the last green cells are 1-bits; the single yellow cell each row is the bit that changed from above rank 0 rank 1 rank 2 rank 3 rank 4 rank 5 rank 6 rank 7 rank 8 rank 9 rank 10 rank 11 rank 12 rank 13 rank 14 rank 15 rank 16 rank 17 rank 18 rank 19 rank 20 rank 21 rank 22 rank 23 rank 24 rank 25 rank 26 rank 27 rank 28 rank 29 rank 30 rank 31
a 5-bit Gray code: each row is the next integer's code, green cells are 1-bits, and the single yellow cell marks the one bit that flipped from the row above -- exactly one changes per step
Combinatorial ranking: objects <-> integers

  Permutations of {0,1,2,3} (lexicographic rank):
    rank  0 <-> [0, 1, 2, 3]  (rank back: 0)
    rank  5 <-> [0, 3, 2, 1]  (rank back: 5)
    rank 12 <-> [2, 0, 1, 3]  (rank back: 12)
    rank 23 <-> [3, 2, 1, 0]  (rank back: 23)

  Combinations: 3-subsets of {0..5} (combinatorial number system):
    rank  0 <-> [0, 1, 2]  (rank back: 0)
    rank  5 <-> [0, 2, 4]  (rank back: 5)
    rank 10 <-> [1, 2, 3]  (rank back: 10)
    rank 19 <-> [3, 4, 5]  (rank back: 19)

  Unranking WITHOUT enumeration -- the trillionth permutation of 15 elements:
    permutation #1,000,000,000,000 of 15!: [11, 6, 8, 10, 1, 2, 14, 4, 5, 13, 3, 9, 12, 0, 7]
    (15! = 1,307,674,368,000; we jump straight to it, no listing)

  Gray code -- consecutive integers whose codes differ in one bit:
    n:    [0, 1, 2, 3, 4, 5, 6, 7]
    gray: [0, 1, 3, 2, 6, 7, 5, 4]
    binary: ['000', '001', '011', '010', '110', '111', '101', '100']
    each adjacent pair flips exactly one bit -- used in rotary encoders to avoid glitches.

  A rank is a unique 0..N-1 index for each object; unranking recovers it. This lets you
  store a permutation as one integer, pick a uniformly random one, or split an enumeration
  across machines by rank range -- all without materializing the (huge) full set.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\combinatorial_rank.svg

Bridges and articulation points: single points of failure

In an undirected network a BRIDGE is an edge whose removal disconnects the graph and an ARTICULATION POINT (cut vertex) is a vertex whose removal does -- the structural weak points where one cut severs the network. Finding them is the first question of reliability analysis: a graph with none is 2-connected and survives any single failure. The naive test deletes each edge or vertex and recounts components, O(V*(V+E)). Tarjan's algorithm finds them all in ONE depth-first pass, O(V+E), using the discovery time disc[u] (stamped when DFS first reaches u) and the low-link low[u] (smallest disc reachable from u's subtree via tree edges plus one back edge). A tree edge (u,v) is a bridge exactly when low[v] > disc[u] -- v's subtree has no back edge climbing past u; a non-root u is a cut vertex when some child has low[v] >= disc[u], and the DFS root when it has two or more children. Parallel edges must be skipped by edge-id, not vertex, so a doubled link is never falsely called a bridge. This module finds all bridges and cut vertices in one iterative DFS (no recursion, safe on a 5000-deep path) and reports the 2-edge-connected components left when every bridge is cut. Verified against the definition on 400 random graphs: an edge is a bridge iff deleting it raises the component count, a vertex is a cut vertex iff deleting it does; trees have every edge a bridge, cycles and K5 have neither.

Network failure points: red edges are bridges, red nodes are cut vertices cut any red edge (or remove any red node) and the network splits into disconnected pieces 0 1 2 3 4 5 6 7 8 9 articulation point bridge
a 10-node network of three clusters: red edges are bridges and red nodes are articulation points -- cut any one and the network splits; the dense cluster (with its extra chord) has no internal bridge, but every inter-cluster link does
Bridges and articulation points: a 10-node, 3-cluster network

  10 nodes, 13 edges, 3 clusters joined by thin links

  Bridges (edge whose loss splits the network): 3
    2 -- 3
    5 -- 6
    8 -- 9

  Articulation points (node whose loss splits the network): [2, 3, 5, 6, 8]

  2-edge-connected components (groups that survive any single link failure):
    group 0: [0, 1, 2]
    group 1: [3, 4, 5]
    group 2: [6, 7, 8]
    group 3: [9]

  Reliability reading: every bridge and cut vertex is a single point of failure. The
  dense cluster B (with its extra 3--5 chord) has no internal bridge, but the two links
  wiring the clusters together (2--3, 5--6) and the leaf link (8--9) are all bridges --
  cut any one and the network splits. Tarjan finds them in one O(V+E) DFS pass; the naive
  check would delete each edge and recount components, O(V*(V+E)).

  wrote C:\Users\acwic\symplectic-nbody\examples\output\bridges.svg

Maximum bipartite matching: pairing two sides

A bipartite graph splits its vertices into two sides -- workers and jobs, students and projects, riders and taxis -- with edges only between them. A MATCHING is a set of edges sharing no vertex; the MAXIMUM matching pairs as many as possible. Unlike the Hungarian algorithm (which minimises total COST on a weighted complete graph), this is the unweighted question -- maximise the COUNT -- on an arbitrary graph where only some pairs are allowed. The engine is the AUGMENTING PATH: an alternating unmatched/matched path between two free vertices, which when flipped raises the matching by one; Berge's theorem says a matching is maximum exactly when none remains. HOPCROFT-KARP runs a BFS to find the shortest augmenting-path length, then a DFS to pack many vertex-disjoint shortest paths and flip them together -- only O(sqrt(V)) phases, so O(E*sqrt(V)) overall versus O(V*E) for one path at a time. The result ties to two classics: KONIG'S THEOREM (max matching == minimum vertex cover in a bipartite graph, recovered from alternating-reachability) and HALL'S THEOREM (a left-perfect matching exists iff every subset S of the left has at least |S| neighbours). This module computes the maximum matching, the minimum vertex cover, and the maximum independent set. Verified against brute force on 500 random graphs (the size is truly maximum), against Konig (the cover size equals the matching and touches every edge), and against Hall (perfect-left iff the subset condition), plus a 2000x2000 sparse instance.

Maximum matching: green edges are the optimal worker-to-job assignment grey edges are qualifications; the solver picks a largest vertex-disjoint set of them WORKERS JOBS Ada Ben Cam Dee Eli frontend backend data ops design
staffing five workers onto five jobs: grey edges are qualifications, green edges the maximum matching the solver picks -- here a perfect placement where every worker gets a job they can do
Maximum bipartite matching: staffing 5 workers onto 5 jobs

  Qualifications:
    Ada  -> frontend, backend
    Ben  -> backend, data
    Cam  -> data, ops
    Dee  -> frontend, design
    Eli  -> design

  Maximum placement: 5 of 5 workers assigned
    Ada  -> backend
    Ben  -> data
    Cam  -> ops
    Dee  -> frontend
    Eli  -> design

  Perfect placement possible? True

  Konig minimum cover (5 = matching size): ['Ada', 'Ben', 'Cam', 'Dee', 'Eli']
  Every qualification edge touches at least one of these -- the smallest such set, and by
  Konig's theorem its size equals the maximum matching. Hopcroft-Karp finds the matching
  in O(E*sqrt(V)) by augmenting many shortest paths per BFS phase, not one at a time.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\bipartite_matching.svg

Minimum-cost maximum flow: cheapest way to push the most

A flow network's edges each carry a CAPACITY and now a COST PER UNIT. Plain max-flow asks how much can be pushed source-to-sink; MINIMUM-COST MAXIMUM FLOW asks for the cheapest way to push that maximum. It is the workhorse of operations research -- route goods factory-to-store minimising shipping, assign workers minimising cost, schedule for maximal throughput at minimal expense -- and it strictly generalises both plain max-flow (all costs zero) and the assignment problem (a unit-capacity bipartite network), so one solver answers all three. The method is SUCCESSIVE SHORTEST PATHS: repeatedly find the cheapest source-to-sink path in the RESIDUAL graph (where each edge of cost c gains a reverse arc of cost -c that lets flow be cancelled) and push as much as the bottleneck allows. Because every augmentation follows a minimum-cost path, the running cost stays minimal, and when none remains the flow is both maximum and cheapest. The negative reverse-arc costs mean Dijkstra alone won't do; this uses SPFA (a queue-based Bellman-Ford), keeping it simple and dependency-free. Verified against independent references: the flow VALUE equals the Edmonds-Karp max-flow (200 random nets), the COST is confirmed minimal by brute force over all integer flows (120 tiny nets), and built as a bipartite assignment network its optimum matches the Hungarian algorithm and the best over all permutations.

Min-cost max-flow: pipe thickness = flow carried, label = used/capacity the cheapest way to push the maximum flow from source (left) to sink (right) 3/4 4/4 3/3 0/2 1/2 3/3 4/4 3/4 7/7 source factA factB whX whY hub sink
a factory-to-store shipping network: pipe thickness is the flow carried and each label is used/capacity -- the solver pushes the maximum 7 units at the minimum total cost by favouring the cheap factB->whY->hub route
Minimum-cost maximum flow: a factory-to-store shipping network

  Maximum shippable: 7 units, at minimum total cost 22

  Flow on each pipe (used / capacity, unit cost):
    factA  -> whX     3/3  @ 2/unit  = 6
    factA  -> whY     0/2  @ 4/unit  = 0
    factB  -> whX     1/2  @ 3/unit  = 3
    factB  -> whY     3/3  @ 1/unit  = 3
    whX    -> hub     4/4  @ 1/unit  = 4
    whY    -> hub     3/4  @ 2/unit  = 6

  Successive shortest paths: each round routes flow along the cheapest remaining path in
  the residual graph (with negative-cost reverse arcs handled by SPFA), so the running
  cost is always minimal for the flow sent. When no path remains the flow is both maximum
  and cheapest -- generalising plain max-flow (zero costs) and the assignment problem.

  Same engine, assignment problem: 3 workers -> 3 jobs, min cost 9
    worker->job: [1, 0, 2]  (worker i takes job assign[i])

  wrote C:\Users\acwic\symplectic-nbody\examples\output\min_cost_flow.svg

Sprague-Grundy: every impartial game is secretly Nim

An IMPARTIAL game -- two players, perfect information, identical moves for both, last to move wins -- has a stunning universal structure. The Sprague-Grundy theorem says every position is equivalent to a single Nim heap: it carries a GRUNDY NUMBER (nimber), and a position is a LOSS for the player to move exactly when that number is 0. The nimber comes from the MEX rule: g(position) = mex{ g(p) : p reachable in one move }, where mex is the smallest non-negative integer absent from the set; a terminal position with no moves has mex{} = 0, a loss for whoever cannot move. The second miracle is COMPOSITION -- when a game splits into independent subgames (several heaps, several rows), the whole's Grundy number is the XOR (Nim-sum) of the parts, so who-wins-a-sum-of-games reduces to XOR-ing small integers, and the winning move is the one making the total Nim-sum zero. This module gives the mex operator, a memoised Grundy solver for any impartial game (supplied as a 'moves from a position' function), the Nim-sum combinator, and three worked games: Nim, subtraction games, and Kayles. Verified against a brute-force minimax oracle (Grundy==0 iff the position is a theoretical loss, on Nim, subtraction, and Kayles) and against known results: Nim's XOR-of-heaps rule, the periodicity of subtraction-game nimbers (remove {1,2,3} gives Grundy = n mod 4), and the published Kayles sequence.

Grundy numbers as colour: each cell n is coloured by its nimber dark cells are Grundy 0 (losing positions); repeating colour blocks reveal periodicity subtract{1,2,3} 0 0 1 1 2 2 3 3 0 4 1 5 2 6 3 7 0 8 1 9 2 10 3 11 0 12 Kayles row 0 0 1 1 2 2 3 3 1 4 4 5 3 6 2 7 1 8 4 9 2 10 6 11 4 12 top: clean period-4 (Grundy = n mod 4). bottom: Kayles, irregular then period-12.
Grundy numbers as colour: the subtraction game {1,2,3} (top) has a clean period-4 pattern where every dark cell (Grundy 0) is a losing multiple of 4, while Kayles (bottom) shows the famously irregular nimbers that only settle into period-12 later
Sprague-Grundy: every impartial game is a Nim heap of size = its Grundy number

  Nim heaps (3, 4, 5): Grundy = 2 (= 3 XOR 4 XOR 5). Nonzero -> a WIN for the mover.
  Winning moves (leave a Grundy-0 position, i.e. Nim-sum 0):
    move to (1, 4, 5)

  Nim heaps (1, 2, 3): Grundy = 0 -> a LOSS for the mover; no move escapes.

  Subtraction game, remove [1, 2, 3] stones: Grundy(n) for n=0..15:
    [0, 1, 2, 3, 0, 1, 2, 3, 0, 1, 2, 3, 0, 1, 2, 3]
    -> periodic with period 4 (Grundy = n mod 4); losing heaps are multiples of 4.

  Kayles (knock down 1 or 2 adjacent pins from a row): Grundy(row of n) for n=0..12:
    [0, 1, 2, 3, 1, 4, 3, 2, 1, 4, 2, 6, 4]
    -> the famous irregular Kayles nimbers, eventually periodic with period 12.

  Composition: a position with a Nim heap of 5 and a Kayles row of 4 has Grundy
    5 XOR 1 = 4. Combine any games by XOR-ing nimbers.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\sprague_grundy.svg

Fast Walsh-Hadamard transform: the FFT for XOR

The FFT multiplies polynomials, which is CYCLIC convolution -- combining sequences by adding indices mod n. A different convolution combines indices by bitwise XOR: (a*b)[k] = sum over i XOR j == k of a[i]*b[j]. This answers questions like the distribution of the XOR of two independent random bitmasks, or -- in game theory -- the Nim-sum distribution of a combined game. Direct computation is O(n^2); the FAST WALSH-HADAMARD TRANSFORM does it in O(n log n), exactly as the FFT speeds up cyclic convolution. The FWHT is a butterfly almost identical to the FFT's: at each of the log2(n) stages, pair entries a distance h apart and replace (x,y) with (x+y, x-y). By the CONVOLUTION THEOREM, transforming both inputs, multiplying pointwise, and inverse-transforming yields their XOR convolution -- because the Hadamard matrix diagonalises the XOR group algebra. Two relatives over the same bit lattice give the OR convolution (via the sum-over-subsets zeta transform and its Mobius inverse) and the AND convolution (via the superset-sum transform), covering all three Boolean operations. Everything is integer-exact -- no floating-point error. Verified against the brute-force O(n^2) definition of each convolution on hundreds of random arrays, against the round-trip identity, linearity, commutativity, the delta identity, and a 2^16-point transform.

Combining two 3-bit distributions by XOR, OR, and AND each panel: the weight on every value 0..7 after convolving A with B under one operation A sum 12 B sum 12 A XOR B sum 144 A OR B sum 144 A AND B sum 144
combining two 3-bit distributions A and B under each bitwise operation: XOR spreads the mass evenly, OR pushes it toward all-ones, AND pulls it toward zero -- and every result conserves the total mass (12x12=144)
Fast Walsh-Hadamard transform: the FFT for XOR

  Two independent processes each emit a 3-bit value with these weights:
    A (sum 12): [4, 3, 2, 1, 1, 1, 0, 0]
    B (sum 12): [0, 0, 1, 2, 3, 3, 2, 1]

  Distribution of the COMBINED value under each bitwise operation:
    value:      [0, 1, 2, 3, 4, 5, 6, 7]
    A XOR B:    [10, 11, 13, 14, 26, 25, 23, 22]
    A OR  B:    [0, 0, 6, 24, 15, 39, 21, 39]
    A AND B:    [85, 23, 15, 3, 14, 4, 0, 0]

  Each total mass = 12*12 = 144: XOR 144, OR 144, AND 144 -- all conserved.

  All three match the brute-force O(n^2) definition exactly (integer, no rounding).

  The FWHT computes XOR convolution in O(n log n) by transforming both inputs with the
  Hadamard butterfly (x,y)->(x+y,x-y), multiplying pointwise, and inverse-transforming --
  the same convolution theorem that makes the FFT fast, but for the XOR group. OR and AND
  convolutions use the subset-sum (zeta) and superset-sum transforms over the bit lattice.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\walsh_hadamard.svg

DPLL: deciding Boolean satisfiability

SATISFIABILITY (SAT) asks whether a formula in conjunctive normal form -- an AND of clauses, each an OR of literals -- can be made true. It is the archetypal NP-complete problem: circuit verification, planning, dependency resolution, and countless puzzles compile down to it. Brute force tries all 2^n assignments; DPLL (Davis-Putnam-Logemann-Loveland, 1962) is the backtracking search at the heart of every modern SAT solver and in practice explores a tiny fraction of that space. It is depth-first assignment with two pruning rules. UNIT PROPAGATION: a clause with all but one literal false forces that literal true, cascading through the formula; a clause with every literal false is a conflict, killing the branch. PURE LITERAL: a variable appearing with only one polarity is fixed that way for free. After propagation DPLL picks a variable, tries true, recurses, and on failure tries false. This module parses a CNF (clauses of signed integers), decides satisfiability with unit propagation and pure-literal elimination, and returns a satisfying model. Verified against a brute-force truth-table oracle on 600 random formulas (SAT exactly when some assignment works, every returned model genuinely satisfies all clauses) and on the pigeonhole principle -- n+1 pigeons into n holes is proven UNSAT without enumerating the 2^n space.

Pigeonhole SAT instances: all UNSAT, and the search space DPLL avoids brute-forcing yellow: number of clauses. red: log2 of the 2^n brute-force assignment space DPLL prunes 3 2^2 2p/1h UNSAT 9 2^6 3p/2h UNSAT 22 2^12 4p/3h UNSAT 45 2^20 5p/4h UNSAT 81 2^30 6p/5h UNSAT
the pigeonhole family n+1 pigeons into n holes: yellow is the clause count and red the log2 of the 2^n brute-force assignment space -- all instances are proven UNSAT by DPLL's pruned search, never touching the full exponential space
DPLL: Boolean satisfiability by backtracking with propagation

  Formula (CNF, AND of ORs):
    (x1 v x2 v ~x3) ^ (~x1 v x3) ^ (~x2 v x3) ^ (x1 v ~x2) ^ (x2 v x3)

  DPLL: SATISFIABLE
    model: x1=T, x2=T, x3=T
    verified: True

  Pigeonhole principle -- can n+1 pigeons occupy n holes, no two sharing?
    2 pigeons, 1 holes: 2 vars, 3 clauses -> UNSAT (proven impossible)
    3 pigeons, 2 holes: 6 vars, 9 clauses -> UNSAT (proven impossible)
    4 pigeons, 3 holes: 12 vars, 22 clauses -> UNSAT (proven impossible)
    5 pigeons, 4 holes: 20 vars, 45 clauses -> UNSAT (proven impossible)
    Every case UNSAT -- DPLL proves the combinatorial impossibility by exhausting the
    (heavily pruned) search tree, never enumerating all 2^n assignments.

  Unit propagation on a forcing chain x1 -> x2 -> ... -> x5:
    x1=T x2=T x3=T x4=T x5=T  -- one forced unit cascades through the whole chain.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\dpll.svg

Berlekamp-Massey: the recurrence hidden in a sequence

Many sequences obey a LINEAR RECURRENCE -- each term a fixed combination of the previous few, s[n] = c1*s[n-1] + ... + cL*s[n-L]. Fibonacci does (s[n]=s[n-1]+s[n-2]); a linear-feedback shift register (LFSR), the workhorse of stream ciphers and CRCs, is exactly such a recurrence over GF(2). Berlekamp-Massey solves the inverse problem: given only the first terms, find the SHORTEST recurrence that produces them. Its length L (the LINEAR COMPLEXITY) measures predictability -- a short recurrence pins down the whole infinite sequence from a handful of coefficients. The algorithm scans left to right keeping a recurrence that predicts every term so far; at each step it computes the DISCREPANCY with the actual next term and, if nonzero, corrects the recurrence by adding a scaled shift of the best previous failed one -- a construction that provably stays minimal, in O(n^2). This module works over the exact rationals (Fibonacci, Tribonacci, Pell all handled with no rounding) and over GF(2) for the binary LFSR case. Verified by round-trip (the recovered recurrence regenerates the input and is never longer than the true generator, on 300 random recurrences), against a brute-force minimality search, and on GF(2) m-sequences whose complexity is recovered from just 2L bits -- the classic reason a raw LFSR keystream is cryptographically broken.

Linear complexity recovered vs bits observed (length-5 LFSR) complexity climbs then locks at 5 -- after 2L bits the whole register is known complexity = 5 (cracked) 2L=10 bits bits observed
cracking a length-5 LFSR keystream: the recovered linear complexity climbs as more bits are seen and locks at 5 exactly once 2L=10 bits are observed -- at which point the whole register is known and every future bit is predictable
Berlekamp-Massey: find the shortest linear recurrence behind a sequence

  Fibonacci    complexity 2:  s[n] = 1*s[n-1] + 1*s[n-2]
               next 3 terms: [55, 89, 144]
  Tribonacci   complexity 3:  s[n] = 1*s[n-1] + 1*s[n-2] + 1*s[n-3]
               next 3 terms: [81, 149, 274]
  Pell         complexity 2:  s[n] = 2*s[n-1] + 1*s[n-2]
               next 3 terms: [408, 985, 2378]
  powers of 2  complexity 1:  s[n] = 2*s[n-1]
               next 3 terms: [128, 256, 512]
  Jacobsthal   complexity 2:  s[n] = 1*s[n-1] + 2*s[n-2]
               next 3 terms: [85, 171, 341]

  Linear-complexity attack on a stream cipher (LFSR keystream):
    true LFSR: length 5, taps [0, 0, 1, 0, 1]
    observed keystream: 10011111000110111010...
    from  6 bits: recovered complexity 3, predicts full stream: False
    from  8 bits: recovered complexity 4, predicts full stream: False
    from 10 bits: recovered complexity 5, predicts full stream: True
    from 12 bits: recovered complexity 5, predicts full stream: True
    Once you see 2L bits, Berlekamp-Massey recovers the whole register and predicts
    every future bit -- why a raw LFSR is cryptographically useless despite a long period.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\berlekamp_massey.svg

Suffix automaton: one tiny machine, every substring

A SUFFIX AUTOMATON is the smallest deterministic automaton that recognises exactly the suffixes of a string -- and whose paths from the start state spell every DISTINCT SUBSTRING. It is astonishingly compact: at most 2n-1 states and 3n-4 transitions for a length-n string, yet it encodes all of the up-to n(n+1)/2 substrings. That makes it the Swiss-army knife of string processing: count distinct substrings, test a pattern in O(pattern), count how many times each substring occurs, find the longest repeated substring, and compute the longest common substring of two strings -- all in linear or near-linear time. It is built ONLINE in amortised O(n): each state is an equivalence class of substrings sharing the same set of end positions, the states form a tree under SUFFIX LINKS (the suffix tree of the reversed string), and appending a character follows those links, CLONING a state when a transition would otherwise conflict -- the trick that keeps the machine minimal. Distinct substrings then equal the sum over states of len[v]-len[link[v]]. Verified against brute force: the distinct-substring count matches the set of all O(n^2) substrings, membership and occurrence counts match direct scanning, and the longest common substring matches an O(n*m) DP -- on hundreds of random strings, with the state count confirmed within the linear 2n bound.

Suffix automaton of 'abracadabra': 12 states, laid out left-to-right by substring length blue arrows are transitions (labelled by character); grey dashed arrows are suffix links a b r c d b c d r a c a d a b r a start
the suffix automaton of 'abracadabra': 12 states laid out left-to-right by substring length, blue transitions labelled by character and grey dashed suffix links -- every path from the green start state spells one of the 54 distinct substrings
Suffix automaton: the smallest machine recognising every substring

  word: 'abracadabra' (11 characters)
  automaton: 12 states (bound is 2n-1 = 21)

  distinct substrings: 54  (vs 66 raw substrings with repeats)
  verified against brute enumeration: True

  occurrence counts:
    'abra' appears 2 time(s)
    'a' appears 5 time(s)
    'ra' appears 2 time(s)
    'cad' appears 1 time(s)

  longest repeated substring: 'abra'
  longest common substring of 'abracadabra' and 'cadabraxyz': 'cadabra'

  Built online in O(n): each character is appended by following suffix links and cloning
  a state when a transition would conflict. The result has at most 2n-1 states yet encodes
  all substrings -- every path from the start spells a distinct one. It answers substring
  membership in O(pattern), counts occurrences, and finds longest common/repeated pieces.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\suffix_automaton.svg

Lyndon words: the primes of strings

A LYNDON WORD is a string strictly smaller (dictionary order) than all of its rotations -- 'aab' beats 'aba' and 'baa', so it is Lyndon; 'aba' is not, since its rotation 'aab' is smaller. Lyndon words are the primes of concatenation: the CHEN-FOX-LYNDON theorem says every string factorises UNIQUELY into a non-increasing sequence of Lyndon words w1 >= w2 >= ... >= wk. This underlies the Lyndon basis of free Lie algebras, the linear-time bijective Burrows-Wheeler transform, and -- through the last factor -- the LEXICOGRAPHICALLY SMALLEST ROTATION of a string (necklace canonicalisation, Booth's problem). DUVAL'S ALGORITHM computes the factorisation in O(n) time and O(1) space with a two-pointer scan: compare each character to the one a period back, extending the period on a tie, restarting on a larger character, and emitting Lyndon words on a smaller one; run over the doubled string it yields the least rotation. The FKM algorithm generates all Lyndon words up to a length in lexicographic order, and concatenating those whose length divides n builds the De Bruijn sequence. Verified against brute force: the factorisation's parts are all Lyndon and non-increasing and concatenate back, membership matches the rotation definition, the least rotation matches an exhaustive scan, and the generated words match both a brute filter and the Mobius necklace-counting formula -- on hundreds of random cases.

Duval factorisation: each string split into non-increasing Lyndon words coloured blocks are the unique Lyndon factors, left to right in non-increasing order banana b a n a n a bbababaab b b a b a b a a b aababcabcd a a b a b c a b c d dcbaabcd d c b a a b c d gaps separate the Lyndon factors; note each factor is <= the one before it
the Duval factorisation of four strings into their unique Lyndon 'primes' (coloured blocks), each factor lexicographically no greater than the one before it -- the string analogue of writing an integer as a non-increasing product of primes
Lyndon words: the primes of string concatenation

  A Lyndon word is strictly smaller than all its rotations. Membership:
    aab    -> Lyndon
    aba    -> not Lyndon
    abcab  -> not Lyndon
    aabb   -> Lyndon
    z      -> Lyndon
    aa     -> not Lyndon

  Chen-Fox-Lyndon factorisation (unique, non-increasing) via Duval O(n):
    banana       = b | an | an | a
    bbababaab    = b | b | ab | ab | aab
    abcabcabc    = abc | abc | abc
    zyxabc       = z | y | x | abc

  Least rotation (necklace canonicalisation) via Duval over the doubled string:
    cabab          -> 'ababc'  (rotate by 1)
    bca            -> 'abc'  (rotate by 2)
    googgle        -> 'egooggl'  (rotate by 6)
    tobeornottobe  -> 'beornottobeto'  (rotate by 2)

  Lyndon words over {a,b} up to length 4 (FKM order -- concatenated they form the
  De Bruijn sequence B(2,4)):
    ['a', 'aaab', 'aab', 'aabb', 'ab', 'abb', 'abbb', 'b']
    De Bruijn B(2,4) = aaaabaabbababbbb  (length 16 = 2^4)

  wrote C:\Users\acwic\symplectic-nbody\examples\output\lyndon.svg

Eertree: every distinct palindrome in linear space

A string of length n contains at most n distinct palindromic substrings (a classical bound), and the EERTREE, or palindromic tree, stores ALL of them in O(n) space, built in O(n) time. Invented by Mikhail Rubinchik in 2014, it is the palindrome analogue of the suffix automaton: where that captures every substring, the eertree captures exactly the distinct palindromic ones, answering 'how many distinct palindromes?', 'how many end at each position?', and 'how often does each occur?' in linear time -- questions that arise in bioinformatics (palindromic DNA marks restriction sites and hairpins) and text indexing. The tree has two roots (an imaginary root of length -1 and the empty root of length 0) and one node per distinct palindrome, each with its length, a SUFFIX LINK to its longest proper palindromic suffix, and character edges where edge c from node v leads to the palindrome c+v+c. Building is online: to add a character the algorithm walks suffix links from the last palindrome to the longest one that can be extended by it, creating a node if new -- and the total link-walking is amortised O(n). Verified against brute force -- the distinct count and the per-length set match all O(n^2) substrings filtered for the palindrome property, and occurrence counts match direct scanning -- on hundreds of random strings, plus the classical <= n bound and that the online build equals batch construction.

Distinct palindromes of 'abacabadabacaba', grouped by length each tile is one distinct palindromic substring; brighter = occurs more often len 1 a b c d len 3 aba aca ada len 5 bacab badab len 7 abacaba abadaba len 9 cabadabac len 11 acabadabaca len 13 bacabadabacab len 15 abacabadabacaba
the distinct palindromes of 'abacabadabacaba' grouped by length: this word is palindromically RICH, hitting the maximum of 15 distinct palindromes for its 15 characters, with brighter tiles marking the palindromes that occur most often
Eertree: all distinct palindromic substrings in one linear structure

  word: 'abacabadabacaba' (15 characters)
  tree: 17 nodes (2 roots + one per distinct palindrome)

  distinct palindromic substrings: 15
  verified against brute enumeration: True
  classical bound (<= n = 15): satisfied

  distinct palindromes by length:
    len 1: ['a', 'b', 'c', 'd']
    len 3: ['aba', 'aca', 'ada']
    len 5: ['bacab', 'badab']
    len 7: ['abacaba', 'abadaba']
    len 9: ['cabadabac']
    len 11: ['acabadabaca']
    len 13: ['bacabadabacab']
    len 15: ['abacabadabacaba']

  most frequent palindromes:
    'a' occurs 8 times
    'b' occurs 4 times
    'aba' occurs 4 times
    'c' occurs 2 times
    'aca' occurs 2 times

  palindromic richness: 15/15 distinct -> RICH (maximal)
  The eertree adds each character in amortised O(1) by walking suffix links to the longest
  extendable palindromic suffix -- capturing all palindromes in O(n), the palindrome
  analogue of the suffix automaton.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\eertree.svg

Li Chao tree: the lower envelope of a bundle of lines

Many optimisation problems reduce to one primitive: keep a growing set of lines y = m*x + b and repeatedly ask for the minimum (or maximum) value at a given x. As x sweeps, the answer traces the LOWER ENVELOPE -- a piecewise-linear convex curve. This is the query behind the CONVEX HULL TRICK, which collapses a large family of O(n^2) dynamic programs (dp[i] = min over j of dp[j] + cost(j,i) with cost linear in i) to O(n log n): each transition is a line, each state a query. The LI CHAO TREE supports it cleanly when lines arrive in ARBITRARY order with queries interleaved -- the case the classic monotonic-stack hull trick cannot handle. It is a segment tree over the x-domain; each node owns the line minimal at its midpoint. Inserting compares the new line to the node's at the midpoint, keeps the lower, and recurses into the half where the loser might still win -- since two lines cross at most once, that is O(log range). A query takes the minimum of every line on its root-to-leaf path, also O(log range). Verified against brute force: every min and max query matches the true optimum over all inserted lines, on hundreds of random line sets and points, including interleaved insert/query order and a convex-hull-trick DP matching its O(n^2) reference.

Li Chao tree: the lower envelope (green) of a bundle of lines thin blue lines are the inputs; the thick green curve is the minimum the tree returns x
six lines inserted in arbitrary order (thin blue) and the lower envelope the tree returns (thick green) -- the minimum value at every x, computed in O(log range) per query without ever scanning all the lines
Li Chao tree: minimum of a set of lines at any x, in O(log range)

  6 lines inserted (in arbitrary order):
    y = -2x + 40
    y = -1x + 20
    y = +0x + 8
    y = +1x + 2
    y = +2x + 6
    y = +3x + 30

  lower-envelope minimum across x:
        x    min y  which line
      -12    -18.0  y=+2x+6
       -9    -12.0  y=+2x+6
       -6     -6.0  y=+2x+6
       -3     -1.0  y=+1x+2
        0      2.0  y=+1x+2
        3      5.0  y=+1x+2
        6      8.0  y=+0x+8
        9      8.0  y=+0x+8
       12      8.0  y=-1x+20

  matches brute-force min over all lines at every integer x: True

  Each line is inserted by comparing it to the node's line at the interval midpoint,
  keeping the lower one and pushing the other into the half where it might still win --
  O(log range) per insert and per query, and unlike the monotonic-stack convex hull trick,
  it handles lines arriving in ANY order with queries interleaved. This is the standard
  speed-up that turns an O(n^2) DP with linear transition costs into O(n log n).

  wrote C:\Users\acwic\symplectic-nbody\examples\output\li_chao.svg

Mo's algorithm: batch range queries by reordering

Given a fixed array and a batch of range queries -- 'how many DISTINCT values in [l, r]?', 'what is the sum of squared frequencies?' -- the naive approach recomputes each in O(n), for O(q*n) total. MO'S ALGORITHM answers all q queries OFFLINE in O((n+q)*sqrt(n)) by maintaining a current window and a running answer, and MOVING the endpoints one element at a time (each an O(1) add/remove) to morph one query's range into the next. The cost is the total distance the two pointers travel, and the trick is an ORDER that minimises it: sort queries into sqrt(n)-sized blocks by left endpoint, then by right endpoint with the direction alternating per block (a snake order), so the left pointer moves O(sqrt(n)) per query and the right O(n) per block. The problem need only supply a cheap add and remove that update the running answer -- a frequency table and a nonzero-count for distinct values, an incremental sum(f^2) for the power sum. Because it permutes the queries it is inherently offline. Verified against brute force: every distinct-count and power-sum answer matches a direct recomputation over the subrange, on hundreds of random arrays and query batches plus a 2000x2000 stress test, with answers returned in the original query order.

Mo's algorithm: query windows drawn in processing order (top to bottom) each bar is a query range [l,r]; sorted by left-block then snaking r, so the ends move little 0 2 4 6 8 10 12 14 16 18 q0 q6 q1 q3 q4 q7 q2 q5 dashed lines are sqrt(n)-blocks; queries in the same block are processed together
eight query ranges drawn top-to-bottom in the order Mo's algorithm processes them: grouped into sqrt(n)-blocks by left endpoint (colour) and snaking by right endpoint, so the window's two ends travel a short total distance (56 steps here versus 99 in the original order)
Mo's algorithm: many range queries in O((n+q) sqrt n) by clever ordering

  array (20 elements): [3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5, 8, 9, 7, 9, 3, 2, 3, 8, 4]

       range  distinct  power-sum
  [ 0, 5]            5          8
  [ 3, 9]            6          9
  [10,15]            5          8
  [ 0,19]            9         52
  [ 5,12]            6         12
  [14,19]            5          8
  [ 2, 8]            6          9
  [ 7,11]            4          7

  distinct counts match brute force: True

  block size = sqrt(20) = 4
  query order chosen (by left-block, then snaking right endpoint):
    query 0: [0,5]  (block 0)
    query 6: [2,8]  (block 0)
    query 1: [3,9]  (block 0)
    query 3: [0,19]  (block 0)
    query 4: [5,12]  (block 1)
    query 7: [7,11]  (block 1)
    query 2: [10,15]  (block 2)
    query 5: [14,19]  (block 3)

  total pointer travel -- Mo's order: 56, original order: 99
  Reordering shrinks the distance the window's two ends must travel; each step adds or
  removes one element in O(1), so total time is the total travel, O((n+q) sqrt n).

  wrote C:\Users\acwic\symplectic-nbody\examples\output\mo_algorithm.svg

Chu-Liu/Edmonds: the directed minimum spanning tree

The minimum spanning tree connects an undirected graph at least cost; its directed cousin is the MINIMUM SPANNING ARBORESCENCE. Given a directed weighted graph and a ROOT, an arborescence is a spanning tree where every non-root vertex has exactly one incoming edge and is reachable from the root -- the cheapest one-way broadcast tree. Greedy MST algorithms (Kruskal, Prim) fail because directions matter: picking each vertex's cheapest incoming edge can form a cycle a tree can't contain. The CHU-LIU/EDMONDS algorithm fixes exactly that. Pick the cheapest incoming edge everywhere; if the result is acyclic it is optimal. If a cycle forms, CONTRACT it to a super-vertex, reweighting each edge entering the cycle by subtracting the in-cycle edge it would replace, and recurse; expanding the contractions -- breaking each cycle at the one vertex entered from outside -- rebuilds the true minimum. The reweighting correctly credits that entering a cycle lets one of its internal edges be dropped. Verified against brute force -- enumerating every choice of one incoming edge per vertex, keeping the valid arborescences, and confirming the minimum weight -- on 500 random graphs, plus trees, unreachable vertices, multi-edges, and nested cycles; the returned edges always form a genuine spanning arborescence.

Minimum spanning arborescence from node 0 (green = chosen edges) every node gets exactly one incoming green edge; the total cost is minimum 10 12 2 3 4 6 5 8 9 0 1 2 3 4 5 root
the minimum broadcast tree from root 0 (green edges): the greedy cheapest-incoming choice would trap nodes 1,2,3 in a cycle, but Chu-Liu/Edmonds contracts and reweights it to reach every node at minimum total cost 26
Minimum spanning arborescence: cheapest one-way broadcast tree

  6 nodes, 9 directed edges, root = 0

  greedy 'cheapest incoming edge per node' would pick:
    node 1 <- node 3 (cost 4)
    node 2 <- node 1 (cost 2)
    node 3 <- node 2 (cost 3)
    node 4 <- node 2 (cost 6)
    node 5 <- node 3 (cost 5)
    ...but 1<-3, 2<-1, 3<-2 form a CYCLE (1->2->3->1), not a tree -- greedy fails.

  Chu-Liu/Edmonds minimum arborescence (total cost 26):
    node 1 <- node 0 (cost 10)
    node 2 <- node 1 (cost 2)
    node 3 <- node 2 (cost 3)
    node 4 <- node 2 (cost 6)
    node 5 <- node 3 (cost 5)

  verified against brute force over all arborescences: True

  The algorithm contracts the cheap cycle into a super-node, reweights edges entering it
  by what they'd save, and recurses -- then expands, breaking each cycle at the one node
  reached from outside. This is the directed analogue of the minimum spanning tree, where
  Kruskal and Prim don't apply because edge directions constrain the tree.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\arborescence.svg

Steiner tree: cheapest network through junctions

The minimum spanning tree connects ALL vertices; the STEINER TREE connects only a chosen subset -- the TERMINALS -- at least cost, free to route through other vertices (STEINER POINTS) when cheaper. It is the true shape of network design: the cheapest fibre backbone linking some cities (routing through junction towns allowed), a chip net touching a set of pins, a phylogenetic tree through inferred ancestors. It is NP-hard in general, but for a small number of terminals k the DREYFUS-WAGNER bitmask DP solves it exactly. It fills dp[S][v] = the minimum weight of a tree connecting terminal subset S and reaching vertex v, by two rules: MERGE two disjoint sub-trees for S1 and S2 rooted at the same v (dp[S][v] = min over splits of dp[S1][v]+dp[S2][v]), and GROW a tree toward a neighbour along a shortest path (a Dijkstra sweep per subset layer). The answer is min over v of dp[full][v], in O(3^k n + 2^k n^2) -- exponential only in the terminal count, not the graph size. Verified against brute force -- enumerating every subset of Steiner points and taking the induced MST -- on 400 random graphs, against the MST when every vertex is a terminal, and on cases where a Steiner point strictly beats the terminal-only tree.

Steiner tree: connect the 4 corner terminals through the cheap hub green = chosen Steiner tree (cost 12); grey = unused edges; perimeter-only MST would cost 30 10 10 10 10 3 3 3 3 NW NE SE SW hub Steiner point
connecting four corner terminals: the terminal-only spanning tree would pay 30 around the perimeter, but routing through the cheap central hub (a Steiner point) links all four for just 12
Steiner tree: cheapest network connecting terminals via optional junctions

  terminals: ['NW', 'NE', 'SE', 'SW']
  a hub vertex 'hub' is available as a Steiner point (not required)

  terminal-only MST (perimeter, no hub): 30
  Steiner tree (allowed to use the hub):  12
  saving from routing through the hub:    18

  verified against brute force: True

  Steiner cost for various terminal sets:
    ['NW', 'SE']: 6
    ['NW', 'NE', 'SE']: 9
    ['NW', 'NE', 'SE', 'SW']: 12

  The Dreyfus-Wagner DP fills dp[S][v] = cheapest tree connecting terminal set S and
  reaching vertex v, by MERGING disjoint terminal subsets at a shared root and GROWING
  along shortest paths. Exponential only in the number of terminals -- so many terminals
  in a large graph stay tractable, unlike brute-forcing every subset of Steiner points.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\steiner_tree.svg

AHU tree isomorphism: same shape, in linear time

Two trees are ISOMORPHIC if one can be relabelled into the other -- same shape, different names. Deciding this for general GRAPHS is a famous open problem with no known polynomial algorithm, but for TREES the Aho-Hopcroft-Ullman algorithm does it in linear time via a CANONICAL FORM: a string identical for isomorphic trees and different otherwise. For a ROOTED tree it is built bottom-up -- each leaf is '()', each internal node SORTS its children's strings, concatenates, and wraps them in parentheses, so the encoding ignores the order children happen to be listed. For an UNROOTED tree there is no distinguished root, so AHU roots at the tree's CENTER -- the one or two vertices at the middle of its longest path, found by repeatedly peeling leaves -- which is isomorphism-invariant, giving a canonical form for the whole tree (comparing both forms when there are two centers). This powers deduplicating parse and syntax trees, matching acyclic molecules, and comparing phylogenies. Verified against brute force -- two trees are isomorphic iff some vertex permutation maps one edge set onto the other -- on hundreds of random trees, including relabelled copies (always isomorphic) and same-size different-shape trees (never), with canonical-form equality shown to coincide exactly with isomorphism.

A and B are the same tree relabelled (isomorphic); C is a different shape yellow nodes are tree centers; AHU roots there and hashes bottom-up to a canonical form A 0 1 2 3 4 5 B (= A relabelled) 0 1 2 3 4 5 C (path, different) 0 1 2 3 4 5
trees A and B are the same tree with vertices shuffled, so their canonical forms match and they test isomorphic; tree C is a path of the same size but a different shape, so its form differs -- yellow nodes mark the centers AHU roots at
AHU tree isomorphism: a canonical string that is equal iff trees match

  tree A edges: [(0, 1), (0, 2), (1, 3), (1, 4), (2, 5)]
  rooted canonical form (root 0): ((()())(()))
  -> each leaf is '()', each node sorts and wraps its children's forms

  tree B = tree A with vertices shuffled: [(5, 2), (5, 4), (2, 0), (2, 1), (4, 3)]
  isomorphic(A, B)?  True  (brute: True)

  tree C = a 6-vertex path: [(0, 1), (1, 2), (2, 3), (3, 4), (4, 5)]
  isomorphic(A, C)?  False  (brute: False)

  unrooted canonical forms (rooted at the center for canonicity):
    A: ((()())(()))
    B: ((()())(()))   (equal to A -> isomorphic)
    C: (((()))(()))|(((()))(()))   (differs -> not isomorphic)

  centers -- A: [0], C (path): [2, 3]

  Rooting at the center makes the form canonical: every tree has 1 or 2 centers (the
  middle of its longest path), found by peeling leaves. General GRAPH isomorphism has no
  known polynomial algorithm, but trees fall in linear time to this bottom-up hashing.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\tree_isomorphism.svg

Eulerian trails: every edge once, from Konigsberg to Hierholzer

An EULERIAN CIRCUIT walks every edge of a graph exactly once and returns to its start; an EULERIAN PATH does so but may end elsewhere. The question is the founding problem of graph theory -- Euler's 1736 proof that the Seven Bridges of Konigsberg cannot all be crossed once -- and it drives genome assembly (an Eulerian path through a De Bruijn graph of k-mers), route planning that covers every street, and figure-drawing without lifting the pen. The existence conditions are complete: an UNDIRECTED connected graph has a circuit iff every vertex has even degree, a path iff exactly two vertices are odd (those being the endpoints); a DIRECTED graph has a circuit iff every vertex is balanced (in-degree = out-degree) and weakly connected, a path iff exactly one vertex has one extra out-edge (the start) and one an extra in-edge (the end). When a trail exists, HIERHOLZER'S ALGORITHM finds it in linear time: walk until stuck, forming a closed sub-tour, then splice in detours from any vertex with unused edges until every edge is consumed. This module classifies undirected and directed multigraphs and builds the trail iteratively. Verified: the existence predicate matches the degree/connectivity definition and the returned trail uses every edge exactly once with real adjacent steps, on hundreds of random graphs -- including Konigsberg (correctly impossible) and multigraphs.

An Eulerian circuit: each edge numbered in the order the trail walks it a closed walk crossing all 6 edges exactly once, returning to the start 1 2 3 4 5 6 0 1 2 3 4 trail: 0->1->2->0->3->4->0
an Eulerian circuit on a bowtie of two triangles: each edge is numbered in the order Hierholzer's walk crosses it, a closed tour using all six edges exactly once and returning to the shared central vertex
Eulerian paths and circuits: every edge exactly once

  triangle                   Eulerian CIRCUIT (closed)  trail: 0->1->2->0
  path 0-1-2-3               Eulerian PATH (open)  trail: 0->1->2->3
  Konigsberg bridges         no Eulerian trail
  K4 (complete, 4 nodes)     no Eulerian trail
  bowtie (two triangles)     Eulerian CIRCUIT (closed)  trail: 0->1->2->0->3->4->0

  The Seven Bridges of Konigsberg has all four land masses at odd degree, so no walk can
  cross every bridge exactly once -- Euler's 1736 proof, the birth of graph theory.

  Hierholzer builds a trail by walking until stuck (a closed sub-tour), then splicing in
  detours from any vertex with unused edges, until every edge is consumed -- linear time.

  bowtie trail valid + closed: True

  wrote C:\Users\acwic\symplectic-nbody\examples\output\eulerian.svg

Yen's algorithm: the K best alternative routes

Shortest-path algorithms find THE cheapest route; navigation apps, resilient networks, and itinerary planners need the K cheapest ALTERNATIVES. YEN'S ALGORITHM (1971) finds the K shortest LOOPLESS (simple, no repeated vertex) paths from source to target in a weighted directed graph, in increasing cost order -- the looplessness is what keeps the alternatives meaningful rather than the best path padded with pointless detours. It elaborates repeated shortest-path search: the first path is plain Dijkstra, and each next path is found by considering every SPUR NODE along the previous one, temporarily removing the edges that already-found paths took out of that node (forcing a different continuation) and the earlier root-path nodes (to stay loopless), then running Dijkstra from the spur to the target. The prefix plus this spur is a CANDIDATE; a priority queue keeps candidates in cost order and the cheapest unused one becomes the next path. Verified against brute force -- enumerating every simple source-to-target path, sorting by cost, and comparing the first K -- on hundreds of random graphs, confirming the paths are loopless, valid, distinct, non-decreasing in cost, and exactly the K cheapest.

Yen's K shortest routes A -> F (route 1 green, then yellow, orange, purple) grey roads are unused by the top routes; a road is coloured by the best route that takes it 4 2 1 5 1 8 10 2 6 3 A B C D E F
the four cheapest routes from A to F, each road coloured by the best-ranked route that uses it (green shortest, then yellow, orange, purple) -- distinct alternatives a driver could actually take, in cost order
Yen's algorithm: the K shortest loopless routes from A to F

  network: 10 one-way roads among 6 junctions

  4 shortest routes (increasing cost):
    1. cost 13:  A -> C -> B -> D -> E -> F
    2. cost 14:  A -> B -> D -> E -> F
    3. cost 14:  A -> C -> B -> D -> F
    4. cost 15:  A -> B -> D -> F

  matches brute-force enumeration of all simple paths: True

  Each next route is found by taking a prefix of the previous one, banning the edges
  already used out of the spur node (to force a different continuation) and the earlier
  nodes (to stay loopless), then running Dijkstra from the spur to the target. The cheapest
  such candidate becomes the next route -- alternatives that are genuinely distinct, not
  the best path padded with detours.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\yen_ksp.svg

Karger's algorithm: minimum cut by random contraction

The GLOBAL MINIMUM CUT is the fewest edges (or least weight) whose removal splits a graph in two -- its weakest point. Deterministic algorithms (Stoer-Wagner) find it exactly; KARGER'S ALGORITHM (1993) takes a startlingly simple RANDOMIZED route built on one operation: CONTRACTION. Pick a random edge, merge its endpoints into a super-vertex (keeping parallel edges, dropping self-loops), and repeat until two super-vertices remain -- the edges between them are a cut, and with decent probability the minimum one. A given minimum cut survives a run only if none of its few edges is ever contracted, which happens with probability at least 2/(n(n-1)); repeating O(n^2 log n) times makes the failure chance vanish. The KARGER-STEIN refinement contracts only to ~n/sqrt(2) vertices -- where a min-cut edge is still unlikely hit -- then recurses twice and keeps the better, for O(n^2 log^3 n). Weighted graphs work identically by choosing edges with probability proportional to weight. Being Monte Carlo, correctness is checked STATISTICALLY: a contraction cut is always valid so never below the true minimum, and given enough trials it equals the exact Stoer-Wagner value -- confirmed on hundreds of random weighted graphs, with a fixed seed for reproducibility.

Karger min cut: red edges cross the cut, blue/green nodes are the two sides the minimum cut severs the two light bridges, splitting the graph into its two clusters 4 4 4 4 4 4 1 1 0 1 2 3 4 5
two heavy triangles joined by two light bridges: random contraction repeatedly rediscovers the minimum cut that severs the bridges (red), splitting the graph into its two natural clusters
Karger's algorithm: minimum cut by random edge contraction

  6 nodes in two heavy triangles joined by two light bridges (2-3 and 0-5)

  exact minimum cut (Stoer-Wagner): 2, side [3, 4, 5]

  best cut found as random trials accumulate:
    after   1 trials: best = 2  <- found the true minimum
    after   2 trials: best = 2  <- found the true minimum
    after   5 trials: best = 2  <- found the true minimum
    after  10 trials: best = 2  <- found the true minimum
    after  25 trials: best = 2  <- found the true minimum
    after  50 trials: best = 2  <- found the true minimum
    after 100 trials: best = 2  <- found the true minimum

  63/100 single trials happened to hit the minimum -- each is unlikely alone, but
  repetition makes finding it almost certain.

  repeated Karger: 2 (matches exact: True)
  Karger-Stein:    2 (matches exact: True)
  cut partition: [0, 1, 2] | [3, 4, 5]

  Each trial merges random edges until two super-nodes remain; the edges between them are
  a cut. A given minimum cut survives only if none of its few edges is ever contracted --
  probability >= 2/(n(n-1)) per run -- so O(n^2 log n) runs drive the failure chance to ~0.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\karger.svg

Three-body stability map

Every pixel is a full three-body integration; colour is the time until a body escapes. The fractal boundary between long-lived and quickly-ionized initial conditions is chaos drawn in IC space. (Generated separately by stability_map_demo.py -- it's compute-heavy.)

Three-body stability map colour = time until a body escapes; circles = two primaries; fractal edge = chaos
escape time over a grid of starting points

Chaos & the Lyapunov exponent

Two trajectories started 1e-9 apart diverge to order unity on an exponential clock. The pythagorean 3-body has a large positive Lyapunov exponent; a regular orbit's estimate decays toward zero.

Sensitive dependence (body 1 of 3) two runs, initial positions 1e-6 apart, tracked over time plane=xy hollow=start filled=end
two runs 1e-6 apart peel apart over time
Chaos in the three-body problem: the largest Lyapunov exponent

  pythagorean 3-body : lambda = 0.523   Lyapunov time ~ 1.9 time units
  regular two-body   : lambda = 0.035   (decays toward 0 with T)

  => the chaotic system's exponent is 15x larger.

separation of two trajectories started 1e-9 apart (log scale):
           ..::::----------------===============+=======++++++++++++************************************#################@
  grew from 1e-9 to ~1.3e-03 -- 6 orders of magnitude. That's why the long-term 3-body problem is unpredictable.

wrote C:\Users\acwic\symplectic-nbody\examples\output\chaos_divergence.svg

Mercury's perihelion precession (general relativity)

A first post-Newtonian correction makes the orbit slowly rotate instead of closing. The closed form gives Mercury's famous 43 arcsec/century; direct integration reproduces it.

Relativistic precession (rosette) GR amplified ~8000x: the ellipse slowly rotates instead of closing plane=xy hollow=start filled=end
GR amplified ~8000x: a precessing rosette
Perihelion precession of Mercury (1PN general relativity)

  analytic advance at the real speed of light: 42.98 arcsec/century
  observed / GR-predicted value:               ~43 arcsec/century

numeric integration reproduces the closed form 6*pi*GM/(c^2 a(1-e^2)):
    c factor   numeric/orbit  analytic/orbit    ratio
  ---------------------------------------------------
  c/300         4.514734e-02    4.517161e-02   0.9995
  c/400         8.019446e-02    8.030509e-02   0.9986
  c/600         1.798247e-01    1.806865e-01   0.9952

wrote C:\Users\acwic\symplectic-nbody\examples\output\precession_rosette.svg
At the true speed of light the same rotation is a mere 43 arcsec/century --
undetectable in one orbit, unmistakable over a century of Mercury's.

Lagrange points & zero-velocity curves

Five equilibria of the rotating-frame restricted 3-body problem. JWST parks at L2; the Trojan asteroids live at L4/L5.

L1 L2 L3 L4 L5 CR3BP Lagrange points & zero-velocity curves Earth-Moon mu=0.01215; dots trace contours of the Jacobi constant
Earth-Moon Lagrange points + Hill curves
Earth-Moon CR3BP (mu = 0.01215), nondimensional units

point            x           y      Jacobi C
--------------------------------------------
L1        0.836918    0.000000      3.188336
L2        1.155680    0.000000      3.172156
L3       -1.005062    0.000000      3.012147
L4        0.487850    0.866025      2.987998
L5        0.487850   -0.866025      2.987998

wrote C:\Users\acwic\symplectic-nbody\examples\output\lagrange.svg
L4/L5 (equilateral points) are stable for the Earth-Moon mass ratio;
that's why Trojan asteroids cluster at the Sun-Jupiter L4/L5.

Hohmann transfer & mission delta-v

The cheapest two-burn hop between circular orbits sets every mission's delta-v budget: ~3.9 km/s from LEO to GEO, ~5.6 km/s and 259 days from Earth to Mars. The launch phase angle is why Mars windows open only every ~26 months.

Hohmann transfer: Earth (blue) -> Mars (pink) gold dashed = transfer ellipse; burns at periapsis and apoapsis
Earth-to-Mars transfer ellipse
Hohmann transfer: the cheapest way between two circular orbits

  LEO (200 km) -> GEO (35786 km):
    burn 1 = 2455 m/s, burn 2 = 1477 m/s, total = 3932 m/s
    transfer time = 5.26 hours
    propellant: m0/mf = 2.44 (LH2/LOX, ve=4.4 km/s)

  Earth -> Mars (heliocentric):
    total delta-v = 5.60 km/s
    transfer time = 259 days
    launch phase angle = 44.4 deg

  These delta-v budgets set the propellant mass via the rocket equation,
  and the phase angle sets the launch window -- why Mars missions leave
  Earth only every ~26 months.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\hohmann.svg

Oberth effect: burn low and fast

A burn's energy gain is v dv + dv^2/2, so the same dv buys far more energy deep in the gravity well where the ship moves fastest. The same burn escapes from periapsis but leaves the ship bound at apoapsis -- why probes dive in before an escape burn and powered flybys work.

Oberth effect: escape speed from a fixed burn vs burn radius burn radius (km) -- lower/faster = more v_infinity for the same dv hyperbolic excess speed (km/s)
escape speed from a fixed burn vs burn radius
The Oberth effect: the same burn buys more energy at high speed

  same 1500 m/s burn on a 300 km x 35786 km orbit:
    at periapsis (v=10.2 km/s): v_inf = 4.05 km/s (escapes)
    at apoapsis  (v=1.6 km/s): v_inf = 0.00 km/s (still bound)
    energy-gain advantage of the periapsis burn: 4.6x

  This is why probes dive toward a planet before their escape burn, and
  why a powered gravity assist (an Oberth maneuver at closest approach)
  extracts far more than the same burn in deep space.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\oberth.svg

Escape & cosmic velocities

The speed thresholds of spaceflight: orbital v1 = sqrt(GM/r), escape v2 = sqrt(2) v1 (11.2 km/s from Earth), and ~42 km/s to leave the Solar System. Push v2 to the speed of light and you recover the Schwarzschild radius -- 3 km for the Sun.

Moon 2 Mars 5 Earth 11 Jupiter 60 Sun 618 white dwarf 7072 Escape velocity by body (km/s) v_escape = sqrt(2) v_orbital; a 1.2 M_sun white dwarf reaches thousands of km/s
escape velocity from Moon to a white dwarf
Escape and cosmic velocities

  body            v_orbit (km/s)  v_escape (km/s)
  -----------------------------------------------
  Moon                      1.68             2.38
  Mars                      3.55             5.03
  Earth                     7.91            11.19
  Jupiter                  42.57            60.20
  Sun                     436.82           617.75
  white dwarf            5000.33          7071.53

  escape / orbital = sqrt(2) always.
  leaving the Solar System from Earth's orbit: 42.1 km/s
  set v_escape = c and you get the Schwarzschild radius: 2954 m for the Sun.

  wrote C:\Users\acwic\symplectic-nbody\examples\output/cosmic_velocities.svg

Atmospheric escape: which worlds keep air

A planet keeps a gas only if its escape speed beats ~6x the molecules' thermal speed (Jeans parameter lambda >= 36). Earth loses H2 and He but keeps N2/O2; the Moon and Mars lose the light gases; Jupiter keeps even hydrogen -- exactly the atmospheres we observe.

retention boundary (v_esc = 6 v_th) Atmospheric retention: escape vs thermal speed retained lost molecular thermal speed (km/s) -> escape speed (km/s)
retained (blue) vs lost (red) across bodies and gases
Atmospheric escape: which worlds keep which gases (lambda >= 36 = keep)

  body            H2      He     H2O      N2     CO2
  --------------------------------------------------
  Moon          lose    lose    lose    lose    keep
  Mars          lose    lose    keep    keep    keep
  Earth         lose    lose    keep    keep    keep
  Jupiter       keep    keep    keep    keep    keep

  Earth loses H2/He but keeps N2/O2/CO2; the Moon and Mars lose the
  light gases; Jupiter keeps everything. Retention needs the escape
  speed to beat ~6x the molecules' thermal speed.

  wrote C:\Users\acwic\symplectic-nbody\examples\output\atmosphere.svg