Tighten relevance scoring and Polymarket ranking

Score against original user intent on Reddit, remove the artificial low-end relevance floor, and make Polymarket semantics dominate generic market quality signals.

Also apply the relevance filter to Polymarket and update the affected cross-source tests.

Validation: uv run python -m unittest
This commit is contained in:
Jeffrey Sperling
2026-03-13 19:21:25 -07:00
parent cbee987f65
commit 946af84f9a
12 changed files with 147 additions and 61 deletions
+1
View File
@@ -1838,6 +1838,7 @@ def main():
deduped_hn = score.relevance_filter(deduped_hn, "HN")
deduped_bsky = score.relevance_filter(deduped_bsky, "BLUESKY")
deduped_ts = score.relevance_filter(deduped_ts, "TRUTHSOCIAL")
deduped_pm = score.relevance_filter(deduped_pm, "POLYMARKET") if deduped_pm else []
# Cross-source linking: annotate items that discuss the same story
dedupe.cross_source_link(
+16 -24
View File
@@ -13,6 +13,7 @@ from typing import Any, Dict, List, Optional
from urllib.parse import quote_plus, urlencode
from . import http
from .relevance import token_overlap_relevance
GAMMA_SEARCH_URL = "https://gamma-api.polymarket.com/public-search"
@@ -314,8 +315,8 @@ def _shorten_question(question: str) -> str:
def _compute_text_similarity(topic: str, title: str, outcomes: List[str] = None) -> float:
"""Score how well the event title (or outcome names) match the search topic.
Returns 0.0-1.0. Title substring match gets 1.0, outcome match gets 0.85/0.7,
title token overlap gets proportional score.
Returns 0.0-1.0. Exact title phrase match gets 1.0. Otherwise we reuse the
shared query-centric relevance scorer and take the best title/outcome match.
"""
core = _extract_core_subject(topic).lower()
title_lower = title.lower()
@@ -326,27 +327,17 @@ def _compute_text_similarity(topic: str, title: str, outcomes: List[str] = None)
if core in title_lower:
return 1.0
# Check if topic appears in any outcome name (bidirectional)
best_score = token_overlap_relevance(core, title)
if outcomes:
core_tokens = set(core.split())
best_outcome_score = 0.0
for outcome_name in outcomes:
outcome_lower = outcome_name.lower()
# Bidirectional: "arizona" in "arizona basketball" OR "arizona basketball" contains "arizona"
outcome_score = token_overlap_relevance(core, outcome_name)
if core in outcome_lower or outcome_lower in core:
best_outcome_score = max(best_outcome_score, 0.85)
elif core_tokens & set(outcome_lower.split()):
best_outcome_score = max(best_outcome_score, 0.7)
if best_outcome_score > 0:
return best_outcome_score
outcome_score = max(outcome_score, 0.92 if len(outcome_lower.split()) >= 2 else 0.88)
best_score = max(best_score, outcome_score)
# Token overlap fallback against title
topic_tokens = set(core.split())
title_tokens = set(title_lower.split())
if not topic_tokens:
return 0.5
overlap = len(topic_tokens & title_tokens)
return overlap / len(topic_tokens)
return round(best_score, 2)
def _safe_float(val, default=0.0) -> float:
@@ -484,7 +475,8 @@ def parse_polymarket_response(response: Dict[str, Any], topic: str = "") -> List
except (IndexError, TypeError):
end_date = None
# Quality-signal relevance (replaces position-based decay)
# Semantic relevance should dominate. Market quality should refine
# relevant matches, not rescue unrelated high-liquidity events.
text_score = _compute_text_similarity(topic, title, all_outcome_names) if topic else 0.5
# Volume signal: log-scaled monthly volume (most stable signal)
@@ -504,13 +496,13 @@ def parse_polymarket_response(response: Dict[str, Any], topic: str = "") -> List
# Competitive bonus: markets near 50/50 are more interesting
competitive_score = event_competitive
relevance = min(1.0, (
0.30 * text_score +
0.30 * vol_score +
0.15 * liq_score +
market_quality = (
0.50 * vol_score +
0.25 * liq_score +
0.15 * movement_score +
0.10 * competitive_score
))
)
relevance = min(1.0, text_score * (0.75 + 0.25 * market_quality))
# Surface the topic-matching outcome to the front before truncating
if topic and outcome_prices:
+5 -3
View File
@@ -108,12 +108,14 @@ def expand_reddit_queries(topic: str, depth: str) -> List[str]:
if core.lower() != original_clean.lower() and len(original_clean.split()) <= 8:
queries.append(original_clean)
# Add opinion/review variant except for how_to/comparison queries
# Opinion/review variants help mostly for product and opinion queries.
# They contaminate broader searches like predictions or breaking news.
qtype = detect_query_type(topic)
if depth in ("default", "deep") and qtype not in ("how_to", "comparison"):
if depth in ("default", "deep") and qtype in ("product", "opinion"):
queries.append(f"{core} worth it OR thoughts OR review")
if depth == "deep":
# Problem/bug variants are useful for tool workflows, not generic news.
if depth == "deep" and qtype in ("product", "opinion", "how_to"):
queries.append(f"{core} issues OR problems OR bug OR broken")
return queries
+62 -8
View File
@@ -1,6 +1,9 @@
"""Shared token-overlap relevance scoring for search result ranking.
Tokenizes text, expands synonyms, and computes query-to-content overlap ratios.
The score is intentionally query-centric:
- exact phrase matches should score very high
- partial matches should pay a meaningful penalty
- matches on generic words alone ("odds", "review") should not pass as relevant
"""
import re
@@ -36,6 +39,18 @@ SYNONYMS = {
'vuejs': {'vue'},
}
# Generic query words that should not carry relevance on their own.
# They still help when paired with stronger entity/topic matches.
LOW_SIGNAL_QUERY_TOKENS = frozenset({
'advice', 'animation', 'animations', 'best', 'chance', 'chances',
'code', 'compare', 'comparison', 'differences', 'explain', 'guide',
'guides', 'how', 'latest', 'news', 'odds', 'opinion', 'opinions',
'prediction', 'predictions', 'probability', 'probabilities', 'prompt',
'prompting', 'prompts', 'rate', 'review', 'reviews', 'thoughts',
'tip', 'tips', 'tutorial', 'tutorials', 'update', 'updates', 'use',
'using', 'versus', 'vs', 'worth',
})
def tokenize(text: str) -> Set[str]:
"""Lowercase, strip punctuation, remove stopwords, drop single-char tokens.
@@ -51,15 +66,25 @@ def tokenize(text: str) -> Set[str]:
return expanded
def _normalize_phrase(text: str) -> str:
"""Normalize text for phrase containment checks."""
return ' '.join(re.sub(r'[^\w\s]', ' ', text.lower()).split())
def token_overlap_relevance(
query: str,
text: str,
hashtags: Optional[List[str]] = None,
) -> float:
"""Compute relevance as ratio of query tokens found in text.
"""Compute a query-centric relevance score between 0.0 and 1.0.
Uses ratio overlap (intersection / query_length) so short queries
score higher when fully represented in the text. Floors at 0.1.
The score combines:
- query coverage
- informative-token coverage
- a small precision term to penalize extra noise
- an exact phrase bonus
Generic tokens alone are capped below the post-retrieval 0.3 threshold.
Args:
query: Search query
@@ -68,7 +93,7 @@ def token_overlap_relevance(
hashtags are split to match query tokens (e.g. "claudecode" matches "claude").
Returns:
Float between 0.1 and 1.0 (0.5 for empty queries)
Float between 0.0 and 1.0 (0.5 for empty queries)
"""
q_tokens = tokenize(query)
@@ -89,6 +114,35 @@ def token_overlap_relevance(
if not q_tokens:
return 0.5 # Neutral fallback for empty/stopword-only queries
overlap = len(q_tokens & t_tokens)
ratio = overlap / len(q_tokens)
return max(0.1, min(1.0, ratio))
overlap_tokens = q_tokens & t_tokens
overlap = len(overlap_tokens)
if overlap == 0:
return 0.0
informative_q_tokens = {t for t in q_tokens if t not in LOW_SIGNAL_QUERY_TOKENS}
if not informative_q_tokens:
informative_q_tokens = q_tokens
coverage = overlap / len(q_tokens)
informative_overlap = len(informative_q_tokens & t_tokens) / len(informative_q_tokens)
precision_denominator = min(len(t_tokens), len(q_tokens) + 4) or 1
precision = overlap / precision_denominator
phrase_bonus = 0.0
normalized_query = _normalize_phrase(query)
normalized_text = _normalize_phrase(combined)
if normalized_query and normalized_query in normalized_text:
phrase_bonus = 0.12 if len(normalized_query.split()) > 1 else 0.16
base = (
0.55 * (coverage ** 1.35) +
0.25 * informative_overlap +
0.20 * precision
)
# If we only matched generic query words, keep the score below the
# normal relevance filter threshold so these do not survive by default.
if informative_q_tokens and not (informative_q_tokens & t_tokens):
return round(min(0.24, base), 2)
return round(min(1.0, base + phrase_bonus), 2)
+9 -3
View File
@@ -11,6 +11,12 @@ WEIGHT_RELEVANCE = 0.45
WEIGHT_RECENCY = 0.25
WEIGHT_ENGAGEMENT = 0.30
# Polymarket needs stronger semantic weighting because volume/liquidity already
# show up as engagement and lightly influence parse-time relevance.
PM_WEIGHT_RELEVANCE = 0.60
PM_WEIGHT_RECENCY = 0.20
PM_WEIGHT_ENGAGEMENT = 0.20
# WebSearch weights (no engagement data available)
WEBSEARCH_WEIGHT_RELEVANCE = 0.55
WEBSEARCH_WEIGHT_RECENCY = 0.45
@@ -632,9 +638,9 @@ def score_polymarket_items(items: List[schema.PolymarketItem]) -> List[schema.Po
)
overall = (
WEIGHT_RELEVANCE * rel_score +
WEIGHT_RECENCY * rec_score +
WEIGHT_ENGAGEMENT * eng_score
PM_WEIGHT_RELEVANCE * rel_score +
PM_WEIGHT_RECENCY * rec_score +
PM_WEIGHT_ENGAGEMENT * eng_score
)
if eng_raw[i] is None:
+2 -2
View File
@@ -57,9 +57,9 @@ class TestComputeRelevance(unittest.TestCase):
boosted = instagram._compute_relevance("claude code", "random video about stuff", ["claudecode", "ai"])
self.assertGreater(boosted, base)
def test_floor_at_01(self):
def test_no_match_returns_zero(self):
rel = instagram._compute_relevance("quantum physics", "cat dancing video")
self.assertGreaterEqual(rel, 0.1)
self.assertEqual(rel, 0.0)
def test_empty_query_returns_default(self):
rel = instagram._compute_relevance("", "Some video title")
+17 -7
View File
@@ -569,8 +569,9 @@ class TestTextSimilarity(unittest.TestCase):
def test_partial_token_overlap(self):
score = polymarket._compute_text_similarity("Arizona Basketball", "Will Arizona win?")
# "Arizona" matches, "Basketball" doesn't -> 0.5
self.assertAlmostEqual(score, 0.5)
# Partial informative match should stay below exact match.
self.assertGreater(score, 0.3)
self.assertLess(score, 0.6)
def test_no_overlap(self):
score = polymarket._compute_text_similarity("Arizona Basketball", "Will AI regulation pass?")
@@ -595,7 +596,7 @@ class TestTextSimilarity(unittest.TestCase):
"Who will be the #1 overall seed?",
outcomes=["Duke", "Arizona", "Houston"],
)
self.assertEqual(score, 0.85)
self.assertEqual(score, 1.0)
def test_outcome_bidirectional_match(self):
"""Topic 'Arizona Basketball' should match outcome 'Arizona' (outcome in core)."""
@@ -604,16 +605,17 @@ class TestTextSimilarity(unittest.TestCase):
"Who will be the #1 overall seed?",
outcomes=["Duke", "Arizona", "Houston"],
)
self.assertEqual(score, 0.85)
self.assertEqual(score, 0.88)
def test_outcome_token_overlap(self):
"""Partial token overlap with outcome gets 0.7 when no substring match."""
"""Partial token overlap with outcome gets a moderate score."""
score = polymarket._compute_text_similarity(
"Iran War",
"Unrelated geopolitics title",
outcomes=["War continues", "Peace deal"],
)
self.assertEqual(score, 0.7)
self.assertGreater(score, 0.3)
self.assertLess(score, 0.6)
def test_outcome_no_match(self):
"""No outcome match falls through to title token overlap."""
@@ -632,7 +634,15 @@ class TestTextSimilarity(unittest.TestCase):
"Unrelated title",
outcomes=["Arizona"],
)
self.assertEqual(score, 0.85)
self.assertEqual(score, 1.0)
def test_generic_only_odds_match_stays_below_threshold(self):
score = polymarket._compute_text_similarity(
"Anthropic odds",
"Republican 2026 House odds",
outcomes=["Yes", "No"],
)
self.assertLess(score, 0.3)
def test_title_match_still_beats_outcome(self):
"""Title substring match (1.0) takes priority over outcome match (0.85)."""
+16 -4
View File
@@ -47,16 +47,28 @@ class TestExpandRedditQueries(unittest.TestCase):
self.assertGreaterEqual(len(queries), 1)
def test_default_includes_review_variant(self):
queries = reddit.expand_reddit_queries("cursor IDE", "default")
queries = reddit.expand_reddit_queries("cursor IDE pricing", "default")
self.assertTrue(any("worth it" in q or "review" in q for q in queries))
def test_default_skips_review_variant_for_prediction(self):
queries = reddit.expand_reddit_queries("anthropic odds", "default")
self.assertFalse(any("worth it" in q or "review" in q for q in queries))
def test_default_skips_review_variant_for_breaking_news(self):
queries = reddit.expand_reddit_queries("kanye west", "default")
self.assertFalse(any("worth it" in q or "review" in q for q in queries))
def test_deep_includes_issues_variant(self):
queries = reddit.expand_reddit_queries("cursor IDE", "deep")
queries = reddit.expand_reddit_queries("cursor IDE pricing", "deep")
self.assertTrue(any("issues" in q or "problems" in q for q in queries))
def test_deep_skips_issues_variant_for_prediction(self):
queries = reddit.expand_reddit_queries("anthropic odds", "deep")
self.assertFalse(any("issues" in q or "problems" in q for q in queries))
def test_deep_has_more_queries_than_quick(self):
quick = reddit.expand_reddit_queries("cursor IDE", "quick")
deep = reddit.expand_reddit_queries("cursor IDE", "deep")
quick = reddit.expand_reddit_queries("cursor IDE pricing", "quick")
deep = reddit.expand_reddit_queries("cursor IDE pricing", "deep")
self.assertGreater(len(deep), len(quick))
+10 -1
View File
@@ -75,7 +75,7 @@ class TestTokenOverlapRelevance(unittest.TestCase):
def test_floor_at_0_1(self):
rel = token_overlap_relevance("quantum physics", "cat dancing video")
self.assertGreaterEqual(rel, 0.1)
self.assertEqual(rel, 0.0)
def test_full_match_returns_1(self):
rel = token_overlap_relevance("python tutorial", "Python Tutorial for Beginners")
@@ -96,6 +96,15 @@ class TestTokenOverlapRelevance(unittest.TestCase):
rel = token_overlap_relevance("the a is", "some content here")
self.assertEqual(rel, 0.5)
def test_generic_only_overlap_stays_below_filter_threshold(self):
rel = token_overlap_relevance("anthropic odds", "Republican house odds update")
self.assertLess(rel, 0.3)
def test_informative_partial_match_stays_above_generic_only(self):
generic_only = token_overlap_relevance("anthropic odds", "Republican house odds update")
informative = token_overlap_relevance("anthropic odds", "Anthropic valuation market")
self.assertGreater(informative, generic_only)
class TestHashtagRelevance(unittest.TestCase):
"""Tests for hashtag-aware relevance (TikTok/Instagram pattern)."""
+2 -2
View File
@@ -44,9 +44,9 @@ class TestComputeRelevance(unittest.TestCase):
score = scrapecreators_x._compute_relevance("", "some text")
self.assertEqual(score, 0.5)
def test_floor_at_01(self):
def test_no_match_returns_zero(self):
score = scrapecreators_x._compute_relevance("abcdef ghijkl", "xyz")
self.assertGreaterEqual(score, 0.1)
self.assertEqual(score, 0.0)
class TestExtractCoreSubject(unittest.TestCase):
+2 -2
View File
@@ -33,9 +33,9 @@ class TestTikTokRelevance(unittest.TestCase):
rel = tiktok._compute_relevance("", "Some video title")
self.assertEqual(rel, 0.5)
def test_floor(self):
def test_no_match_returns_zero(self):
rel = tiktok._compute_relevance("quantum physics", "cat dancing video")
self.assertGreaterEqual(rel, 0.1)
self.assertEqual(rel, 0.0)
class TestExtractCoreSubject(unittest.TestCase):
+5 -5
View File
@@ -62,7 +62,7 @@ class TestComputeRelevance(unittest.TestCase):
def test_no_match(self):
result = _compute_relevance("Claude Code", "Python Web Scraping")
self.assertEqual(result, 0.1) # Floor
self.assertEqual(result, 0.0)
def test_empty_query_returns_neutral(self):
result = _compute_relevance("", "Some Video Title")
@@ -74,7 +74,7 @@ class TestComputeRelevance(unittest.TestCase):
def test_empty_title(self):
result = _compute_relevance("Claude Code", "")
self.assertEqual(result, 0.1) # Floor
self.assertEqual(result, 0.0)
def test_case_insensitive(self):
result = _compute_relevance("claude code", "CLAUDE CODE Tutorial")
@@ -89,9 +89,9 @@ class TestComputeRelevance(unittest.TestCase):
)
self.assertEqual(result, 1.0)
def test_floor_at_0_1(self):
def test_no_match_returns_zero(self):
result = _compute_relevance("quantum computing", "cat videos compilation")
self.assertEqual(result, 0.1)
self.assertEqual(result, 0.0)
def test_cap_at_1_0(self):
result = _compute_relevance("AI", "AI AI AI AI AI")
@@ -103,7 +103,7 @@ class TestComputeRelevance(unittest.TestCase):
def test_single_word_no_match(self):
result = _compute_relevance("Seedance", "Random cooking video")
self.assertEqual(result, 0.1)
self.assertEqual(result, 0.0)
if __name__ == "__main__":