Thomas Lopez — RevOps
← All writing

The fit score tells you who to call. The surge score tells you when.

A high-fit account is high-fit whether you call them today or nine months from now. The score barely moves. So if fit is the only thing on the board, your rep list is just a static ranking with no sense of who's actually in a window right now.

The short version

I already built a five-tier account fit model in Clay. It has one blind spot: fit is stable, and buying is not. The surge score is the companion layer — a rolling, decaying 0–100 number built in Python on top of the same fit-scored accounts, from behavioral signals like champion moves, new hires in the buying center, pricing-page visits, and job postings.

The two scores stay in separate columns on purpose. Fit sets the floor for who belongs on a rep's list at all; surge sets the order within it. A rep's daily list is high fit, sorted by surge, and nothing with a poor fit ever earns its way onto the list no matter how loud its signals get.

Start from the firmographic base, don't rebuild it

The mistake would be to start over. The fit model already did the expensive work: it enriched, normalized, and scored every account on the durable attributes. The surge score doesn't re-litigate any of that. It takes the fit-scored account table as its input — one row per account, fit tier already assigned — and adds a second, faster-moving number next to it.

So the data model is two columns living side by side:

  • fit_tier — High-Fit Platform, High-Fit Services, Medium, Low, Not a Fit. Recalculated in a batch, maybe quarterly. Slow.
  • surge_score — a rolling number, 0 to 100, recalculated as often as the signals refresh. Fast, and decays.

Keeping them separate is the whole design. A rep's daily list is fit tier in the top two bands, and surge above a threshold, sorted by surge. Fit sets the floor; surge sets the order. You never let surge pull a Not-a-Fit account onto the list no matter how loud its signals get — a badly-fit account making noise is still a badly-fit account.

The surge inputs: market signals, not attributes

Fit is built from what an account is. Surge is built from what an account is doing. Every input is behavioral, external, and time-stamped, because a signal without a timestamp can't decay, and decay is the point.

Third-party intent

Someone at the account is researching the category on properties you don't own — review sites, publisher networks, comparison pages. This is the earliest and noisiest signal. It tells you a buying committee is forming somewhere, but not who and not how serious. Low weight per hit, but volume and topic relevance matter.

Web visits to your own properties

Anonymous or de-anonymized traffic to your pricing page, your comparison pages, your docs. Much higher-confidence than third-party intent because it's directed at you specifically. A pricing-page visit is worth several category-research hits.

Job postings

An account posting a role that implies they're building out the function your product serves is a structural signal — it means budget got approved and a mandate exists. Slower to decay than a web visit, because hiring cycles are long. Worth a lot when the role title matches.

New hires in the buying center

The inverse of the job posting — someone just landed in a role that owns your category. New leaders re-evaluate the stack in their first 90 days almost by reflex. A new VP of the relevant function is one of the highest-value windows you get.

Champion tracking

A known contact — someone who championed you at a past company, or engaged deeply before — shows up at a new account. This is the signal I'd weight highest of all, because it's not a proxy for intent, it is warm intent with a name attached. When a champion moves, that account should surface immediately regardless of what the other signals say.

Building it in Python

The reason this lives in Python and not in the enrichment tool's point-and-click scoring is that surge needs three things a rules-builder handles badly: time-decay math, signal-source deduplication, and weighting you can version-control and argue with. None of those are one-click.

The core is an additive model with decay — deliberately not machine learning, for the same reason the fit model isn't. At the deal volume most GTM teams actually have, an ML model has nowhere near enough closed outcomes to learn real weights, so it overfits to noise and hands you a black box no rep can interrogate. A hand-weighted model is legible: a rep can look at why an account surged, disagree, and get the weight corrected. That feedback loop is worth more than a marginal accuracy gain you can't audit.

surge_score.py — additive model with per-signal decay
from datetime import datetime, timezone
import math

# Base weight per signal type — the part you version-control and argue about
SIGNAL_WEIGHTS = {
    "champion_moved":   40,   # a known champion lands at the account
    "new_hire_buyer":   30,   # new leader in the buying center
    "pricing_visit":    25,   # visit to your own pricing/comparison pages
    "job_posting":      20,   # account hiring for the function you serve
    "web_visit":        10,   # general visit to your properties
    "intent_topic":      6,   # third-party category research (noisiest)
}

# Half-life in days: how fast each signal type loses relevance.
# Web behavior decays in days; hiring signals persist for weeks.
HALF_LIFE = {
    "champion_moved":   90,
    "new_hire_buyer":   60,
    "pricing_visit":    14,
    "job_posting":      45,
    "web_visit":         7,
    "intent_topic":     10,
}

