Metadata-Version: 2.4
Name: samesim
Version: 0.2.1
Summary: Same seed, same simulation: a deterministic discrete-event simulator for message-passing agents
Author: Agnivesh Kumar
License: MIT
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Scientific/Engineering
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: pyyaml>=6.0
Provides-Extra: dev
Requires-Dist: pytest>=7.4; extra == "dev"
Requires-Dist: pytest-cov>=4.1; extra == "dev"
Dynamic: license-file

# SameSim

**Same seed, same simulation.** A deterministic discrete-event simulator for message-passing agents on dynamic networks: gossip, consensus, epidemics, queues, opinion dynamics. The same seed gives the same run, bit for bit, on Linux, macOS and Windows and on Python 3.10–3.13.

The core engine is domain-agnostic and manages only agents, virtual time, event scheduling, communication routing, and metric dispatch. All domain-specific details (agent behaviors, network topologies, routing protocols, and metric collection) are implemented as pluggable adapters.

*SameSim was called Simul8 until October 2026. It is unrelated to the commercial SIMUL8 product.*

## Features

- **Clean Hexagonal Architecture**: Strictly separated domain, ports, core engine, and application layers. Plugins cannot import the core; a test enforces it.
- **Deterministic and Reproducible**: Every agent gets its own RNG stream, hashed from the seed and its id, and events are ordered by `(virtual_time, priority, event_id)`, so the same seed produces the same run, bit for bit, on Python 3.10 through 3.13 and across Linux, macOS and Windows. CI checks this on every push. `TraceDigestMetric` gives each run a SHA-256 fingerprint you can publish with a result as a reproducibility receipt.
- **Pluggable Architecture**: Swap behaviors, communication protocols, network topologies, metrics, and exporters from YAML. No core changes needed.
- **Lean**: Pure Python with a single runtime dependency (PyYAML). Built with a future Rust port in mind. See [By the numbers](#by-the-numbers) for what that costs in speed.

## Documentation

| Document | For |
|---|---|
| [User Guide](docs/user_guide.md) | Running experiments: the config file, activation modes, latency, churn, every plugin's options, output files |
| [Developer Guide](docs/developer_guide.md) | Writing plugins: architecture, the event loop, determinism rules, the ports, contract tests |
| [Design](docs/design.md) | Why it's built this way: principles, the time model, lessons learned, validation status, roadmap |

Each study under [`experiments/`](experiments/) has its own README next to its scripts and raw data.

## Directory Structure

```
samesim/
├── domain/        # Data: Agent, Message, Event, State, Topology, Delivery, Timer, TopologyChange, portable_math
├── ports/         # Contracts: Behavior, Communication, TopologyGenerator, MetricCollector, Persistence, TopologyDynamics
├── core/          # Engine: SimulationEngine, Scheduler, EventQueue, AgentRegistry, TopologyManager, ...
├── app/           # Orchestration: ConfigLoader, PluginLoader, ExperimentRunner
├── plugins/       # behaviors/ communication/ topologies/ metrics/ persistence/ dynamics/
├── examples/      # Ready-to-run YAML configs (`samesim examples`)
└── cli/           # samesim run, samesim visualize
experiments/       # Validation studies: scripts, raw results, write-ups
tests/             # unit/ integration/ regression/
```


## Two ways to run agents

- **Synchronous** (default): every agent steps on every tick, like a round-based simulator. Message delays round up to whole ticks.
- **Event-driven** (`simulation.activation: event`): agents act only when a message reaches them or a timer they set fires. Delays are exact. This is how you model timeouts, heartbeats and Poisson clocks.

A behavior declares which modes it supports, and a config asking for any other mode is rejected when it loads, before it can run and produce misleading numbers.

## What a run produces

Each experiment writes self-describing CSV time series (a comment header carries the experiment name, seed, and schema version) plus a `summary.json` / `summary.md` with the config and wall-clock runtime:

```
# experiment: sir_random
# seed: 9876
# schema_version: 1.0
# metric: sir_infected
virtual_time,value
0.0,2.0
1.0,5.0
...
```

| Plugin type | Included |
|---|---|
| Behaviors | Gossip averaging, leader election (max-id flooding), SIR epidemic, asynchronous pairwise gossip (Boyd et al.), Raft leader election, single-server queue (M/M/1), voter model |
| Protocols | Gossip (point-to-point), broadcast, lossy (configurable drop rate), latency (constant, uniform, exponential or lognormal per-message delay, plus loss) |
| Topologies | Ring, 2-D grid (optional wrap), Erdős–Rényi, Watts–Strogatz, Barabási–Albert |
| Metrics | Convergence variance, message count, S/I/R counts, leader-consensus fraction, full per-agent state trace, topology edge list, run fingerprint (SHA-256 per tick), Raft elections and election-safety violations, running agents and lost messages under churn, consensus among running vs. all agents, queue waits and occupancy, voter-model opinion shares |
| Exporters | CSV |
| Churn (optional) | Scheduled faults with state-based targeting ("crash whoever is leader at t=1000"), random Poisson failure/recovery |

All eight shipped example configs (`samesim examples`) finish in under 3 seconds each (seven of them in under a second), including interpreter startup.

## By the numbers

Everything below comes from committed scripts under [`experiments/`](experiments/) with raw JSON next to them. Re-run any of it with the commands in each study's README.

### Scale

Gossip (fan-out 2), 50 ticks, one core, pure Python:

| Agents | Events processed | Wall time | Throughput | Peak memory |
|---:|---:|---:|---:|---:|
| 1,000 | 100,052 | 1.3 s | ~77k events/s | 38 MB |
| 10,000 | 1,000,052 | 16.5 s | ~60k events/s | 216 MB |
| 30,000 | 3,000,052 | 57 s | ~53k events/s | 612 MB |
| 100,000 | 10,000,052 | 172 s | ~58k events/s | 2.0 GB |

- **The engine scales linearly.** On a ring, the local log-log slope of runtime vs. agents stays between 0.92 and 1.13 from 100 to 100,000 agents. On Erdős–Rényi (average degree 8) it's 0.81–1.35, mildly superlinear in the middle of the range: runtime is 1.0× Ring's at 300 agents and 1.7× at 100,000. The extra cost has been traced to `GossipBehavior.step()`, not the engine, but not yet to a specific line.
- **No topology generator is quadratic.** Erdős–Rényi with 100,000 agents builds in 0.82 s. A contract test fails the build if any generator, including future ones, grows quadratically.
- **Against PeerSim, the P2P simulator, PeerSim is 9× faster at 1,000 agents and 91× at 100,000** on push-pull averaging. PeerSim's cycle-driven engine is a compiled Java loop of direct method calls, and its JVM startup dominates small runs; in SameSim each exchange is a timer plus two messages through the event queue. Details: [`experiments/peersim_validation/`](experiments/peersim_validation/).
- **Against Mesa, the ABM library, Mesa is 7.4× faster** per agent update on the voter model, where SameSim sends opinions as messages. Details: [`experiments/voter_mesa_validation/`](experiments/voter_mesa_validation/).
- **Against another simulator, SimPy is 5× faster** on the same M/M/1 queue (about 96,000 vs 19,000 customers per second, median of 100 runs each). Each SameSim customer is 4 scheduled events plus metric dispatch and immutable-state copies; the profile has no single hotspot. Details: [`experiments/mm1_simpy_validation/`](experiments/mm1_simpy_validation/).
- **The architecture costs about an order of magnitude.** A bare-loop implementation of the same gossip protocol runs 6–12× faster across our measurements, and the ratio stops growing above ~1,000 agents. That overhead pays for the event queue, determinism, and plugin isolation.

*All rows are from the current engine. Timings were taken on a developer laptop and vary about ±10% run to run. Slopes and event counts are the reliable figures.*

### Correctness against independent tools

SameSim was checked against software and math it shares no code with, on the identical input graph:

| Check | SameSim | Reference | Verdict |
|---|---|---|---|
| SIR epidemic, 500 agents, 200 seeds: peak infected (mean ± 95% CI) | 360.4 ± 1.5 | 362.3 ± 1.5 ([NDlib](https://ndlib.readthedocs.io)); 361.6 ± 1.6 (independent reference) | Equivalent to the reference within ±1% (TOST p = 0.015); vs. NDlib no significant difference (p = 0.07), equivalence borderline (TOST p = 0.065) |
| SIR: final recovered | 498.77 ± 0.13 | 498.75 ± 0.13 (NDlib) | Equivalent within ±1% (TOST p < 10⁻¹⁶⁰) |
| M/M/1 queue, ρ = 0.5 / 0.8 / 0.9, 100 seeds: mean wait | 1.002 / 4.01 / 9.01 | 1.000 / 4.05 / 9.05 ([SimPy](https://simpy.readthedocs.io)); closed form 1 / 4 / 9 | Equivalent to the closed form within ±3% at every load; every customer's wait also matches Lindley's recursion exactly |
| Voter model, 2,000 seeds: P(opinion 1 wins) | 0.267 | 0.255 ([Mesa](https://mesa.readthedocs.io)); exact martingale result 0.272; naive 0.10 | SameSim equivalent to the exact result within ±0.03 (TOST p = 0.006); Mesa not significantly different, equivalence not shown |
| Push-pull averaging, 1,000 agents, 50 seeds: per-cycle variance factor, complete / sparse graph | 0.3026 / 0.3360 | 0.3022 / 0.3367 ([PeerSim](https://peersim.sourceforge.net)); closed form 1/(2√e) = 0.3033 on the complete graph | Both tools equivalent to the closed form and to each other within ±0.01 (TOST p < 10⁻¹⁵) |
| Gossip ticks to converge, 300 agents, 200 seeds | 10.91 ± 0.21 | 10.89 ± 0.21 (independent numpy impl.) | Equivalent within ±0.5 tick (TOST p = 0.001) |
| Watts–Strogatz clustering / avg. path | 0.4164 / 4.094 | 0.4164 / 4.094 ([NetworkX](https://networkx.org)) | Exact |
| Ring and grid diameter / avg. path | 250 / 125.25, 20 / 10.03 | Identical (NetworkX) | Exact |
| Erdős–Rényi, Barabási–Albert structure | avg. degree, diameter, clustering | NetworkX | Within single-sample noise |
| Leader election consensus time | ≤ diameter in 15/15 cases (n = 50–1,000) | Graph diameter (analytical bound) | Always within bound |
| Total messages delivered | n × fan-out × ticks | Closed-form count | Exact |

These checks found three real bugs, which are fixed and have regression tests: an O(n²) topology generator, a dropped final tick, and a double fan-out that inflated SIR transmission by ~28%. An outside review of the NDlib comparison then found a fourth: runs with different seeds shared the same agent random streams (`seed XOR agent_id`), so replicates were partly copies of each other and a 0.3% difference looked significant. Streams are now hashed per (seed, agent), a guard test covers it, and every study was re-run. Details: [`experiments/external_validation/`](experiments/external_validation/).

### Reproducing theory

Gossip convergence time vs. network size (20 seeds per point, average degree 8, time to 1% of the initial variance), fitted log-log slope with a bootstrap 95% CI:

| Topology | Slope (95% CI) | Meaning |
|---|---:|---|
| Ring | 1.37 (1.24–1.49) | Superlinear at this threshold; about quadratic at a strict one (below) |
| Barabási–Albert | 0.11 (0.09–0.12) | Nearly flat |
| Watts–Strogatz | 0.11 (0.08–0.14) | Nearly flat |
| Erdős–Rényi | 0.08 (0.05–0.10) | Nearly flat |

This matches mixing-time theory: well-connected graphs converge in roughly constant time, and a cycle doesn't. Details: [`experiments/gossip_topology_validation/`](experiments/gossip_topology_validation/).

The ring's exponent depends on the threshold. At 1e-4 it is about quadratic (1.83, CI 1.78–1.87, for synchronous push gossip; 1.80, CI 1.75–1.84, for Boyd et al.'s asynchronous pairwise gossip), matching the classical O(n²) result; at 1% it is about 1.35 for both. An earlier version reported "linear at 1%" (slope 0.99); with independent seeds that didn't hold. Details: [`experiments/async_gossip_validation/`](experiments/async_gossip_validation/).

Raft leader election reproduces the qualitative findings of the Raft paper (Ongaro & Ousterhout 2014, Fig. 16). From a cold start without timeout randomization, no leader is ever elected. A 150–300 ms range elects in the first term in almost every trial (mean winning term 1.01). Timeouts near the network delay cause about 27 unnecessary re-elections per 5 seconds. In the paper's actual scenario, crashing the leader of a running cluster, a 150–300 ms range restores a leader in a median of 185 ms (95% CI 183–188, p95 335 ms), and at every range a crash needs a second election round more often than a cold start does. Election Safety (at most one leader per term) held in all 11,000 trials across both studies. Details: [`experiments/raft_election_validation/`](experiments/raft_election_validation/).

With per-message latency, gossip convergence time grows linearly with mean delay (R² ≥ 0.997). At equal mean, exponential delays converge 19% faster (95% CI 16–23%) than constant ones at mean 16 but slower at mean 1, so the shape of the delay distribution matters, not just its average. Details: [`experiments/latency_validation/`](experiments/latency_validation/).

Under churn, gossip degrades gracefully: with half the agents down at any moment, the running agents still converge, 3–3.5× slower, with no cliff anywhere in between (t ∝ (1 − f)^−1.5 to (1 − f)^−1.8, 40 seeds per point). Agreement across *every* agent, crashed ones included, is a different matter: with long outages it waits for agents that crashed before consensus formed to come back, and at 10% down takes about twice as long as the running agents do. Churn doesn't bias the consensus value, and lost messages match f(1 − f)·n·k·T within 2.5%. Details: [`experiments/churn_convergence_validation/`](experiments/churn_convergence_validation/).

### Reproducibility across platforms, measured

The same fixed-seed scenarios in SameSim, SimPy, Mesa, NDlib and plain Python, on Linux, macOS and Windows × Python 3.10–3.13, with identical library versions. SameSim's runs were bit-identical in all 12 combinations. Two things broke bit-identity in ordinary Python model code: CPython 3.12's change to float `sum()` (an averaging model in Mesa or plain Python gives different results on 3.10–3.11 and 3.12–3.13), and the platform C math library, whose `log`, `exp`, `pow`, `sin` and random variates differ in the last bit between operating systems (e.g. at least 0.1% of `random.expovariate` draws). SameSim's `portable_math` was identical everywhere. Details: [`experiments/determinism_survey/`](experiments/determinism_survey/).

### Tests

**1137 passing on each of Python 3.10, 3.11, 3.12 and 3.13** (unit, integration, regression). Seven of the suites are *contract tests that discover their targets automatically*, so they also cover plugins and files that don't exist yet:
- every behavior × protocol pairing delivers exactly once per intended recipient
- no topology generator scales quadratically
- every module respects the layering (`plugins` → `domain`, `ports` only), with relative imports resolved
- no committed script hardcodes a machine-specific path
- under random churn, no failed agent ever runs or receives a message, for every behavior × activation mode × protocol
- no `plugin_configs` option can be silently ignored, for every configurable plugin role: a misspelled or inapplicable option is rejected before the run
- **golden traces**: every behavior × protocol × topology is fingerprinted, so any change that alters a single delivered message fails the build and names the first tick that diverged

## What it can't do yet

All three phases of the time-model design are in: per-message latency, event-driven agents with timers, and churn (nodes that fail, recover and join, and links that change). See [Design](docs/design.md). Still missing:

- **Network partitions and asymmetric links.** Churn fails nodes, not links in one direction. A partition can be modeled with `remove_edges`, but the messages already in flight across it still arrive.
- **Log replication.** `RaftElectionBehavior` is Raft's leader election only.
- **Scale beyond ~10⁵ agents.** It's pure Python at about 60k events/s.

## Getting Started

```bash
pip install samesim
```

Python 3.10–3.13; the only runtime dependency is PyYAML.

### Try it: same seed, same simulation

```bash
samesim run raft_leader_crash -o a        # prints a digest: a SHA-256 of every message and state change
samesim run raft_leader_crash -o b        # same seed: the same digest, on any OS and Python 3.10–3.13
samesim run raft_leader_crash --seed 2    # another seed: a different run, a different digest
samesim visualize a                       # dashboard.html: interactive charts of every metric
```

Every shipped example records this fingerprint. `samesim examples` lists them: gossip averaging, leader election, Raft, an SIR epidemic, an M/M/1 queue and the voter model.

### From the command line

```bash
samesim examples                          # the shipped example configs
samesim run raft_leader_crash             # run one by name
samesim new my_study --from sir_random    # copy one to start your own
samesim validate my_study.yaml            # check everything without running
samesim run my_study.yaml --seed 3 --set simulation.num_agents=500 -o results
samesim sweep my_study.yaml --seeds 1-20 -o sweep   # many seeds, one at a time
samesim plugins                           # every plugin; `samesim plugins NAME` for its options
samesim visualize results                 # interactive dashboard.html
```

`validate` does everything a run does before its first event, so a misspelled option, an unknown field or an incompatible plugin fails in a second instead of after a long sweep.

### From Python

```python
import samesim

result = samesim.run("sir_random", seed=3, overrides={"simulation.num_agents": 500})
infected = result.series["sir_infected"]
print([(r.virtual_time, r.value) for r in infected.records][:5])
```

`samesim.run` takes a YAML path, an example name or a config dict, keeps results in memory, and writes files only when given `output_dir`. The [User Guide](docs/user_guide.md) covers writing your own configs.

### Development

```bash
git clone https://github.com/ImCYMBIOT/samesim.git && cd samesim
pip install -e ".[dev]"
pytest                  # ~90 s
pytest -m "not slow"    # skip the scale tests
```

## License

MIT. See [LICENSE](LICENSE).
