Last active
May 6, 2026 04:20
-
-
Save Protonk/0887a8de014b047f5eebb445d0b1c101 to your computer and use it in GitHub Desktop.
benford pig breeding sim
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| """ | |
| 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() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| """ | |
| 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() |
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
Author
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
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:
The goal is not to perfectly simulate Minecraft internals. The goal is to distinguish:
This makes the lesson clearer: