Skip to content
zit Artifact [2d9fa01c55]
Login

Artifact [2d9fa01c55]

Artifact 2d9fa01c55e8edffc94334745ceec1fc012f454fff50c31c4a86c973b18d55e5:


//! Sparse-cone classification (draft-fossil-sparse-cone-00 sections 5.1,
//! 5.2, 6.3; draft-fossil-horizon-00 section 4): which artifacts a
//! cone-scoped transfer keeps, and which absences are deliberate. Shared by
//! the clone/pull/sync client layer and the cone-aware server, which is why
//! it lives in the library rather than the CLI: both sides must classify
//! identically or a server could withhold what its client would have kept.

const std = @import("std");
const artifact = @import("artifact.zig");
const repostate = @import("repostate.zig");
const cone_mod = @import("cone.zig");

/// Sparse-cone RFC sections 5.1 and 6.3, horizon draft section 4.1: decide,
/// per held artifact, whether a cone-scoped clone persists it. KEEPS every
/// artifact that parses as structural (manifests, sub-manifest nodes,
/// control, wiki, tickets, technotes, forum, attachments, clusters -- holding
/// more than the section 5.1 minimum is sound and keeps this pass simple),
/// every blob an in-cone F card of a check-in in the held set names, every
/// attachment source, and -- conservatively -- every blob nothing references
/// at all. ELIDES exactly the blobs whose every reference is an F card the
/// cone does not keep: out-of-cone by path, or, under a horizon, named only
/// by trees below it. Eliding writes nothing new: phantoms never reach disk,
/// and the next open re-interns the dangling references, which the local
/// cone then classifies as elided.
pub const SparseClassification = struct {
    /// Rids a cone-scoped persist keeps (structural + in-cone-referenced +
    /// unreferenced-held).
    keep: std.DynamicBitSetUnmanaged,
    /// Rids referenced by at least one F card the cone does not keep.
    /// `out_only` minus `keep` is the elided set -- the only absence class
    /// sync must not spontaneously request (RFC section 5.2 rule 3). A
    /// phantom referenced by nothing (cluster-only, unversioned) is NOT here
    /// and stays a transit phantom: suppression never widens past what a
    /// held F card proves out-of-cone.
    out_only: std.DynamicBitSetUnmanaged,
    /// Horizon draft section 5.3 -- under a structural horizon, the rids of
    /// check-ins below the horizon that a held check-in in K names as a
    /// parent: the boundary hashes the origin labels for the client. Empty
    /// without a structural horizon. Owned.
    boundary: []const repostate.Rid = &.{},

    pub fn elided(self: *const SparseClassification, rid: repostate.Rid) bool {
        // A rid interned AFTER classification (an arrival mid-run) is beyond
        // the bitsets and is never elided -- new content is wanted content.
        if (rid >= self.keep.capacity()) return false;
        return self.out_only.isSet(rid) and !self.keep.isSet(rid);
    }

    pub fn deinit(self: *SparseClassification, gpa: std.mem.Allocator) void {
        self.keep.deinit(gpa);
        self.out_only.deinit(gpa);
        gpa.free(self.boundary);
        self.* = undefined;
    }
};

/// What `aboveHorizon` computes: the blobs above the horizon, the structural
/// artifacts the held set reaches (K's manifests, their baselines and nodes),
/// and the boundary. `reach` and `boundary` are empty for a content horizon,
/// which keeps every structural artifact and labels nothing.
const HorizonSets = struct {
    above: std.DynamicBitSetUnmanaged,
    reach: std.DynamicBitSetUnmanaged,
    boundary: std.ArrayListUnmanaged(repostate.Rid),

    fn deinit(self: *HorizonSets, gpa: std.mem.Allocator) void {
        self.above.deinit(gpa);
        self.reach.deinit(gpa);
        self.boundary.deinit(gpa);
    }
};

/// Set `rid` in `bits`, growing the set when the rid is beyond it. Classification
/// INTERNS every name a held F card cites (a phantom is session state the
/// store creates on demand, repo-state section 4.2), so rids can appear past
/// the count the sets were sized for; growing keeps the invariant "every
/// classified rid is addressable" without a second counting pass.
fn setGrow(bits: *std.DynamicBitSetUnmanaged, gpa: std.mem.Allocator, rid: repostate.Rid) error{OutOfMemory}!void {
    if (rid >= bits.capacity()) try bits.resize(gpa, rid + 1, false);
    bits.set(rid);
}

