Skip to content
zit Artifact [13fc5526d8]
Login

Artifact [13fc5526d8]

Artifact 13fc5526d85f4b974078d380013e3cdd91882bc1731b9f67b66492f5c3ef50df:


//! Sparse-cone classification (draft-fossil-sparse-cone-00 sections 5.1,
//! 5.2, 6.3): 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");
const coneindex = @import("coneindex.zig");
const logstore = @import("logstore.zig");

/// Sparse-cone RFC sections 5.1 and 6.3, Wave-2 posture: 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 names, every attachment source, and -- conservatively --
/// every blob nothing references at all. ELIDES exactly the blobs whose
/// every reference is an out-of-cone F card, and the absent sub-manifest
/// nodes whose every referencing S-card slot lies outside the cone. 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 OUT-of-cone F card.
    /// `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
    /// filename proves out-of-cone.
    out_only: std.DynamicBitSetUnmanaged,
    /// Lowercase names of the absent sub-manifest nodes elided by their
    /// slots, whether or not the store has interned them. Kept apart from
    /// the bitsets for two reasons: a clone stores wire-backed and never
    /// interns a manifest's S references, so the persist hook that later
    /// mints their phantoms can only ask by name; and a commit's structural
    /// fetch must still request exactly these (section 5.3) while it keeps
    /// suppressing elided blobs.
    elided_nodes: std.StringHashMapUnmanaged(void),
    /// Owns `elided_nodes`' keys.
    arena: std.heap.ArenaAllocator,

    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);
    }

    /// Whether `name` (40 or 64 hex, either case) is an absent sub-manifest
    /// node this classification elided.
    pub fn elidedNode(self: *const SparseClassification, name: []const u8) bool {
        var buf: [64]u8 = undefined;
        if (name.len > buf.len) return false;
        return self.elided_nodes.contains(std.ascii.lowerString(buf[0..name.len], name));
    }

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

/// The cone as a tree-walk filter: an absent node whose slot the cone
/// cannot reach is elided, so a sparse checkout's walk steps over it.
pub fn slotFilter(cone: *const cone_mod.Cone) artifact.SlotFilter {
    return .{ .ctx = cone, .mayHoldFn = coneMayHold };
}

fn coneMayHold(ctx: *const anyopaque, first: []const u8, limit: ?[]const u8) bool {
    const cone: *const cone_mod.Cone = @ptrCast(@alignCast(ctx));
    return cone.slotMayCover(first, limit);
}

pub fn classifyKeepRids(
    gpa: std.mem.Allocator,
    store: repostate.ArtifactStore,
    sparse: *const cone_mod.Cone,
) error{OutOfMemory}!SparseClassification {
    return classifyKeepRidsReleasing(gpa, store, sparse, null);
}

/// How a reading classification sheds the plaintext its store memoises.
/// Both stores a client classifies over keep every artifact `content()`
/// inflated until told otherwise, so one pass over a large store would end
/// holding its whole uncompressed history (a sub-manifest store is mostly
/// tree nodes, and each is read). `release` is called whenever `every` bytes
/// have been read since the last call, between artifacts, when no borrowed
/// slice is live; it must leave the store answering identically.
pub const PlaintextRelease = struct {
    ctx: *anyopaque,
    releaseFn: *const fn (ctx: *anyopaque) void,
    every: usize = 64 << 20,
};

