Something went wrong. Try again.
A Zig library for Bluesky activity and feed analytics, built on Zat.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170const std = @import("std");const zat = @import("zat");
pub const Kind = enum { post, reply, repost, like };pub const Window = struct { since: i64, until: i64, pub fn validate(self: Window) !void { if (self.since > self.until) return error.InvalidWindow; } pub fn contains(self: Window, micros: i64) bool { return micros >= self.since and micros <= self.until; }};pub const Evidence = struct { kind: Kind, uri: []const u8, micros: i64, subject: ?[]const u8 = null, root: ?[]const u8 = null,};pub const Coverage = struct { post: bool = false, reply: bool = false, repost: bool = false, like: bool = false, pub const all: Coverage = .{ .post = true, .reply = true, .repost = true, .like = true }; pub fn complete(self: Coverage) bool { return self.post and self.reply and self.repost and self.like; }};pub const EvidenceSet = struct { records: []const Evidence, coverage: Coverage, observed_at: i64,
pub fn coverageFor(self: EvidenceSet, window: Window) Coverage { return if (window.until <= self.observed_at) self.coverage else .{}; }};pub const Counts = struct { post: usize = 0, reply: usize = 0, repost: usize = 0, like: usize = 0, coverage: Coverage = .{}, pub fn complete(self: Counts) bool { return self.coverage.complete(); } pub fn add(self: *Counts, kind: Kind) void { switch (kind) { .post => self.post += 1, .reply => self.reply += 1, .repost => self.repost += 1, .like => self.like += 1, } }};pub const collections = [_][]const u8{ "app.bsky.feed.post", "app.bsky.feed.repost", "app.bsky.feed.like" };pub const Signals = struct { post: bool, reply: bool, repost: bool, like: bool };pub const Pattern = union(enum) { incomplete, active: Signals, no_retained_activity };
pub fn pattern(c: Counts) Pattern { if (!c.complete()) return .incomplete; if (c.post == 0 and c.reply == 0 and c.repost == 0 and c.like == 0) return .no_retained_activity; return .{ .active = .{ .post = c.post > 0, .reply = c.reply > 0, .repost = c.repost > 0, .like = c.like > 0 } };}
pub fn count(evidence: EvidenceSet, window: Window) !Counts { try window.validate(); var result: Counts = .{ .coverage = evidence.coverageFor(window) }; for (evidence.records) |record| if (record.micros <= evidence.observed_at and window.contains(record.micros)) { result.add(record.kind); }; return result;}
pub const Interaction = struct { subject: []const u8, replies: usize = 0, reposts: usize = 0, likes: usize = 0 };pub const Interactions = struct { rows: []const Interaction, coverage: Coverage, pub fn deinit(self: Interactions, allocator: std.mem.Allocator) void { allocator.free(self.rows); }};pub fn interactions(allocator: std.mem.Allocator, evidence: EvidenceSet, window: Window) !Interactions { try window.validate(); var indices = std.StringHashMap(usize).init(allocator); defer indices.deinit(); var rows: std.ArrayList(Interaction) = .empty; errdefer rows.deinit(allocator); for (evidence.records) |record| { if (record.micros > evidence.observed_at or !window.contains(record.micros)) continue; const subject = record.subject orelse continue; const entry = try indices.getOrPut(subject); if (!entry.found_existing) { entry.value_ptr.* = rows.items.len; try rows.append(allocator, .{ .subject = subject }); } const row = &rows.items[entry.value_ptr.*]; switch (record.kind) { .reply => row.replies += 1, .repost => row.reposts += 1, .like => row.likes += 1, .post => {}, } } std.mem.sort(Interaction, rows.items, {}, struct { fn less(_: void, a: Interaction, b: Interaction) bool { return std.mem.lessThan(u8, a.subject, b.subject); } }.less); return .{ .rows = try rows.toOwnedSlice(allocator), .coverage = evidence.coverageFor(window) };}
pub fn classify(record: zat.cbor.Value, collection: []const u8, uri: []const u8) !Evidence { const tag = record.getString("$type") orelse return error.MissingType; if (!std.mem.eql(u8, tag, collection)) return error.RecordTypeMismatch; const stamp = record.getString("createdAt") orelse return error.MissingTimestamp; const time = zat.Datetime.parse(stamp) orelse return error.InvalidTimestamp; var result: Evidence = .{ .kind = .post, .uri = uri, .micros = time.micros }; if (std.mem.eql(u8, collection, collections[0])) { if (record.get("reply")) |reply| { result.kind = .reply; result.subject = try reference(reply.get("parent") orelse return error.InvalidReply); result.root = try reference(reply.get("root") orelse return error.InvalidReply); } } else if (std.mem.eql(u8, collection, collections[1]) or std.mem.eql(u8, collection, collections[2])) { result.kind = if (std.mem.eql(u8, collection, collections[1])) .repost else .like; result.subject = try reference(record.get("subject") orelse return error.MissingSubject); } else return error.UnsupportedCollection; return result;}
fn reference(value: zat.cbor.Value) ![]const u8 { const uri = value.getString("uri") orelse return error.InvalidReference; const parsed = zat.AtUri.parse(uri) orelse return error.InvalidReference; if (parsed.collection() == null or parsed.rkey() == null) return error.InvalidReference; return uri;}
test "patterns retain four independent signals and incomplete evidence" { try std.testing.expect(pattern(.{ .like = 5 }) == .incomplete); const p = pattern(.{ .reply = 2, .repost = 3, .like = 5, .coverage = .all }); try std.testing.expect(p.active.reply and p.active.repost and p.active.like and !p.active.post); try std.testing.expect(pattern(.{ .coverage = .all }) == .no_retained_activity);}
test "offline windows and interaction targets compose on the same evidence" { const evidence: []const Evidence = &.{ .{ .kind = .reply, .uri = "one", .micros = 100, .subject = "at://did:plc:alice/app.bsky.feed.post/one" }, .{ .kind = .like, .uri = "two", .micros = 101, .subject = "at://did:plc:alice/app.bsky.feed.post/one" }, .{ .kind = .repost, .uri = "three", .micros = 102, .subject = "at://did:plc:bob/app.bsky.feed.post/two" }, }; const window: Window = .{ .since = 100, .until = 101 }; const set: EvidenceSet = .{ .records = evidence, .coverage = .all, .observed_at = 102 }; const counts = try count(set, window); try std.testing.expect(counts.reply == 1 and counts.like == 1 and counts.repost == 0 and counts.complete()); const rows = try interactions(std.testing.allocator, set, window); defer rows.deinit(std.testing.allocator); try std.testing.expect(rows.rows.len == 1 and rows.rows[0].replies == 1 and rows.rows[0].likes == 1); try std.testing.expectError(error.InvalidWindow, count(set, .{ .since = 2, .until = 1 }));}
test "recounting beyond observation retains positive counts but cannot claim absence" { const set: EvidenceSet = .{ .records = &.{.{ .kind = .like, .uri = "one", .micros = 10 }}, .coverage = .all, .observed_at = 10 }; const result = try count(set, .{ .since = 0, .until = 11 }); try std.testing.expect(result.like == 1 and !result.complete());}