Artifact 7a2e9e522066e5e052090da4b175f8fa5ea51452d30c019b5f46079034fd2f79:
- File
src/dst.zig
— part of check-in
[bdb49cc5ca]
at
2026-07-19 11:56:16
on branch blockers
— repostate: break same-mtime resolution ties by DAG ancestry, not hash order
Three commits made inside the same second made `zit` return the WRONG file content for `tip` and `trunk`, and made the binary contradict itself: `zit info tip` reported "leaf: no" while `zit leaves` named the same check-in a leaf. `zit cat f.txt tip` returned the ROOT's content and `zit cat f.txt trunk` returned the MIDDLE's, on a three-commit chain whose newest content was `v3`. A version-control system that silently hands back an old revision under a name that means "newest" is disqualifying, so this lands before anything else.
WHAT WAS WRONG — three independent tie sites, not one
The originally-recorded diagnosis blamed a single lexicographic artifact-hash tiebreak in `mergeBaseOf`. That was wrong for the reported symptom: the RED-proof gate built the equal-mtime chain in BOTH hash orderings and observed IDENTICAL failures, which rules out hash order as the mechanism. Fixing only that line would have left `cat tip` and `cat trunk` still returning wrong answers. The real picture:
- `tipForm` — `mtime > best_mtime`, a strict `>` with no tiebreak at all, so the first entry in `events` INSERTION order won. Symptom: `tip` -> ROOT. - `resolveName` — ties settled by `tagxref` hash-map ITERATION order. Symptom: `trunk` -> MID. - `mergeBaseOf` — lexicographic artifact hash. A genuine defect, proven RED at unit level, but not the cause of the tip/trunk symptom and unreachable from the e2e merge path.
WHAT CHANGED
Equal-`event.mtime` ties are now settled by DAG ancestry: a descendant beats its ancestor. Genuinely concurrent nodes — same mtime, no ancestry relation — keep a deterministic least-artifact-hash fallback, documented at each site; hash order between UNRELATED leaves was never the bug. The shared helper (`MtimeMaxPick`) is used by `tipForm`, `resolveName`, `openLeafOf` and `dateResolve`, so every symbolic-name surface settles ties one way.
Timeline ordering gets a second pass that re-orders each equal-mtime RUN descendants-first, deliberately overriding the hash tiebreak. It is a per-run repeated-maximal-element extraction, NOT a sort predicate: "descends-from, else hash" is not a strict weak order, and handing it to `std.mem.sort` would be undefined behavior. Runs are bounded by the check-ins sharing one millisecond.
Settling a tie by ancestry allocates, so `resolveName`/`resolve`/`openLeafOf` gained `OutOfMemory`. Every caller switches it as an ABORT, never as a mismatch or "already correct" verdict — reporting an allocation failure as the exit-0 up-to-date message is exactly the lie those switches exist to prevent. `repository_test`'s closed-error-set assertion moved 2 -> 3 for that reason: it encoded the pre-fix set, not an inconvenient expectation.
Two ride-alongs on the same resolution surface:
- SF1 — `zit update trunk` used to fail as "invalid check-in ID": update accepted ONLY canonical 40/64-hex. Its argument is a check-in name like every other verb's, so it now runs the shared 8.3 procedure. A canonical ID still short-circuits, leaving update's cold path byte-for-byte as it was. - SF5 — a `tip` that names a non-leaf is now an in-binary assertion, not just a test. It is legitimate ONLY under a non-monotone clock (a child stamped strictly earlier than its parent); a child that TIES its parent means the ancestry tiebreak failed to fire, which is the defect itself. This tripwire would have caught the original bug.
RED PROOF (recorded by the RED-proof gate, 2026-07-19; per BLOCKER-FIX-PLAN.md section 2, no product code changed until RED was proven)
Unit, `src/repostate_test.zig`, deterministic equal-mtime chains built directly in BOTH hash orderings — no wall-clock dependence:
repostate REGRESSION B1: tip resolves to the descendant on an equal-mtime chain (head has the greatest hash) repostate REGRESSION B1: tip resolves to the descendant on an equal-mtime chain (head has the least hash) repostate REGRESSION B1: resolveName picks the descendant on an equal-mtime chain (head has the greatest hash) repostate REGRESSION B1: resolveName picks the descendant on an equal-mtime chain (head has the least hash) repostate REGRESSION B1: tip and resolveName agree with the leaf set in both hash orderings repostate REGRESSION B1: mergeBaseOf picks the deepest common ancestor when every candidate shares an mtime [failed pre-fix: expected 2, found 1]
End-to-end, `src/e2e.zig`, same-second commits with NO sleep between them — same-second IS the point:
cli REGRESSION B1: same-second commits resolve tip and trunk to the newest check-in cli REGRESSION B1 (SF1): update accepts a symbolic name and lands on the newest check-in cli REGRESSION B1: open materializes the newest same-second check-in into a fresh clone cli REGRESSION B1: a same-second two-branch merge picks the correct base and is never up to date
All of the above failed against pre-fix trunk; the two hash-ordering pairs failed IDENTICALLY, which is the evidence that retired the hash-order theory.
TEST EVIDENCE NOW
In this worktree at the committed tree: `zig fmt --check .`, `zig build`, and `zig build test` each exit 0 — full battery, no filtering. The only pre-existing expectation changed is the `repository_test` error-set count noted above, and it changed because it encoded the old set.
Markers `// REGRESSION B1:` are stable so state probes can grep trunk for them.
No borrowed technique: the ancestry tiebreak and the per-run topological micro-ordering are original to this change. (user: dmestas, size: 28367) [annotate] [blame] [check-ins using] [more...]
//! Deterministic simulation of the repository-state derivation. //! //! `zig build dst` runs the fast pre-commit GATE sweep (a few seeds, a few //! thousand steps each — seconds, not minutes: the per-step oracle recompute //! is linear in the live check-in count, so the gate stays small and DEEP //! sweeps run out-of-band via `zig build dst -- --seeds K [--steps M]`). //! `zig build dst -- --seed N [--steps M]` replays one seed and `--seeds K` //! sweeps 1..K. One `std.Random.DefaultPrng.init(seed)` is the //! sole entropy source of a run — the seed is the whole universe — so any //! failure replays exactly from the printed `reproduce with seed=N` line. //! //! Phase 1 (safety), every step: crosslink one generated record and assert //! the leaf table equals the oracle's brute-force recompute of the //! normative leaf test (repo-state Section 7.2), over the public API only. //! Phase 2 (liveness): assert the incrementally maintained leaf table //! equals a full rebuild (Section 7.3), replay the record log in permuted //! orders — exhaustively when the log holds at most 5 records, else three //! sampled shuffles — asserting an identical leaf set by hash, and run a //! fixed four-record fork/merge scenario through every insertion order. const std = @import("std"); const zit = @import("zit"); const repostate = zit.repostate; const repoimage = zit.repoimage; const gen_mod = @import("sim/gen.zig"); const oracle_mod = @import("sim/oracle.zig"); const storage_sweep = @import("sim/storage_sweep.zig"); const sync_sweep = @import("sim/sync_sweep.zig"); test { // storage/logstore/checkout and logstore_test are now folded into the `zit` // module and aggregate through root.zig; dst.zig only carries its sim // collaborators (external to that module) and the sibling fuzz targets. _ = @import("sim/faulty_file.zig"); _ = @import("sim/storage_sweep.zig"); _ = @import("sim/net.zig"); _ = @import("sim/sync_gen.zig"); _ = @import("sim/sync_sweep.zig"); // Sibling fuzz targets aggregate into `zig build test` here: this root // carries the `zit` dep (sync_fuzz) and reaches logstore/storage/sim // relatively (logstore_fuzz), which root.zig cannot. _ = @import("sync_fuzz.zig"); _ = @import("logstore_fuzz.zig"); // The CLI-layer wire-surface targets: this root reaches serve.zig and // transport.zig relatively (they are outside the `zit` module) while // carrying the `zit` dep those two files import. _ = @import("transport_fuzz.zig"); _ = @import("serve_fuzz.zig"); // The three-way line merge's algebraic laws. Same reason as the two above: // merge3.zig lives outside the `zit` module, so this root is where its // fuzz target's smoke tests can reach it relatively. _ = @import("merge3_fuzz.zig"); // The runner itself, for its registration pin only: `zig build fuzz` has // no test step, so this is the only place that pin can run. _ = @import("fuzz_tests.zig"); } // Pre-commit GATE budget: kept to seconds. The per-step oracle recompute is // linear in the live check-in count, so wall-clock is ~linear in steps*seeds; // deep coverage (dozens/hundreds of seeds x 10k steps) runs out-of-band. const default_steps: usize = 1_000; const default_sweep_seeds: u64 = 6; /// Log sizes at or below this replay every permutation; larger logs get /// `sampled_shuffles` PRNG-sampled orders instead (5! = 120 replays max). const exhaustive_max = 5; const sampled_shuffles = 3; const Failure = error{ OutOfMemory, LeafMismatch, LeafTableDrift, OpenLeafMismatch, RebuildMismatch, OrderDependence, NameResolutionMismatch, ImageMismatch, BranchListMismatch, AmbiguityMismatch, WalkMismatch, // Storage sweep (storage-track-plan.md §B — native design, no RFC). Io, MissingAcknowledged, PhantomFrame, IndexMismatch, HashMismatch, SilentCorruption, ConfigMismatch, }; /// A name never emitted by the generator: probes resolveName/openLeafOf's /// NotFound/null case every step alongside the branch names in use. const absent_branch_name = "no-such-branch"; /// The full set of names probed every step and after every replay order. const check_names = gen_mod.branch_names ++ [_][]const u8{absent_branch_name}; pub fn main(init: std.process.Init) !void { const gpa = init.gpa; const args = try init.minimal.args.toSlice(init.arena.allocator()); var stderr_buffer: [1024]u8 = undefined; var stderr_writer: std.Io.File.Writer = .init(.stderr(), init.io, &stderr_buffer); const out = &stderr_writer.interface; var seed_arg: ?u64 = null; var seeds_arg: ?u64 = null; var steps: usize = default_steps; parseFlags(args[1..], &seed_arg, &seeds_arg, &steps) catch |e| { try out.print("dst: {s}\n", .{@errorName(e)}); try out.writeAll("usage: zig build dst -- [--seed N | --seeds K] [--steps M]\n"); try out.flush(); std.process.exit(2); }; const first: u64 = if (seed_arg) |s| s else 1; const total: u64 = if (seed_arg != null) 1 else seeds_arg orelse default_sweep_seeds; var i: u64 = 0; while (i < total) : (i += 1) { const seed = first + i; runSeed(gpa, seed, steps) catch |e| { try out.print("dst: {s}\n", .{@errorName(e)}); try out.print("reproduce with seed={d}\n", .{seed}); try out.flush(); std.process.exit(1); }; // The sync SimNet sweep is the PRIMARY sync DST (transport-simnet-plan.md // §A3): it joins the same per-seed gate. Its own closed error set stays // separate from the repostate Failure set; the reproduce line is shared. sync_sweep.runSeed(gpa, seed) catch |e| { try out.print("sync-dst: {s}\n", .{@errorName(e)}); try out.print("reproduce with seed={d}\n", .{seed}); try out.flush(); std.process.exit(1); }; } try out.print("dst: {d} seed(s) x {d} steps ok\n", .{ total, steps }); try out.flush(); } /// Parse `[--seed N] [--seeds K] [--steps M]` in any order. `--seed` and /// `--seeds` are mutually exclusive; an unknown flag or missing value is an /// explicit error, never ignored. fn parseFlags(args: []const []const u8, seed: *?u64, seeds: *?u64, steps: *usize) !void { var i: usize = 0; while (i < args.len) : (i += 1) { if (std.mem.eql(u8, args[i], "--seed")) { i += 1; if (i >= args.len) return error.MissingSeedValue; seed.* = try std.fmt.parseInt(u64, args[i], 0); } else if (std.mem.eql(u8, args[i], "--seeds")) { i += 1; if (i >= args.len) return error.MissingSeedsValue; seeds.* = try std.fmt.parseInt(u64, args[i], 0); } else if (std.mem.eql(u8, args[i], "--steps")) { i += 1; if (i >= args.len) return error.MissingStepsValue; steps.* = try std.fmt.parseInt(usize, args[i], 0); } else { return error.UnknownArgument; } } if (seed.* != null and seeds.* != null) return error.SeedAndSeedsExclusive; } fn runSeed(gpa: std.mem.Allocator, seed: u64, steps: usize) Failure!void { var prng = std.Random.DefaultPrng.init(seed); const random = prng.random(); var store = repostate.MemStore.init(gpa); defer store.deinit(); var rs = repostate.RepoState.init(gpa, store.store()); defer rs.deinit(); var oracle = oracle_mod.Oracle.init(gpa); defer oracle.deinit(); var gen = gen_mod.Gen.init(gpa, random); defer gen.deinit(); // The oracle's slot order is its own; keep the matching rid per slot so // the per-step comparison stays cheap. var rids: std.ArrayList(repostate.Rid) = .empty; defer rids.deinit(gpa); var flags: [gen_mod.max_checkins]bool = undefined; var open_flags: [gen_mod.max_checkins]bool = undefined; // Phase 1: safety under the generated op stream, checked every step. var step: usize = 0; while (step < steps) : (step += 1) { const rec = try gen.next(); _ = try rs.crosslink(rec); try oracle.apply(rec); std.debug.assert(oracle.count() <= gen_mod.max_checkins); while (rids.items.len < oracle.count()) { try rids.append(gpa, try store.store().intern(oracle.hashAt(rids.items.len))); } try checkLeaves(&rs, rids.items, &oracle, flags[0..oracle.count()]); try checkOpenLeaves(&rs, rids.items, &oracle, open_flags[0..oracle.count()]); // Invariants (b)/(c): reuse `flags`, which checkLeaves just filled // with the raw (non-open) leaf recompute — exactly what // Oracle.openLeafOf's leaf_flags parameter expects. try checkNameResolution(&rs, rids.items, &oracle); try checkOpenLeafOf(&rs, rids.items, &oracle, flags[0..oracle.count()]); // Invariants (e)-(h): batch 8's §6.3/§7.5/§8.3-8.4 derivation // surface. `flags` still holds the raw leaf recompute from // checkLeaves above, unchanged by the calls in between. try checkAmbiguousLeaves(&rs, rids.items, &oracle, flags[0..oracle.count()]); try checkTip(&rs, rids.items, &oracle); try checkWalkForms(&rs, rids.items, &oracle); try checkBranches(gpa, &rs, rids.items, &oracle); } // Phase 2: liveness. No pending rechecks remain (crosslink batch-applies // its schedule), so the incremental table must equal a full rebuild. try checkRebuildMatches(gpa, &rs); try checkOrderIndependence(gpa, gen.log(), random); try checkImageRoundTrip(gpa, &rs, rids.items); try fixedScenario(gpa); // Storage-engine sweep (storage-track-plan.md §B): an independent // subsystem with its own PRNG stream off the same seed, so it neither // reads nor perturbs the repostate simulation above. try storage_sweep.run(gpa, seed, steps); // Step-2 (§5/§7.2) delta-base-policy equivalence: a naive full-form store // and a pickBase best-of-N store decode byte-identical for every artifact // (subsuming identical repostate/current-trunk-hash views). Its own PRNG // stream off the same seed; replays from the shared reproduce line. try storage_sweep.pickBaseEquivalence(gpa, seed); // Step-3 (§6.1/§7.2-§7.3) pack-redeltify equivalence: a naive full-form // store and a `zit.pack.packLog`-repacked copy of a production-shaped source // (verbatim wire deltas over poor bases + check-in manifests) decode // byte-identical for every artifact, trunk tip preserved, config carried // through. Its own disjoint PRNG stream off the same seed. try storage_sweep.packRedeltifyEquivalence(gpa, seed); // Step-4 (§6.2/§7.2-§7.3) same-path pack equivalence: multiple interleaved // tracked files push each file's earlier version OUT of the bounded // chronological window of its later version, so pack reaches the ideal base // only through the §6.2 same-path manifest walk. Every artifact must still // decode byte-identical across a naive full-form store and the repacked copy, // trunk tip preserved. Its own disjoint PRNG stream off the same seed. try storage_sweep.packSamePathEquivalence(gpa, seed); } /// Invariant (a): the leaf table equals the oracle's recompute — same /// membership per known hash, and no extra rows. fn checkLeaves( rs: *const repostate.RepoState, rids: []const repostate.Rid, oracle: *const oracle_mod.Oracle, flags: []bool, ) Failure!void { oracle.leafFlags(flags); var expected_total: usize = 0; for (flags, rids) |flag, rid| { if (flag) expected_total += 1; if (rs.isLeaf(rid) != flag) return error.LeafMismatch; } var it = rs.leaves(); var actual: usize = 0; while (it.next()) |_| actual += 1; if (actual != expected_total) return error.LeafTableDrift; } /// Image round-trip: a `repoimage` materialized from the DST-proven derived /// state must be query-equivalent to it — the same leaf set/order (§7.1), event /// times (§7.5/§8.3), open predicate (§7.4), branch-of (§6.1), name resolution /// (§8.2), hash, and lookup, for every rid the run produced. Because the op /// stream is random and the derived state has already been checked against the /// oracle each step, this pins that the memo cannot violate any invariant the /// derivation owns. fn checkImageRoundTrip(gpa: std.mem.Allocator, rs: *const repostate.RepoState, rids: []const repostate.Rid) Failure!void { const mb = "trunk"; var body: std.ArrayList(u8) = .empty; defer body.deinit(gpa); try repoimage.serialize(gpa, rs, mb, &body); var img = (repoimage.Image.load(gpa, body.items) catch return error.OutOfMemory) orelse return error.ImageMismatch; defer img.deinit(gpa); if (img.count() != rs.store.count()) return error.ImageMismatch; // Leaf set AND iteration order. var di = rs.leaves(); const il = img.leafRids(); var k: usize = 0; while (di.next()) |lr| : (k += 1) { if (k >= il.len or il[k] != lr) return error.ImageMismatch; } if (k != il.len) return error.ImageMismatch; for (rids) |rid| { if (!std.meta.eql(rs.mtimeOf(rid), img.mtimeOf(rid))) return error.ImageMismatch; if (rs.isLeaf(rid) != img.isLeaf(rid)) return error.ImageMismatch; if (rs.isOpenLeaf(rid) != img.isOpenLeaf(rid)) return error.ImageMismatch; if (!std.mem.eql(u8, rs.store.hashOf(rid), img.hashOf(rid))) return error.ImageMismatch; if (img.lookup(rs.store.hashOf(rid)) != rid) return error.ImageMismatch; if (rs.mtimeOf(rid) != null and !std.mem.eql(u8, rs.branchOf(rid, mb), img.branchOf(rid, mb))) return error.ImageMismatch; } // §8.2 name resolution: every probed name resolves identically. for (check_names) |name| { // An allocation failure is an abort, never a mismatch verdict. const want = rs.resolveName(name) catch |e| switch (e) { error.NotFound => null, error.OutOfMemory => return error.OutOfMemory, }; const got = img.resolveName(name); if (!std.meta.eql(want, got)) return error.ImageMismatch; } } /// Invariant (d): isOpenLeaf equals the oracle's open-leaf recompute /// (repo-state Section 7.4) for every known rid. fn checkOpenLeaves( rs: *const repostate.RepoState, rids: []const repostate.Rid, oracle: *const oracle_mod.Oracle, flags: []bool, ) Failure!void { oracle.openLeafFlags(flags); for (flags, rids) |flag, rid| { if (rs.isOpenLeaf(rid) != flag) return error.OpenLeafMismatch; } } /// Invariant (b): resolveName agrees with the oracle's brute-force /// max-mtime scan (Section 8.2), for every generated branch name plus one /// name the generator never emits (the NotFound case). fn checkNameResolution( rs: *const repostate.RepoState, rids: []const repostate.Rid, oracle: *const oracle_mod.Oracle, ) Failure!void { for (check_names) |name| { // An allocation failure is an abort, never a mismatch verdict. const got = rs.resolveName(name) catch |e| switch (e) { error.NotFound => null, error.OutOfMemory => return error.OutOfMemory, }; const want_slot = oracle.resolveName(name); const want: ?repostate.Rid = if (want_slot) |slot| rids[slot] else null; if (got != want) return error.NameResolutionMismatch; } } /// Invariant (c): openLeafOf agrees with the oracle's brute-force /// three-condition scan (Section 9), for the same name set as (b). `flags` /// is the raw (non-open) leaf recompute Oracle.openLeafOf expects. fn checkOpenLeafOf( rs: *const repostate.RepoState, rids: []const repostate.Rid, oracle: *const oracle_mod.Oracle, flags: []const bool, ) Failure!void { for (check_names) |name| { const got = try rs.openLeafOf(name); const want_slot = oracle.openLeafOf(name, flags); const want: ?repostate.Rid = if (want_slot) |slot| rids[slot] else null; if (got != want) return error.OpenLeafMismatch; } } /// Invariant (e): isAmbiguousLeaf agrees with the oracle's brute-force /// leaf-and-open-sibling scan (repo-state Section 7.5), for every known /// rid. `flags` is the raw (non-open) leaf recompute `Oracle.isAmbiguousLeaf` /// expects. fn checkAmbiguousLeaves( rs: *const repostate.RepoState, rids: []const repostate.Rid, oracle: *const oracle_mod.Oracle, flags: []const bool, ) Failure!void { for (rids, 0..) |rid, i| { const want = oracle.isAmbiguousLeaf(@intCast(i), flags); if (rs.isAmbiguousLeaf(rid) != want) return error.AmbiguityMismatch; } } /// Invariant (f): `resolve("tip", .checkin)` agrees with the oracle's /// brute-force global-maximum-mtime scan (Section 8.3 form 3). fn checkTip( rs: *const repostate.RepoState, rids: []const repostate.Rid, oracle: *const oracle_mod.Oracle, ) Failure!void { const want_slot = oracle.tipSlot(); const want: ?repostate.Rid = if (want_slot) |s| rids[s] else null; const got: ?repostate.Rid = rs.resolve("tip", .checkin) catch |e| switch (e) { error.NotFound => null, error.Ambiguous => return error.WalkMismatch, error.OutOfMemory => return error.OutOfMemory, }; if (got != want) return error.WalkMismatch; } /// Writes `prefix ++ name` into `buf`, returning the written slice. 96 /// comfortably bounds "start:"/"root:" (6/5 bytes) plus the longest /// generator branch name ("feature", 7 bytes), so the assert is provably /// dead against every caller here. fn prefixedName(buf: []u8, prefix: []const u8, name: []const u8) []const u8 { std.debug.assert(prefix.len + name.len <= buf.len); @memcpy(buf[0..prefix.len], prefix); @memcpy(buf[prefix.len..][0..name.len], name); return buf[0 .. prefix.len + name.len]; } /// Invariant (g): `resolve("start:"++name, .checkin)` and /// `resolve("root:"++name, .checkin)` agree with the oracle's brute-force /// primary-parent walk (Section 8.4), for each generator branch name with /// a resolvable carrier this step. fn checkWalkForms( rs: *const repostate.RepoState, rids: []const repostate.Rid, oracle: *const oracle_mod.Oracle, ) Failure!void { for (gen_mod.branch_names) |name| { const base_slot = oracle.resolveName(name) orelse continue; var buf: [96]u8 = undefined; const start_name = prefixedName(&buf, "start:", name); const got_start = rs.resolve(start_name, .checkin) catch |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, else => return error.WalkMismatch, }; const want_start = rids[oracle.branchWalk(base_slot, .earliest_on_branch)]; if (got_start != want_start) return error.WalkMismatch; var buf2: [96]u8 = undefined; const root_name = prefixedName(&buf2, "root:", name); const got_root = rs.resolve(root_name, .checkin) catch |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, else => return error.WalkMismatch, }; const want_root = rids[oracle.branchWalk(base_slot, .first_off_branch)]; if (got_root != want_root) return error.WalkMismatch; } } /// Invariant (h): `rs.branches()` agrees with the oracle's brute-force /// grouping (Section 6.3) — same set of names, each with the same /// representative rid, mtime, member count, and closed status. fn checkBranches( gpa: std.mem.Allocator, rs: *const repostate.RepoState, rids: []const repostate.Rid, oracle: *const oracle_mod.Oracle, ) Failure!void { const got = try rs.branches(gpa); defer gpa.free(got); const want = try oracle.branchGroups(gpa); defer gpa.free(want); if (got.len != want.len) return error.BranchListMismatch; for (want) |w| { var found = false; for (got) |g| { if (!std.mem.eql(u8, g.name, w.name)) continue; found = true; if (g.tip != rids[w.rep] or g.mtime != w.mtime or g.count != w.count or g.closed != w.closed) { return error.BranchListMismatch; } break; } if (!found) return error.BranchListMismatch; } } /// Section 7.3: the incrementally maintained leaf table must match a /// from-scratch full rebuild exactly. fn checkRebuildMatches(gpa: std.mem.Allocator, rs: *repostate.RepoState) Failure!void { const before = try collectLeafRids(gpa, rs); defer gpa.free(before); try rs.rebuildLeaves(); const after = try collectLeafRids(gpa, rs); defer gpa.free(after); if (!std.mem.eql(repostate.Rid, before, after)) return error.RebuildMismatch; } fn collectLeafRids(gpa: std.mem.Allocator, rs: *const repostate.RepoState) error{OutOfMemory}![]repostate.Rid { var list: std.ArrayList(repostate.Rid) = .empty; errdefer list.deinit(gpa); var it = rs.leaves(); while (it.next()) |rid| try list.append(gpa, rid); const slice = try list.toOwnedSlice(gpa); std.mem.sort(repostate.Rid, slice, {}, std.sort.asc(repostate.Rid)); return slice; } /// Derivation must not depend on crosslink order: rids differ across /// orders, so replays are compared by leaf and open-leaf hash set, plus the /// per-name resolved hash (Section 8.2) — unique mtimes make the resolved /// check-in deterministic, so a replay that disagrees is a real order /// dependency, not a fair tie. Phantom arrival order (Section 4.2 step 1) /// is exercised by every child-before-parent permutation; the open-leaf set /// additionally exercises closed-tag skip ordering (Section 7.4). fn checkOrderIndependence(gpa: std.mem.Allocator, log: []const repostate.Record, random: std.Random) Failure!void { var baseline_store = repostate.MemStore.init(gpa); defer baseline_store.deinit(); var baseline_rs = repostate.RepoState.init(gpa, baseline_store.store()); defer baseline_rs.deinit(); for (log) |rec| _ = try baseline_rs.crosslink(rec); const baseline = try collectLeafHashes(gpa, &baseline_rs, baseline_store.store()); defer gpa.free(baseline); const baseline_open = try collectOpenLeafHashes(gpa, &baseline_rs, baseline_store.store()); defer gpa.free(baseline_open); var baseline_resolve: [check_names.len]?[]const u8 = undefined; for (check_names, 0..) |name, i| { baseline_resolve[i] = resolvedHash(&baseline_rs, baseline_store.store(), name); } const order = try gpa.alloc(usize, log.len); defer gpa.free(order); for (order, 0..) |*slot, i| slot.* = i; if (log.len <= exhaustive_max) { var permutations: usize = 0; while (true) { try replayAndCompare(gpa, log, order, baseline, baseline_open, &baseline_resolve); permutations += 1; std.debug.assert(permutations <= 120); // 5! orders at most if (!nextPermutation(order)) break; } } else { for (0..sampled_shuffles) |_| { random.shuffle(usize, order); try replayAndCompare(gpa, log, order, baseline, baseline_open, &baseline_resolve); } } } /// `name` resolved against `rs`, as a hash rather than a rid so it compares /// across replays that assign rids differently. The returned slice points /// into `store`, which must outlive the result. fn resolvedHash(rs: *const repostate.RepoState, store: repostate.ArtifactStore, name: []const u8) ?[]const u8 { const rid = rs.resolveName(name) catch return null; return store.hashOf(rid); } /// A fixed fork/merge graph replayed through all 24 insertion orders: /// root A; children B and C of A; merge D with parents (B, C). The leaf /// set must always be exactly {D} — C's only child arrives via a merge /// edge, which still disqualifies it (Section 7.2), and out-of-order /// arrival resolves through phantoms (Section 4.2 step 1). fn fixedScenario(gpa: std.mem.Allocator) Failure!void { const ha = "a" ** 64; const hb = "b" ** 64; const hc = "c" ** 64; const hd = "d" ** 64; const log = [_]repostate.Record{ .{ .hash = ha, .mtime = 1 }, .{ .hash = hb, .mtime = 2, .parents = &.{ha} }, .{ .hash = hc, .mtime = 3, .parents = &.{ha} }, .{ .hash = hd, .mtime = 4, .parents = &.{ hb, hc } }, }; // No closed tags in this fixed scenario, so the open-leaf set equals // the leaf set, and no branch tag means every name resolution is // NotFound (null) regardless of order. const expected = [_][]const u8{hd}; const expected_resolve = [_]?[]const u8{null} ** check_names.len; var order = [_]usize{ 0, 1, 2, 3 }; var permutations: usize = 0; while (true) { try replayAndCompare(gpa, &log, &order, &expected, &expected, &expected_resolve); permutations += 1; std.debug.assert(permutations <= 24); // 4! orders if (!nextPermutation(&order)) break; } std.debug.assert(permutations == 24); } /// Replay `log[order]` into a fresh state and compare its leaf hash set, /// open-leaf hash set, and per-name resolved hash (`check_names` order) /// against `expected`/`expected_open`/`expected_resolve`. fn replayAndCompare( gpa: std.mem.Allocator, log: []const repostate.Record, order: []const usize, expected: []const []const u8, expected_open: []const []const u8, expected_resolve: []const ?[]const u8, ) Failure!void { var store = repostate.MemStore.init(gpa); defer store.deinit(); var rs = repostate.RepoState.init(gpa, store.store()); defer rs.deinit(); for (order) |i| _ = try rs.crosslink(log[i]); const got = try collectLeafHashes(gpa, &rs, store.store()); defer gpa.free(got); if (got.len != expected.len) return error.OrderDependence; for (got, expected) |a, b| { if (!std.mem.eql(u8, a, b)) return error.OrderDependence; } const got_open = try collectOpenLeafHashes(gpa, &rs, store.store()); defer gpa.free(got_open); if (got_open.len != expected_open.len) return error.OrderDependence; for (got_open, expected_open) |a, b| { if (!std.mem.eql(u8, a, b)) return error.OrderDependence; } for (check_names, expected_resolve) |name, want| { const got_resolve = resolvedHash(&rs, store.store(), name); if (!hashOptEql(got_resolve, want)) return error.OrderDependence; } } fn hashOptEql(a: ?[]const u8, b: ?[]const u8) bool { if (a == null or b == null) return a == null and b == null; return std.mem.eql(u8, a.?, b.?); } /// The leaf set as hashes, sorted lexically. The returned slices point /// into `store`, which must outlive the result. fn collectLeafHashes( gpa: std.mem.Allocator, rs: *const repostate.RepoState, store: repostate.ArtifactStore, ) error{OutOfMemory}![][]const u8 { var list: std.ArrayList([]const u8) = .empty; errdefer list.deinit(gpa); var it = rs.leaves(); while (it.next()) |rid| try list.append(gpa, store.hashOf(rid)); const slice = try list.toOwnedSlice(gpa); std.mem.sort([]const u8, slice, {}, hashLessThan); return slice; } /// The open-leaf set as hashes, sorted lexically (Section 7.4). fn collectOpenLeafHashes( gpa: std.mem.Allocator, rs: *const repostate.RepoState, store: repostate.ArtifactStore, ) error{OutOfMemory}![][]const u8 { var list: std.ArrayList([]const u8) = .empty; errdefer list.deinit(gpa); var it = rs.leaves(); while (it.next()) |rid| { if (rs.isOpenLeaf(rid)) try list.append(gpa, store.hashOf(rid)); } const slice = try list.toOwnedSlice(gpa); std.mem.sort([]const u8, slice, {}, hashLessThan); return slice; } fn hashLessThan(_: void, a: []const u8, b: []const u8) bool { return std.mem.order(u8, a, b) == .lt; } /// Advance `order` to the next lexicographic permutation; false once the /// last permutation has been visited. fn nextPermutation(order: []usize) bool { if (order.len < 2) return false; var i = order.len - 1; while (i > 0 and order[i - 1] >= order[i]) i -= 1; if (i == 0) return false; var j = order.len - 1; while (order[j] <= order[i - 1]) j -= 1; std.mem.swap(usize, &order[i - 1], &order[j]); std.mem.reverse(usize, order[i..]); return true; }