Open source

The algorithm is yours

Most apps hide how your feed is ranked. Globe does the opposite. Every signal below is a knob you control, and the exact code that runs is printed at the bottom of this page. Tune it, save it, share it.

Open the Algorithm Lab

What goes into your feed

Popularity

More likes rank higher.

Recent

Newer posts first.

Small accounts

Boost smaller accounts so new voices surface.

My place

Favor posts from your declared place.

Photos

Favor posts with images.

Longer posts

Favor longer captions; go negative for short, punchy posts.

Other places

Favor posts from places that aren't yours.

Hidden gems

Favor posts with very few likes.

Late night

Favor posts made overnight.

Randomness

Share of the feed left to random discovery.

Decay speed

How fast old posts fade. Lower is snappier, higher keeps them around.

Starting points

Presets are just bundles of those knobs. Pick one in the Lab, then adjust from there.

Balanced

Popularity 1Recent 1Small accounts 0.3Randomness 0.15Decay speed 24

Chronological

Popularity 0Recent 1Small accounts 0Randomness 0Decay speed 6

Discovery

Popularity 0.4Recent 0.8Small accounts 0.8Randomness 0.4Decay speed 48

Small accounts

Popularity 0.3Recent 0.7Small accounts 1.5Randomness 0.25Decay speed 36

Hometown pride

Popularity 0.8Recent 0.9My place 2.5Randomness 0.1Decay speed 24

Globetrotter

Popularity 0.6Recent 0.8Other places 2Randomness 0.3Decay speed 48

Hipster

Popularity 0Recent 0.6Small accounts 0.6Hidden gems 2Randomness 0.35Decay speed 36

Eye candy

Popularity 0.7Recent 0.7Photos 2Randomness 0.15Decay speed 30

Night owl

Popularity 0.4Recent 0.5Late night 2Randomness 0.2Decay speed 48

Essayist

Popularity 0.5Recent 0.8Longer posts 2Randomness 0.15Decay speed 36

Chaos

Popularity 0.3Recent 0.3Small accounts 0.5Randomness 0.85Decay speed 72

The actual code that runs

lib/algorithm/ranking.ts
/**
 * GLOBE OPEN RANKING ALGORITHM
 * ============================
 * This is the whole feed. It is public, readable, and deterministic on purpose.
 * No black box: given the same posts + the same controls + the same seed, it always
 * produces the same order. Users tune it themselves via presets and a few sliders.
 *
 * The score of a post is a weighted sum of a handful of plainly-named signals:
 *
 *     score =  popularity * likeSignal(likes)
 *            +   recency   * recencySignal(ageHours)
 *            +  smallBoost * smallAccountSignal(authorPostCount)
 *            +  placeBoost * (post is from "my place" ? 1 : 0)
 *
 * Then we reserve a slice of the feed (exploration%) for fresh / low-like posts so new
 * voices can actually surface instead of being buried forever. That's the entire trick.
 */

export type RankablePost = {
  id: string;
  likes: number;
  ageHours: number;          // hours since the post was created
  authorPostCount: number;   // proxy for "small account" (fewer posts = smaller)
  place: string | null;      // self-declared place/region of the author
  hasImage: boolean;         // true if the post carries media
  bodyLength: number;        // caption length in characters
  hour: number;              // hour of day the post was made (0..23, UTC)
};

export type Controls = {
  // Core weights
  popularity: number;   // weight on likes ("main character energy")
  recency: number;      // weight on freshness
  smallBoost: number;   // weight toward smaller/newer accounts ("underdog")
  placeBoost: number;   // weight toward posts from `myPlace` ("hometown pride")
  // Fun weights (all map to real, readable signals below)
  pictures: number;     // weight toward image posts ("eye candy")
  wordy: number;        // weight toward longer captions ("essayist"); negative favors short
  wanderlust: number;   // weight toward posts NOT from your place ("see the world")
  hipster: number;      // weight toward low-like posts ("before it was cool")
  nightowl: number;     // weight toward posts made late at night ("3am thoughts")
  // Knobs
  exploration: number;  // 0..1, fraction of the feed reserved for discovery ("chaos")
  halfLifeHours: number;// recency half-life: lower = feed turns over faster ("time machine")
  myPlace: string | null;       // the viewer's chosen place lens (for placeBoost / wanderlust)
  onlyPlace: string | null;     // hard filter: if set, ONLY show posts from this place
};