pub fn classifyKeepRids(
    gpa: std.mem.Allocator,
    store: repostate.ArtifactStore,
    sparse: *const cone_mod.Cone,
) error{OutOfMemory}!SparseClassification {
    const n = store.count();
    var keep = try std.DynamicBitSetUnmanaged.initEmpty(gpa, n + 1);
    errdefer keep.deinit(gpa);
    var referenced = try std.DynamicBitSetUnmanaged.initEmpty(gpa, n + 1);
    errdefer referenced.deinit(gpa);

    // Horizon draft section 4.2 -- under a horizon, a blob is above it when
    // some check-in in K names it, directly or through a sub-manifest node.
    // Computed first, as a bitset over rids, so the single pass below can
    // ask one question per F card. Null without a horizon: every F card's
    // target is then above by definition.
    var sets: ?HorizonSets = if (sparse.horizon) |h|
        try aboveHorizon(gpa, store, h)
    else
        null;
    defer if (sets) |*hs| hs.deinit(gpa);
    const above: ?*const std.DynamicBitSetUnmanaged = if (sets) |*hs| &hs.above else null;
    const structural = if (sparse.horizon) |h| h.depth == .structural else false;

    var arena_state = std.heap.ArenaAllocator.init(gpa);
    defer arena_state.deinit();

    var rid: repostate.Rid = 1;
    while (rid <= n) : (rid += 1) {
        if (!store.held(rid)) continue;
        const plain = store.content(rid) orelse continue;
        _ = arena_state.reset(.retain_capacity);
        const aa = arena_state.allocator();
        const im = artifact.parseForImport(aa, plain) catch |e| switch (e) {
            error.OutOfMemory => return error.OutOfMemory,
            // Not structural: a content blob. Classified by its referencers.
            else => continue,
        };
        // Horizon draft sections 4.5 and 5.1: under a horizon a cluster is
        // never kept (its members are below the horizon as often as not, and
        // the client acts on none of them); under a STRUCTURAL horizon a
        // check-in manifest or sub-manifest node is kept only when K reaches
        // it. Everything else structural persists, whatever its age.
        const kind = im.manifest.kind;
        const unkept_structural = (sparse.horizon != null and kind == .cluster) or
            (structural and (kind == .manifest or kind == .submanifest) and !(rid < sets.?.reach.capacity() and sets.?.reach.isSet(rid)));
        if (unkept_structural) {
            // Classified elided, not transit: a held artifact the policy drops
            // must never be requested back by the next sync. Its F cards are
            // still walked below, as references the cone does not keep, so
            // the blobs only it names classify elided rather than as
            // unreferenced holdings the last pass would keep.
            try setGrow(&referenced, gpa, rid);
        } else {
            keep.set(rid); // every other structural artifact persists (spine included)
        }
        // F cards: flat runs, delta patch runs, and sub-manifest leaf runs all
        // name blobs by full decoded path -- one rule covers all three.
        for (im.manifest.f) |fc| {
            const h = fc.hash orelse continue; // delete marker names nothing
            // INTERN, never merely look up: a blob this store does not hold
            // must still be classified, or the phantom sync creates for it
            // later is beyond these sets and `elided` answers false -- which
            // is a spontaneous request for every elided blob on every pull
            // (RFC section 5.2 rule 3 would be a dead letter). Interning is
            // session-only bookkeeping; nothing reaches disk.
            const target = try store.intern(h);
            const in_path = sparse.covers(fc.filename);
            const in_time = if (above) |a| (target < a.capacity() and a.isSet(target)) else true;
            if (!unkept_structural and in_path and in_time) {
                try setGrow(&keep, gpa, target);
            } else {
                try setGrow(&referenced, gpa, target);
            }
        }
        // An attachment's source blob is non-file content (section 5.1's
        // "non-manifest structural artifacts dense" extends to what they
        // directly carry).
        if (!unkept_structural) {
            if (im.manifest.a) |a| {
                if (a.source) |src| {
                    if (store.lookup(src)) |target| try setGrow(&keep, gpa, target);
                }
            }
        }
    }
    // The two sets must address the same rids: a rid only one of them grew to
    // reads as unset in the other, which is the right answer, but `elided`
    // indexes both by one capacity test.
    const final_len = @max(keep.capacity(), referenced.capacity());
    if (keep.capacity() < final_len) try keep.resize(gpa, final_len, false);
    if (referenced.capacity() < final_len) try referenced.resize(gpa, final_len, false);

    // A held blob nothing referenced stays: unknown provenance is kept, and
    // "referenced only by an F card the cone does not keep" is the sole
    // elision. Interned phantoms are not held, so the bound is the held
    // count the pass began with.
    rid = 1;
    while (rid <= n) : (rid += 1) {
        if (!store.held(rid)) continue;
        if (keep.isSet(rid)) continue;
        if (!referenced.isSet(rid)) keep.set(rid);
    }
    const boundary: []const repostate.Rid = if (sets) |*hs| try hs.boundary.toOwnedSlice(gpa) else &.{};
    return .{ .keep = keep, .out_only = referenced, .boundary = boundary };
}