def decayed_value(signal_type, age_days):
    """Exponential decay: a signal is worth its full weight today and
    half its weight after one half-life."""
    base = SIGNAL_WEIGHTS[signal_type]
    half_life = HALF_LIFE[signal_type]
    return base * math.pow(0.5, age_days / half_life)

def surge_score(signals, now=None):
    """
    signals: list of dicts like
        {"type": "pricing_visit", "timestamp": <datetime>, "weight_mult": 1.0}
    weight_mult lets you scale a single hit up or down — e.g. an intent
    topic that exactly matches your category vs. a loosely-related one.
    """
    now = now or datetime.now(timezone.utc)
    raw = 0.0
    for s in signals:
        age_days = (now - s["timestamp"]).total_seconds() / 86400
        if age_days < 0:
            continue  # ignore future-dated / clock-skew rows
        raw += decayed_value(s["type"], age_days) * s.get("weight_mult", 1.0)

    # Squash to 0–100 so the number is readable and comparable across accounts.
    # Diminishing returns: ten weak signals shouldn't out-score one champion move.
    return round(100 * (1 - math.exp(-raw / 60)), 1)

Three design choices in there are worth calling out, because they're where the model earns its keep:

Decay is per-signal-type, not global. A pricing-page visit is stale in two weeks; a champion who just moved is relevant for a quarter. Giving every signal the same half-life throws away the most useful thing you know about it — how long it stays true. Web behavior decays in days, hiring and champion signals persist for weeks. Encoding that is most of the value.

The squash function creates diminishing returns. Without it, an account that trips ten weak third-party-intent hits would out-score an account where a champion just walked in the door, purely on volume. The exponential squash means the first strong signal moves the needle hard and the tenth weak one barely registers — which matches how a good rep actually reads a list.

Everything is a weight you can version-control. The two dictionaries at the top are the entire argument surface. When a rep says "champion moves aren't converting like we thought," you don't rebuild anything — you change one number in SIGNAL_WEIGHTS, commit it, and the whole book re-scores on the next run. That auditability is the reason it's code and not a model.

Where the two scores meet

The payoff is a two-dimensional board instead of a one-dimensional ranked list:

Low surgeHigh surge
High fit Nurture. Right account, wrong time. Automated touch, no AE hours. Call today. This is the entire point — your best-fit accounts in an active window.
Low fit Ignore. Handle with caution. Something's happening, but they're a poor structural match — often a competitor's problem, not yours.

Reps live in the top-right cell. Marketing owns the top-left. The bottom row mostly stays off the phones. And because surge decays, the top-right cell empties itself over time without anyone manually pruning it — an account that stops signaling slides back into nurture on its own. That self-cleaning property is why the decay math is non-negotiable. A surge score that never falls is just a second fit score wearing a costume.

What I'd tell you before you build this

Don't turn on surge until your fit model is trustworthy. Surge amplifies fit — it decides ordering within the accounts fit already approved. If the fit floor is wrong, surge just helps your reps get to the wrong accounts faster. Fit first, surge second, always in that order.

Instrument the decay before you trust the weights. The initial weights are educated guesses; they're supposed to be. The half-lives are the part you'll actually get wrong at first, because "how long is a job posting worth something" is genuinely an empirical question you don't have the answer to on day one. Log which signal fired on every account that converts, look at the age of that signal at the moment the deal opened, and let the real windows correct your half-lives over the first quarter. The model gets good by watching outcomes — not by being clever up front.

Fit tells you who belongs on the list. Surge tells you who to call first. Keep them in separate columns, let the second one decay, and your reps' time follows the accounts that are actually moving.

Questions I get about this

What is a surge score?

A fast-moving, decaying number that measures whether an account is actively buying right now, built from behavioral signals like web visits, hiring, and champion moves — separate from fit, which measures whether an account should ever buy at all.

How is surge different from fit scoring?

Fit is what an account is — category, revenue, tooling — and barely moves. Surge is what an account is doing, and decays within days or weeks. Fit sets the floor for the list; surge sets the order within it.

Why exponential decay instead of a fixed window?

A fixed window treats a signal as fully relevant until an arbitrary cutoff, then zero. Exponential decay with a per-signal half-life matches how confidence actually erodes — modeling each signal's own half-life is most of the value in the system.

Machine learning or fixed weights, again?

Fixed weights. A hand-weighted model is legible — a rep can see which weight is wrong and argue with it, which is how the model actually improves. At typical B2B deal volumes, a learned model overfits to noise.

Which signals matter most?

Roughly in order: a champion landing at a new account, a new hire in the buying center, visits to your own pricing pages, relevant job postings, general site visits, then third-party category research. Signals directed at you outweigh signals about the category in general.

I'm Thomas Lopez. I run revenue operations solo — pipeline analytics, outbound automation, and the data plumbing underneath demand gen — for a nonprofit fundraising SaaS platform.