/** Likes on a log scale so 10→100 likes doesn't dwarf everything else. */
export function likeSignal(likes: number): number {
  return Math.log10(Math.max(0, likes) + 1); // 0 likes → 0, 9 → 1, 99 → 2 ...
}

/** Exponential time-decay. 1.0 at age 0, 0.5 at one half-life, etc. */
export function recencySignal(ageHours: number, halfLifeHours: number): number {
  const hl = Math.max(0.1, halfLifeHours);
  return Math.pow(2, -Math.max(0, ageHours) / hl);
}

/** Smaller accounts score higher; flattens out so it can't be gamed by spammy new accounts forever. */
export function smallAccountSignal(authorPostCount: number): number {
  return 1 / (1 + Math.log10(Math.max(0, authorPostCount) + 1));
}

/** Longer captions trend toward 1 (saturates around a tweet-and-a-half). */
export function wordinessSignal(bodyLength: number): number {
  return Math.min(1, Math.max(0, bodyLength) / 280);
}

/** Few likes trend toward 1, lots of likes toward 0 (the "before it was cool" signal). */
export function lowLikeSignal(likes: number): number {
  return 1 / (1 + Math.max(0, likes));
}

/** 1 if the post was made late at night (22:00 to 04:59), else 0. */
export function nightOwlSignal(hour: number): number {
  const h = ((Math.floor(hour) % 24) + 24) % 24;
  return h >= 22 || h < 5 ? 1 : 0;
}

/** The deterministic score for a single post under a set of controls. */
export function scorePost(post: RankablePost, c: Controls): number {
  const fromMyPlace = c.myPlace && post.place === c.myPlace ? 1 : 0;
  const awayFromMyPlace = c.myPlace && post.place !== c.myPlace ? 1 : 0;
  return (
    c.popularity * likeSignal(post.likes) +
    c.recency * recencySignal(post.ageHours, c.halfLifeHours) +
    c.smallBoost * smallAccountSignal(post.authorPostCount) +
    c.placeBoost * fromMyPlace +
    c.pictures * (post.hasImage ? 1 : 0) +
    c.wordy * wordinessSignal(post.bodyLength) +
    c.wanderlust * awayFromMyPlace +
    c.hipster * lowLikeSignal(post.likes) +
    c.nightowl * nightOwlSignal(post.hour)
  );
}

/** Coerce a control value to a finite number, falling back to a default. Guards NaN/Infinity/strings. */
function num(x: unknown, fallback: number): number {
  const n = typeof x === "number" ? x : Number(x);
  return Number.isFinite(n) ? n : fallback;
}

/** Clamp every control to a sane, finite range so user sliders can never break the sort. */
export function sanitizeControls(c: Controls): Controls {
  const exploration = Math.min(1, Math.max(0, num(c.exploration, 0)));
  return {
    popularity: num(c.popularity, 0),
    recency: num(c.recency, 0),
    smallBoost: num(c.smallBoost, 0),
    placeBoost: num(c.placeBoost, 0),
    pictures: num(c.pictures, 0),
    wordy: num(c.wordy, 0),
    wanderlust: num(c.wanderlust, 0),
    hipster: num(c.hipster, 0),
    nightowl: num(c.nightowl, 0),
    exploration,
    halfLifeHours: Math.max(0.1, num(c.halfLifeHours, 24)),
    myPlace: c.myPlace ?? null,
    onlyPlace: c.onlyPlace ?? null,
  };
}

// --- deterministic helpers (no Math.random, the feed must be reproducible) ---

function hashStringToUnit(s: string): number {
  // FNV-1a → [0,1). Used to deterministically pick the exploration slice.
  let h = 2166136261;
  for (let i = 0; i < s.length; i++) {
    h ^= s.charCodeAt(i);
    h = Math.imul(h, 16777619);
  }
  return ((h >>> 0) % 100000) / 100000;
}

/**
 * Rank a set of posts into a feed.
 *  1. Optionally hard-filter to a single place ("My country" filter).
 *  2. Score + sort everything (the "main" lane).
 *  3. Carve out `exploration` of the slots for low-like / fresh posts (the "discovery" lane),
 *     chosen deterministically by a per-(seed,post) hash so the slice is stable but varied.
 */