/// Horizon draft sections 3.1 and 4.2 -- the rids of every blob some
/// check-in in the held set K names. K is computed over the P cards of the
/// held check-in manifests from the horizon's C under its rule; the blobs
/// are then every F-card target reachable from K's manifests through B
/// (baseline) and S (sub-manifest) cards.
///
/// Deliberately an over-approximation in one direction: a delta manifest's
/// deletions and overrides are not applied, so a blob the baseline named and
/// the delta removed is still counted above. Keeping more than the exact
/// tree is sound (section 5.1 says so of every structural artifact) and it
/// makes the closure a plain reachability walk with one visited set, which
/// is what keeps this O(K's reach) rather than O(K x tree). A C this store
/// does not hold, or holds only as a phantom, yields the empty set: nothing
/// is above a horizon the store cannot see, and every blob then classifies
/// by its referencers as elided.
fn aboveHorizon(
    gpa: std.mem.Allocator,
    store: repostate.ArtifactStore,
    horizon: cone_mod.Horizon,
) error{OutOfMemory}!HorizonSets {
    const n = store.count();
    var sets: HorizonSets = .{
        .above = try std.DynamicBitSetUnmanaged.initEmpty(gpa, n + 1),
        .reach = try std.DynamicBitSetUnmanaged.initEmpty(gpa, n + 1),
        .boundary = .empty,
    };
    errdefer sets.deinit(gpa);

    const c_rid = store.lookup(horizon.hash) orelse return sets;
    if (!store.held(c_rid)) return sets;

    var arena_state = std.heap.ArenaAllocator.init(gpa);
    defer arena_state.deinit();

    // Pass 1: the check-in graph as child edges, from every held manifest's
    // P cards. Edges to parents this store cannot name are dropped: a
    // boundary hash is below the horizon by construction.
    const Edge = struct { parent: repostate.Rid, child: repostate.Rid };
    var edges: std.ArrayListUnmanaged(Edge) = .empty;
    defer edges.deinit(gpa);
    var is_checkin = try std.DynamicBitSetUnmanaged.initEmpty(gpa, n + 1);
    defer is_checkin.deinit(gpa);
    var has_child = try std.DynamicBitSetUnmanaged.initEmpty(gpa, n + 1);
    defer has_child.deinit(gpa);
    var rid: repostate.Rid = 1;
    while (rid <= n) : (rid += 1) {
        if (!store.held(rid)) continue;
        const plain = store.content(rid) orelse continue;
        _ = arena_state.reset(.retain_capacity);
        const aa = arena_state.allocator();
        const im = artifact.parseForImport(aa, plain) catch |e| switch (e) {
            error.OutOfMemory => return error.OutOfMemory,
            else => continue,
        };
        if (!isCheckin(&im.manifest)) continue;
        is_checkin.set(rid);
        for (im.manifest.p) |pc| {
            var it = pc.hashes();
            while (it.next()) |ph| {
                const parent = store.lookup(ph) orelse continue;
                try edges.append(gpa, .{ .parent = parent, .child = rid });
                has_child.set(parent);
            }
        }
    }
    // Adjacency by parent: sort edges, then index ranges. Bounded by the
    // edge count, which is at most the P-card count of the store.
    std.mem.sort(Edge, edges.items, {}, struct {
        fn less(_: void, a: Edge, b: Edge) bool {
            return a.parent < b.parent;
        }
    }.less);

    // Pass 2: K. Descendants of C by BFS over child edges.
    var in_k = try std.DynamicBitSetUnmanaged.initEmpty(gpa, n + 1);
    defer in_k.deinit(gpa);
    var work: std.ArrayListUnmanaged(repostate.Rid) = .empty;
    defer work.deinit(gpa);
    in_k.set(c_rid);
    try work.append(gpa, c_rid);
    while (work.pop()) |cur| {
        const first = lowerBound(edges.items, cur);
        var i = first;
        while (i < edges.items.len and edges.items[i].parent == cur) : (i += 1) {
            const child = edges.items[i].child;
            if (in_k.isSet(child)) continue;
            in_k.set(child);
            try work.append(gpa, child);
        }
    }
    if (horizon.rule == .leaves) {
        // Ancestors of C, so a leaf's walk knows where C's ancestry begins.
        // A parent-side adjacency is the P cards themselves; walk by
        // re-parsing, bounded by the check-in count.
        var anc_c = try std.DynamicBitSetUnmanaged.initEmpty(gpa, n + 1);
        defer anc_c.deinit(gpa);
        try walkParents(gpa, store, &arena_state, c_rid, &anc_c, null);
        // Every held check-in without a held child is a leaf. Closed leaves
        // are not excluded here (that needs the tag derivation); including
        // them keeps more, never less.
        rid = 1;
        while (rid <= n) : (rid += 1) {
            if (!is_checkin.isSet(rid) or has_child.isSet(rid)) continue;
            if (in_k.isSet(rid) or anc_c.isSet(rid)) continue;
            // Walk this leaf's ancestry into K, stopping at C's ancestry
            // (the nearest common ancestor is included) or at K.
            try walkParents(gpa, store, &arena_state, rid, &in_k, &anc_c);
        }
    }

    // Pass 3: the blobs of K's trees -- every F-card target reachable from a
    // K manifest through B and S cards, one visited set (`reach`) over
    // structural rids so shared baselines and nodes are expanded once. The
    // same walk records the boundary: a parent a K check-in names that is
    // not itself in K (horizon draft section 5.3).
    const visited = &sets.reach;
    work.clearRetainingCapacity();
    rid = 1;
    while (rid <= n) : (rid += 1) {
        if (in_k.isSet(rid)) {
            visited.set(rid);
            try work.append(gpa, rid);
        }
    }
    var boundary_seen = try std.DynamicBitSetUnmanaged.initEmpty(gpa, n + 1);
    defer boundary_seen.deinit(gpa);
    while (work.pop()) |cur| {
        const plain = store.content(cur) orelse continue;
        _ = arena_state.reset(.retain_capacity);
        const aa = arena_state.allocator();
        const im = artifact.parseForImport(aa, plain) catch |e| switch (e) {
            error.OutOfMemory => return error.OutOfMemory,
            else => continue,
        };
        for (im.manifest.f) |fc| {
            const h = fc.hash orelse continue;
            // Interned for the same reason the main pass interns: the keep
            // decision for a blob K names must be reachable whether or not
            // the store holds its bytes yet.
            try setGrow(&sets.above, gpa, try store.intern(h));
        }
        if (in_k.isSet(cur)) {
            for (im.manifest.p) |pc| {
                var it = pc.hashes();
                while (it.next()) |ph| {
                    const parent = store.lookup(ph) orelse continue;
                    if (in_k.isSet(parent) or boundary_seen.isSet(parent)) continue;
                    boundary_seen.set(parent);
                    try sets.boundary.append(gpa, parent);
                }
            }
        }
        if (im.manifest.b) |b| {
            if (store.lookup(b.hash)) |base| {
                if (!visited.isSet(base)) {
                    visited.set(base);
                    try work.append(gpa, base);
                }
            }
        }
        for (im.manifest.s) |sc| {
            if (store.lookup(sc.hash)) |node| {
                if (!visited.isSet(node)) {
                    visited.set(node);
                    try work.append(gpa, node);
                }
            }
        }
    }
    return sets;
}

