Skip to content

Instantly share code, notes, and snippets.

@Protonk
Last active May 6, 2026 04:20
Show Gist options
  • Select an option

  • Save Protonk/0887a8de014b047f5eebb445d0b1c101 to your computer and use it in GitHub Desktop.

Select an option

Save Protonk/0887a8de014b047f5eebb445d0b1c101 to your computer and use it in GitHub Desktop.
benford pig breeding sim
"""
Naive mean-field Minecraft pig-breeding simulation.
Deterministic two-compartment continuous-time model. Idealized regime only:
infinite carrots, no pen cap. For carrot-limited, pen-capped, or
player-scheduled regimes, see the stochastic per-individual file.
Mechanics approximated:
* 5-min cooldown per pig: max breeding rate = A/10 events per minute
(A adult pigs form A/2 pairs; each pair breeds once per cooldown)
* 20-min maturation: piglets born now become breeders 20 min later
* Carrots are not modeled in the idealized regime.
State:
A(t) = adult pigs (age >= 20 min)
B(t) = baby pigs (age < 20 min)
N(t) = A + B
Dynamics:
dB/dt = births(t) - maturations(t)
dA/dt = maturations(t)
births(t) = A(t) / 10
maturations(t) = births(t - 20)
In steady-state exponential growth, r solves 10r = exp(-20r), giving
r ≈ 0.04263 /min, doubling time ≈ 16.26 min, adult fraction ≈ 0.4263.
Run as `python pig_breeding_naive.py`.
"""
from __future__ import annotations
import math
import numpy as np
# ---- Vanilla mechanics ----
COOLDOWN_MIN = 5.0
MATURATION_MIN = 20.0
# ---- Sim defaults ----
T_TOTAL_MIN = 150.0
DT_MIN = 0.5
WARMUP_MIN = 30.0
A0_DEFAULT = 2.0
# ---- Numerical tolerances ----
TIME_TOL = 1e-12
SLOPE_TOL = 1e-10
def _exact_step_count(duration: float, dt: float, name: str) -> int:
"""Return duration / dt as an integer, or raise if it is not integral."""
if duration < 0:
raise ValueError(f"{name} must be nonnegative")
steps = int(round(duration / dt))
if not math.isclose(steps * dt, duration, rel_tol=0.0, abs_tol=TIME_TOL):
raise ValueError(f"{name}={duration!r} must be an integer multiple of dt={dt!r}")
return steps
def solve_steady_state_r() -> float:
"""Solve 10r = exp(-20r) numerically for the idealized growth rate."""
lo, hi = 0.0, 0.1
for _ in range(100):
mid = 0.5 * (lo + hi)
if 10 * mid - math.exp(-20 * mid) < 0:
lo = mid
else:
hi = mid
return 0.5 * (lo + hi)
R_THEORY = solve_steady_state_r()
T_DOUBLE_THEORY = math.log(2) / R_THEORY
ADULT_FRAC_THEORY = math.exp(-MATURATION_MIN * R_THEORY)
def simulate(
T_total: float = T_TOTAL_MIN,
dt: float = DT_MIN,
A0: float = A0_DEFAULT,
B0: float = 0.0,
) -> dict[str, np.ndarray]:
"""
Run the two-compartment mean-field idealized simulation.
Returns a dict with keys 'times', 'N', 'A', 'B' (numpy arrays of length
n_steps + 1; index 0 holds the initial state).
"""
if dt <= 0:
raise ValueError("dt must be positive")
if A0 < 0 or B0 < 0:
raise ValueError("A0 and B0 must be nonnegative")
n_steps = _exact_step_count(T_total, dt, "T_total")
delay_steps = _exact_step_count(MATURATION_MIN, dt, "MATURATION_MIN")
maturation_due = np.zeros(n_steps + delay_steps + 1)
A = float(A0)
B = float(B0)
times = np.arange(n_steps + 1, dtype=float) * dt
N_rec = np.zeros(n_steps + 1)
A_rec = np.zeros(n_steps + 1)
B_rec = np.zeros(n_steps + 1)
A_rec[0] = A
B_rec[0] = B
N_rec[0] = A + B
for step in range(n_steps):
maturations = maturation_due[step]
if maturations > B and maturations - B < 1e-9:
maturations = B
if maturations > B + 1e-9:
raise RuntimeError("scheduled maturations exceeded baby population")
A += maturations
B -= maturations
rate = A / (2.0 * COOLDOWN_MIN)
births = rate * dt
B += births
maturation_due[step + delay_steps] += births
A_rec[step + 1] = A
B_rec[step + 1] = B
N_rec[step + 1] = A + B
return {"times": times, "N": N_rec, "A": A_rec, "B": B_rec}
# ---- Shared analysis helpers (definitions match the stochastic file) ----
BENFORD = [math.log10(1.0 + 1.0 / d) for d in range(1, 10)]
def leading_digit(x: float) -> int:
"""Return the base-10 leading digit of x, or 0 for nonpositive/non-finite x."""
if x <= 0 or not math.isfinite(x):
return 0
exponent = math.floor(math.log10(x))
digit = int(x / (10 ** exponent))
return max(1, min(9, digit))
def histogram(values) -> list[float]:
"""Leading-digit histogram from a flat sequence of population values."""
digits = [leading_digit(float(v)) for v in values]
n = len(digits)
if n == 0:
raise ValueError("no values to histogram")
return [digits.count(d) / n for d in range(1, 10)]
def l1_to_benford(hist: list[float]) -> float:
"""L1 distance between a leading-digit histogram and Benford's law."""
return sum(abs(h - b) for h, b in zip(hist, BENFORD))
# ---- File-specific helper ----
def empirical_logfit_doubling_time(
result: dict[str, np.ndarray], warmup_min: float = WARMUP_MIN
) -> float:
"""Fit slope of log N vs t after warmup; return implied doubling time."""
times = result["times"]
N = result["N"]
warmup_idx = int(np.searchsorted(times, warmup_min, side="left"))
if warmup_idx >= len(N) - 1:
raise ValueError("warmup_min leaves too few samples")
fit_t = times[warmup_idx:]
fit_N = N[warmup_idx:]
positive = fit_N > 0
if positive.sum() < 2:
return float("inf")
slope, _ = np.polyfit(fit_t[positive], np.log(fit_N[positive]), 1)
if slope <= SLOPE_TOL:
return float("inf")
return float(math.log(2) / slope)
def main() -> None:
print(f"Theoretical r: {R_THEORY:.5f} /min")
print(f"Theoretical doubling time: {T_DOUBLE_THEORY:.3f} min")
print(f"Theoretical adult fraction: {ADULT_FRAC_THEORY:.4f}")
print()
result = simulate()
times = result["times"]
N = result["N"]
warmup_idx = int(np.searchsorted(times, WARMUP_MIN, side="left"))
values = N[warmup_idx:]
hist = histogram(values)
l1 = l1_to_benford(hist)
final_N = float(N[-1])
decades = math.log10(max(final_N, 1.0) / 2.0)
Td_emp = empirical_logfit_doubling_time(result, WARMUP_MIN)
print("=" * 58)
print(" NAIVE MEAN-FIELD IDEALIZED")
print("=" * 58)
print(f" Run time: {T_TOTAL_MIN:.0f} min")
print(f" Warmup discarded: {WARMUP_MIN:.0f} min")
print(f" Final N: {final_N:.3e}")
print(f" Decades of growth: {decades:.2f}")
print(f" Empirical doubling time: {Td_emp:.3f} min")
print(f" L1 to Benford: {l1:.4f}")
print()
print(" Leading-digit histogram (uniform-in-time samples after warmup)")
print(f" {'D':>2} | {'Benford':>8} | {'Empirical':>10}")
print(" " + "-" * 28)
for d in range(1, 10):
print(f" {d:>2} | {BENFORD[d - 1]:>8.4f} | {hist[d - 1]:>10.4f}")
if __name__ == "__main__":
main()
"""
Stochastic/discrete Minecraft pig-breeding simulator.
This is a per-pig, integer-population simulator intended to complement the
naive mean-field idealized model. It is not a full Minecraft implementation.
It models the breeding-relevant mechanics only, with explicit time-step and
resource semantics.
Mechanics represented:
* 5-minute breeding cooldown per adult pig.
* 20-minute maturation delay from baby to adult.
* 2 carrots consumed per breeding event.
* Optional per-minute activation probability for each breedable adult.
* Random pairing among activated adults.
* Optional experimental population cap, counting adults plus babies.
Time semantics:
* snapshots[0] is the initial state at t = 0, before any breeding.
* Each step advances one interval [t, t + step_min).
* Carrot supply is credited at the start of the interval.
* Breeding, if possible, occurs at the start of the interval.
* snapshots[k] records the state at the end of the kth interval.
* The final snapshot is exactly at duration_min; no events are processed at
the endpoint after the run has already ended.
Activation semantics:
activation_prob_per_min is the probability that a breedable adult activates
during one minute. For a step of length step_min, the interval activation
probability is 1 - (1 - activation_prob_per_min) ** step_min.
Regimes compared:
Idealized-max unlimited carrots, no cap, all breedable adults activate
Stochastic-activation unlimited carrots, no cap, activation probability < 1
Carrot-limited steady carrot supply caps the event rate
Pen-capped experimental hard population ceiling pins late behavior
Player-scheduled sparse carrot bursts; nothing in between
Run as `python pig_breeding_stochastic_improved.py`.
"""
from __future__ import annotations
import math
import random
import statistics
from dataclasses import dataclass
from typing import Callable
# ---- Breeding mechanics ----
COOLDOWN_MIN = 5
MATURATION_MIN = 20
CARROTS_PER_BREEDING = 2
# A stochastic behavior parameter, not a vanilla cooldown/maturation constant.
DEFAULT_ACTIVATION_PROB_PER_MIN = 0.5
# ---- Domain types ----
@dataclass(frozen=True)
class Pig:
id: int
mature_at: int
cooldown_until: int
def is_adult(self, time_min: int) -> bool:
return time_min >= self.mature_at
def can_breed(self, time_min: int) -> bool:
return self.is_adult(time_min) and time_min >= self.cooldown_until
@dataclass(frozen=True)
class Snapshot:
"""
State at a snapshot time.
breeding_events is the number of events during the immediately preceding
interval. For the initial snapshot at t = 0, it is 0.
"""
time_min: int
total_pigs: int
adult_pigs: int
baby_pigs: int
carrots: int
breeding_events: int
cumulative_breeding_events: int
CarrotSupply = Callable[[int, int], int]
@dataclass(frozen=True)
class SimulationConfig:
duration_min: int
step_min: int
initial_adults: int
initial_carrots: int
carrot_supply: CarrotSupply
pen_cap: int | None
random_seed: int | None
activation_prob_per_min: float = 1.0
# ---- Carrot supply factories ----
def constant_supply(rate_per_min: int) -> CarrotSupply:
"""
Return a supply function that adds rate_per_min * step_min carrots per step.
The result must be integral because the simulator keeps carrots discrete.
"""
if rate_per_min < 0:
raise ValueError("rate_per_min must be nonnegative")
def supply(time_min: int, step_min: int) -> int:
del time_min
return rate_per_min * step_min
return supply
def burst_supply(interval_min: int, amount: int) -> CarrotSupply:
"""
Return a supply function that adds amount carrots at t = interval_min,
2 * interval_min, 3 * interval_min, ... . No burst occurs at t = 0.
"""
if interval_min <= 0:
raise ValueError("interval_min must be positive")
if amount < 0:
raise ValueError("amount must be nonnegative")
def supply(time_min: int, step_min: int) -> int:
if interval_min % step_min != 0:
raise ValueError("step_min must divide burst interval_min")
if time_min <= 0:
return 0
if time_min % interval_min != 0:
return 0
return amount
return supply
# ---- Simulation ----
class PigBreedingSimulation:
def __init__(self, config: SimulationConfig) -> None:
self.validate_config(config)
self.config = config
self.rng = random.Random(config.random_seed)
self.next_pig_id = 1
self.pigs: list[Pig] = []
self.carrots = config.initial_carrots
self.cumulative_breeding_events = 0
for _ in range(config.initial_adults):
self.pigs.append(self.create_adult_pig())
@staticmethod
def validate_config(config: SimulationConfig) -> None:
if config.duration_min < 0:
raise ValueError("duration_min must be nonnegative")
if config.step_min <= 0:
raise ValueError("step_min must be positive")
if config.initial_adults < 0 or config.initial_carrots < 0:
raise ValueError("initial_adults and initial_carrots must be nonnegative")
if config.duration_min % config.step_min != 0:
raise ValueError("duration_min must be a multiple of step_min")
if COOLDOWN_MIN % config.step_min != 0:
raise ValueError("step_min must divide COOLDOWN_MIN")
if MATURATION_MIN % config.step_min != 0:
raise ValueError("step_min must divide MATURATION_MIN")
if not 0.0 <= config.activation_prob_per_min <= 1.0:
raise ValueError("activation_prob_per_min must lie in [0, 1]")
if config.pen_cap is not None and config.pen_cap < config.initial_adults:
raise ValueError("pen_cap is below initial_adults")
def create_adult_pig(self) -> Pig:
pig = Pig(id=self.next_pig_id, mature_at=0, cooldown_until=0)
self.next_pig_id += 1
return pig
def create_baby_pig(self, birth_time_min: int) -> Pig:
mature_at = birth_time_min + MATURATION_MIN
pig = Pig(
id=self.next_pig_id,
mature_at=mature_at,
cooldown_until=mature_at,
)
self.next_pig_id += 1
return pig
def run(self) -> list[Snapshot]:
snapshots: list[Snapshot] = [self.create_snapshot(time_min=0, breeding_events=0)]
for start_min in range(0, self.config.duration_min, self.config.step_min):
breeding_events = self.step_interval(start_min)
end_min = start_min + self.config.step_min
snapshots.append(self.create_snapshot(end_min, breeding_events))
return snapshots
def step_interval(self, start_min: int) -> int:
"""Advance the interval [start_min, start_min + step_min)."""
self.add_carrots(start_min)
breeders = self.get_activated_breeders(start_min)
pair_count = len(breeders) // 2
event_count = self.get_event_limit(pair_count)
if event_count <= 0:
return 0
self.apply_breeding(start_min, breeders, event_count)
self.cumulative_breeding_events += event_count
return event_count
def add_carrots(self, start_min: int) -> None:
added = self.config.carrot_supply(start_min, self.config.step_min)
if added < 0:
raise ValueError("carrot_supply returned a negative amount")
self.carrots += added
def interval_activation_probability(self) -> float:
p = self.config.activation_prob_per_min
return 1.0 - (1.0 - p) ** self.config.step_min
def get_activated_breeders(self, time_min: int) -> list[Pig]:
candidates = [pig for pig in self.pigs if pig.can_breed(time_min)]
p_activate = self.interval_activation_probability()
activated = [pig for pig in candidates if self.rng.random() < p_activate]
self.rng.shuffle(activated)
return activated
def get_event_limit(self, pair_count: int) -> int:
carrot_limit = self.carrots // CARROTS_PER_BREEDING
capacity_limit = self.get_capacity_limit()
return min(pair_count, carrot_limit, capacity_limit)
def get_capacity_limit(self) -> int:
# This is an experimental cap used to study constrained behavior. It
# counts adults plus babies and is not meant to claim exact equivalence
# to Minecraft's passive-mob spawning rules.
if self.config.pen_cap is None:
return 10**18
return max(0, self.config.pen_cap - len(self.pigs))
def apply_breeding(
self, time_min: int, breeders: list[Pig], event_count: int
) -> None:
breeder_ids = {pig.id for pig in breeders[: event_count * 2]}
self.pigs = [
self.with_cooldown(pig, time_min) if pig.id in breeder_ids else pig
for pig in self.pigs
]
for _ in range(event_count):
self.pigs.append(self.create_baby_pig(time_min))
self.carrots -= event_count * CARROTS_PER_BREEDING
if self.carrots < 0:
raise RuntimeError("carrot count became negative")
@staticmethod
def with_cooldown(pig: Pig, time_min: int) -> Pig:
return Pig(
id=pig.id,
mature_at=pig.mature_at,
cooldown_until=time_min + COOLDOWN_MIN,
)
def create_snapshot(self, time_min: int, breeding_events: int) -> Snapshot:
adult_count = sum(1 for pig in self.pigs if pig.is_adult(time_min))
baby_count = len(self.pigs) - adult_count
return Snapshot(
time_min=time_min,
total_pigs=len(self.pigs),
adult_pigs=adult_count,
baby_pigs=baby_count,
carrots=self.carrots,
breeding_events=breeding_events,
cumulative_breeding_events=self.cumulative_breeding_events,
)
# ---- Shared analysis helpers ----
BENFORD = [math.log10(1.0 + 1.0 / d) for d in range(1, 10)]
def leading_digit(x: float) -> int:
"""Return the base-10 leading digit of x, or 0 for nonpositive/non-finite x."""
if x <= 0 or not math.isfinite(x):
return 0
exponent = math.floor(math.log10(x))
digit = int(x / (10 ** exponent))
return max(1, min(9, digit))
def histogram(values: list[int] | list[float]) -> list[float]:
"""Leading-digit histogram from a flat sequence of population values."""
digits = [leading_digit(float(v)) for v in values]
if not digits:
raise ValueError("no values to histogram")
return [digits.count(d) / len(digits) for d in range(1, 10)]
def l1_to_benford(hist: list[float]) -> float:
"""L1 distance between a leading-digit histogram and Benford's law."""
return sum(abs(h - b) for h, b in zip(hist, BENFORD))
def values_from_snapshots(
snapshots: list[Snapshot], warmup_min: int, *, include_warmup_time: bool = True
) -> list[int]:
"""Pull total_pigs from snapshots after the warmup cutoff."""
if include_warmup_time:
return [s.total_pigs for s in snapshots if s.time_min >= warmup_min]
return [s.total_pigs for s in snapshots if s.time_min > warmup_min]
# ---- Multi-seed runner ----
@dataclass(frozen=True)
class RegimeResult:
name: str
final_n_mean: float
final_n_std: float
l1_mean: float
l1_std: float
l1_of_average_hist: float
average_hist: list[float]
def run_regime(
name: str,
config_builder: Callable[[int], SimulationConfig],
seeds: list[int],
warmup_min: int,
) -> RegimeResult:
if not seeds:
raise ValueError("seeds must be nonempty")
finals: list[int] = []
l1_values: list[float] = []
hist_accum = [0.0] * 9
for seed in seeds:
config = config_builder(seed)
snapshots = PigBreedingSimulation(config).run()
finals.append(snapshots[-1].total_pigs)
values = values_from_snapshots(snapshots, warmup_min)
hist = histogram(values)
l1_values.append(l1_to_benford(hist))
for digit_index, value in enumerate(hist):
hist_accum[digit_index] += value
seed_count = len(seeds)
average_hist = [h / seed_count for h in hist_accum]
return RegimeResult(
name=name,
final_n_mean=statistics.mean(finals),
final_n_std=statistics.pstdev(finals) if seed_count > 1 else 0.0,
l1_mean=statistics.mean(l1_values),
l1_std=statistics.pstdev(l1_values) if seed_count > 1 else 0.0,
l1_of_average_hist=l1_to_benford(average_hist),
average_hist=average_hist,
)
# ---- Regime definitions ----
DURATION_MIN = 150
STEP_MIN = 1
INITIAL_ADULTS = 2
WARMUP_MIN = 30
SEEDS = list(range(20))
def build_idealized_max(seed: int) -> SimulationConfig:
return SimulationConfig(
duration_min=DURATION_MIN,
step_min=STEP_MIN,
initial_adults=INITIAL_ADULTS,
initial_carrots=10**9,
carrot_supply=constant_supply(10**6),
pen_cap=None,
random_seed=seed,
activation_prob_per_min=1.0,
)
def build_stochastic_activation(seed: int) -> SimulationConfig:
return SimulationConfig(
duration_min=DURATION_MIN,
step_min=STEP_MIN,
initial_adults=INITIAL_ADULTS,
initial_carrots=10**9,
carrot_supply=constant_supply(10**6),
pen_cap=None,
random_seed=seed,
activation_prob_per_min=DEFAULT_ACTIVATION_PROB_PER_MIN,
)
def build_carrot_limited(seed: int) -> SimulationConfig:
return SimulationConfig(
duration_min=DURATION_MIN,
step_min=STEP_MIN,
initial_adults=INITIAL_ADULTS,
initial_carrots=20,
carrot_supply=constant_supply(1),
pen_cap=None,
random_seed=seed,
activation_prob_per_min=1.0,
)
def build_pen_capped(seed: int) -> SimulationConfig:
return SimulationConfig(
duration_min=DURATION_MIN,
step_min=STEP_MIN,
initial_adults=INITIAL_ADULTS,
initial_carrots=10**9,
carrot_supply=constant_supply(10**6),
pen_cap=200,
random_seed=seed,
activation_prob_per_min=1.0,
)
def build_player_scheduled(seed: int) -> SimulationConfig:
return SimulationConfig(
duration_min=DURATION_MIN,
step_min=STEP_MIN,
initial_adults=INITIAL_ADULTS,
initial_carrots=0,
carrot_supply=burst_supply(interval_min=30, amount=30),
pen_cap=None,
random_seed=seed,
activation_prob_per_min=1.0,
)
REGIMES: list[tuple[str, Callable[[int], SimulationConfig]]] = [
("Idealized-max", build_idealized_max),
("Stochastic-activation", build_stochastic_activation),
("Carrot-limited", build_carrot_limited),
("Pen-capped", build_pen_capped),
("Player-scheduled", build_player_scheduled),
]
# ---- Reporting ----
def print_summary(results: list[RegimeResult]) -> None:
print("=" * 106)
print(" REGIME COMPARISON (mean +/- population std over seeds)")
print("=" * 106)
print(
f"{'Regime':<23} | {'Final N (mean +/- std)':>30} | "
f"{'mean L1 to B':>22} | {'L1(avg hist)':>12}"
)
print("-" * 106)
for r in results:
final_col = f"{r.final_n_mean:.3e} +/- {r.final_n_std:.3e}"
l1_col = f"{r.l1_mean:.4f} +/- {r.l1_std:.4f}"
print(
f"{r.name:<23} | {final_col:>30} | "
f"{l1_col:>22} | {r.l1_of_average_hist:>12.4f}"
)
def print_histograms(results: list[RegimeResult]) -> None:
short = {
"Idealized-max": "Max",
"Stochastic-activation": "Stoch",
"Carrot-limited": "Carrot",
"Pen-capped": "Pen",
"Player-scheduled": "Player",
}
print()
print("=" * 106)
print(" AVERAGED LEADING-DIGIT HISTOGRAMS (uniform-in-time snapshots after warmup)")
print("=" * 106)
head = f"{'D':>2} | {'Benford':>7}"
for r in results:
head += f" | {short[r.name]:>8}"
print(head)
print("-" * len(head))
for d in range(1, 10):
row = f"{d:>2} | {BENFORD[d - 1]:>7.4f}"
for r in results:
row += f" | {r.average_hist[d - 1]:>8.4f}"
print(row)
def main() -> None:
print(f"Seeds per regime: {len(SEEDS)}")
print(f"Duration: {DURATION_MIN} min, warmup: {WARMUP_MIN} min, step: {STEP_MIN} min")
print(
"Snapshot convention: t=0 is initial state; each later snapshot follows "
"one completed interval."
)
print()
results = [
run_regime(name, builder, SEEDS, WARMUP_MIN)
for name, builder in REGIMES
]
print_summary(results)
print_histograms(results)
if __name__ == "__main__":
main()
@srtdog64
Copy link
Copy Markdown

