~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2
260 lines · 9.1 KB Raw History
import { defineFeed } from "$hatk";
import { hydrateGalleries } from "../hydrate/galleries.ts";
import { hideLabelsFilter } from "../labels/_hidden.ts";
import { blockMuteFilter } from "../filters/blockMute.ts";
import { galleryFeedTable } from "./_galleryTable.ts";

// ─── Scoring parameters (spacecowboy17's optimized A/B values) ───────
const HALF_LIFE_HOURS = 6;
const SMOOTHING_FACTOR = 0.5;
const POPULARITY_PENALTY = 0.3;
const DIVISOR_POWER = 1.0;
const CORATER_DECAY = 0;
const TIME_SHIFT_HOURS = 24;
const SEED_LIMIT = 500;
const MAX_COLIKERS = 1000;
const PAGE_SIZE = 50;

export default defineFeed({
  collection: "social.grain.gallery",
  label: "For You",

  hydrate: hydrateGalleries,

  async generate(ctx) {
    const actor = ctx.params.actor;
    if (!actor) return ctx.ok({ uris: [] });

    const limit = Math.min(Number(ctx.params.limit) || PAGE_SIZE, PAGE_SIZE);
    const offset = Number(ctx.params.cursor) || 0;

    // Step 1: Get user's recent favorites (seed)
    const seedRows = (await ctx.db.query(
      `SELECT f.subject AS gallery_uri, f.created_at
       FROM "social.grain.favorite" f
       WHERE f.did = $1
       ORDER BY f.created_at DESC
       LIMIT $2`,
      [actor, SEED_LIMIT],
    )) as { gallery_uri: string; created_at: string }[];

    // Cold start: fall back to popular galleries
    if (seedRows.length === 0) {
      return coldStartFeed(ctx, actor, limit, offset);
    }

    const seedUris = seedRows.map((r) => r.gallery_uri);

    // Build a map of seed gallery → when the user liked it
    const seedLikeTime = new Map<string, number>();
    for (const row of seedRows) {
      seedLikeTime.set(row.gallery_uri, new Date(row.created_at).getTime());
    }

    // Step 2: Find co-likers (people who favorited the same galleries)
    const placeholders = seedUris.map((_, i) => `$${i + 2}`).join(", ");
    const colikerRows = (await ctx.db.query(
      `SELECT f.did AS coliker, f.subject AS gallery_uri, f.created_at
       FROM "social.grain.favorite" f
       WHERE f.subject IN (${placeholders})
         AND f.did != $1
       ORDER BY f.created_at DESC`,
      [actor, ...seedUris],
    )) as { coliker: string; gallery_uri: string; created_at: string }[];

    // Filter co-likers: only those who liked before the user (unless ignore_order)
    // + apply time_shift window
    const validColikers = new Set<string>();
    const colikerSeedPairs = new Map<string, Set<string>>(); // coliker → set of shared galleries

    for (const row of colikerRows) {
      const userLikeTime = seedLikeTime.get(row.gallery_uri);
      if (!userLikeTime) continue;

      const colikeTime = new Date(row.created_at).getTime();
      // Only consider likes before the user's like (+ time_shift window)
      if (colikeTime <= userLikeTime + TIME_SHIFT_HOURS * 3600_000) {
        validColikers.add(row.coliker);
        if (!colikerSeedPairs.has(row.coliker)) {
          colikerSeedPairs.set(row.coliker, new Set());
        }
        colikerSeedPairs.get(row.coliker)!.add(row.gallery_uri);
      }
    }

    if (validColikers.size === 0) {
      return coldStartFeed(ctx, actor, limit, offset);
    }

    // Step 3: Get co-likers' other favorites (candidates) + their total like counts
    // Cap co-likers, preferring those with more overlap
    const colikerList = [...validColikers]
      .sort((a, b) => (colikerSeedPairs.get(b)?.size ?? 0) - (colikerSeedPairs.get(a)?.size ?? 0))
      .slice(0, MAX_COLIKERS);

    const colikerPlaceholders = colikerList.map((_, i) => `$${i + 2}`).join(", ");
    const seedPlaceholders = seedUris.map((_, i) => `$${i + 2 + colikerList.length}`).join(", ");

    const likeCountPlaceholders = colikerList.map((_, i) => `$${i + 1}`).join(", ");

    const [candidateRows, likeCounts] = await Promise.all([
      ctx.db.query(
        `SELECT f.did AS coliker, f.subject AS gallery_uri, t.sort_at AS gallery_created_at
         FROM "social.grain.favorite" f
         JOIN ${galleryFeedTable} ON t.uri = f.subject
         LEFT JOIN _repos r ON t.did = r.did
         WHERE f.did IN (${colikerPlaceholders})
           AND f.subject NOT IN (${seedPlaceholders})
           AND t.did != $1
           AND (r.status IS NULL OR r.status NOT IN ('takendown', 'deactivated', 'deleted'))
           AND ${hideLabelsFilter("t.uri")}
           AND (SELECT count(*) FROM "social.grain.gallery.item" gi WHERE gi.gallery = t.uri) > 0
           AND ${blockMuteFilter("t.did", "$1")}`,
        [actor, ...colikerList, ...seedUris],
      ) as Promise<{ coliker: string; gallery_uri: string; gallery_created_at: string }[]>,

      ctx.db.query(
        `SELECT did, COUNT(*) as cnt
         FROM "social.grain.favorite"
         WHERE did IN (${likeCountPlaceholders})
         GROUP BY did`,
        colikerList,
      ) as Promise<{ did: string; cnt: number }[]>,
    ]);

    // Build co-liker total likes map
    const colikerTotalLikes = new Map<string, number>();
    for (const row of likeCounts) {
      colikerTotalLikes.set(row.did, Number(row.cnt));
    }

    // Step 4: Score each candidate gallery
    const now = Date.now();
    const galleryScores = new Map<
      string,
      { score: number; paths: number; created_at: string; coratersSeenCount: Map<string, number> }
    >();

    // Get popularity (total favorites) for candidate galleries
    const candidateUris = [...new Set(candidateRows.map((r) => r.gallery_uri))];

    const popularityMap = new Map<string, number>();
    if (candidateUris.length > 0) {
      const popPlaceholders = candidateUris.map((_, i) => `$${i + 1}`).join(", ");
      const popRows = (await ctx.db.query(
        `SELECT subject, COUNT(*) as cnt
         FROM "social.grain.favorite"
         WHERE subject IN (${popPlaceholders})
         GROUP BY subject`,
        candidateUris,
      )) as { subject: string; cnt: number }[];
      for (const row of popRows) {
        popularityMap.set(row.subject, Number(row.cnt));
      }
    }

    for (const row of candidateRows) {
      const totalLikes = colikerTotalLikes.get(row.coliker) || 1;

      let entry = galleryScores.get(row.gallery_uri);
      if (!entry) {
        entry = {
          score: 0,
          paths: 0,
          created_at: row.gallery_created_at,
          coratersSeenCount: new Map(),
        };
        galleryScores.set(row.gallery_uri, entry);
      }

      // Track how many items we've seen from this corater (for decay)
      const seenCount = entry.coratersSeenCount.get(row.coliker) || 0;
      entry.coratersSeenCount.set(row.coliker, seenCount + 1);

      // Path score: 1 / total_likes^divisor_power
      let pathScore = 1 / Math.pow(totalLikes, DIVISOR_POWER);

      // Corater decay
      if (CORATER_DECAY > 0) {
        pathScore *= Math.pow(1 - CORATER_DECAY, seenCount);
      }

      entry.score += pathScore;
      entry.paths += 1;
    }

    // Apply smoothing, time decay, and popularity penalty
    const scored: { uri: string; score: number }[] = [];

    for (const [uri, entry] of galleryScores) {
      let score = entry.score;

      // Smoothing: boost multi-path galleries
      if (SMOOTHING_FACTOR > 0) {
        score *= Math.pow(entry.paths, SMOOTHING_FACTOR);
      }

      // Time decay
      if (HALF_LIFE_HOURS > 0) {
        const ageHours = (now - new Date(entry.created_at).getTime()) / 3_600_000;
        score *= Math.pow(0.5, ageHours / HALF_LIFE_HOURS);
      }

      // Popularity penalty
      if (POPULARITY_PENALTY > 0) {
        const popularity = popularityMap.get(uri) || 1;
        score /= Math.pow(popularity, POPULARITY_PENALTY);
      }

      scored.push({ uri, score });
    }

    // Sort by score descending
    scored.sort((a, b) => b.score - a.score);

    // Paginate
    const page = scored.slice(offset, offset + limit);
    const nextCursor = offset + limit < scored.length ? String(offset + limit) : undefined;

    return ctx.ok({
      uris: page.map((r) => r.uri),
      cursor: nextCursor,
    });
  },
});