/// Walk `start`'s ancestry through P cards, setting `out` for every
/// check-in reached. When `stop` is given, a check-in already in `stop` is
/// set in `out` (the nearest common ancestor is held) and not expanded.
/// Bounded by the check-in count: `out` is the visited set.
fn walkParents(
    gpa: std.mem.Allocator,
    store: repostate.ArtifactStore,
    arena_state: *std.heap.ArenaAllocator,
    start: repostate.Rid,
    out: *std.DynamicBitSetUnmanaged,
    stop: ?*const std.DynamicBitSetUnmanaged,
) error{OutOfMemory}!void {
    var work: std.ArrayListUnmanaged(repostate.Rid) = .empty;
    defer work.deinit(gpa);
    if (out.isSet(start)) return;
    out.set(start);
    try work.append(gpa, start);
    while (work.pop()) |cur| {
        if (stop) |s| if (s.isSet(cur)) continue;
        const plain = store.content(cur) orelse continue;
        _ = arena_state.reset(.retain_capacity);
        const aa = arena_state.allocator();
        const im = artifact.parseForImport(aa, plain) catch |e| switch (e) {
            error.OutOfMemory => return error.OutOfMemory,
            else => continue,
        };
        for (im.manifest.p) |pc| {
            var it = pc.hashes();
            while (it.next()) |ph| {
                const parent = store.lookup(ph) orelse continue;
                if (out.isSet(parent)) continue;
                out.set(parent);
                try work.append(gpa, parent);
            }
        }
    }
}