srtdog64 commented May 6, 2026

Discrete Minecraft Pig Breeding Simulation Proposal

This is a proposal for a more Minecraft-like pig breeding simulation.
The existing mean-field model is useful as a smooth mathematical approximation, but it may be too clean for the explanatory purpose. It risks showing:
xponential growth in, Benford-like behavior out

That is mathematically understandable, but it may make the Minecraft example feel decorative.

I think the stronger model is a discrete event simulation that keeps the core Minecraft mechanics visible:

  • individual adult pigs
  • individual baby pigs
  • 5-minute breeding cooldown
  • 20-minute maturation delay
  • 2 carrots consumed per breeding event
  • optional carrot supply rate
  • optional pen capacity
  • time snapshots of population

The goal is not to perfectly simulate Minecraft internals. The goal is to distinguish:

  1. unconstrained branching growth
  2. carrot-limited growth
  3. pen-capped growth
  4. player-scheduled or sparse-interaction growth

This makes the lesson clearer:

Benford-like behavior is not caused by "pig breeding" in general.
It appears only when the process behaves like multiplicative branching across enough orders of magnitude.
Once the process is pinned by carrots, space, cooldown, or player scheduling, the digit behavior can change.

"""
Stochastic discrete-event Minecraft pig breeding simulator.

This is a per-individual approximation to vanilla Minecraft pig mechanics,
designed to expose the conditions under which Benford-like behavior emerges.
Unlike a mean-field ODE, every adult pig carries its own cooldown clock and
pair formation is randomized, so the same configuration produces a
distribution of trajectories rather than a single curve.

Per-pig mechanics:
  * 5-minute breeding cooldown
  * 20-minute maturation delay (baby -> adult)
  * 2 carrots per breeding event
  * Each breedable adult independently activates with probability
    ACTIVATION_PROB per step, modeling stochastic in-love-mode entry.
    Activated pigs pair up uniformly at random within carrot and pen limits.

Regimes compared (all on the same interface):
  Idealized          unlimited carrots, no pen
  Carrot-limited     steady carrot supply caps event rate
  Pen-capped         hard population ceiling pins late behavior
  Player-scheduled   sparse carrot bursts; nothing in between

The lesson is that Benford-like digit behavior tracks "process spends roughly
equal log10-time at each scale", not "pigs are breeding". When carrots, space,
or scheduling pin the trajectory, the digit distribution diverges from
Benford in regime-specific ways.
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import Optional, Callable
import math
import random
import statistics


COOLDOWN_MIN = 5
MATURATION_MIN = 20
CARROTS_PER_BREEDING = 2
ACTIVATION_PROB = 0.5


# ---- Domain types ----

@dataclass(frozen=True)
class Pig:
    id: int
    matureAt: int
    cooldownUntil: int

    def isAdult(self, timeMin: int) -> bool:
        return timeMin >= self.matureAt

    def canBreed(self, timeMin: int) -> bool:
        return self.isAdult(timeMin) and timeMin >= self.cooldownUntil


@dataclass(frozen=True)
class Snapshot:
    timeMin: int
    totalPigs: int
    adultPigs: int
    babyPigs: int
    carrots: int
    breedingEvents: int
    cumulativeBreedingEvents: int


CarrotSupply = Callable[[int, int], int]


@dataclass(frozen=True)
class SimulationConfig:
    durationMin: int
    stepMin: int
    initialAdults: int
    initialCarrots: int
    carrotSupply: CarrotSupply
    penCap: Optional[int]
    randomSeed: Optional[int]


# ---- Carrot supply factories ----

def constantSupply(ratePerMin: int) -> CarrotSupply:
    def supply(timeMin: int, stepMin: int) -> int:
        return ratePerMin * stepMin
    return supply


def burstSupply(intervalMin: int, amount: int) -> CarrotSupply:
    def supply(timeMin: int, stepMin: int) -> int:
        if timeMin <= 0:
            return 0
        if timeMin % intervalMin != 0:
            return 0
        return amount
    return supply


# ---- Simulation ----

class PigBreedingSimulation:
    def __init__(self, config: SimulationConfig) -> None:
        if config.durationMin < 0 or config.stepMin <= 0:
            raise ValueError("durationMin must be nonneg and stepMin must be positive")
        if config.initialAdults < 0 or config.initialCarrots < 0:
            raise ValueError("initialAdults and initialCarrots must be nonnegative")
        if config.penCap is not None and config.penCap < config.initialAdults:
            raise ValueError("penCap is below initialAdults")

        self.config = config
        self.rng = random.Random(config.randomSeed)
        self.nextPigId = 1
        self.pigs: list[Pig] = []
        self.carrots = config.initialCarrots
        self.cumulativeBreedingEvents = 0

        for _ in range(config.initialAdults):
            self.pigs.append(self.createAdultPig())

    def createAdultPig(self) -> Pig:
        pig = Pig(id=self.nextPigId, matureAt=0, cooldownUntil=0)
        self.nextPigId += 1
        return pig

    def createBabyPig(self, timeMin: int) -> Pig:
        pig = Pig(
            id=self.nextPigId,
            matureAt=timeMin + MATURATION_MIN,
            cooldownUntil=timeMin + MATURATION_MIN,
        )
        self.nextPigId += 1
        return pig

    def run(self) -> list[Snapshot]:
        snapshots: list[Snapshot] = []
        for timeMin in range(0, self.config.durationMin + 1, self.config.stepMin):
            breedingEvents = self.step(timeMin)
            snapshots.append(self.createSnapshot(timeMin, breedingEvents))
        return snapshots

    def step(self, timeMin: int) -> int:
        self.addCarrots(timeMin)
        breeders = self.getBreeders(timeMin)
        eventLimit = self.getEventLimit(len(breeders) // 2)

        if eventLimit <= 0:
            return 0

        self.applyBreeding(timeMin, breeders, eventLimit)
        self.cumulativeBreedingEvents += eventLimit
        return eventLimit

    def addCarrots(self, timeMin: int) -> None:
        self.carrots += self.config.carrotSupply(timeMin, self.config.stepMin)

    def getBreeders(self, timeMin: int) -> list[Pig]:
        candidates = [pig for pig in self.pigs if pig.canBreed(timeMin)]
        activated = [
            pig for pig in candidates
            if self.rng.random() < ACTIVATION_PROB
        ]
        self.rng.shuffle(activated)
        return activated

    def getEventLimit(self, pairCount: int) -> int:
        carrotLimit = self.carrots // CARROTS_PER_BREEDING
        capacityLimit = self.getCapacityLimit()
        return min(pairCount, carrotLimit, capacityLimit)

    def getCapacityLimit(self) -> int:
        if self.config.penCap is None:
            return 10**18
        return max(0, self.config.penCap - len(self.pigs))

    def applyBreeding(
        self, timeMin: int, breeders: list[Pig], eventCount: int
    ) -> None:
        breederIds: set[int] = set()
        for index in range(eventCount * 2):
            breederIds.add(breeders[index].id)

        self.pigs = [
            self.withCooldown(pig, timeMin) if pig.id in breederIds else pig
            for pig in self.pigs
        ]

        for _ in range(eventCount):
            self.pigs.append(self.createBabyPig(timeMin))

        self.carrots -= eventCount * CARROTS_PER_BREEDING

    @staticmethod
    def withCooldown(pig: Pig, timeMin: int) -> Pig:
        return Pig(
            id=pig.id,
            matureAt=pig.matureAt,
            cooldownUntil=timeMin + COOLDOWN_MIN,
        )

    def createSnapshot(self, timeMin: int, breedingEvents: int) -> Snapshot:
        adultCount = sum(1 for pig in self.pigs if pig.isAdult(timeMin))
        babyCount = len(self.pigs) - adultCount
        return Snapshot(
            timeMin=timeMin,
            totalPigs=len(self.pigs),
            adultPigs=adultCount,
            babyPigs=babyCount,
            carrots=self.carrots,
            breedingEvents=breedingEvents,
            cumulativeBreedingEvents=self.cumulativeBreedingEvents,
        )


# ---- Benford analysis ----

BENFORD = [math.log10(1.0 + 1.0 / d) for d in range(1, 10)]


def leadingDigit(x: int) -> int:
    if x <= 0:
        return 0
    return int(str(x)[0])


def histogramFromSnapshots(
    snapshots: list[Snapshot], warmupMin: int
) -> list[float]:
    values = [s.totalPigs for s in snapshots if s.timeMin >= warmupMin]
    if not values:
        raise ValueError("warmupMin leaves no samples")
    digits = [leadingDigit(v) for v in values]
    sampleCount = len(digits)
    return [digits.count(d) / sampleCount for d in range(1, 10)]


def l1ToBenford(hist: list[float]) -> float:
    return sum(abs(h - b) for h, b in zip(hist, BENFORD))


# ---- Multi-seed runner ----

@dataclass(frozen=True)
class RegimeResult:
    name: str
    finalNMean: float
    finalNStd: float
    l1Mean: float
    l1Std: float
    averageHist: list[float]


def runRegime(
    name: str,
    configBuilder: Callable[[int], SimulationConfig],
    seeds: list[int],
    warmupMin: int,
) -> RegimeResult:
    finals: list[int] = []
    l1Values: list[float] = []
    histAccum = [0.0] * 9

    for seed in seeds:
        config = configBuilder(seed)
        snapshots = PigBreedingSimulation(config).run()
        finals.append(snapshots[-1].totalPigs)
        hist = histogramFromSnapshots(snapshots, warmupMin)
        l1Values.append(l1ToBenford(hist))
        for digitIndex in range(9):
            histAccum[digitIndex] += hist[digitIndex]

    seedCount = len(seeds)
    averageHist = [h / seedCount for h in histAccum]

    return RegimeResult(
        name=name,
        finalNMean=statistics.mean(finals),
        finalNStd=statistics.pstdev(finals) if seedCount > 1 else 0.0,
        l1Mean=statistics.mean(l1Values),
        l1Std=statistics.pstdev(l1Values) if seedCount > 1 else 0.0,
        averageHist=averageHist,
    )


# ---- Regime definitions ----

DURATION_MIN = 150
STEP_MIN = 1
INITIAL_ADULTS = 2
WARMUP_MIN = 30
SEEDS = list(range(20))


def buildIdealized(seed: int) -> SimulationConfig:
    return SimulationConfig(
        durationMin=DURATION_MIN,
        stepMin=STEP_MIN,
        initialAdults=INITIAL_ADULTS,
        initialCarrots=10**9,
        carrotSupply=constantSupply(10**6),
        penCap=None,
        randomSeed=seed,
    )


def buildCarrotLimited(seed: int) -> SimulationConfig:
    return SimulationConfig(
        durationMin=DURATION_MIN,
        stepMin=STEP_MIN,
        initialAdults=INITIAL_ADULTS,
        initialCarrots=20,
        carrotSupply=constantSupply(1),
        penCap=None,
        randomSeed=seed,
    )


def buildPenCapped(seed: int) -> SimulationConfig:
    return SimulationConfig(
        durationMin=DURATION_MIN,
        stepMin=STEP_MIN,
        initialAdults=INITIAL_ADULTS,
        initialCarrots=10**9,
        carrotSupply=constantSupply(10**6),
        penCap=200,
        randomSeed=seed,
    )


def buildPlayerScheduled(seed: int) -> SimulationConfig:
    return SimulationConfig(
        durationMin=DURATION_MIN,
        stepMin=STEP_MIN,
        initialAdults=INITIAL_ADULTS,
        initialCarrots=0,
        carrotSupply=burstSupply(intervalMin=30, amount=30),
        penCap=None,
        randomSeed=seed,
    )


REGIMES: list[tuple[str, Callable[[int], SimulationConfig]]] = [
    ("Idealized", buildIdealized),
    ("Carrot-limited", buildCarrotLimited),
    ("Pen-capped", buildPenCapped),
    ("Player-scheduled", buildPlayerScheduled),
]


# ---- Reporting ----

def printSummary(results: list[RegimeResult]) -> None:
    print("=" * 84)
    print(" REGIME COMPARISON (mean +/- std over seeds)")
    print("=" * 84)
    print(f"{'Regime':<18} | {'Final N (mean +/- std)':>30} | {'L1 to Benford':>24}")
    print("-" * 84)
    for r in results:
        finalCol = f"{r.finalNMean:.3e} +/- {r.finalNStd:.3e}"
        l1Col = f"{r.l1Mean:.4f} +/- {r.l1Std:.4f}"
        print(f"{r.name:<18} | {finalCol:>30} | {l1Col:>24}")


def printHistograms(results: list[RegimeResult]) -> None:
    short = {
        "Idealized": "Ideal",
        "Carrot-limited": "Carrot",
        "Pen-capped": "Pen",
        "Player-scheduled": "Player",
    }
    print()
    print("=" * 84)
    print(" AVERAGED LEADING-DIGIT HISTOGRAMS (uniform-in-time, after warmup)")
    print("=" * 84)
    head = f"{'D':>2} | {'Benford':>7}"
    for r in results:
        head += f" | {short[r.name]:>8}"
    print(head)
    print("-" * len(head))
    for d in range(1, 10):
        row = f"{d:>2} | {BENFORD[d - 1]:>7.4f}"
        for r in results:
            row += f" | {r.averageHist[d - 1]:>8.4f}"
        print(row)


def main() -> None:
    print(f"Seeds per regime: {len(SEEDS)}")
    print(f"Duration: {DURATION_MIN} min, warmup: {WARMUP_MIN} min, step: {STEP_MIN} min")
    print()

    results = [
        runRegime(name, builder, SEEDS, WARMUP_MIN)
        for name, builder in REGIMES
    ]

    printSummary(results)
    printHistograms(results)


if __name__ == "__main__":
    main()
```python

```output
Seeds per regime: 20
Duration: 150 min, warmup: 30 min, step: 1 min

====================================================================================
 REGIME COMPARISON (mean +/- std over seeds)
====================================================================================
Regime             |         Final N (mean +/- std) |            L1 to Benford
------------------------------------------------------------------------------------
Idealized          |        7.949e+02 +/- 1.487e+02 |        0.0914 +/- 0.0205
Carrot-limited     |        8.700e+01 +/- 0.000e+00 |        0.7456 +/- 0.0168
Pen-capped         |        2.000e+02 +/- 0.000e+00 |        0.4407 +/- 0.0519
Player-scheduled   |        7.615e+01 +/- 1.314e+00 |        0.5929 +/- 0.0446

====================================================================================
 AVERAGED LEADING-DIGIT HISTOGRAMS (uniform-in-time, after warmup)
====================================================================================
 D | Benford |    Ideal |   Carrot |      Pen |   Player
--------------------------------------------------------
 1 |  0.3010 |   0.3062 |   0.1603 |   0.3041 |   0.1603
 2 |  0.1761 |   0.1719 |   0.0897 |   0.3727 |   0.1103
 3 |  0.1249 |   0.1182 |   0.0583 |   0.0583 |   0.1149
 4 |  0.0969 |   0.0917 |   0.0483 |   0.0467 |   0.1752
 5 |  0.0792 |   0.0773 |   0.1087 |   0.0397 |   0.0715
 6 |  0.0669 |   0.0707 |   0.1756 |   0.0446 |   0.2777
 7 |  0.0580 |   0.0607 |   0.1810 |   0.0421 |   0.0393
 8 |  0.0512 |   0.0583 |   0.1574 |   0.0496 |   0.0260
 9 |  0.0458 |   0.0450 |   0.0207 |   0.0421 |   0.0248
```output

@srtdog64
Copy link
Copy Markdown

srtdog64 commented May 6, 2026

using AI ver

GEMMNI +CLAUDE OPUT 4.7+GPT5.4

"""
Compare mean-field, deterministic discrete, and stochastic discrete
pig-breeding regimes against Benford's law.

