Skip to Content
Part 2: Intelligence EngineCh 8: Intent Scoring

Fit tells you if a company could be a good customer. Intent tells you if they’re ready to buy right now.

A company might be a perfect fit — right industry, right size — but if they haven’t opened an email in 6 months, they’re not ready. Conversely, a mediocre-fit company that’s been visiting your pricing page daily and replying to emails is showing buying intent.

The Intent Score captures this urgency.

Intent Score Formula
Intent = (Volume Ă— 40%) + (Recency Ă— 30%) + (Topic Relevance Ă— 30%)
ComponentWeightDescription
Volume40%Number of intent signals detected, normalized 0-100
Recency30%Exponential decay — recent signals weighted higher
Topic Relevance30%Semantic similarity between signals and winning deal topics

The Intent Score Formula

Intent Score = (Volume Ă— 40%) + (Recency Ă— 30%) + (Topic Relevance Ă— 30%)
// src/config/scoring.ts, lines 30-47 export const INTENT_SCORE = { WEIGHTS: { VOLUME: 0.4, // How much activity RECENCY: 0.3, // How recent TOPICS: 0.3, // How relevant }, MAX_ACTIVITY: 100, RECENCY_HALF_LIFE_DAYS: 21, // Signal strength halves every 21 days };

Each component ranges from 0 to 100. Let’s examine them.

Component 1: Volume Score (40%)

Volume measures raw activity count — emails opened, pages visited, form submissions, and other signals.

// src/domain/scoring/services/intent/intentScoreService.ts, lines 87-96 calculateVolumeScore(activityCount: number): number { if (!activityCount || activityCount <= 0) { return 0; } const maxActivity = INTENT_SCORE.MAX_ACTIVITY; // 100 // Log normalization to reduce bot noise const normalized = Math.log1p(activityCount) / Math.log1p(maxActivity); const score = Math.min(100, normalized * 100); return Math.round(score * 10) / 10; }

The formula: volumeScore = ln(1 + count) / ln(1 + 100) Ă— 100

Math.log1p(x) computes ln(1 + x) — the natural logarithm of 1 plus x. Using 1 + x instead of just x avoids ln(0) = -infinity when the count is 0.

Why Logarithmic, Not Linear?

Consider linear scoring:

ActivitiesLinear ScoreLog Score
1115.0
5539.0
101050.0
252570.0
505085.0
100100100.0

Linear scoring makes the difference between 1 and 10 activities the same as the difference between 91 and 100. But in sales, the jump from “no engagement” to “10 touchpoints” is far more significant than going from 91 to 100.

Logarithmic scoring compresses the high end and expands the low end. The first few signals carry the most information. After 50+ activities, additional signals barely increase the score — the prospect is already clearly engaged.

Bot noise protection: This also protects against automated systems. If a marketing automation tool sends 500 activity signals, the log score is 100 (maxed out) instead of 500 (linear). You can’t game the ranking with volume alone.

Component 2: Recency Score (30%)

A signal from yesterday is worth more than a signal from 3 months ago. Recency measures how recently the prospect was active, using exponential decay with a 21-day half-life.

// src/domain/scoring/services/intent/intentScoreService.ts, lines 102-117 calculateRecencyScore(lastActivityDate: Date): number { if (!lastActivityDate) { return 0; } const now = new Date(); const daysSinceActivity = Math.floor( (now.getTime() - lastActivityDate.getTime()) / (1000 * 60 * 60 * 24) ); const halfLife = INTENT_SCORE.RECENCY_HALF_LIFE_DAYS; // 21 days // Exponential decay: score = 100 Ă— e^(-days / halfLife) const score = 100 * Math.exp(-daysSinceActivity / halfLife); return Math.round(score * 10) / 10; }

The formula: recencyScore = 100 Ă— e^(-days / 21)

This is the same math used in radioactive decay, drug metabolism, and capacitor discharge. The score drops by a fixed percentage per unit time, not a fixed amount.

Days Since ActivityRecency ScoreInterpretation
0 (today)100.0Maximally fresh
386.7Very recent
771.6Within the week
1451.3Two weeks ago
2136.8One half-life (37%)
4213.5Two half-lives (14%)
605.7Practically dead
901.4Ancient history

Why 21-day half-life? Sales cycles in B2B SaaS typically span weeks to months. A signal from 3 weeks ago should still carry meaningful weight. A 7-day half-life would penalize normal buying cycles too aggressively. A 60-day half-life would keep stale signals relevant for too long.

21 days means:

  • Last week’s signals retain ~72% of their value
  • Last month’s signals retain ~25%
  • 3 months ago: essentially zero

This matches the “weekly sales review” cadence — prospects who were active in the last review cycle still score well.

Component 3: Topic Relevance Score (30%)

Not all activity is equally relevant. A prospect reading your pricing page is a stronger signal than one reading your blog post about industry trends. Topic relevance measures how well the prospect’s activities match buying intent.

Intent Signal Types

Each signal carries a signal strength that reflects its buying intent:

