The Top Repost module in HelperX monitors a watchlist of accounts and reposts (or quote-tweets) their highest-engagement posts. The idea is simple: surface the best content from accounts you trust, with your own commentary.
The hard part is the word "highest-engagement." We don't have access to those accounts' analytics. We see public counts — likes, replies, retweets, quotes — at moments in time, and we have to rank tweets against each other using only that. A tweet with 500 likes posted 10 minutes ago and a tweet with 5,000 likes posted 3 days ago are both "high engagement," but they're not equivalent, and the ranking has to know that.
This article is about the scoring function we built. It's a small piece of math with a lot of judgment baked in.
The problem with raw counts
If you rank watchlist tweets by raw like count, two things go wrong immediately.
Problem 1: Old tweets always win. A mediocre tweet from 4 days ago has accumulated more likes than a great tweet from 2 hours ago. Ranking by raw count means you always repost the oldest thing in the window, which is exactly backwards — you want fresh, climbing content.
Problem 2: Account size dominates. A 200K-follower account's average tweet has more likes than a 20K account's best tweet. Ranking by raw count means you only ever repost from your biggest watchlist account, ignoring better content from smaller ones.
Both problems have the same root cause: raw counts conflate quality with time and audience size. A useful score has to normalize for both.
The inputs we actually have
For each watchlist tweet, at the moment we evaluate it, we observe:
-
likes,replies,retweets,quotes— public counts -
age_minutes— minutes since the tweet was posted -
author_followers— the author's follower count (for normalization)
We sample these periodically, so we also have the tweet's history — how its counts grew over time. That history turns out to be more valuable than the current counts.
Step 1: Normalize for account size
First, we stop comparing raw counts and start comparing engagement rates — engagement relative to what that account normally gets.
function engagementRate(tweet) {
const total = tweet.likes + tweet.replies + tweet.retweets + tweet.quotes;
// Weighted: replies and quotes matter more than likes (see my algorithm post)
const weighted =
tweet.likes * 1 +
tweet.replies * 4 +
tweet.retweets * 3 +
tweet.quotes * 5;
return weighted / Math.max(tweet.author_followers, 1);
}
This gives us a per-impression engagement intensity that's comparable across accounts of different sizes. A tweet from the 20K account that got 200 weighted engagement is scoring higher than a tweet from the 200K account that got 1,000 — because the smaller account's audience engaged more intensely.
This single normalization fixes Problem 2. The smaller account's breakout tweet now competes on a level field with the bigger account's average tweet.
Step 2: Normalize for age (the time-decay problem)
Engagement accumulates over time, so older tweets have higher counts even if they're decaying. We need to distinguish "high counts because it's old" from "high counts because it's genuinely hot."
The cleanest approach is to score by engagement velocity — how fast engagement is accumulating right now — rather than by cumulative totals. We estimate velocity from our periodic samples:
function engagementVelocity(samples) {
// samples: [{ at: epochMs, weighted: number }, ...] ordered by time
if (samples.length < 2) return 0;
const recent = samples[samples.length - 1];
const earlier = samples[samples.length - 2];
const dtMs = recent.at - earlier.at;
if (dtMs <= 0) return 0;
const dtMinutes = dtMs / 60000;
return (recent.weighted - earlier.weighted) / dtMinutes; // engagement per minute
}
Velocity naturally handles age: a tweet that's still gaining 50 engagement/minute at hour 3 is hotter than a tweet gaining 5 engagement/minute at hour 1. Cumulative counts would rank the hour-1 tweet higher (less time to accumulate); velocity ranks the still-climbing tweet higher, which is what we want to repost.
But pure velocity has its own failure mode: a tweet that just got posted has artificially low velocity because it hasn't had time to accumulate, and a tweet in a brief spike can show misleadingly high velocity over a short window.
Step 3: Combine, with a confidence adjustment
Our final score blends normalized intensity with velocity, but discounts tweets we don't have enough history on:
function scoreTweet(tweet, samples) {
const intensity = engagementRate(tweet);
const velocity = engagementVelocity(samples);
// Confidence: how much history do we have? A tweet sampled only once
// gets discounted — we don't trust its velocity yet.
const confidence = Math.min(1, samples.length / 4); // full confidence at 4+ samples
const score =
intensity * 0.4 + // how engaged is it, normalized
velocity * 0.6 * confidence; // how fast is it climbing, if we trust the signal
return score;
}
The weights (0.4 intensity, 0.6 velocity) reflect our judgment that climbing matters more than total for a repost decision — we want to catch content on the way up, not after it's peaked. The confidence factor prevents freshly-posted tweets (one sample, unreliable velocity) from winning on noise.
The "has it peaked?" check
Even with a good score, reposting a tweet that's already peaked is low-value — the audience has seen it. We want to repost before the peak, riding the wave. So we add a peak-detection check that disqualifies tweets whose velocity is clearly declining:
function isPastPeak(samples) {
if (samples.length < 3) return false; // not enough data to tell
// Compare recent velocity to earlier velocity
const recent = velocityOverWindow(samples, -2); // last 2 samples
const earlier = velocityOverWindow(samples, -4, -2); // the 2 before that
// If velocity has dropped by more than half, the tweet is likely past peak
return recent < earlier * 0.5;
}
A tweet past peak is excluded from repost consideration regardless of score. This is conservative — we'd rather skip a borderline tweet than repost something that's already been seen by most of the audience.
The ranking, end to end
Putting it together, each module run does this:
async function pickRepostCandidate(watchlistTweets) {
const scored = [];
for (const tweet of watchlistTweets) {
const samples = await getSamples(tweet.id);
// Exclude tweets we've already reposted (dedup, similar to reply dedup)
if (await alreadyReposted(slotId, tweet.id)) continue;
// Exclude tweets past their peak
if (isPastPeak(samples)) continue;
// Exclude tweets too old or too new
if (tweet.age_minutes < 15 || tweet.age_minutes > 6 * 60) continue;
scored.push({ tweet, score: scoreTweet(tweet, samples) });
}
scored.sort((a, b) => b.score - a.score);
return scored[0]?.tweet ?? null;
}
The age bounds (15 minutes minimum, 6 hours maximum) matter: under 15 minutes and we don't trust the velocity signal; over 6 hours and the content is stale regardless of score. The window in between is where reposting adds value.
What the weights cost us
Every weight in the scoring function is a judgment call with a tradeoff. A few we deliberated:
- Velocity weight (0.6) vs. intensity weight (0.4): Higher velocity weight means we repost faster-rising content but occasionally miss slow-burn high-quality posts. We chose velocity-heavy because reposting a slow burn late adds little value.
- The 4-sample confidence threshold: Lower means we trust fresh tweets sooner (faster reposts) but with noisier scores. Higher is safer but slower. Four samples (~30–60 minutes of history depending on poll interval) is our balance.
- The 0.5 peak threshold: Stricter (e.g., 0.3) would repost more post-peak content; looser (e.g., 0.7) would skip more borderline. We chose to err toward skipping — a missed repost is recoverable, a stale repost is low-value.
These aren't universal constants. They're tuned for our use case (catching watchlist content on the rise, for an audience that values freshness). A different use case — say, surfacing all-time best content — would weight intensity far higher and ignore velocity entirely.
What we learned
1. Normalize before you compare. Raw counts across different-sized accounts and different ages are meaningless. Normalize for size (engagement rate) and for age (velocity) before any ranking.
2. Velocity beats total for "what's hot now." Cumulative totals reward old content; velocity rewards content that's currently climbing. For a repost decision — where freshness matters — velocity is the right signal.
3. Confidence-weight your signals. A score computed from one data point is noise. Discounting low-confidence signals prevents fresh noise from dominating the ranking.
4. Explicitly model "past peak." A score alone doesn't tell you whether the content is still rising or already seen. A separate peak check handles what the score can't.
5. Constrain the age window. Too-new and the signal is unreliable; too-old and the content is stale. The useful window is narrower than you'd guess.
The scoring function is small — maybe 60 lines of real logic — but every line encodes a judgment about what "best content to repost" actually means. The math is trivial; the tuning is everything. And the tuning only converges because we had clear opinions about what we wanted: fresh, rising, appropriately-sized content, not just "the tweet with the biggest numbers."
HelperX powers Top Repost with velocity-aware, size-normalized engagement scoring that catches watchlist content on the rise — not after it's peaked. Free 30-day trial.
Top comments (0)