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 LabWhat 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
Chronological
Discovery
Small accounts
Hometown pride
Globetrotter
Hipster
Eye candy
Night owl
Essayist
Chaos
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 });
}