/// `classifyKeepRids`, shedding plaintext through `release` as it reads.
pub fn classifyKeepRidsReleasing(
    gpa: std.mem.Allocator,
    store: repostate.ArtifactStore,
    sparse: *const cone_mod.Cone,
    release: ?PlaintextRelease,
) 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);
    // The S-bearing artifacts, for the slot walk below should it be needed.
    var trees_at = try std.DynamicBitSetUnmanaged.initEmpty(gpa, n + 1);
    defer trees_at.deinit(gpa);
    // Whether some S card names a node the store does not hold. Only such a
    // node can be elided by slot, so without one the walk -- and the copy of
    // every tree's S run it needs, gigabytes on a deep sub-manifest history
    // -- is skipped: its answer would be empty.
    var holes = false;

    var arena_state = std.heap.ArenaAllocator.init(gpa);
    defer arena_state.deinit();
    var read_since: usize = 0;

    var rid: repostate.Rid = 1;
    while (rid <= n) : (rid += 1) {
        if (!store.held(rid)) continue;
        const plain = store.content(rid) orelse continue;
        defer if (release) |r| {
            read_since += plain.len;
            if (read_since >= r.every) {
                r.releaseFn(r.ctx); // `plain` and the parse are dead by now
                read_since = 0;
            }
        };
        _ = 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,
        };
        keep.set(rid); // every structural artifact persists (spine included)
        if (im.manifest.s.len != 0) {
            trees_at.set(rid);
            if (!holes) holes = try namesAbsentNode(store, &im.manifest);
        }
        // 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
            const target = store.lookup(h) orelse continue; // absent = already phantom
            if (sparse.covers(fc.filename)) {
                keep.set(target);
            } else {
                referenced.set(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 (im.manifest.a) |a| {
            if (a.source) |src| {
                if (store.lookup(src)) |target| keep.set(target);
            }
        }
    }

    // Sub-manifest nodes by slot. A held node is structural and already
    // kept; an absent one is elided only when no slot naming it may hold a
    // covered path, the same interval test a section 6.2 server descends by.
    var node_arena = std.heap.ArenaAllocator.init(gpa);
    errdefer node_arena.deinit();
    var elided_nodes: std.StringHashMapUnmanaged(void) = .empty;
    errdefer elided_nodes.deinit(gpa);
    if (holes) {
        var trees = TreeSlots.init(gpa);
        defer trees.deinit();
        var it_trees = trees_at.iterator(.{});
        while (it_trees.next()) |t| {
            const plain = store.content(t) orelse continue;
            _ = arena_state.reset(.retain_capacity);
            const im = artifact.parseForImport(arena_state.allocator(), plain) catch |e| switch (e) {
                error.OutOfMemory => return error.OutOfMemory,
                else => continue,
            };
            try trees.note(store.hashOf(t), &im.manifest);
            if (release) |r| {
                read_since += plain.len;
                if (read_since >= r.every) {
                    r.releaseFn(r.ctx);
                    read_since = 0;
                }
            }
        }
        var verdicts = try trees.classify(sparse);
        defer verdicts.deinit(gpa);
        var it = verdicts.iterator();
        while (it.next()) |e| {
            if (e.value_ptr.*) continue; // some slot may hold a covered path
            if (store.lookup(e.key_ptr.*)) |target| if (store.held(target)) continue;
            try elided_nodes.put(gpa, try node_arena.allocator().dupe(u8, e.key_ptr.*), {});
        }
    }

    // A held blob nothing referenced stays: unknown provenance is kept, and
    // "referenced only out-of-cone" is the sole elision.
    rid = 1;
    while (rid <= n) : (rid += 1) {
        if (!store.held(rid)) continue;
        if (keep.isSet(rid)) continue;
        if (!referenced.isSet(rid)) keep.set(rid);
    }
    return .{ .keep = keep, .out_only = referenced, .elided_nodes = elided_nodes, .arena = node_arena };
}

/// Whether one of `m`'s S cards names a node `store` does not hold, by the
/// lowercase name the slot walk looks it up under.
fn namesAbsentNode(store: repostate.ArtifactStore, m: *const artifact.Manifest) error{OutOfMemory}!bool {
    for (m.s) |sc| {
        var buf: [64]u8 = undefined;
        if (sc.hash.len > buf.len) return true; // unreachable by the S grammar; let the walk decide
        const lower = std.ascii.lowerString(buf[0..sc.hash.len], sc.hash);
        const target = store.lookup(lower) orelse return true;
        if (!store.held(target)) return true;
    }
    return false;
}

/// A log rid's rid in the store being classified, or null when the store
/// would not find that artifact by name (masked, a phantom, not carried).
pub const RidMap = struct {
    ctx: *const anyopaque,
    mapFn: *const fn (ctx: *const anyopaque, log_rid: logstore.Rid) ?repostate.Rid,

    fn map(self: RidMap, log_rid: logstore.Rid) ?repostate.Rid {
        return self.mapFn(self.ctx, log_rid);
    }
};

/// `classifyKeepRids` answered from a `coneindex` over the log `store` is
/// backed by, instead of by reading every held artifact: the same keep set,
/// out_only set and elided nodes, by the same rules, from the facts that
/// reading would have found. The cost is one pass over the index's distinct
/// (path, blob) pairs and its structural bits -- no artifact is read.
///
/// Null when the index cannot answer exactly: some S card names a node the
/// store does not hold, so absent nodes need the slot walk, which needs every
/// tree's S run. The caller then reads.
pub fn classifyIndexed(
    gpa: std.mem.Allocator,
    store: repostate.ArtifactStore,
    sparse: *const cone_mod.Cone,
    idx: *const coneindex.Index,
    rids: RidMap,
) error{OutOfMemory}!?SparseClassification {
    var absent = idx.texts(.absent);
    while (absent.next()) |name| {
        const r = store.lookup(name) orelse return null;
        if (!store.held(r)) return null;
    }

    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);

    // Every held structural artifact persists (spine included).
    var lr: usize = 1;
    while (lr < idx.n_bits) : (lr += 1) {
        if (!idx.isStructural(lr)) continue;
        const r = rids.map(lr) orelse continue;
        if (store.held(r)) keep.set(r);
    }
    // F cards, one cone test per distinct path rather than per card.
    var paths = idx.pathRecords();
    while (paths.next()) |rec| {
        const in_cone = sparse.covers(rec.path);
        var k: u32 = 0;
        while (k < rec.count) : (k += 1) {
            const r = rids.map(rec.target(k)) orelse continue;
            if (in_cone) keep.set(r) else referenced.set(r);
        }
    }
    var dangling = idx.texts(.dangling);
    while (dangling.next()) |entry| {
        const sep = std.mem.indexOfScalar(u8, entry, 0).?;
        const r = store.lookup(entry[sep + 1 ..]) orelse continue;
        if (sparse.covers(entry[0..sep])) keep.set(r) else referenced.set(r);
    }
    var ai: usize = 0;
    while (ai < idx.n_attach) : (ai += 1) {
        if (rids.map(idx.attachAt(ai))) |r| keep.set(r);
    }
    var attach_text = idx.texts(.attach_dangling);
    while (attach_text.next()) |name| {
        if (store.lookup(name)) |r| keep.set(r);
    }

    // As the reading pass: a held blob nothing referenced stays.
    var rid: repostate.Rid = 1;
    while (rid <= n) : (rid += 1) {
        if (!store.held(rid)) continue;
        if (keep.isSet(rid)) continue;
        if (!referenced.isSet(rid)) keep.set(rid);
    }
    return .{
        .keep = keep,
        .out_only = referenced,
        .elided_nodes = .empty,
        .arena = std.heap.ArenaAllocator.init(gpa),
    };
}

/// The S runs of a store's held tree artifacts, gathered in one pass and
/// classified once every parent is known. A slot's upper bound is the next
/// card's name, or for a node's final card its parent's bound (submanifests
/// §3.6), so an interior node's slots are only decidable from the root down:
/// gathering first lets nodes arrive in any order.
pub const TreeSlots = struct {
    gpa: std.mem.Allocator,
    arena: std.heap.ArenaAllocator,
    /// Lowercase node name -> its S run, for every held S-bearing artifact.
    nodes: std.StringHashMapUnmanaged(Node) = .empty,
    /// Names of the held tree MANIFESTS: the walk's roots, unbounded above.
    roots: std.ArrayListUnmanaged([]const u8) = .empty,

    const Node = struct {
        /// Card names, ascending (§3.4); `hashes[i]` is the node `names[i]`'s
        /// slot refers to, lowercased.
        names: []const []const u8,
        hashes: []const []const u8,
    };

    pub fn init(gpa: std.mem.Allocator) TreeSlots {
        return .{ .gpa = gpa, .arena = std.heap.ArenaAllocator.init(gpa) };
    }

    pub fn deinit(self: *TreeSlots) void {
        self.nodes.deinit(self.gpa);
        self.roots.deinit(self.gpa);
        self.arena.deinit();
        self.* = undefined;
    }

    /// Record one held artifact `name`'s S run. Copies what it keeps, so
    /// `m` need not outlive the call.
    pub fn note(self: *TreeSlots, name: []const u8, m: *const artifact.Manifest) error{OutOfMemory}!void {
        if (m.s.len == 0) return;
        const aa = self.arena.allocator();
        const names = try aa.alloc([]const u8, m.s.len);
        const hashes = try aa.alloc([]const u8, m.s.len);
        for (m.s, 0..) |sc, i| {
            names[i] = try aa.dupe(u8, sc.filename);
            hashes[i] = try lowerDupe(aa, sc.hash);
        }
        const key = try lowerDupe(aa, name);
        const gop = try self.nodes.getOrPut(self.gpa, key);
        if (gop.found_existing) return; // content-addressed: the same run
        gop.value_ptr.* = .{ .names = names, .hashes = hashes };
        if (m.kind == .manifest) try self.roots.append(self.gpa, key);
    }

    /// Every node some held slot refers to, mapped to whether any of its
    /// slots may hold a covered path. Keys borrow this `TreeSlots`.
    pub fn classify(
        self: *TreeSlots,
        cone: *const cone_mod.Cone,
    ) error{OutOfMemory}!std.StringHashMapUnmanaged(bool) {
        const gpa = self.gpa;
        var verdicts: std.StringHashMapUnmanaged(bool) = .empty;
        errdefer verdicts.deinit(gpa);
        const Visit = struct { name: []const u8, upper: ?[]const u8 };
        var work: std.ArrayListUnmanaged(Visit) = .empty;
        defer work.deinit(gpa);
        // A node shared by several check-ins is usually reached under one
        // slot; each distinct (node, bound) pair is walked once, which also
        // bounds the walk on a malformed tree.
        var seen: std.StringHashMapUnmanaged(void) = .empty;
        defer seen.deinit(gpa);
        var key_arena = std.heap.ArenaAllocator.init(gpa);
        defer key_arena.deinit();

        for (self.roots.items) |root| try work.append(gpa, .{ .name = root, .upper = null });
        while (work.pop()) |v| {
            const key = try std.fmt.allocPrint(key_arena.allocator(), "{s}\x00{s}{s}", .{
                v.name, if (v.upper == null) "-" else "+", v.upper orelse "",
            });
            const fresh = try seen.getOrPut(gpa, key);
            if (fresh.found_existing) continue;
            const node = self.nodes.get(v.name) orelse continue;
            for (node.names, node.hashes, 0..) |first, child, i| {
                const limit: ?[]const u8 = if (i + 1 < node.names.len) node.names[i + 1] else v.upper;
                const may = cone.slotMayCover(first, limit);
                const gop = try verdicts.getOrPut(gpa, child);
                gop.value_ptr.* = (gop.found_existing and gop.value_ptr.*) or may;
                // Descend held nodes whatever their verdict: an out-of-cone
                // node's absent children are elided by the same test.
                if (self.nodes.contains(child)) try work.append(gpa, .{ .name = child, .upper = limit });
            }
        }
        return verdicts;
    }

    fn lowerDupe(aa: std.mem.Allocator, s: []const u8) error{OutOfMemory}![]const u8 {
        const out = try aa.alloc(u8, s.len);
        return std.ascii.lowerString(out, s);
    }
};

// ---------------------------------------------------------------------------
// Tests. Goal: pin which absent sub-manifest nodes a cone elides -- wholly
// outside, inside an exclusion, out only by the bound a PARENT's next card
// sets -- and that a node straddling the cone boundary is never elided.
// Method: a two-level tree in a MemStore with the nodes a section 6.2 server
// would not send left out, classified under a two-member cone with an
// exclusion.
// ---------------------------------------------------------------------------

const testing = std.testing;

const TestTree = struct {
    gpa: std.mem.Allocator,
    ms: repostate.MemStore,
    names: std.ArrayListUnmanaged([]u8) = .empty,

    fn deinit(self: *TestTree) void {
        for (self.names.items) |n| self.gpa.free(n);
        self.names.deinit(self.gpa);
        self.ms.deinit();
    }

    /// Name `bytes` and, when `held`, store them. Returns the name.
    fn add(self: *TestTree, bytes: []const u8, held: bool) ![]const u8 {
        var buf: [64]u8 = undefined;
        const name = try self.gpa.dupe(u8, artifact.artifactName(.sha3_256, bytes, &buf));
        errdefer self.gpa.free(name);
        try self.names.append(self.gpa, name);
        if (held) _ = try self.ms.store().put(name, bytes);
        return name;
    }

    /// A card run sealed with its Z card; `letter` is 'F' or 'S'.
    fn node(self: *TestTree, head: []const u8, letter: u8, cards: []const [2][]const u8, tail: []const u8, held: bool) ![]const u8 {
        var body: std.ArrayListUnmanaged(u8) = .empty;
        defer body.deinit(self.gpa);
        try body.appendSlice(self.gpa, head);
        for (cards) |c| try body.print(self.gpa, "{c} {s} {s}\n", .{ letter, c[0], c[1] });
        try body.appendSlice(self.gpa, tail);
        var digest: [16]u8 = undefined;
        std.crypto.hash.Md5.hash(body.items, &digest, .{});
        try body.print(self.gpa, "Z {x}\n", .{&digest});
        return self.add(body.items, held);
    }

    fn leaf(self: *TestTree, paths: []const []const u8, held: bool) ![]const u8 {
        var cards: [4][2][]const u8 = undefined;
        for (paths, 0..) |p, i| cards[i] = .{ p, try self.add(p, held) };
        return self.node("", 'F', cards[0..paths.len], "", held);
    }
};

test "sparse classifyKeepRids: absent sub-manifest nodes elide by slot, never across the boundary" {
    const gpa = testing.allocator;
    var t: TestTree = .{ .gpa = gpa, .ms = repostate.MemStore.init(gpa) };
    defer t.deinit();

    // Absent nodes are the ones a section 6.2 server skips, plus one it
    // must not skip (the straddler) to prove that absence stays a hole.
    const l_docs = try t.leaf(&.{ "docs/a", "docs/b" }, false); // wholly before
    // Starts below `lib`: a slot ending at `lib/a` could still hold the
    // path `lib` itself, so the slot before must end at or below it.
    const l_lib = try t.leaf(&.{ "kit/a", "lib/a" }, true);
    const l_mid = try t.leaf(&.{ "m/a", "n/a" }, false); // out only by I1's bound, rc/x
    const l_straddle = try t.leaf(&.{ "rc/x", "src/a" }, false); // in: reaches src/a
    const l_gen = try t.leaf(&.{ "src/gen/a", "src/gen/b" }, false); // inside the exclusion
    const l_gen_tail = try t.leaf(&.{ "src/gen/c", "src/gen/d" }, true); // runs past it to src/h
    const l_src = try t.leaf(&.{ "src/h", "src/z" }, true);
    const l_zzz = try t.leaf(&.{"zzz/a"}, false);
    const i_low = try t.node("", 'S', &.{ .{ "docs/a", l_docs }, .{ "kit/a", l_lib }, .{ "m/a", l_mid } }, "", true);
    const i_src = try t.node("", 'S', &.{ .{ "rc/x", l_straddle }, .{ "src/gen/a", l_gen }, .{ "src/gen/c", l_gen_tail }, .{ "src/h", l_src } }, "", true);
    const i_high = try t.node("", 'S', &.{.{ "zzz/a", l_zzz }}, "", false); // wholly after
    _ = try t.node(
        "C tree\nD 2026-10-21T12:00:00.000\n",
        'S',
        &.{ .{ "docs/a", i_low }, .{ "rc/x", i_src }, .{ "zzz/a", i_high } },
        "U u\n",
        true,
    );

    var cone = try cone_mod.Cone.init(gpa, &.{ "lib", "src" }, &.{"src/gen"});
    defer cone.deinit(gpa);
    var c = try classifyKeepRids(gpa, t.ms.store(), &cone);
    defer c.deinit(gpa);

    try testing.expect(c.elidedNode(l_docs));
    try testing.expect(c.elidedNode(l_mid));
    try testing.expect(c.elidedNode(l_gen));
    try testing.expect(c.elidedNode(i_high));
    try testing.expect(!c.elidedNode(l_straddle));
    // Held nodes are never elided, whatever their slots; and a node no held
    // artifact names (l_zzz, under the absent i_high) is not classified.
    for ([_][]const u8{ l_lib, l_gen_tail, l_src, i_low, i_src, l_zzz }) |n| try testing.expect(!c.elidedNode(n));
    // The case of the name does not matter (submanifests §3.5).
    var upper: [64]u8 = undefined;
    try testing.expect(c.elidedNode(std.ascii.upperString(&upper, l_docs)));

    // Without the exclusion, the excluded leaf is in the cone again.
    var plain = try cone_mod.Cone.init(gpa, &.{ "lib", "src" }, &.{});
    defer plain.deinit(gpa);
    var c2 = try classifyKeepRids(gpa, t.ms.store(), &plain);
    defer c2.deinit(gpa);
    try testing.expect(!c2.elidedNode(l_gen));
    try testing.expect(c2.elidedNode(l_mid));
}

