Seeding & Determinism
3.3 Seeding & Determinism
Deterministic simulation is a non-negotiable requirement for scientific reproducibility and regulatory compliance. The same model with the same seed must produce the same output, regardless of whether it runs on one process or ten, on one machine or a cluster, today or next year. The SDK achieves this through SHA-256 hierarchical seeding (Patent 2) and provides tooling to verify it.
SeedManager
The SeedManager encapsulates the SHA-256 seed hierarchy described in Section 1.4. It is initialised automatically in ABMModel.setup() from the seed key in the config dictionary, which is why super().setup(config) must always be called first.
class SeedManager:
def __init__(self, global_seed: int = 42) -> None
@property
def global_seed(self) -> int
def model_seed(self, override: Optional[int] = None) -> int
def set_model_seed_override(self, seed: Optional[int]) -> None
def scenario_seed(self, scenario_index: int,
model_seed_override: Optional[int] = None) -> int
def agent_seed(self, scenario_seed: int, agent_id: str) -> int
def environment_seed(self, scenario_seed: int) -> int
def agent_rng(self, scenario_seed: int, agent_id: str) -> np.random.Generator
def environment_rng(self, scenario_seed: int) -> np.random.Generator
def mc_seeds(self, num_scenarios: int,
model_seed_override: Optional[int] = None) -> List[int]
The mc_seeds() method is used by the Monte Carlo runner to generate statistically independent scenario seeds. Each scenario seed is derived from the model seed and the scenario index via SHA-256 mixing, ensuring that scenarios are uncorrelated even when run in parallel on different machines.
The agent_rng() method returns a numpy.random.Generator for a specific agent in a specific scenario. Because the seed depends only on the agent's string ID (not its creation order or memory address), adding or removing agent N does not affect agent M's random number stream. This is the agent independence property that makes the SDK's seeding cross-process deterministic.
DeterminismChecker
The DeterminismChecker verifies that a model is actually deterministic by running it multiple times with the same seed and comparing agent state field-by-field:
class DeterminismChecker:
def __init__(
self,
model_cls: Type[ABMModel],
config: Dict[str, Any],
num_pairs: int = 3, # Number of independent run-pairs to compare
tolerance: float = 1e-10, # Floating-point comparison tolerance
) -> None
def check(self, steps: Optional[int] = None) -> DeterminismResult
The DeterminismResult reports whether all pairs matched, how many pairs were checked, and a list of DeterminismFailure objects (each identifying the seed, step, agent ID, field name, and the two divergent values) if any mismatches were found.
For distributed models, the cross_process_check() function runs the same verification across separate Python processes to detect non-determinism caused by floating-point ordering differences in parallel execution:
def cross_process_check(
model_cls: Type[ABMModel],
config: Dict[str, Any],
steps: int = 100,
tolerance: float = 1e-10,
) -> DeterminismResult
Common causes of non-determinism that the checker detects: using Python's random module instead of self.rng, iterating over set or dict (which have non-deterministic iteration order in some Python versions), using time.time() or other system-dependent values, and shared mutable state between agents.
Determinism Across Execution Backends
A critical property of the SDK is that the same model with the same seed produces identical results regardless of execution backend — whether running on a single thread, multiple threads, multiple processes, or distributed across MCP servers with A2A protocol. This is not merely an aspiration; it is a verified property.
The following table shows the result of running the MarketSurveillanceModel (27 agents, 50 steps, seed=42) across all seven supported execution configurations:
| Backend | Price | Trades | Orders | Alerts | Deterministic |
|---|---|---|---|---|---|
| LocalBackend (single-thread) | 99.921429 | 1,578 | 3,224 | 236 | YES |
| ThreadPoolBackend (4 threads) | 99.921429 | 1,578 | 3,224 | 236 | YES |
| ProcessPoolBackend | 99.921429 | 1,578 | 3,224 | 236 | YES |
| MCPBackend (emulated) | 99.921429 | 1,578 | 3,224 | 236 | YES |
| MCP 2-server orchestrated | 99.921429 | 1,578 | 3,224 | 236 | YES |
| MCP 3-server orchestrated | 99.921429 | 1,578 | 3,224 | 236 | YES |
| MCP 4-server orchestrated | 99.921429 | 1,578 | 3,224 | 236 | YES |
Every metric is bitwise identical. This is achieved through three design invariants:
1. Agent-level SHA-256 seeding is partition-independent. Each agent's RNG seed is SHA256(global_seed + agent_id). Because the seed depends only on the agent's string ID — not its creation order, memory address, or which server it runs on — agent HonestTrader_3 gets the same random number sequence whether it runs on Server 0, Server 2, or in a single-threaded process.
2. Sequence semantics are preserved across partitions. The SDK's Sequence guarantees that messages sent in phase N are delivered before phase N+1 executes, and that all inboxes are cleared between phases. The InProcessOrchestrator replicates this exactly: after each action executes on all servers, it clears all inboxes on all servers, then delivers the collected A2A messages. This ensures that each agent sees exactly the same messages in each phase, regardless of partitioning.
3. Agent-produced data flows through messages, not shared state. Prices, trade confirmations, and market data travel via typed Message objects through Link connections. Since messages serialize losslessly via to_dict()/from_dict() (all custom __slots__ fields are preserved) and the A2A protocol routes them by target_partition, the information each agent receives is identical regardless of whether the sender is on the same server or a different one.
Why Message-Based Communication Is Essential for MCP Determinism
If a model uses GlobalState to carry mutable agent-produced data (e.g., g.current_price = avg_price), distributed execution breaks determinism because:
- Each MCP server has its own
GlobalStateinstance — there is no shared global memory across servers - A price written by a MarketMaker on Server 0 is invisible to traders on Server 1
- Synchronizing GlobalState across servers introduces ordering dependencies that vary with network latency
By contrast, when price flows through a PriceMessage from MarketMaker to traders via links, the A2A protocol handles cross-partition delivery deterministically. The MarketMaker sends the message in phase N; the orchestrator collects it, routes it to the target partition, and delivers it before phase N+1 executes. The trader receives the same price message regardless of partition layout.
This is why Rule 1 of the SIMUDYNE_SDK_STYLE_GUIDE.md mandates: GlobalState is for read-only configuration parameters only. Agent-produced data must flow through messages.
Cross-Partition State Synchronization
When a model's post-sequence bookkeeping reads agent state across types (e.g., a surveillance agent reading trader statistics), the InProcessOrchestrator.sync_agent_states() method copies __state_schema__ fields from each server's authoritative local agents to all other servers' stale copies. This ensures that cross-type reads see current data without violating agent ownership.
The recommended pattern for post-sequence state sharing:
# WRONG: model.step() reads agent state directly from other types
surv.agent_trades[ht.agent_id] = ht.trades_executed # stale on different server
# RIGHT: agents update their own state via messages within the Sequence
# MarketMaker sends TradeConfirmation to filled traders
mm.send(TradeConfirmation, link, price=fill_price, quantity=1)
# Trader increments its own trades_executed (runs on authoritative server)
def _receive_confirmations(trader):
for msg in trader.get_messages(TradeConfirmation):
trader.trades_executed += 1
# Then sync_agent_states() copies the authoritative value to all servers
Verifying Determinism
To verify cross-backend determinism for your own model:
from simudyne.engine.determinism import DeterminismChecker, cross_process_check
# Within-process determinism (fast)
checker = DeterminismChecker(MyModel, config, pairs=5)
result = checker.check()
assert result.passed
# Cross-process determinism (slower, more rigorous)
result = cross_process_check(MyModel, config, num_processes=4)
assert result.passed
For MCP-specific verification, the examples/27_mcp_distributed.py demonstrates running the same model on LocalBackend and InProcessOrchestrator and comparing all metrics.