0
2
// The "For You" feed: collaborative filtering over favorites, with a
// most-favorited fallback when there is nothing to filter on.
//
// Scores decay with a six-hour half life, so every gallery here is dated
// relative to now rather than to a fixed date — a fixture pinned to a calendar
// date would score ~0 across the board and the ordering would stop meaning
// anything. The two paths get a world each, because the cold start's ranking
// is over every gallery present and a shared fixture would make both sets of
// assertions guesswork.
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { startTestServer } from "@hatk/hatk/test";
const ALICE = "did:plc:alice";
const BOB = "did:plc:bob";
const DAN = "did:plc:dan";
const ERIN = "did:plc:erin";
const FRANK = "did:plc:frank";
const HANK = "did:plc:hank";
const GINA = "did:plc:gina";
const VIC = "did:plc:vic";
const ZOE = "did:plc:zoe";
const MALLORY = "did:plc:mallory"; // muted
const TAKEN = "did:plc:taken"; // taken down
const hoursAgo = (h: number) => new Date(Date.now() - h * 3600_000).toISOString();
async function repos(server: any, dids: [string, string][]) {
for (const [did, status] of dids) {
await server.db.run(`INSERT INTO _repos (did, status, handle) VALUES ($1, $2, $3)`, [
did,
status,
`${did.split(":").pop()}.test`,
]);
}
}
async function gallery(
server: any,
opts: { id: string; did: string; ageHours: number; empty?: boolean; hidden?: boolean },
) {
const { db } = server;
const at = hoursAgo(opts.ageHours);
const uri = `at://${opts.did}/social.grain.gallery/${opts.id}`;
await db.run(
`INSERT INTO "social.grain.gallery" (uri, cid, did, indexed_at, title, created_at)
VALUES ($1, $2, $3, $4, $5, $4)`,
[uri, `cid-${opts.id}`, opts.did, at, opts.id],
);
if (!opts.empty) {
const photoUri = `at://${opts.did}/social.grain.photo/${opts.id}-p`;
await db.run(
`INSERT INTO "social.grain.photo" (uri, cid, did, indexed_at, photo, aspect_ratio, created_at)
VALUES ($1, $2, $3, 'i', $4, '{"width":3,"height":2}', $5)`,
[
photoUri,
`cid-p-${opts.id}`,
opts.did,
JSON.stringify({
$type: "blob",
ref: { $link: `bafy-${opts.id}` },
mimeType: "image/jpeg",
size: 1,
}),
at,
],
);
await db.run(
`INSERT INTO "social.grain.gallery.item" (uri, cid, did, indexed_at, created_at, gallery, item, position)
VALUES ($1, $2, $3, 'i', $4, $5, $6, 0)`,
[
`at://${opts.did}/social.grain.gallery.item/${opts.id}`,
`cid-i-${opts.id}`,
opts.did,
at,
uri,
photoUri,
],
);
}
if (opts.hidden) {
await db.run(
`INSERT INTO _labels (src, uri, val, neg, cts) VALUES ('did:plc:mod', $1, 'spam', 0, $2)`,
[uri, at],
);
}
return uri;
}
/** `who` favorited gallery `id`, `ageHours` ago. */
async function favorite(server: any, who: string, ownerDid: string, id: string, ageHours: number) {
const at = hoursAgo(ageHours);
await server.db.run(
`INSERT INTO "social.grain.favorite" (uri, cid, did, indexed_at, created_at, subject)
VALUES ($1, $2, $3, 'i', $4, $5)`,
[
`at://${who}/social.grain.favorite/${id}`,
`cid-fav-${who}-${id}`,
who,
at,
`at://${ownerDid}/social.grain.gallery/${id}`,
],
);
}
/** The test harness skips server/setup, so _mutes isn't there by default — and
* every feed query references it through blockMuteFilter, so a world without
* it answers 500 rather than merely skipping the mute check. */
async function ensureMutes(server: any) {
await server.db.run(
`CREATE TABLE IF NOT EXISTS _mutes (
did TEXT NOT NULL, subject TEXT NOT NULL, created_at TEXT NOT NULL,
PRIMARY KEY (did, subject)
)`,
);
}
async function mute(server: any, did: string, subject: string) {
await ensureMutes(server);
await server.db.run(`INSERT INTO _mutes (did, subject, created_at) VALUES ($1, $2, 'i')`, [
did,
subject,
]);
}
async function feed(server: any, query: string) {
const res = await server.fetch(`/xrpc/dev.hatk.getFeed?feed=foryou&${query}`);
expect(res.status).toBe(200);
const body = await res.json();
return {
ids: (body.items ?? []).map((i: any) => i.uri.split("/").pop()),
cursor: body.cursor as string | undefined,
};
}
describe("for you feed, collaborative filtering", () => {
let server: any;
// Alice favorited S1. Five accounts favorited S1 before her, so each is a
// co-liker, and whatever else they favorited becomes a candidate. Their
// total like counts differ, which is what separates a discriminating
// recommendation from a promiscuous one.
//
// BOB likes S1, C1 -> 2 total
// DAN likes S1, C1, C2, and 4 excluded -> 7 total
// ERIN likes S1, C3 -> 2 total
// FRANK likes S1, C4, and 8 of Alice's -> 10 total
// HANK likes S1, C6, C7 -> 3 total
// GINA likes S1 two days *after* Alice -> outside the window
beforeAll(async () => {
server = await startTestServer();
await repos(server, [
[ALICE, "active"],
[BOB, "active"],
[DAN, "active"],
[ERIN, "active"],
[FRANK, "active"],
[HANK, "active"],
[GINA, "active"],
[VIC, "active"],
[MALLORY, "active"],
[TAKEN, "takendown"],
]);
await mute(server, ALICE, MALLORY);
// Candidates share an age of 2h so time decay cancels between them, except
// C6 and C7 which exist only to show the decay.
for (const id of ["S1", "S2", "C1", "C2", "C3", "C4", "C5"]) {
await gallery(server, { id, did: BOB, ageHours: 2 });
}
await gallery(server, { id: "C6", did: BOB, ageHours: 1 });
await gallery(server, { id: "C7", did: BOB, ageHours: 25 });
for (let i = 1; i <= 8; i++) await gallery(server, { id: `A${i}`, did: ALICE, ageHours: 2 });
await gallery(server, { id: "X_down", did: TAKEN, ageHours: 2 });
await gallery(server, { id: "X_hidden", did: BOB, ageHours: 2, hidden: true });
await gallery(server, { id: "X_empty", did: BOB, ageHours: 2, empty: true });
await gallery(server, { id: "X_muted", did: MALLORY, ageHours: 2 });
// Alice's seed, 10 hours ago.
await favorite(server, ALICE, BOB, "S1", 10);
for (const who of [BOB, DAN, ERIN, FRANK, HANK]) {
await favorite(server, who, BOB, "S1", 11); // an hour before Alice
}
await favorite(server, GINA, BOB, "S1", -38); // 48h after Alice, outside the window
await favorite(server, BOB, BOB, "C1", 3);
await favorite(server, DAN, BOB, "C1", 3);
await favorite(server, DAN, BOB, "C2", 3);
await favorite(server, ERIN, BOB, "C3", 3);
await favorite(server, FRANK, BOB, "C4", 3);
await favorite(server, GINA, BOB, "C5", 3);
await favorite(server, HANK, BOB, "C6", 3);
await favorite(server, HANK, BOB, "C7", 3);
for (const id of ["X_down", "X_hidden", "X_empty", "X_muted"]) {
const owner = id === "X_down" ? TAKEN : id === "X_muted" ? MALLORY : BOB;
await favorite(server, DAN, owner, id, 3);
}
// Frank's eight extra likes are all on Alice's own galleries, which can
// never be recommended to her — they inflate his like count without adding
// candidates that would muddy the ranking.
for (let i = 1; i <= 8; i++) await favorite(server, FRANK, ALICE, `A${i}`, 3);
// Vic favorited a gallery nobody else did, so he has no co-likers at all.
await favorite(server, VIC, BOB, "S2", 5);
});
afterAll(async () => await server?.close());
test("ranks what co-likers also favorited", async () => {
const { ids } = await feed(server, `actor=${ALICE}&limit=50`);
expect(ids).toEqual(["C1", "C3", "C6", "C2", "C4", "C7"]);
});
test("prefers a co-liker who favorites sparingly over one who favorites everything", async () => {
// C3 came from Erin (2 likes total), C4 from Frank (10). Same age, same
// popularity, one path each — the like count is the only difference.
const { ids } = await feed(server, `actor=${ALICE}&limit=50`);
expect(ids.indexOf("C3")).toBeLessThan(ids.indexOf("C4"));
});
test("ranks a gallery two co-likers agreed on above one only a single co-liker liked", async () => {
const { ids } = await feed(server, `actor=${ALICE}&limit=50`);
expect(ids.indexOf("C1")).toBeLessThan(ids.indexOf("C2"));
});
test("decays a gallery's score with its age", async () => {
// C6 and C7 both come from Hank alone and are equally popular; C6 is an
// hour old and C7 is twenty-five, more than four half-lives apart.
const { ids } = await feed(server, `actor=${ALICE}&limit=50`);
expect(ids.indexOf("C6")).toBeLessThan(ids.indexOf("C7"));
});
test("never recommends the seed it started from", async () => {
const { ids } = await feed(server, `actor=${ALICE}&limit=50`);
expect(ids).not.toContain("S1");
});
test("never recommends the actor's own galleries", async () => {
const { ids } = await feed(server, `actor=${ALICE}&limit=50`);
for (let i = 1; i <= 8; i++) expect(ids).not.toContain(`A${i}`);
});
test("ignores a co-liker who liked the seed long after the actor did", async () => {
// Gina liked S1 two days after Alice, past the 24h window, so C5 — the
// only gallery reachable through her — is not a candidate.
const { ids } = await feed(server, `actor=${ALICE}&limit=50`);
expect(ids).not.toContain("C5");
});
test("drops taken-down, labelled, empty and muted candidates", async () => {
const { ids } = await feed(server, `actor=${ALICE}&limit=50`);
expect(ids).not.toContain("X_down");
expect(ids).not.toContain("X_hidden");
expect(ids).not.toContain("X_empty");
expect(ids).not.toContain("X_muted");
});
test("pages by offset", async () => {
const first = await feed(server, `actor=${ALICE}&limit=2`);
expect(first.ids).toEqual(["C1", "C3"]);
expect(first.cursor).toBe("2");
const second = await feed(server, `actor=${ALICE}&limit=2&cursor=2`);
expect(second.ids).toEqual(["C6", "C2"]);
expect(second.cursor).toBe("4");
const third = await feed(server, `actor=${ALICE}&limit=2&cursor=4`);
expect(third.ids).toEqual(["C4", "C7"]);
expect(third.cursor).toBeUndefined();
});
test("falls back to popularity for an actor whose likes nobody shares", async () => {
// Vic has a seed but no co-likers, which is the same dead end as having no
// seed at all. S1 is the most-favorited gallery in this world.
const { ids } = await feed(server, `actor=${VIC}&limit=50`);
expect(ids[0]).toBe("S1");
});
test("is empty without an actor", async () => {
const { ids } = await feed(server, "limit=50");
expect(ids).toEqual([]);
});
});
describe("for you feed, scoring terms", () => {
let server: any;
// Two pairs, each built so that dropping one scoring term flips its order.
//
// G_multi 2 co-likers with 5 likes each -> raw 0.40, 2 paths, popularity 2
// G_single 1 co-liker with 2 likes -> raw 0.50, 1 path, popularity 2
// Multi wins only because paths^0.5 boosts it past a higher raw score.
//
// H_pop 1 co-liker with 2 likes -> raw 0.50, popularity 20
// H_rare 1 co-liker with 4 likes -> raw 0.25, popularity 1
// Rare wins only because the popularity penalty drags H_pop under it.
//
// Everything is two hours old, so time decay is the same multiplier
// throughout and cannot account for any of the ordering.
const P = "did:plc:pp";
const Q = "did:plc:qq";
const R = "did:plc:rr";
const U = "did:plc:uu";
const V = "did:plc:vv";
beforeAll(async () => {
server = await startTestServer();
await repos(server, [
[ALICE, "active"],
[BOB, "active"],
[P, "active"],
[Q, "active"],
[R, "active"],
[U, "active"],
[V, "active"],
]);
await ensureMutes(server);
for (const id of ["S", "G_multi", "G_single", "H_pop", "H_rare"]) {
await gallery(server, { id, did: BOB, ageHours: 2 });
}
for (let i = 1; i <= 8; i++) await gallery(server, { id: `A${i}`, did: ALICE, ageHours: 2 });
await favorite(server, ALICE, BOB, "S", 10);
for (const who of [P, Q, R, U, V]) await favorite(server, who, BOB, "S", 11);
await favorite(server, P, BOB, "G_multi", 3);
await favorite(server, Q, BOB, "G_multi", 3);
await favorite(server, R, BOB, "G_single", 3);
await favorite(server, U, BOB, "H_pop", 3);
await favorite(server, V, BOB, "H_rare", 3);
// Filler likes on Alice's own galleries, which can never be recommended to
// her — they set each co-liker's total like count without adding candidates.
for (const [who, ids] of [
[P, ["A1", "A2", "A3"]],
[Q, ["A4", "A5", "A6"]],
[V, ["A7", "A8"]],
] as [string, string[]][]) {
for (const id of ids) await favorite(server, who, ALICE, id, 3);
}
// Nineteen accounts who favorited H_pop and nothing else. They never
// touched the seed, so they are not co-likers and add no paths — they
// exist only to make H_pop popular.
for (let i = 0; i < 19; i++) {
await favorite(server, `did:plc:pop${i}`, BOB, "H_pop", 3);
}
// One non-co-liker on G_single so both G galleries are equally popular and
// the popularity penalty cancels between them.
await favorite(server, "did:plc:zz", BOB, "G_single", 3);
});
afterAll(async () => await server?.close());
test("boosts agreement between co-likers above a single stronger signal", async () => {
const { ids } = await feed(server, `actor=${ALICE}&limit=50`);
expect(ids.indexOf("G_multi")).toBeLessThan(ids.indexOf("G_single"));
});
test("penalises a gallery everyone already favorited", async () => {
const { ids } = await feed(server, `actor=${ALICE}&limit=50`);
expect(ids.indexOf("H_rare")).toBeLessThan(ids.indexOf("H_pop"));
});
test("ranks the four together as the combined score dictates", async () => {
const { ids } = await feed(server, `actor=${ALICE}&limit=50`);
expect(ids).toEqual(["G_multi", "G_single", "H_rare", "H_pop"]);
});
});
describe("for you feed, cold start", () => {
let server: any;
// Zoe has favorited nothing, so the feed can only rank by raw popularity
// over the last thirty days.
beforeAll(async () => {
server = await startTestServer();
await repos(server, [
[ZOE, "active"],
[BOB, "active"],
[DAN, "active"],
[ERIN, "active"],
[FRANK, "active"],
[MALLORY, "active"],
[TAKEN, "takendown"],
]);
await mute(server, ZOE, MALLORY);
await gallery(server, { id: "P3", did: BOB, ageHours: 2 });
await gallery(server, { id: "P2", did: BOB, ageHours: 3 });
await gallery(server, { id: "P1", did: BOB, ageHours: 4 });
await gallery(server, { id: "P0", did: BOB, ageHours: 5 });
await gallery(server, { id: "OLD", did: BOB, ageHours: 24 * 40 });
await gallery(server, { id: "ZOWN", did: ZOE, ageHours: 2 });
await gallery(server, { id: "DOWN", did: TAKEN, ageHours: 2 });
await gallery(server, { id: "HID", did: BOB, ageHours: 2, hidden: true });
await gallery(server, { id: "MT", did: BOB, ageHours: 2, empty: true });
await gallery(server, { id: "MUTED", did: MALLORY, ageHours: 2 });
const likers = [DAN, ERIN, FRANK];
for (const [id, owner, n] of [
["P3", BOB, 3],
["P2", BOB, 2],
["P1", BOB, 1],
["OLD", BOB, 3],
["ZOWN", ZOE, 3],
["DOWN", TAKEN, 3],
["HID", BOB, 3],
["MT", BOB, 3],
["MUTED", MALLORY, 3],
] as [string, string, number][]) {
for (const who of likers.slice(0, n)) await favorite(server, who, owner, id, 3);
}
});
afterAll(async () => await server?.close());
test("ranks recent galleries by how many favorites they have", async () => {
const { ids } = await feed(server, `actor=${ZOE}&limit=50`);
expect(ids).toEqual(["P3", "P2", "P1", "P0"]);
});
test("ignores galleries older than thirty days however popular", async () => {
const { ids } = await feed(server, `actor=${ZOE}&limit=50`);
expect(ids).not.toContain("OLD");
});
test("never recommends the actor's own galleries", async () => {
const { ids } = await feed(server, `actor=${ZOE}&limit=50`);
expect(ids).not.toContain("ZOWN");
});
test("drops taken-down, labelled, empty and muted galleries", async () => {
const { ids } = await feed(server, `actor=${ZOE}&limit=50`);
expect(ids).not.toContain("DOWN");
expect(ids).not.toContain("HID");
expect(ids).not.toContain("MT");
expect(ids).not.toContain("MUTED");
});
test("pages by offset, and offers a cursor whenever it filled the page", async () => {
const first = await feed(server, `actor=${ZOE}&limit=2`);
expect(first.ids).toEqual(["P3", "P2"]);
expect(first.cursor).toBe("2");
// A full second page still hands back a cursor even though nothing
// follows it — the cold start infers "more" from a full page rather than
// counting. The empty third page is how a client finds the end.
const second = await feed(server, `actor=${ZOE}&limit=2&cursor=2`);
expect(second.ids).toEqual(["P1", "P0"]);
expect(second.cursor).toBe("4");
const third = await feed(server, `actor=${ZOE}&limit=2&cursor=4`);
expect(third.ids).toEqual([]);
expect(third.cursor).toBeUndefined();
});
});