All three simulators share a single carrot-supply abstraction
(SupplyContext + CarrotSupply callable), so any constraint configuration
can be applied uniformly across modeling levels. This isolates the effect
of stochastic noise from the effect of constraint shape.

Three sources of stochasticity in StochasticPigSimulator:
  1. binomial(pairCapacity, breedProbabilityPerPair) per step
  2. optional Poisson-Gamma carrot arrivals via poissonGammaSupply
  3. seed-controlled np.random.Generator threaded through SupplyContext

Config validation is performed in __post_init__ as a panic mechanism;
Result types are reserved for recoverable failures, not input contracts.
"""

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
import argparse
import csv
import math
from typing import Callable, Iterable, Literal, Optional

import numpy as np


# ---- Mechanics ----
COOLDOWN_MIN = 5
MATURATION_MIN = 20
CARROTS_PER_EVENT = 2

# ---- Tolerances ----
TIME_TOL = 1e-12
SLOPE_TOL = 1e-10


TargetName = Literal[
    "totalPigs",
    "adultPigs",
    "babyPigs",
    "cumulativeBreedingEvents",
]


# ---- Snapshot, Series, Stats ----

@dataclass(frozen=True)
class Snapshot:
    timeMin: float
    totalPigs: float
    adultPigs: float
    babyPigs: float
    carrots: float
    breedingEvents: float
    cumulativeBreedingEvents: float


@dataclass(frozen=True)
class Series:
    name: str
    snapshots: tuple[Snapshot, ...]


@dataclass(frozen=True)
class DigitStats:
    histogram: np.ndarray
    l1Distance: float
    maxDeviationDigit: int
    maxDeviation: float
    sampleCount: int


@dataclass(frozen=True)
class EnsembleStats:
    meanHistogram: np.ndarray
    stdHistogram: np.ndarray
    meanL1Distance: float
    stdL1Distance: float
    runCount: int


BENFORD = np.array([math.log10(1.0 + 1.0 / d) for d in range(1, 10)])


# ---- Carrot supply abstraction ----

@dataclass(frozen=True)
class SupplyContext:
    timeMin: float
    stepMin: float
    rng: Optional[np.random.Generator]


CarrotSupply = Callable[[SupplyContext], float]


def constantSupply(ratePerMin: float) -> CarrotSupply:
    def supply(ctx: SupplyContext) -> float:
        return ratePerMin * ctx.stepMin
    return supply


def burstSupply(intervalMin: float, amount: float) -> CarrotSupply:
    def supply(ctx: SupplyContext) -> float:
        if ctx.timeMin <= 0:
            return 0.0
        if not math.isclose(ctx.timeMin % intervalMin, 0.0, abs_tol=TIME_TOL):
            return 0.0
        return amount
    return supply


def poissonGammaSupply(
    visitsPerHour: float, batchMean: float, batchShape: float,
) -> CarrotSupply:
    def supply(ctx: SupplyContext) -> float:
        if ctx.rng is None:
            raise RuntimeError("poissonGammaSupply requires an rng in context.")
        rate = visitsPerHour * ctx.stepMin / 60.0
        visits = int(ctx.rng.poisson(rate))
        if visits <= 0:
            return 0.0
        scale = batchMean / batchShape
        total = 0.0
        for _ in range(visits):
            total += max(0.0, ctx.rng.gamma(batchShape, scale))
        return total
    return supply


# ---- Configs (validated in __post_init__) ----

@dataclass(frozen=True)
class MeanFieldConfig:
    durationMin: float = 1200.0
    dtMin: float = 0.5
    initialAdults: float = 2.0
    initialBabies: float = 0.0
    initialBabyAgeMin: float = 0.0
    carrotSupply: Optional[CarrotSupply] = None
    penCap: Optional[float] = None

    def __post_init__(self) -> None:
        validateMeanFieldConfig(self)


@dataclass(frozen=True)
class DiscreteConfig:
    durationMin: int = 1200
    stepMin: int = 5
    initialAdults: int = 2
    initialBabies: int = 0
    initialCarrots: int = 0
    carrotSupply: Optional[CarrotSupply] = None
    penCap: Optional[int] = None

    def __post_init__(self) -> None:
        validateDiscreteConfig(self)


@dataclass(frozen=True)
class StochasticConfig:
    durationMin: int = 1200
    stepMin: int = 5
    initialAdults: int = 2
    initialBabies: int = 0
    initialCarrots: int = 0
    carrotSupply: Optional[CarrotSupply] = None
    penCap: Optional[int] = None
    breedProbabilityPerPair: float = 0.55

    def __post_init__(self) -> None:
        validateStochasticConfig(self)


def validateMeanFieldConfig(config: MeanFieldConfig) -> None:
    if config.durationMin < 0:
        raise ValueError("durationMin must be nonnegative.")
    if config.dtMin <= 0:
        raise ValueError("dtMin must be positive.")
    if config.initialAdults < 0 or config.initialBabies < 0:
        raise ValueError("initial pig masses must be nonnegative.")
    if config.penCap is not None and config.penCap < 0:
        raise ValueError("penCap must be nonnegative.")
    if config.penCap is not None and config.initialAdults + config.initialBabies > config.penCap:
        raise ValueError("initial population must not exceed penCap.")
    if not 0.0 <= config.initialBabyAgeMin <= MATURATION_MIN:
        raise ValueError("initialBabyAgeMin must lie in [0, MATURATION_MIN].")


def validateSharedDiscreteFields(
    durationMin: int, stepMin: int, initialAdults: int, initialBabies: int,
    initialCarrots: int, penCap: Optional[int],
) -> None:
    if durationMin < 0:
        raise ValueError("durationMin must be nonnegative.")
    if stepMin <= 0:
        raise ValueError("stepMin must be positive.")
    if COOLDOWN_MIN % stepMin != 0:
        raise ValueError("stepMin must divide COOLDOWN_MIN.")
    if MATURATION_MIN % stepMin != 0:
        raise ValueError("stepMin must divide MATURATION_MIN.")
    if initialAdults < 0 or initialBabies < 0:
        raise ValueError("initial pig counts must be nonnegative.")
    if initialCarrots < 0:
        raise ValueError("initialCarrots must be nonnegative.")
    if penCap is not None and penCap < 0:
        raise ValueError("penCap must be nonnegative.")
    if penCap is not None and initialAdults + initialBabies > penCap:
        raise ValueError("initial population must not exceed penCap.")


def validateDiscreteConfig(config: DiscreteConfig) -> None:
    validateSharedDiscreteFields(
        config.durationMin, config.stepMin, config.initialAdults,
        config.initialBabies, config.initialCarrots, config.penCap,
    )


def validateStochasticConfig(config: StochasticConfig) -> None:
    validateSharedDiscreteFields(
        config.durationMin, config.stepMin, config.initialAdults,
        config.initialBabies, config.initialCarrots, config.penCap,
    )
    if not 0.0 <= config.breedProbabilityPerPair <= 1.0:
        raise ValueError("breedProbabilityPerPair must lie in [0, 1].")


# ---- Helpers ----

def exactStepCount(duration: float, dt: float, name: str) -> int:
    if duration < 0:
        raise ValueError(f"{name} must be nonnegative.")
    steps = int(round(duration / dt))
    if not math.isclose(steps * dt, duration, rel_tol=0.0, abs_tol=TIME_TOL):
        raise ValueError(
            f"{name}={duration!r} must be an integer multiple of dt={dt!r}."
        )
    return steps


def solveSteadyStateRate() -> float:
    lo = 0.0
    hi = 0.1
    for _ in range(100):
        mid = 0.5 * (lo + hi)
        if 10.0 * mid - math.exp(-20.0 * mid) < 0.0:
            lo = mid
        else:
            hi = mid
    return 0.5 * (lo + hi)


def makeSnapshot(
    timeMin: float, adultPigs: float, babyPigs: float, carrots: float,
    breedingEvents: float, cumulativeBreedingEvents: float,
) -> Snapshot:
    return Snapshot(
        timeMin=timeMin,
        totalPigs=adultPigs + babyPigs,
        adultPigs=adultPigs,
        babyPigs=babyPigs,
        carrots=carrots,
        breedingEvents=breedingEvents,
        cumulativeBreedingEvents=cumulativeBreedingEvents,
    )


# ---- Mean-field simulator ----

class MeanFieldSimulator:
    def __init__(self, config: MeanFieldConfig) -> None:
        self.config = config
        self.nSteps = exactStepCount(config.durationMin, config.dtMin, "durationMin")
        self.delaySteps = exactStepCount(MATURATION_MIN, config.dtMin, "MATURATION_MIN")
        self.maturationDue = np.zeros(self.nSteps + self.delaySteps + 1)
        self.adultMass = float(config.initialAdults)
        self.babyMass = float(config.initialBabies)
        self.cumulativeEvents = 0.0
        if self.babyMass > 0.0:
            self.scheduleInitialBabies()

    def scheduleInitialBabies(self) -> None:
        remaining = MATURATION_MIN - self.config.initialBabyAgeMin
        initialDelay = exactStepCount(
            remaining, self.config.dtMin, "initialBabyAgeMin remaining"
        )
        if initialDelay == 0:
            self.adultMass += self.babyMass
            self.babyMass = 0.0
            return
        self.maturationDue[initialDelay] += self.babyMass

    def run(self) -> Series:
        snapshots = [self.createSnapshot(0.0, 0.0)]
        for step in range(self.nSteps):
            births = self.runStep(step)
            timeMin = (step + 1) * self.config.dtMin
            snapshots.append(self.createSnapshot(timeMin, births))
        return Series("mean-field", tuple(snapshots))

    def runStep(self, step: int) -> float:
        self.applyMaturation(step)
        births = self.computeBirths(step)
        self.babyMass += births
        self.cumulativeEvents += births
        self.maturationDue[step + self.delaySteps] += births
        return births

    def applyMaturation(self, step: int) -> None:
        matured = min(self.maturationDue[step], self.babyMass)
        self.adultMass += matured
        self.babyMass -= matured

    def computeBirths(self, step: int) -> float:
        rate = self.adultMass / (2.0 * COOLDOWN_MIN)
        if self.config.carrotSupply is not None:
            rate = min(rate, self.computeCarrotRate(step))
        births = rate * self.config.dtMin
        if self.config.penCap is not None:
            remaining = max(0.0, self.config.penCap - self.adultMass - self.babyMass)
            births = min(births, remaining)
        return births

    def computeCarrotRate(self, step: int) -> float:
        supply = self.config.carrotSupply
        if supply is None:
            raise RuntimeError("computeCarrotRate called without carrotSupply.")
        timeMin = step * self.config.dtMin
        ctx = SupplyContext(timeMin=timeMin, stepMin=self.config.dtMin, rng=None)
        return supply(ctx) / (CARROTS_PER_EVENT * self.config.dtMin)

    def createSnapshot(self, timeMin: float, births: float) -> Snapshot:
        return makeSnapshot(
            timeMin=timeMin,
            adultPigs=self.adultMass,
            babyPigs=self.babyMass,
            carrots=math.inf,
            breedingEvents=births,
            cumulativeBreedingEvents=self.cumulativeEvents,
        )


# ---- Discrete simulator (deterministic) ----

class DiscretePigSimulator:
    def __init__(self, config: DiscreteConfig) -> None:
        self.config = config
        self.cooldownSteps = COOLDOWN_MIN // config.stepMin
        self.maturationSteps = MATURATION_MIN // config.stepMin
        self.nSteps = config.durationMin // config.stepMin
        self.availableAdults = int(config.initialAdults)
        self.babyPigs = int(config.initialBabies)
        self.cooldownPigs = 0
        self.totalPigs = int(config.initialAdults) + int(config.initialBabies)
        self.carrots = int(config.initialCarrots)
        self.cumulativeEvents = 0
        self.cooldownDue = np.zeros(self.nSteps + self.cooldownSteps + 1, dtype=np.int64)
        self.maturationDue = np.zeros(
            self.nSteps + self.maturationSteps + 1, dtype=np.int64
        )
        if self.babyPigs > 0:
            self.maturationDue[self.maturationSteps] += self.babyPigs

    def run(self) -> Series:
        snapshots = [self.createSnapshot(0, 0)]
        for step in range(self.nSteps):
            events = self.runStep(step)
            snapshots.append(self.createSnapshot(step + 1, events))
        return Series(self.getSeriesName(), tuple(snapshots))

    def getSeriesName(self) -> str:
        return "deterministic-discrete"

    def runStep(self, step: int) -> int:
        self.applyScheduledTransitions(step)
        self.addCarrots(step)
        requested = self.getRequestedPairs()
        events = self.getEventLimit(requested)
        self.applyBreeding(step, events)
        return events

    def applyScheduledTransitions(self, step: int) -> None:
        cooledDown = int(self.cooldownDue[step])
        matured = int(self.maturationDue[step])
        self.availableAdults += cooledDown + matured
        self.cooldownPigs -= cooledDown
        self.babyPigs -= matured
        self.cooldownDue[step] = 0
        self.maturationDue[step] = 0

    def addCarrots(self, step: int) -> None:
        if self.config.carrotSupply is None:
            return
        timeMin = step * self.config.stepMin
        ctx = SupplyContext(timeMin=timeMin, stepMin=self.config.stepMin, rng=None)
        self.carrots += int(self.config.carrotSupply(ctx))

    def getRequestedPairs(self) -> int:
        return self.availableAdults // 2

    def getEventLimit(self, requestedPairs: int) -> int:
        capacityLimit = self.getCapacityLimit()
        if self.config.carrotSupply is None:
            # no carrot constraint: only adult availability and pen cap bind
            return min(requestedPairs, capacityLimit)
        carrotLimit = self.carrots // CARROTS_PER_EVENT
        return min(requestedPairs, carrotLimit, capacityLimit)

    def getCapacityLimit(self) -> int:
        if self.config.penCap is None:
            return 10**18
        return max(0, self.config.penCap - self.totalPigs)

    def applyBreeding(self, step: int, events: int) -> None:
        if events <= 0:
            return
        self.availableAdults -= events * 2
        self.cooldownPigs += events * 2
        self.babyPigs += events
        self.totalPigs += events
        if self.config.carrotSupply is not None:
            self.carrots -= events * CARROTS_PER_EVENT
        self.cumulativeEvents += events
        self.cooldownDue[step + self.cooldownSteps] += events * 2
        self.maturationDue[step + self.maturationSteps] += events

    def createSnapshot(self, step: int, events: int) -> Snapshot:
        timeMin = step * self.config.stepMin
        carrots = math.inf if self.config.carrotSupply is None else float(self.carrots)
        return makeSnapshot(
            timeMin=float(timeMin),
            adultPigs=float(self.availableAdults + self.cooldownPigs),
            babyPigs=float(self.babyPigs),
            carrots=carrots,
            breedingEvents=float(events),
            cumulativeBreedingEvents=float(self.cumulativeEvents),
        )


# ---- Stochastic simulator ----

class StochasticPigSimulator(DiscretePigSimulator):
    def __init__(self, config: StochasticConfig, seed: int) -> None:
        super().__init__(makeBaseDiscreteConfig(config))
        self.stochasticConfig = config
        self.rng = np.random.default_rng(seed)
        self.seed = seed

    def getSeriesName(self) -> str:
        return f"stochastic-discrete-seed-{self.seed}"

    def addCarrots(self, step: int) -> None:
        if self.stochasticConfig.carrotSupply is None:
            return
        timeMin = step * self.config.stepMin
        ctx = SupplyContext(
            timeMin=timeMin, stepMin=self.config.stepMin, rng=self.rng,
        )
        self.carrots += int(self.stochasticConfig.carrotSupply(ctx))

    def getRequestedPairs(self) -> int:
        pairCapacity = self.availableAdults // 2
        if pairCapacity <= 0:
            return 0
        return int(
            self.rng.binomial(pairCapacity, self.stochasticConfig.breedProbabilityPerPair)
        )


def makeBaseDiscreteConfig(config: StochasticConfig) -> DiscreteConfig:
    # passthrough carrotSupply: parent's getEventLimit needs to know whether
    # carrots constrain breeding. addCarrots is overridden in the stochastic
    # subclass so the supply is never actually invoked through the parent path.
    return DiscreteConfig(
        durationMin=config.durationMin,
        stepMin=config.stepMin,
        initialAdults=config.initialAdults,
        initialBabies=config.initialBabies,
        initialCarrots=config.initialCarrots,
        carrotSupply=config.carrotSupply,
        penCap=config.penCap,
    )


# ---- Public entrypoints ----

def simulateMeanField(config: MeanFieldConfig) -> Series:
    return MeanFieldSimulator(config).run()


def simulateDeterministicDiscrete(config: DiscreteConfig) -> Series:
    return DiscretePigSimulator(config).run()


def simulateStochasticDiscrete(config: StochasticConfig, seed: int) -> Series:
    return StochasticPigSimulator(config, seed).run()


# ---- Benford analysis ----

def getSeriesValues(
    series: Series, target: TargetName, warmupMin: float,
) -> np.ndarray:
    values = [
        getattr(snapshot, target)
        for snapshot in series.snapshots
        if snapshot.timeMin >= warmupMin
    ]
    return np.array(values, dtype=float)


def leadingDigit(value: float) -> int:
    if value <= 0 or not math.isfinite(value):
        return 0
    exponent = math.floor(math.log10(value))
    digit = int(value / (10.0 ** exponent))
    return max(1, min(9, digit))


def histogramFromValues(values: np.ndarray) -> np.ndarray:
    if len(values) == 0:
        return np.zeros(9)
    digits = np.array([leadingDigit(float(v)) for v in values])
    valid = digits[(digits >= 1) & (digits <= 9)]
    if len(valid) == 0:
        return np.zeros(9)
    return np.array([(valid == d).mean() for d in range(1, 10)])


def digitStats(
    series: Series, target: TargetName, warmupMin: float,
) -> DigitStats:
    values = getSeriesValues(series, target, warmupMin)
    histogram = histogramFromValues(values)
    if histogram.sum() == 0:
        return DigitStats(histogram, float("nan"), 0, float("nan"), 0)
    deviations = histogram - BENFORD
    maxIdx = int(np.argmax(np.abs(deviations)))
    return DigitStats(
        histogram=histogram,
        l1Distance=float(np.sum(np.abs(deviations))),
        maxDeviationDigit=maxIdx + 1,
        maxDeviation=float(deviations[maxIdx]),
        sampleCount=len(values),
    )


def ensembleStats(
    seriesList: Iterable[Series], target: TargetName, warmupMin: float,
) -> EnsembleStats:
    statsList = [digitStats(series, target, warmupMin) for series in seriesList]
    histograms = np.array([s.histogram for s in statsList])
    l1Values = np.array([s.l1Distance for s in statsList])
    return EnsembleStats(
        meanHistogram=histograms.mean(axis=0),
        stdHistogram=histograms.std(axis=0),
        meanL1Distance=float(l1Values.mean()),
        stdL1Distance=float(l1Values.std()),
        runCount=len(statsList),
    )


def l1ToBenford(histogram: np.ndarray) -> float:
    return float(np.sum(np.abs(histogram - BENFORD)))


def empiricalDoublingTime(
    series: Series, target: TargetName, warmupMin: float,
) -> float:
    times = np.array([s.timeMin for s in series.snapshots], dtype=float)
    values = np.array([getattr(s, target) for s in series.snapshots], dtype=float)
    mask = (times >= warmupMin) & (values > 0)
    if mask.sum() < 2:
        return float("inf")
    slope, _ = np.polyfit(times[mask], np.log(values[mask]), 1)
    if slope <= SLOPE_TOL:
        return float("inf")
    return float(math.log(2.0) / slope)


def countDecades(series: Series, target: TargetName) -> float:
    values = np.array([getattr(s, target) for s in series.snapshots], dtype=float)
    positive = values[values > 0]
    if len(positive) == 0:
        return 0.0
    return float(math.log10(positive.max() / positive.min()))


# ---- CSV writers ----

def writeSeriesCsv(path: Path, series: Series) -> None:
    with path.open("w", newline="", encoding="utf-8") as file:
        writer = csv.writer(file)
        writer.writerow([
            "timeMin", "totalPigs", "adultPigs", "babyPigs",
            "carrots", "breedingEvents", "cumulativeBreedingEvents",
        ])
        for snapshot in series.snapshots:
            writer.writerow(snapshotToRow(snapshot))


def snapshotToRow(snapshot: Snapshot) -> list[float]:
    return [
        snapshot.timeMin, snapshot.totalPigs, snapshot.adultPigs,
        snapshot.babyPigs, snapshot.carrots, snapshot.breedingEvents,
        snapshot.cumulativeBreedingEvents,
    ]


def writeHistogramSummary(path: Path, rows: list[list[str]]) -> None:
    with path.open("w", newline="", encoding="utf-8") as file:
        writer = csv.writer(file)
        writer.writerow([
            "regime", "digit", "benford", "observedMean", "observedStd",
        ])
        writer.writerows(rows)


def buildHistogramRows(
    regime: str, histogram: np.ndarray, std: Optional[np.ndarray],
) -> list[list[str]]:
    result = []
    for index, digit in enumerate(range(1, 10)):
        stdValue = 0.0 if std is None else float(std[index])
        result.append([
            regime,
            str(digit),
            f"{BENFORD[index]:.8f}",
            f"{float(histogram[index]):.8f}",
            f"{stdValue:.8f}",
        ])
    return result


# ---- Reporting ----

def printHeader(target: TargetName, warmupMin: float) -> None:
    rate = solveSteadyStateRate()
    print(f"Theoretical idealized growth rate: {rate:.5f} / min")
    print(f"Theoretical idealized doubling:    {math.log(2.0) / rate:.3f} min")
    print(f"Target series:                     {target}")
    print(f"Warmup:                            {warmupMin} min")
    print()
    print("REGIME COMPARISON")
    print(
        "Regime                       | Final target | Decades |   LogT2 | "
        "   L1-B |   MaxDev"
    )
    print("-" * 89)


def printRegimeSummary(
    series: Series, target: TargetName, warmupMin: float,
    isExponential: bool = False,
) -> None:
    stats = digitStats(series, target, warmupMin)
    finalValue = getattr(series.snapshots[-1], target)
    decades = countDecades(series, target)
    doublingText = formatDoublingTime(series, target, warmupMin, isExponential)
    maxDevText = f"d{stats.maxDeviationDigit}{stats.maxDeviation:+.3f}"
    print(
        f"{series.name:<28} | {finalValue:>12.3g} | {decades:>7.2f} | "
        f"{doublingText:>8} | {stats.l1Distance:>8.4f} | {maxDevText:>10}"
    )


def formatDoublingTime(
    series: Series, target: TargetName, warmupMin: float,
    isExponential: bool,
) -> str:
    if not isExponential:
        return "—"
    doubling = empiricalDoublingTime(series, target, warmupMin)
    if math.isfinite(doubling):
        return f"{doubling:.2f}"
    return "—"


def printEnsembleSummary(name: str, stats: EnsembleStats) -> None:
    deviations = stats.meanHistogram - BENFORD
    maxIdx = int(np.argmax(np.abs(deviations)))
    maxDev = float(deviations[maxIdx])
    maxDevText = f"d{maxIdx + 1}{maxDev:+.3f}"
    print(
        f"{name:<28} | {'ensemble':>12} | {'—':>7} | {'—':>8} | "
        f"{stats.meanL1Distance:>8.4f} | {maxDevText:>10}"
    )


def printHistogramTable(
    meanField: Series, deterministic: Series,
    stochasticStats: EnsembleStats, target: TargetName, warmupMin: float,
) -> None:
    meanFieldHist = digitStats(meanField, target, warmupMin).histogram
    deterministicHist = digitStats(deterministic, target, warmupMin).histogram
    print()
    print("LEADING-DIGIT HISTOGRAMS")
    print("D | Benford | MeanField | DetDiscrete | StochMean | StochStd")
    print("-" * 69)
    for index, digit in enumerate(range(1, 10)):
        print(
            f"{digit} | {BENFORD[index]:>7.4f} | {meanFieldHist[index]:>9.4f} | "
            f"{deterministicHist[index]:>11.4f} | "
            f"{stochasticStats.meanHistogram[index]:>9.4f} | "
            f"{stochasticStats.stdHistogram[index]:>8.4f}"
        )


# ---- CLI orchestration ----

def buildCarrotSupply(
    args: argparse.Namespace, allowStochastic: bool,
) -> Optional[CarrotSupply]:
    builders: dict[str, Callable[[], Optional[CarrotSupply]]] = {
        "none": lambda: None,
        "steady": lambda: constantSupply(args.carrot_rate),
        "burst": lambda: burstSupply(args.burst_interval, args.burst_amount),
        "stochastic": lambda: buildStochasticOrFallback(args, allowStochastic),
    }
    if args.carrot_mode not in builders:
        raise ValueError(f"unknown carrot_mode: {args.carrot_mode}")
    return builders[args.carrot_mode]()


def buildStochasticOrFallback(
    args: argparse.Namespace, allowStochastic: bool,
) -> CarrotSupply:
    if allowStochastic:
        return poissonGammaSupply(
            args.visits_per_hour, args.carrot_batch_mean, args.carrot_batch_shape,
        )
    return constantSupply(args.carrot_rate)


def makeMeanFieldConfig(args: argparse.Namespace) -> MeanFieldConfig:
    return MeanFieldConfig(
        durationMin=float(args.duration),
        dtMin=args.dt,
        initialAdults=float(args.initial_adults),
        initialBabies=float(args.initial_babies),
        carrotSupply=buildCarrotSupply(args, allowStochastic=False),
        penCap=None if args.pen_cap is None else float(args.pen_cap),
    )


def makeDiscreteConfig(args: argparse.Namespace) -> DiscreteConfig:
    return DiscreteConfig(
        durationMin=args.duration,
        stepMin=args.step,
        initialAdults=args.initial_adults,
        initialBabies=args.initial_babies,
        initialCarrots=args.initial_carrots,
        carrotSupply=buildCarrotSupply(args, allowStochastic=False),
        penCap=args.pen_cap,
    )


def makeStochasticConfig(args: argparse.Namespace) -> StochasticConfig:
    return StochasticConfig(
        durationMin=args.duration,
        stepMin=args.step,
        initialAdults=args.initial_adults,
        initialBabies=args.initial_babies,
        initialCarrots=args.initial_carrots,
        carrotSupply=buildCarrotSupply(args, allowStochastic=True),
        penCap=args.pen_cap,
        breedProbabilityPerPair=args.breed_probability,
    )


def runComparison(args: argparse.Namespace) -> None:
    target: TargetName = args.target
    meanField = simulateMeanField(makeMeanFieldConfig(args))
    deterministic = simulateDeterministicDiscrete(makeDiscreteConfig(args))
    stochasticRuns = [
        simulateStochasticDiscrete(makeStochasticConfig(args), seed)
        for seed in range(args.seeds)
    ]
    stochasticSummary = ensembleStats(stochasticRuns, target, args.warmup)
    isExponential = (args.carrot_mode == "none" and args.pen_cap is None)

    printHeader(target, args.warmup)
    printRegimeSummary(meanField, target, args.warmup, isExponential=isExponential)
    printRegimeSummary(deterministic, target, args.warmup, isExponential=False)
    printEnsembleSummary("stochastic-discrete", stochasticSummary)
    printHistogramTable(
        meanField, deterministic, stochasticSummary, target, args.warmup,
    )

    if args.out is not None:
        writeOutputs(
            Path(args.out), meanField, deterministic,
            stochasticRuns, stochasticSummary, target, args.warmup,
        )


def writeOutputs(
    outDir: Path, meanField: Series, deterministic: Series,
    stochasticRuns: list[Series], stochasticSummary: EnsembleStats,
    target: TargetName, warmupMin: float,
) -> None:
    outDir.mkdir(parents=True, exist_ok=True)
    writeSeriesCsv(outDir / "mean_field.csv", meanField)
    writeSeriesCsv(outDir / "deterministic_discrete.csv", deterministic)
    for index, series in enumerate(stochasticRuns):
        writeSeriesCsv(
            outDir / f"stochastic_discrete_seed_{index:04d}.csv", series,
        )
    rows = buildAllHistogramRows(
        meanField, deterministic, stochasticSummary, target, warmupMin,
    )
    writeHistogramSummary(outDir / "histogram_summary.csv", rows)


def buildAllHistogramRows(
    meanField: Series, deterministic: Series,
    stochasticSummary: EnsembleStats, target: TargetName, warmupMin: float,
) -> list[list[str]]:
    rows = []
    rows += buildHistogramRows(
        "mean-field", digitStats(meanField, target, warmupMin).histogram, None,
    )
    rows += buildHistogramRows(
        "deterministic-discrete",
        digitStats(deterministic, target, warmupMin).histogram, None,
    )
    rows += buildHistogramRows(
        "stochastic-discrete",
        stochasticSummary.meanHistogram, stochasticSummary.stdHistogram,
    )
    return rows


# ---- Self tests (real invariants, not smoke checks) ----

def runSelfTests() -> None:
    testLeadingDigit()
    testConfigValidation()
    testPenCapValidation()
    testEventAccountingInvariant()
    testNonNegativeCarrotsInvariant()
    testTotalPigsInvariant()
    testPenCapInvariant()
    testUnlimitedCarrotsAllowGrowth()
    testStochasticUnlimitedMatchesDeterministic()
    testInitialBabyAtMaturationAge()
    testStochasticVsDeterministicAgreement()
    print("self-tests passed.")


def testLeadingDigit() -> None:
    assert leadingDigit(1) == 1
    assert leadingDigit(9.99) == 9
    assert leadingDigit(10) == 1
    assert leadingDigit(0) == 0
    assert leadingDigit(-1) == 0
    assert leadingDigit(float("inf")) == 0
    assert leadingDigit(float("nan")) == 0


def expectValueError(fn: Callable[[], object]) -> None:
    try:
        fn()
    except ValueError:
        return
    raise AssertionError("expected ValueError but none raised")


def testConfigValidation() -> None:
    expectValueError(lambda: DiscreteConfig(durationMin=-1))
    expectValueError(lambda: DiscreteConfig(stepMin=3))
    expectValueError(lambda: StochasticConfig(breedProbabilityPerPair=1.5))
    expectValueError(lambda: StochasticConfig(breedProbabilityPerPair=-0.1))
    expectValueError(lambda: MeanFieldConfig(initialBabyAgeMin=-1.0))


def testPenCapValidation() -> None:
    expectValueError(
        lambda: DiscreteConfig(initialAdults=10, initialBabies=1, penCap=5),
    )
    expectValueError(
        lambda: StochasticConfig(initialAdults=10, initialBabies=1, penCap=5),
    )
    expectValueError(
        lambda: MeanFieldConfig(initialAdults=10.0, initialBabies=1.0, penCap=5.0),
    )


def testInitialBabyAtMaturationAge() -> None:
    config = MeanFieldConfig(
        durationMin=10, dtMin=0.5,
        initialAdults=0, initialBabies=5,
        initialBabyAgeMin=MATURATION_MIN,
    )
    series = simulateMeanField(config)
    first = series.snapshots[0]
    assert first.adultPigs == 5, (
        f"baby at full maturation age should start as adult, got {first.adultPigs}"
    )
    assert first.babyPigs == 0, (
        f"baby at full maturation age should not appear as baby, got {first.babyPigs}"
    )


def testEventAccountingInvariant() -> None:
    series = simulateDeterministicDiscrete(
        DiscreteConfig(durationMin=200, carrotSupply=constantSupply(20)),
    )
    eventsTotal = sum(s.breedingEvents for s in series.snapshots)
    cumulativeFinal = series.snapshots[-1].cumulativeBreedingEvents
    assert math.isclose(eventsTotal, cumulativeFinal), (
        f"event accounting broken: sum={eventsTotal}, cum={cumulativeFinal}"
    )


def testNonNegativeCarrotsInvariant() -> None:
    constrained = simulateDeterministicDiscrete(
        DiscreteConfig(durationMin=200, carrotSupply=constantSupply(5)),
    )
    for s in constrained.snapshots:
        assert s.carrots >= 0, f"negative carrots at t={s.timeMin}: {s.carrots}"
    unlimited = simulateDeterministicDiscrete(
        DiscreteConfig(durationMin=60, carrotSupply=None),
    )
    for s in unlimited.snapshots:
        assert s.carrots >= 0, (
            f"unlimited regime carrots went negative at t={s.timeMin}: {s.carrots}"
        )


def testTotalPigsInvariant() -> None:
    series = simulateDeterministicDiscrete(
        DiscreteConfig(durationMin=100, carrotSupply=constantSupply(10)),
    )
    for s in series.snapshots:
        assert math.isclose(s.adultPigs + s.babyPigs, s.totalPigs), (
            f"adult+baby != total at t={s.timeMin}"
        )


def testPenCapInvariant() -> None:
    cap = 50
    series = simulateDeterministicDiscrete(
        DiscreteConfig(
            durationMin=300, carrotSupply=constantSupply(1000), penCap=cap,
        ),
    )
    for s in series.snapshots:
        assert s.totalPigs <= cap + TIME_TOL, (
            f"penCap exceeded at t={s.timeMin}: total={s.totalPigs}, cap={cap}"
        )


def testUnlimitedCarrotsAllowGrowth() -> None:
    # carrotSupply=None and initialCarrots=0 must mean "unlimited carrots",
    # not "zero carrots forever". The simulator must grow and the carrots
    # field must report inf rather than draining negative.
    series = simulateDeterministicDiscrete(
        DiscreteConfig(durationMin=60, initialCarrots=0, carrotSupply=None),
    )
    final = series.snapshots[-1].totalPigs
    assert final > 2, (
        f"deterministic-discrete with no carrot constraint must grow; "
        f"got final={final}"
    )
    for s in series.snapshots:
        assert s.carrots == math.inf, (
            f"unlimited regime should report inf carrots at t={s.timeMin}, "
            f"got {s.carrots}"
        )


def testStochasticUnlimitedMatchesDeterministic() -> None:
    shared = dict(durationMin=60, initialCarrots=0, carrotSupply=None)
    det = simulateDeterministicDiscrete(DiscreteConfig(**shared))
    sto = simulateStochasticDiscrete(
        StochasticConfig(**shared, breedProbabilityPerPair=1.0), seed=0,
    )
    detFinal = det.snapshots[-1].totalPigs
    stoFinal = sto.snapshots[-1].totalPigs
    assert stoFinal == detFinal, (
        f"stochastic with p=1 and unlimited carrots must match deterministic; "
        f"det={detFinal}, sto={stoFinal}"
    )


def testStochasticVsDeterministicAgreement() -> None:
    sharedConfig = dict(
        durationMin=60, stepMin=5, initialAdults=2, initialCarrots=10_000,
        carrotSupply=constantSupply(100),
    )
    det = simulateDeterministicDiscrete(DiscreteConfig(**sharedConfig))
    sto = simulateStochasticDiscrete(
        StochasticConfig(**sharedConfig, breedProbabilityPerPair=1.0), seed=0,
    )
    detFinal = det.snapshots[-1].totalPigs
    stoFinal = sto.snapshots[-1].totalPigs
    assert detFinal == stoFinal, (
        f"with p=1, stochastic should match deterministic: "
        f"det={detFinal}, sto={stoFinal}"
    )


# ---- CLI ----

def addBasicArgs(parser: argparse.ArgumentParser) -> None:
    parser.add_argument("--duration", type=int, default=1200)
    parser.add_argument("--step", type=int, default=5)
    parser.add_argument("--dt", type=float, default=0.5)
    parser.add_argument("--warmup", type=float, default=100.0)
    parser.add_argument("--seeds", type=int, default=50)
    parser.add_argument("--initial-adults", type=int, default=2)
    parser.add_argument("--initial-babies", type=int, default=0)
    parser.add_argument(
        "--initial-carrots", type=int, default=0,
        help="initial carrot stock; only meaningful when carrot-mode != none",
    )
    parser.add_argument("--breed-probability", type=float, default=0.55)


def addCarrotArgs(parser: argparse.ArgumentParser) -> None:
    parser.add_argument(
        "--carrot-mode",
        choices=["none", "steady", "burst", "stochastic"], default="none",
    )
    parser.add_argument("--carrot-rate", type=float, default=100.0)
    parser.add_argument("--burst-interval", type=float, default=60.0)
    parser.add_argument("--burst-amount", type=float, default=200.0)
    parser.add_argument("--visits-per-hour", type=float, default=2.0)
    parser.add_argument("--carrot-batch-mean", type=float, default=128.0)
    parser.add_argument("--carrot-batch-shape", type=float, default=2.0)


def parseArgs() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Compare mean-field, deterministic discrete, and stochastic discrete pig-breeding regimes.",
    )
    addBasicArgs(parser)
    addCarrotArgs(parser)
    parser.add_argument(
        "--pen-cap", type=int, default=None,
        help="population cap; default None means no cap",
    )
    parser.add_argument(
        "--target",
        choices=["totalPigs", "adultPigs", "babyPigs", "cumulativeBreedingEvents"],
        default="totalPigs",
    )
    parser.add_argument("--out", type=str, default=None)
    parser.add_argument("--self-test", action="store_true")
    return parser.parse_args()


def main() -> None:
    args = parseArgs()
    if args.self_test:
        runSelfTests()
        return
    if args.seeds <= 0:
        raise ValueError("seeds must be positive.")
    runComparison(args)


if __name__ == "__main__":
    main()
Theoretical idealized growth rate: 0.04263 / min
Theoretical idealized doubling:    16.260 min
Target series:                     totalPigs
Warmup:                            100.0 min

REGIME COMPARISON
Regime                       | Final target | Decades |   LogT2 |    L1-B |   MaxDev
-----------------------------------------------------------------------------------------
mean-field                   |      5.4e+04 |    4.43 |        — |   0.4751 |   d4+0.108
deterministic-discrete       |        6e+04 |    4.48 |        — |   0.5752 |   d5+0.106
stochastic-discrete          |     ensemble |       — |        — |   0.6459 |   d1-0.149

LEADING-DIGIT HISTOGRAMS
D | Benford | MeanField | DetDiscrete | StochMean | StochStd
---------------------------------------------------------------------
1 |  0.3010 |    0.2022 |      0.1991 |    0.1519 |   0.0106
2 |  0.1761 |    0.2085 |      0.1946 |    0.2060 |   0.0048
3 |  0.1249 |    0.2063 |      0.1946 |    0.2003 |   0.0049
4 |  0.0969 |    0.2045 |      0.1900 |    0.1973 |   0.0035
5 |  0.0792 |    0.0954 |      0.1855 |    0.1966 |   0.0040
6 |  0.0669 |    0.0214 |      0.0090 |    0.0170 |   0.0026
7 |  0.0580 |    0.0209 |      0.0090 |    0.0119 |   0.0028
8 |  0.0512 |    0.0209 |      0.0000 |    0.0104 |   0.0033
9 |  0.0458 |    0.0200 |      0.0181 |    0.0087 |   0.0028
Theoretical idealized growth rate: 0.04263 / min
Theoretical idealized doubling:    16.260 min
Target series:                     totalPigs
Warmup:                            100.0 min

REGIME COMPARISON
Regime                       | Final target | Decades |   LogT2 |    L1-B |   MaxDev
-----------------------------------------------------------------------------------------
mean-field                   |     5.59e+22 |   22.45 |    16.17 |   0.0226 |   d1-0.006
deterministic-discrete       |     5.79e+19 |   19.46 |        — |   0.1317 |   d4+0.025
stochastic-discrete          |     ensemble |       — |        — |   0.0343 |   d1-0.003

LEADING-DIGIT HISTOGRAMS
D | Benford | MeanField | DetDiscrete | StochMean | StochStd
---------------------------------------------------------------------
1 |  0.3010 |    0.2953 |      0.2760 |    0.2978 |   0.0053
2 |  0.1761 |    0.1808 |      0.1765 |    0.1757 |   0.0041
3 |  0.1249 |    0.1281 |      0.1448 |    0.1259 |   0.0052
4 |  0.0969 |    0.0990 |      0.1222 |    0.0975 |   0.0041
5 |  0.0792 |    0.0804 |      0.0995 |    0.0814 |   0.0047
6 |  0.0669 |    0.0654 |      0.0543 |    0.0671 |   0.0040
7 |  0.0580 |    0.0563 |      0.0452 |    0.0584 |   0.0039
8 |  0.0512 |    0.0495 |      0.0452 |    0.0513 |   0.0049
9 |  0.0458 |    0.0450 |      0.0362 |    0.0450 |   0.0048

@Protonk
Copy link
Copy Markdown
Author

Protonk commented May 6, 2026

I integrated this version in. yours now powers the pinned version and I've narrowed mine down to just a naive idealized version.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment