//! 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]);
}