test "sparse classifyKeepRidsReleasing: shedding plaintext between artifacts changes no answer" {
    const gpa = testing.allocator;
    var t: TestTree = .{ .gpa = gpa, .ms = repostate.MemStore.init(gpa) };
    defer t.deinit();
    const l_in = try t.leaf(&.{ "src/a", "src/b" }, true);
    const l_out = try t.leaf(&.{ "docs/a", "docs/b" }, true);
    const l_gone = try t.leaf(&.{"zzz/a"}, false); // absent: the slot walk runs
    _ = try t.node(
        "C tree\nD 2026-10-21T12:00:00.000\n",
        'S',
        &.{ .{ "docs/a", l_out }, .{ "src/a", l_in }, .{ "zzz/a", l_gone } },
        "U u\n",
        true,
    );
    var cone = try cone_mod.Cone.init(gpa, &.{"src"}, &.{});
    defer cone.deinit(gpa);

    var plain = try classifyKeepRids(gpa, t.ms.store(), &cone);
    defer plain.deinit(gpa);
    const Counter = struct {
        ms: *repostate.MemStore,
        calls: usize = 0,
        fn release(ctx: *anyopaque) void {
            const self: *@This() = @ptrCast(@alignCast(ctx));
            self.ms.releasePlaintext();
            self.calls += 1;
        }
    };
    var counter: Counter = .{ .ms = &t.ms };
    var shed = try classifyKeepRidsReleasing(gpa, t.ms.store(), &cone, .{ .ctx = &counter, .releaseFn = Counter.release, .every = 1 });
    defer shed.deinit(gpa);

    // Every artifact read, in both passes, sheds once.
    try testing.expect(counter.calls >= t.ms.store().count());
    var r: usize = 0;
    while (r < plain.keep.capacity()) : (r += 1) {
        try testing.expectEqual(plain.keep.isSet(r), shed.keep.isSet(r));
        try testing.expectEqual(plain.out_only.isSet(r), shed.out_only.isSet(r));
    }
    try testing.expect(plain.elidedNode(l_gone) and shed.elidedNode(l_gone));
    try testing.expectEqual(plain.elided_nodes.count(), shed.elided_nodes.count());
}