db-16 step 02 — Deterministic simulator

Goal

Build a discrete-event simulator whose (seed, nodes, rounds) triple completely determines its event log, and produce a canonical serialization of that log.

Tasks

  1. PRNG. Implement splitmix64 in each language with unsigned wrapping multiplication. Seed it per-decision with seed ^ (t << 32) ^ (s + 1) so that no shared mutable PRNG state crosses a (t, s) boundary. This eliminates the "whose turn is it to read the RNG?" ambiguity that bites every multi-language implementation.

  2. Per-tick decision. For each (t < rounds, s ∈ 0..nodes), compute:

    • dest_pre = (r & 0xFFFF) % (nodes - 1) then skip-self: dest = dest_pre + (1 if dest_pre >= s else 0).
    • delay = 1 + ((r >> 16) & 0xFFFF) % 3.
    • payload = (r >> 32) & 0xFF.
  3. Scheduler. Maintain a min-heap of in-flight messages keyed on (delivery_time, sender_id, global_seq). global_seq is a single monotonic counter incremented every time a message is enqueued. This guarantees a total order even when two messages have identical (delivery_time, sender_id).

  4. Tick loop. For t in 0 .. rounds + MAX_DELAY:

    1. Drain all heap entries with delivery_time == t: for each, run recv on the destination node, emit a Recv event.
    2. If t < rounds: for each s in 0..nodes, compute the decision, enqueue the message, run send on the sender, emit a Send event.
  5. Wire format. As documented in CONCEPTS.md. Magic "DSE6", u32 LE event count, then event_count events. Each event uses little-endian integers and serializes its vector clock with entries sorted ascending by node id.

Acceptance

Inline unit tests:

  • splitmix64_known_values — for seed=0, the first three outputs are 0xE220A8397B1DCDAF, 0x6E789E6AA1B965F4, 0x06C45D188009454F.
  • sim_deterministic_one_node--nodes 2 --rounds 3 --seed 1 produces a fixed event count and a fixed first-event byte sequence.
  • sim_event_count_formula — for any (nodes ≥ 2, rounds ≥ 1), total events = 2 * nodes * rounds (every send becomes exactly one recv).
  • causality_holds — after running simulate(...), walk the event log: every Recv from peer has a strictly-greater VC than the paired Send.
  • byte_round_trip — serializing the same event log twice yields identical bytes (no nondeterminism in the serializer itself).

All five green in Rust, Go, and C++.

Discussion prompts

  • Why deliver before send within a single tick?
  • What breaks if global_seq is per-sender instead of global?
  • The simulator never drops or reorders messages beyond delay-based interleaving. What new wire-format field would --drop-rate p need, and would it break the cross-language hash if defaulted to 0?