export function rankFeed(
  posts: RankablePost[],
  c: Controls,
  opts: { seed?: string; limit?: number } = {},
): RankablePost[] {
  const seed = opts.seed ?? "globe";
  c = sanitizeControls(c);
  const limit = Math.max(0, Math.floor(num(opts.limit, posts.length)));

  // onlyPlace is a comma-separated list of place codes (the places filter is
  // multi-select). Empty/null means no place filter.
  const onlyCodes = c.onlyPlace ? c.onlyPlace.split(",").filter(Boolean) : [];
  const pool = onlyCodes.length
    ? posts.filter((p) => p.place !== null && onlyCodes.includes(p.place))
    : posts.slice();
  if (pool.length === 0) return [];

  const scored = pool
    .map((p) => ({ p, score: scorePost(p, c) }))
    .sort((a, b) => b.score - a.score);

  const exploreSlots = Math.round(limit * Math.min(1, Math.max(0, c.exploration)));
  const mainSlots = Math.max(0, limit - exploreSlots);

  const main = scored.slice(0, mainSlots).map((s) => s.p);

  // Discovery pool = everything not already in `main`, biased toward few likes,
  // then deterministically jittered by hash(seed+id) so it's reproducible but not pure-by-score.
  const taken = new Set(main.map((p) => p.id));
  const discovery = scored
    .filter((s) => !taken.has(s.p.id))
    .map((s) => ({
      p: s.p,
      key: (1 / (1 + Math.max(0, s.p.likes))) * 0.5 + hashStringToUnit(seed + s.p.id) * 0.5,
    }))
    .sort((a, b) => b.key - a.key)
    .slice(0, exploreSlots)
    .map((s) => s.p);

  return [...main, ...discovery].slice(0, limit);
}

/** User-facing presets. A preset is just a named bundle of control values. */
export const PRESETS: Record<string, Partial<Controls>> = {
  Balanced:       { popularity: 1.0, recency: 1.0, smallBoost: 0.3, exploration: 0.15, halfLifeHours: 24 },
  Chronological:  { popularity: 0.0, recency: 1.0, smallBoost: 0.0, exploration: 0.0,  halfLifeHours: 6  },
  Discovery:      { popularity: 0.4, recency: 0.8, smallBoost: 0.8, exploration: 0.4,  halfLifeHours: 48 },
  "Small accounts": { popularity: 0.3, recency: 0.7, smallBoost: 1.5, exploration: 0.25, halfLifeHours: 36 },
  "Hometown pride": { popularity: 0.8, recency: 0.9, placeBoost: 2.5, exploration: 0.1, halfLifeHours: 24 },
  Globetrotter:   { popularity: 0.6, recency: 0.8, wanderlust: 2.0, exploration: 0.3, halfLifeHours: 48 },
  Hipster:        { popularity: 0.0, recency: 0.6, smallBoost: 0.6, hipster: 2.0, exploration: 0.35, halfLifeHours: 36 },
  "Eye candy":    { popularity: 0.7, recency: 0.7, pictures: 2.0, exploration: 0.15, halfLifeHours: 30 },
  "Night owl":    { popularity: 0.4, recency: 0.5, nightowl: 2.0, exploration: 0.2, halfLifeHours: 48 },
  Essayist:       { popularity: 0.5, recency: 0.8, wordy: 2.0, exploration: 0.15, halfLifeHours: 36 },
  Chaos:          { popularity: 0.3, recency: 0.3, smallBoost: 0.5, exploration: 0.85, halfLifeHours: 72 },
};

export const DEFAULT_CONTROLS: Controls = {
  popularity: 1.0,
  recency: 1.0,
  smallBoost: 0.3,
  placeBoost: 0.0,
  pictures: 0.0,
  wordy: 0.0,
  wanderlust: 0.0,
  hipster: 0.0,
  nightowl: 0.0,
  exploration: 0.15,
  halfLifeHours: 24,
  myPlace: null,
  onlyPlace: null,
};

/** Merge a preset (and any explicit overrides) onto the defaults. */
export function resolveControls(
  presetName?: string,
  overrides: Partial<Controls> = {},
): Controls {
  const preset = presetName ? PRESETS[presetName] ?? {} : {};
  return sanitizeControls({ ...DEFAULT_CONTROLS, ...preset, ...overrides });
}