// ─── Cold start: most-favorited galleries from last 30 days ──────────
async function coldStartFeed(
  ctx: Parameters<Parameters<typeof defineFeed>[0]["generate"]>[0],
  actor: string,
  limit: number,
  offset: number,
) {
  const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 3600_000).toISOString();

  const rows = (await ctx.db.query(
    `SELECT t.uri, COUNT(f.did) as fav_count
     FROM ${galleryFeedTable}
     LEFT JOIN "social.grain.favorite" f ON f.subject = t.uri
     LEFT JOIN _repos r ON t.did = r.did
     WHERE (r.status IS NULL OR r.status NOT IN ('takendown', 'deactivated', 'deleted'))
       AND t.did != $1
       AND t.sort_at > $2
       AND ${hideLabelsFilter("t.uri")}
       AND (SELECT count(*) FROM "social.grain.gallery.item" gi WHERE gi.gallery = t.uri) > 0
       AND ${blockMuteFilter("t.did", "$1")}
     GROUP BY t.uri
     ORDER BY fav_count DESC, t.sort_at DESC
     LIMIT $3 OFFSET $4`,
    [actor, thirtyDaysAgo, limit, offset],
  )) as { uri: string; fav_count: number }[];

  // Check if there are more results
  const hasMore = rows.length === limit;
  const nextCursor = hasMore ? String(offset + limit) : undefined;

  return ctx.ok({
    uris: rows.map((r) => r.uri),
    cursor: nextCursor,
  });
}