/// First index in the parent-sorted edge list whose parent is >= `parent`.
fn lowerBound(edges: anytype, parent: repostate.Rid) usize {
    var lo: usize = 0;
    var hi: usize = edges.len;
    while (lo < hi) {
        const mid = lo + (hi - lo) / 2;
        if (edges[mid].parent < parent) lo = mid + 1 else hi = mid;
    }
    return lo;
}

/// A check-in manifest: the parser's own §5.1 type code, which is final on
/// every successful parse.
fn isCheckin(m: *const artifact.Manifest) bool {
    return m.kind == .manifest;
}

// ---------------------------------------------------------------------------
// Tests. Goal: pin the horizon keep predicate of draft-fossil-horizon §4.2
// over a small real check-in graph: blobs named only below C are elided,
// blobs named by C or its descendants are kept, the `leaves` rule pulls a
// side branch's ancestry (and its blobs) into K, a path cone intersects the
// horizon on blobs, and a C the store does not hold keeps nothing. Method:
// manifests minted by the artifact writer into a MemStore, classified, and
// compared against hand-computed keep/elide verdicts.
// ---------------------------------------------------------------------------

const testing = std.testing;

const Fixture = struct {
    ms: repostate.MemStore,
    // Blob names: 64-hex, distinct.
    f_old: []const u8 = "1111111111111111111111111111111111111111111111111111111111111111",
    f_keep: []const u8 = "2222222222222222222222222222222222222222222222222222222222222222",
    f_new: []const u8 = "3333333333333333333333333333333333333333333333333333333333333333",
    f_branch: []const u8 = "4444444444444444444444444444444444444444444444444444444444444444",
    f_other: []const u8 = "5555555555555555555555555555555555555555555555555555555555555555",
    // Manifest names, filled by `build`.
    c1: [64]u8 = undefined,
    c2: [64]u8 = undefined,
    c3: [64]u8 = undefined, // C
    c4: [64]u8 = undefined,
    b1: [64]u8 = undefined, // a leaf forked from c1

    fn store(self: *Fixture) repostate.ArtifactStore {
        return self.ms.store();
    }

    /// Mint and store one check-in; returns its name into `out`.
    fn checkin(self: *Fixture, ts: []const u8, parent: ?[]const u8, files: []const artifact.FCard, out: *[64]u8) !void {
        var parent_buf: [1]artifact.PCard = undefined;
        var p: []const artifact.PCard = &.{};
        if (parent) |ph| {
            parent_buf[0] = .{ .parents = ph, .count = 1 };
            p = parent_buf[0..1];
        }
        var m = artifact.Manifest{
            .arena_state = std.heap.ArenaAllocator.init(testing.allocator),
            .kind = .manifest,
            .a = null,
            .b = null,
            .c = .{ .comment = "c" },
            .d = .{ .timestamp = ts },
            .e = null,
            .g = null,
            .h = null,
            .i = null,
            .k = null,
            .l = null,
            .n = null,
            .r = null,
            .u = .{ .login = "u" },
            .z = null,
            .w = null,
            .f = files,
            .j = &.{},
            .m = &.{},
            .p = p,
            .q = &.{},
            .t = &.{},
        };
        defer m.deinit();
        const bytes = try artifact.create(&m, testing.allocator);
        defer testing.allocator.free(bytes);
        const name = artifact.artifactName(.sha3_256, bytes, out);
        _ = try self.store().put(name, bytes);
    }

    fn fcard(name: []const u8, hash: []const u8) artifact.FCard {
        return .{ .filename = name, .hash = hash, .perm = null, .old_name = null, .mode = .regular };
    }

    /// c1 -> c2 -> c3 (C) -> c4, with b1 forked from c1.
    ///   c1: src/keep.txt=f_keep, src/old.txt=f_old
    ///   c2: same as c1
    ///   c3: src/keep.txt=f_keep                      (old.txt deleted)
    ///   c4: src/keep.txt=f_keep, src/new.txt=f_new, other/x=f_other
    ///   b1: src/branch.txt=f_branch, src/keep.txt=f_keep
    fn build(self: *Fixture) !void {
        const s = self.store();
        for ([_][]const u8{ self.f_old, self.f_keep, self.f_new, self.f_branch, self.f_other }) |h| {
            _ = try s.put(h, h); // any content; the name is what the F cards cite
        }
        const c1_files = [_]artifact.FCard{ fcard("src/keep.txt", self.f_keep), fcard("src/old.txt", self.f_old) };
        try self.checkin("2026-01-01T00:00:00", null, &c1_files, &self.c1);
        try self.checkin("2026-01-02T00:00:00", &self.c1, &c1_files, &self.c2);
        const c3_files = [_]artifact.FCard{fcard("src/keep.txt", self.f_keep)};
        try self.checkin("2026-01-03T00:00:00", &self.c2, &c3_files, &self.c3);
        const c4_files = [_]artifact.FCard{ fcard("other/x", self.f_other), fcard("src/keep.txt", self.f_keep), fcard("src/new.txt", self.f_new) };
        try self.checkin("2026-01-04T00:00:00", &self.c3, &c4_files, &self.c4);
        const b1_files = [_]artifact.FCard{ fcard("src/branch.txt", self.f_branch), fcard("src/keep.txt", self.f_keep) };
        try self.checkin("2026-01-05T00:00:00", &self.c1, &b1_files, &self.b1);
    }

    fn rid(self: *Fixture, hash: []const u8) repostate.Rid {
        return self.store().lookup(hash).?;
    }
};

