//! Seeded generator of two-endpoint sync scenarios (transport-simnet-plan.md
//! §A2). From one injected `std.Random` it deterministically builds the DATA a
//! sync sweep replays: two pre-seeded artifact stores (a client and a server),
//! a per-link SimNet fault profile, the operation, and an optional credential.
//! It never runs the sync loop — that is the sweep's job (§A3); this module
//! only composes the starting universe so any exchange replays from its seed.
//!
//! Native harness design — determinism is the whole contract, so the tests
//! below assert generator properties (same seed => identical scenario, honored
//! counts and bounds), never RFC clauses; the sync-protocol tests carry the
//! §-citations.
//!
//! The seed is the whole universe: every count, hash, content byte, fault knob,
//! and credential derives from the single run PRNG, in a fixed draw order, so a
//! failing sweep prints `reproduce with seed=N` and this generator rebuilds the
//! exact same two stores and wire.
const std = @import("std");
const zit = @import("zit");
const sync = zit.sync;
const repostate = zit.repostate;
const net = @import("net.zig");
const assert = std.debug.assert;
/// Closed error set: seeding a store is the only fallible step.
pub const Error = error{OutOfMemory};
/// Absolute ceiling on one scenario's logical artifact universe, so every
/// seeding loop has a fixed upper bound and allocation stays bounded.
pub const max_universe: u16 = 64;
/// Hash width of a generated artifact: 64 lowercase hex chars (a SHA-256-shaped
/// name), unique per artifact by construction.
pub const hash_len: usize = 64;
/// Explicit upper bound on one generated artifact's content, in bytes.
pub const max_content: usize = 96;
/// Tunable draw bounds and probabilities. Defaults suit the pre-commit sweep;
/// tests and deep sweeps override them. Every field is an explicit bound the
/// generator asserts and never exceeds.
pub const Params = struct {
/// Artifacts both endpoints hold (the shared-ancestor set).
max_shared: u16 = 12,
/// Artifacts only the client holds (push candidates).
max_client_only: u16 = 8,
/// Artifacts only the server holds (pull candidates); at least one, so a
/// clone always has something to fetch.
max_server_only: u16 = 8,
/// Chance the non-holder has interned a content-absent phantom for an
/// artifact the peer holds.
phantom_pct: u8 = 50,
/// Chance the whole run uses a perfect wire (both links all-zero) — the
/// phase-2 liveness shape.
perfect_wire_pct: u8 = 20,
/// Per-link fault-knob upper bounds (percent), each rolled uniformly in
/// `0..=bound`.
max_drop_pct: u8 = 40,
max_dup_pct: u8 = 40,
max_corrupt_pct: u8 = 25,
max_clog_pct: u8 = 25,
/// Upper bound on a link's mean delivery delay, in ticks.
max_mean_delay: u32 = 8,
/// A generator precondition: the three placement bounds cannot exceed the
/// universe ceiling, so the seeding loops stay within `max_universe`, and a
/// clone must have room for at least one server artifact.
fn assertValid(self: Params) void {
assert(self.max_shared + self.max_client_only + self.max_server_only <= max_universe);
assert(self.max_server_only >= 1);
assert(self.phantom_pct <= 100 and self.perfect_wire_pct <= 100);
assert(self.max_drop_pct <= 100 and self.max_dup_pct <= 100);
assert(self.max_corrupt_pct <= 100 and self.max_clog_pct <= 100);
}
};
/// The realized artifact composition of a generated scenario — the actual
/// counts (each within its `Params` bound), so a sweep or test reads the shape
/// without re-deriving it from the stores.
pub const Composition = struct {
/// Held by both endpoints.
shared: u16 = 0,
/// Held only by the client.
client_only: u16 = 0,
/// Held only by the server.
server_only: u16 = 0,
/// Server-only artifacts the client interned as a content-absent phantom.
client_phantoms: u16 = 0,
/// Client-only artifacts the server interned as a content-absent phantom.
server_phantoms: u16 = 0,
/// Held entries expected in the client store.
pub fn clientHeld(self: Composition) u16 {
return self.shared + self.client_only;
}
/// Held entries expected in the server store.
pub fn serverHeld(self: Composition) u16 {
return self.shared + self.server_only;
}
};
/// A candidate login-name pool; a credentialed run picks one. Static literals,
/// so the credential owns no heap and the scenario stays move-safe.
const login_pool = [_][]const u8{ "alice", "bob", "carol", "dave" };
/// One generated two-endpoint sync scenario. Holds both stores by value, so it
/// must live at a stable address once generated — the `*Scenario` accessors
/// recompute the `ArtifactStore` vtable pointer from the current location.
pub const Scenario = struct {
gpa: std.mem.Allocator,
/// The client and server artifact stores, pre-seeded and owned here.
client: repostate.MemStore,
server: repostate.MemStore,
/// The operation the run performs.
operation: sync.Operation,
/// The login credential, or null for an anonymous run.
credential: ?sync.Credential,
/// The client's own repository identifiers (emitted in the operation card).
/// Inline hex buffers; the accessors below expose them as slices.
servercode_buf: [16]u8,
project_buf: [16]u8,
/// The client version pragma value.
version: sync.VersionInfo,
/// Per-link fault profiles, indexed exactly as `SimNet.links` (by
/// destination endpoint): `link_to_a` is the server->client link,
/// `link_to_b` the client->server link.
link_to_a: net.LinkCfg,
link_to_b: net.LinkCfg,
/// True iff both links are a perfect wire (the phase-2 liveness shape).
perfect_wire: bool,
/// The realized artifact composition.
composition: Composition,
/// Frees both stores. Idempotent-hostile — call exactly once.
pub fn deinit(self: *Scenario) void {
self.client.deinit();
self.server.deinit();
self.* = undefined;
}
/// The client store's type-erased interface, valid while `self` lives.
pub fn clientStore(self: *Scenario) repostate.ArtifactStore {
return self.client.store();
}
/// The server store's type-erased interface, valid while `self` lives.
pub fn serverStore(self: *Scenario) repostate.ArtifactStore {
return self.server.store();
}
/// The client's SERVERCODE identifier.
pub fn selfServercode(self: *const Scenario) []const u8 {
return self.servercode_buf[0..];
}
/// The shared PROJECTCODE identifier.
pub fn projectCode(self: *const Scenario) []const u8 {
return self.project_buf[0..];
}
/// A stable digest over every observable of the scenario — operation,
/// credential, identity, version, both links, and each store's entries in
/// dense rid order. Two scenarios generated from the same seed hash equal;
/// it is the reproducibility oracle.
pub fn digest(self: *Scenario) u64 {
var h = std.hash.Wyhash.init(0);
h.update(&.{@intFromEnum(self.operation)});
if (self.credential) |c| {
h.update(&.{1});
h.update(c.user);
h.update(&c.secret_hex);
} else {
h.update(&.{0});
}
h.update(self.selfServercode());
h.update(self.projectCode());
var vbuf: [4]u8 = undefined;
std.mem.writeInt(u32, &vbuf, self.version.version, .big);
h.update(&vbuf);
hashLink(&h, self.link_to_a);
hashLink(&h, self.link_to_b);
h.update(&.{@intFromBool(self.perfect_wire)});
storeDigest(&h, self.clientStore());
storeDigest(&h, self.serverStore());
return h.final();
}
};
/// Generate one scenario from `random` under `params`. The only fallible work
/// is store allocation; a mid-seed failure frees everything via the errdefer.
pub fn generate(gpa: std.mem.Allocator, random: std.Random, params: Params) Error!Scenario {
params.assertValid();
const operation = randomOperation(random);
const is_clone = operation == .clone;
// Fault profile: a perfect wire (phase-2 shape) or bounded per-link faults.
const perfect = drawPct(random, params.perfect_wire_pct);
const link_to_a: net.LinkCfg = if (perfect) .{} else faultyLink(random, params);
const link_to_b: net.LinkCfg = if (perfect) .{} else faultyLink(random, params);
// Identity: hex identifiers, version, and an optional credential.
var scen = Scenario{
.gpa = gpa,
.client = repostate.MemStore.init(gpa),
.server = repostate.MemStore.init(gpa),
.operation = operation,
.credential = randomCredential(random),
.servercode_buf = undefined,
.project_buf = undefined,
.version = .{ .version = 20000 + random.uintAtMost(u32, 3000) },
.link_to_a = link_to_a,
.link_to_b = link_to_b,
.perfect_wire = perfect,
.composition = .{
.shared = random.uintAtMost(u16, params.max_shared),
.client_only = random.uintAtMost(u16, params.max_client_only),
.server_only = random.uintAtMost(u16, params.max_server_only),
},
};
errdefer scen.deinit();
fillHex16(random, &scen.servercode_buf);
fillHex16(random, &scen.project_buf);
// A clone starts the client empty and fetches from a non-empty server.
if (is_clone) {
scen.composition.shared = 0;
scen.composition.client_only = 0;
scen.composition.server_only = 1 + random.uintAtMost(u16, params.max_server_only - 1);
}
const total = scen.composition.shared + scen.composition.client_only + scen.composition.server_only;
assert(total <= max_universe);
try seedStores(random, &scen, params, is_clone);
return scen;
}
/// Seed both stores from `scen.composition`'s held counts, rolling a phantom
/// for each peer that lacks a held artifact, and record the realized phantom
/// counts back into the composition.
fn seedStores(random: std.Random, scen: *Scenario, params: Params, is_clone: bool) Error!void {
const comp = &scen.composition;
var content_buf: [max_content]u8 = undefined;
var hash: [hash_len]u8 = undefined;
var id: u16 = 0;
// Shared: held on both endpoints, identical content-addressed bytes.
var k: u16 = 0;
while (k < comp.shared) : (k += 1) {
artifactHash(random, &hash, id);
const c = randomContent(random, &content_buf);
_ = try scen.client.store().put(&hash, c);
_ = try scen.server.store().put(&hash, c);
id += 1;
}
// Client-only: the client holds it; the server may know it as a phantom.
k = 0;
while (k < comp.client_only) : (k += 1) {
artifactHash(random, &hash, id);
const c = randomContent(random, &content_buf);
_ = try scen.client.store().put(&hash, c);
if (drawPct(random, params.phantom_pct)) {
_ = try scen.server.store().intern(&hash);
comp.server_phantoms += 1;
}
id += 1;
}
// Server-only: the server holds it; the client may know it as a phantom
// (never on a clone, whose client store starts empty).
k = 0;
while (k < comp.server_only) : (k += 1) {
artifactHash(random, &hash, id);
const c = randomContent(random, &content_buf);
_ = try scen.server.store().put(&hash, c);
if (!is_clone and drawPct(random, params.phantom_pct)) {
_ = try scen.client.store().intern(&hash);
comp.client_phantoms += 1;
}
id += 1;
}
assert(id <= max_universe);
}
/// Draw the run's operation uniformly.
fn randomOperation(random: std.Random) sync.Operation {
return switch (random.uintLessThan(u8, 4)) {
0 => .pull,
1 => .push,
2 => .sync,
else => .clone,
};
}
/// Draw an optional credential: roughly one run in three is anonymous.
fn randomCredential(random: std.Random) ?sync.Credential {
if (random.uintLessThan(u8, 3) == 0) return null;
const user = login_pool[random.uintLessThan(usize, login_pool.len)];
var raw: [20]u8 = undefined;
random.bytes(&raw);
return .{ .user = user, .secret_hex = std.fmt.bytesToHex(raw, .lower) };
}
/// A bounded per-link fault profile; `max_delay_ticks`/`max_clog_ticks` keep
/// `LinkCfg`'s own bounded defaults.
fn faultyLink(random: std.Random, params: Params) net.LinkCfg {
return .{
.drop_pct = random.uintAtMost(u8, params.max_drop_pct),
.dup_pct = random.uintAtMost(u8, params.max_dup_pct),
.corrupt_pct = random.uintAtMost(u8, params.max_corrupt_pct),
.clog_pct = random.uintAtMost(u8, params.max_clog_pct),
.mean_delay_ticks = random.uintAtMost(u32, params.max_mean_delay),
};
}
/// A unique, well-formed 64-hex artifact name: a 2-byte `id` prefix guarantees
/// uniqueness across the universe, the remaining 30 bytes are seeded draws.
fn artifactHash(random: std.Random, out: *[hash_len]u8, id: u16) void {
var raw: [hash_len / 2]u8 = undefined;
std.mem.writeInt(u16, raw[0..2], id, .big);
random.bytes(raw[2..]);
const hex = std.fmt.bytesToHex(raw, .lower);
@memcpy(out, &hex);
}
/// Fill a fixed 16-char lowercase-hex identifier from the run PRNG.
fn fillHex16(random: std.Random, out: *[16]u8) void {
var raw: [8]u8 = undefined;
random.bytes(&raw);
const hex = std.fmt.bytesToHex(raw, .lower);
@memcpy(out, &hex);
}
/// Fill `buf` with a random-length (`1..=max_content`) body and return it.
fn randomContent(random: std.Random, buf: *[max_content]u8) []const u8 {
const len = 1 + random.uintLessThan(usize, max_content);
assert(len >= 1 and len <= max_content);
random.bytes(buf[0..len]);
return buf[0..len];
}
/// True with probability `pct/100` against the run PRNG; `0`/`100` short so a
/// perfect or certain roll draws no bits (keeps the replay stream aligned).
fn drawPct(random: std.Random, pct: u8) bool {
assert(pct <= 100);
if (pct == 0) return false;
if (pct == 100) return true;
return random.uintLessThan(u8, 100) < pct;
}
/// Fold one store's entries (hash + held/phantom + content) into `h`, in dense
/// rid order — deterministic because intern order is deterministic.
fn storeDigest(h: *std.hash.Wyhash, store: repostate.ArtifactStore) void {
const n = store.count();
assert(n <= max_universe);
var rid: repostate.Rid = 1;
while (rid <= n) : (rid += 1) {
h.update(store.hashOf(rid));
if (store.content(rid)) |bytes| {
h.update(&.{1});
h.update(bytes);
} else {
h.update(&.{0});
}
}
}
/// Fold one link's every field into `h`.
fn hashLink(h: *std.hash.Wyhash, link: net.LinkCfg) void {
h.update(&.{ link.drop_pct, link.dup_pct, link.corrupt_pct, link.clog_pct });
var buf: [12]u8 = undefined;
std.mem.writeInt(u32, buf[0..4], link.mean_delay_ticks, .big);
std.mem.writeInt(u32, buf[4..8], link.max_delay_ticks, .big);
std.mem.writeInt(u32, buf[8..12], link.max_clog_ticks, .big);
h.update(&buf);
}
// ----------------------------------------------------------------------------
// Tests: properties of the generator's determinism — the seed is the whole
// universe. No RFC clause is at stake here (the sync-protocol tests carry the
// §-citations), so these carry no §-citation.
// ----------------------------------------------------------------------------
const testing = std.testing;
const StoreCount = struct { held: usize, phantom: usize };
fn countStore(store: repostate.ArtifactStore) StoreCount {
var held: usize = 0;
var phantom: usize = 0;
const n = store.count();
var rid: repostate.Rid = 1;
while (rid <= n) : (rid += 1) {
if (store.content(rid) == null) phantom += 1 else held += 1;
}
return .{ .held = held, .phantom = phantom };
}
fn isLowerHex(ch: u8) bool {
return (ch >= '0' and ch <= '9') or (ch >= 'a' and ch <= 'f');
}
fn expectWellFormedHashes(store: repostate.ArtifactStore) !void {
const n = store.count();
var rid: repostate.Rid = 1;
while (rid <= n) : (rid += 1) {
const hash = store.hashOf(rid);
try testing.expectEqual(hash_len, hash.len);
for (hash) |ch| try testing.expect(isLowerHex(ch));
}
}
test "sync_gen: the same seed reproduces an identical scenario" {
const seed: u64 = 0xA2;
var digests: [2]u64 = undefined;
for (0..2) |run| {
var prng = std.Random.DefaultPrng.init(seed);
var scen = try generate(testing.allocator, prng.random(), .{});
defer scen.deinit();
digests[run] = scen.digest();
}
try testing.expectEqual(digests[0], digests[1]);
}
test "sync_gen: distinct seeds produce distinct scenarios" {
var first: ?u64 = null;
var all_equal = true;
var seed: u64 = 1;
while (seed <= 8) : (seed += 1) {
var prng = std.Random.DefaultPrng.init(seed);
var scen = try generate(testing.allocator, prng.random(), .{});
defer scen.deinit();
const d = scen.digest();
if (first) |f| {
if (d != f) all_equal = false;
} else first = d;
}
try testing.expect(!all_equal); // the seed genuinely drives the universe
}
test "sync_gen: composition stays within the requested bounds" {
const params = Params{ .max_shared = 5, .max_client_only = 4, .max_server_only = 4 };
var seed: u64 = 1;
while (seed <= 40) : (seed += 1) {
var prng = std.Random.DefaultPrng.init(seed);
var scen = try generate(testing.allocator, prng.random(), params);
defer scen.deinit();
const c = scen.composition;
try testing.expect(c.shared <= params.max_shared);
try testing.expect(c.client_only <= params.max_client_only);
try testing.expect(c.server_only <= params.max_server_only);
// A phantom is minted only for a held artifact on the peer, so it is
// bounded by the peer's held-only count.
try testing.expect(c.client_phantoms <= params.max_server_only);
try testing.expect(c.server_phantoms <= params.max_client_only);
}
}
test "sync_gen: stores realize the composition and phantoms hold no content" {
var seed: u64 = 1;
while (seed <= 40) : (seed += 1) {
var prng = std.Random.DefaultPrng.init(seed);
var scen = try generate(testing.allocator, prng.random(), .{});
defer scen.deinit();
const c = scen.composition;
const cs = countStore(scen.clientStore());
const ss = countStore(scen.serverStore());
// held == shared + own-only; phantom == interned-from-peer.
try testing.expectEqual(@as(usize, c.clientHeld()), cs.held);
try testing.expectEqual(@as(usize, c.client_phantoms), cs.phantom);
try testing.expectEqual(@as(usize, c.serverHeld()), ss.held);
try testing.expectEqual(@as(usize, c.server_phantoms), ss.phantom);
try expectWellFormedHashes(scen.clientStore());
try expectWellFormedHashes(scen.serverStore());
}
}
test "sync_gen: a forced perfect wire yields all-zero links" {
var seed: u64 = 1;
while (seed <= 20) : (seed += 1) {
var prng = std.Random.DefaultPrng.init(seed);
var scen = try generate(testing.allocator, prng.random(), .{ .perfect_wire_pct = 100 });
defer scen.deinit();
try testing.expect(scen.perfect_wire);
try testing.expect(std.meta.eql(scen.link_to_a, net.LinkCfg{}));
try testing.expect(std.meta.eql(scen.link_to_b, net.LinkCfg{}));
}
}
test "sync_gen: a faulty profile stays within its bounds and injects faults" {
const params = Params{
.perfect_wire_pct = 0,
.max_drop_pct = 30,
.max_dup_pct = 30,
.max_corrupt_pct = 20,
.max_clog_pct = 20,
.max_mean_delay = 5,
};
var any_fault = false;
var seed: u64 = 1;
while (seed <= 40) : (seed += 1) {
var prng = std.Random.DefaultPrng.init(seed);
var scen = try generate(testing.allocator, prng.random(), params);
defer scen.deinit();
try testing.expect(!scen.perfect_wire);
for ([_]net.LinkCfg{ scen.link_to_a, scen.link_to_b }) |l| {
try testing.expect(l.drop_pct <= params.max_drop_pct);
try testing.expect(l.dup_pct <= params.max_dup_pct);
try testing.expect(l.corrupt_pct <= params.max_corrupt_pct);
try testing.expect(l.clog_pct <= params.max_clog_pct);
try testing.expect(l.mean_delay_ticks <= params.max_mean_delay);
if (l.drop_pct > 0 or l.dup_pct > 0 or l.corrupt_pct > 0 or l.clog_pct > 0) any_fault = true;
}
}
try testing.expect(any_fault);
}
test "sync_gen: a clone starts the client empty and the server holding" {
var saw_clone = false;
var seed: u64 = 1;
while (seed <= 200 and !saw_clone) : (seed += 1) {
var prng = std.Random.DefaultPrng.init(seed);
var scen = try generate(testing.allocator, prng.random(), .{});
defer scen.deinit();
if (scen.operation != .clone) continue;
saw_clone = true;
try testing.expectEqual(@as(usize, 0), scen.clientStore().count());
try testing.expect(scen.serverStore().count() >= 1);
try testing.expectEqual(@as(u16, 0), scen.composition.shared);
try testing.expectEqual(@as(u16, 0), scen.composition.client_only);
try testing.expect(scen.composition.server_only >= 1);
}
try testing.expect(saw_clone);
}
fn generateOnce(gpa: std.mem.Allocator) !void {
var prng = std.Random.DefaultPrng.init(0xBEEF);
var scen = try generate(gpa, prng.random(), .{});
scen.deinit();
}
test "sync_gen: generate frees everything on an injected allocation failure" {
try std.testing.checkAllAllocationFailures(testing.allocator, generateOnce, .{});
}