| Signal Type | Strength | Examples | |---------------|----------|---------------------------------| | Social Ask | 90 | LinkedIn: "Looking for a CRM" | | Tech Change | 85 | "Switching from Salesforce to..." | | Job Posting | 80 | "Hiring a Sales Operations VP" | | Funding | 75 | "Series B announced" | | Content View | 60 | Pricing page, case study | | Email Engage | 50 | Email opened, link clicked | | Web Visit | 40 | Generic website visit | | Social Mention | 30 | Brand mention, industry post |

A “Social Ask” (someone publicly asking for a solution like yours) scores 90/100. A generic web visit scores 40. These weights are stored in the intent_signals table’s signal_strength column.

Semantic Topic Matching

Here’s where it gets interesting. Instead of just matching exact keywords, the system uses the LLM for semantic matching:

// src/domain/scoring/services/intent/semanticTopicMatcher.ts, lines 69-90 export class SemanticTopicMatcher { async matchTopics(request: TopicMatchRequest): Promise<SemanticMatchResult> { // Check cache first (7-day TTL) const cacheKey = generateCacheKey(request); const cached = await redisCacheService.get<SemanticMatchResult>(cacheKey); if (cached) return cached; try { const result = await this.matchWithLLM(request); await redisCacheService.set(cacheKey, result, CACHE_TTL); return result; } catch (error) { // Fallback: exact string matching return this.matchExact(request); } } }

The LLM understands that “CRM” and “Customer Relationship Management” are the same thing, that “Cloud Migration” relates to “AWS Migration,” and that “DevOps” overlaps with “CI/CD Automation.”

Scoring rules:

  • Exact match: +5 points per matching signal
  • Semantic match: +3 points per matching signal
  • Semantic match to loss topics: -7 points per match (this company’s interests align with segments you lose in)
  • Exact match to loss topics: -10 points per match

The caching is critical — LLM calls are expensive (~100ms and ~$0.001 per call). With 7-day TTL, the same topic pair is only matched once per week.

Learned Intent Weights

The default weights (40/30/30) are starting points. Over time, Astrelo learns which weight distribution best predicts deal outcomes for YOUR data:

// learned_intent_weights table (per user) { volume_weight: 0.45, // This user's wins correlate more with volume recency_weight: 0.25, // Recency matters less for their cycle topic_weight: 0.30, // Topics are about average volume_correlation: 0.72, // Statistical correlation strength recency_correlation: 0.41, topic_correlation: 0.58, based_on_deals: 47, // Based on 47 closed deals confidence: 'high' // Enough data to be confident }

The learning algorithm analyzes closed deals:

  1. For each won deal, what was the volume/recency/topic score at the time of close?
  2. For each lost deal, same question.
  3. Which component had the strongest correlation with winning?
  4. Adjust weights proportionally.

If volume strongly predicts your wins (correlation 0.72), volume gets more weight. If recency is weaker (correlation 0.41), it gets less. The confidence field reflects the sample size — with fewer than 10 deals, the system keeps the defaults.

Putting Intent Together

Let’s score a prospect:

“DataFlow Inc.” activity:

  • 15 total activities (emails opened, page views, form fills)
  • Last activity: 5 days ago
  • Topics: “CRM integration,” “sales automation,” “API documentation”
  • Your ICP topics: “CRM,” “sales enablement,” “enterprise software”

Calculation:

  1. Volume: ln(1 + 15) / ln(1 + 100) Ă— 100 = 2.77 / 4.62 Ă— 100 = 60.0 points
  2. Recency: 100 Ă— e^(-5/21) = 100 Ă— 0.788 = 78.8 points
  3. Topic Relevance:
    • “CRM integration” ↔ “CRM” → semantic match → +3
    • “sales automation” ↔ “sales enablement” → semantic match → +3
    • “API documentation” → no match → +0
    • Base score + bonuses: 50 + 3 + 3 = 56.0 points

Intent Score:

(60.0 Ă— 0.4) + (78.8 Ă— 0.3) + (56.0 Ă— 0.3) = 24.0 + 23.6 + 16.8 = 64.4

DataFlow scores 64.4/100 on intent — moderate engagement. The recency is strong (active 5 days ago), volume is decent (15 activities), and topics partially match. To score higher, they’d need more activities, more recent ones, or closer topic alignment.

Key Takeaways

  1. Intent = Volume (40%) + Recency (30%) + Topics (30%). Three signals that together capture buying urgency.

  2. Logarithmic volume scoring compresses high activity counts and expands low ones. The first few signals matter most.

  3. Exponential decay with a 21-day half-life models signal freshness. Last week = ~72%, last month = ~25%, 3 months = ~1%.

  4. Semantic topic matching uses LLM to catch conceptual overlaps that keyword matching misses. Results are cached for 7 days.

  5. Weights are learnable. The system analyzes your deal history to find the optimal volume/recency/topic ratio for YOUR sales cycle.

Next chapter: we combine Fit and Intent into the Composite Score that actually ranks your pipeline.

Last updated on