test "horizon §4.1/§4.2: descendants rule keeps C's and its descendants' blobs, elides the rest, holds every manifest" {
    const gpa = testing.allocator;
    var fx = Fixture{ .ms = repostate.MemStore.init(gpa) };
    defer fx.ms.deinit();
    try fx.build();
    var cone = try cone_mod.Cone.initHorizon(gpa, &.{}, &.{}, .{ .hash = &fx.c3, .rule = .descendants });
    defer cone.deinit(gpa);
    var cls = try classifyKeepRids(gpa, fx.store(), &cone);
    defer cls.deinit(gpa);

    // Every manifest is structural and kept, above or below the horizon.
    for ([_][]const u8{ &fx.c1, &fx.c2, &fx.c3, &fx.c4, &fx.b1 }) |m| try testing.expect(cls.keep.isSet(fx.rid(m)));
    // Blobs in the trees of C and c4 are kept.
    try testing.expect(cls.keep.isSet(fx.rid(fx.f_keep)));
    try testing.expect(cls.keep.isSet(fx.rid(fx.f_new)));
    try testing.expect(cls.keep.isSet(fx.rid(fx.f_other)));
    // Named only below the horizon, or only on a branch outside K: elided.
    try testing.expect(cls.elided(fx.rid(fx.f_old)));
    try testing.expect(cls.elided(fx.rid(fx.f_branch)));
    try testing.expect(!cls.elided(fx.rid(fx.f_keep)));
}

