Skip to content
zit Artifact [238d469429]
Login

Artifact [238d469429]

Artifact 238d46942915b5c0e989dfc738b5927b5228468ea123e6f17b6fb593aa5cbf6e:


//! 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, .{});
}