test "horizon §3.1: the leaves rule pulls a side branch's ancestry into K, and its blobs with it" {
    const gpa = testing.allocator;
    var fx = Fixture{ .ms = repostate.MemStore.init(gpa) };
    defer fx.ms.deinit();
    try fx.build();
    var cone = try cone_mod.Cone.initHorizon(gpa, &.{}, &.{}, .{ .hash = &fx.c3, .rule = .leaves });
    defer cone.deinit(gpa);
    var cls = try classifyKeepRids(gpa, fx.store(), &cone);
    defer cls.deinit(gpa);
    // b1 is an open leaf forked at c1: K gains b1 and c1 (the nearest common
    // ancestor), so f_branch and f_old (named by c1) are above the horizon.
    try testing.expect(cls.keep.isSet(fx.rid(fx.f_branch)));
    try testing.expect(cls.keep.isSet(fx.rid(fx.f_old)));
    try testing.expect(cls.keep.isSet(fx.rid(fx.f_keep)));
    try testing.expect(cls.keep.isSet(fx.rid(fx.f_new)));
}

test "horizon §3.2: a path cone and a horizon intersect on blobs" {
    const gpa = testing.allocator;
    var fx = Fixture{ .ms = repostate.MemStore.init(gpa) };
    defer fx.ms.deinit();
    try fx.build();
    var cone = try cone_mod.Cone.initHorizon(gpa, &.{"src"}, &.{}, .{ .hash = &fx.c3, .rule = .descendants });
    defer cone.deinit(gpa);
    var cls = try classifyKeepRids(gpa, fx.store(), &cone);
    defer cls.deinit(gpa);
    try testing.expect(cls.keep.isSet(fx.rid(fx.f_keep)));
    try testing.expect(cls.keep.isSet(fx.rid(fx.f_new)));
    try testing.expect(cls.elided(fx.rid(fx.f_other))); // above the horizon, outside the path cone
    try testing.expect(cls.elided(fx.rid(fx.f_old))); // inside the path cone, below the horizon
}

test "horizon §4.2: a C this store does not hold puts nothing above the horizon" {
    const gpa = testing.allocator;
    var fx = Fixture{ .ms = repostate.MemStore.init(gpa) };
    defer fx.ms.deinit();
    try fx.build();
    const absent = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee";
    var cone = try cone_mod.Cone.initHorizon(gpa, &.{}, &.{}, .{ .hash = absent, .rule = .descendants });
    defer cone.deinit(gpa);
    var cls = try classifyKeepRids(gpa, fx.store(), &cone);
    defer cls.deinit(gpa);
    // Structure is kept; every referenced blob is elided.
    try testing.expect(cls.keep.isSet(fx.rid(&fx.c4)));
    for ([_][]const u8{ fx.f_old, fx.f_keep, fx.f_new, fx.f_branch, fx.f_other }) |h| try testing.expect(cls.elided(fx.rid(h)));
}

test "horizon §5.1/§5.3: the structural depth elides manifests K does not reach and reports the boundary" {
    const gpa = testing.allocator;
    var fx = Fixture{ .ms = repostate.MemStore.init(gpa) };
    defer fx.ms.deinit();
    try fx.build();
    var cone = try cone_mod.Cone.initHorizon(gpa, &.{}, &.{}, .{ .hash = &fx.c3, .rule = .descendants, .depth = .structural });
    defer cone.deinit(gpa);
    var cls = try classifyKeepRids(gpa, fx.store(), &cone);
    defer cls.deinit(gpa);
    // K = {c3, c4}: their manifests are kept; c1, c2 and the side leaf b1 are
    // elided structure, not transit (they must never be requested back).
    try testing.expect(cls.keep.isSet(fx.rid(&fx.c3)));
    try testing.expect(cls.keep.isSet(fx.rid(&fx.c4)));
    try testing.expect(cls.elided(fx.rid(&fx.c1)));
    try testing.expect(cls.elided(fx.rid(&fx.c2)));
    try testing.expect(cls.elided(fx.rid(&fx.b1)));
    // Blobs: as under the content depth.
    try testing.expect(cls.keep.isSet(fx.rid(fx.f_keep)));
    try testing.expect(cls.elided(fx.rid(fx.f_old)));
    // The boundary is exactly c2, the one parent a K check-in names below K.
    try testing.expectEqual(@as(usize, 1), cls.boundary.len);
    try testing.expectEqual(fx.rid(&fx.c2), cls.boundary[0]);
}