fix(rerank): ground entity-miss demotion on head token, not full phrase

The entity-grounding demotion required the full multi-word primary
entity as a contiguous substring, so on-entity items missing a trailing
search descriptor were buried: a 323-pt HN thread "Stripe is friendly
to 'friendly fraud'" scored 0 on a "Stripe payments" query. New
_entity_grounded helper keys on the brand head token; items that never
name the brand still miss it and stay demoted. reddit_keyless
_slot_priority, which had re-implemented the old check while claiming
to mirror rerank's signal, now calls the shared helper so the two
paths cannot diverge.
This commit is contained in:
Trevin Chow
2026-06-09 16:24:16 -07:00
parent fd0e47d99f
commit 6a92f63a56
4 changed files with 71 additions and 22 deletions
@@ -170,9 +170,11 @@ def _slot_priority(topic: str, posts: List[Dict[str, Any]]) -> List[Dict[str, An
posts that rerank later demotes as entity misses starves the on-topic posts that rerank later demotes as entity misses starves the on-topic
posts the user actually sees (2026-06-06 "OpenClaw vs Hermes" run: posts the user actually sees (2026-06-06 "OpenClaw vs Hermes" run:
2,000+ upvote Gemma/GPU threads took every slot, then were demoted to 2,000+ upvote Gemma/GPU threads took every slot, then were demoted to
zero). Mirror rerank's demotion signal the topic's stripped primary zero). Mirror rerank's demotion signal via the shared `_entity_grounded`
entity contained in the post text — so slots go to posts likely to check (head token of the topic's stripped primary entity present in the
survive final ranking. Falls back to token-overlap relevance when the post text) so slots go to posts likely to survive final ranking — keying
on the same head token keeps the two paths from diverging. Falls back to
token-overlap relevance when the
topic yields no usable primary entity. Within each tier the incoming topic yields no usable primary entity. Within each tier the incoming
(score-first) order is preserved. Never raises; on any failure the (score-first) order is preserved. Never raises; on any failure the
incoming order is returned unchanged. incoming order is returned unchanged.
@@ -186,7 +188,7 @@ def _slot_priority(topic: str, posts: List[Dict[str, Any]]) -> List[Dict[str, An
entity = rerank._primary_entity(topic).lower() entity = rerank._primary_entity(topic).lower()
if entity: if entity:
def _matches(post: Dict[str, Any]) -> bool: def _matches(post: Dict[str, Any]) -> bool:
return entity in _post_text(post).lower() return rerank._entity_grounded(_post_text(post).lower(), entity)
else: else:
prepared = relevance.PreparedQuery(topic) prepared = relevance.PreparedQuery(topic)
+29 -8
View File
@@ -247,6 +247,29 @@ def _candidate_haystack(candidate: schema.Candidate) -> str:
return " ".join(parts).lower() return " ".join(parts).lower()
def _entity_grounded(haystack: str, primary_entity: str) -> bool:
"""True if the candidate text plausibly mentions the primary entity.
Grounds on the HEAD token of the primary entity (the brand / proper-noun
core), not the full multi-word phrase. Trailing tokens are usually category
descriptors the user/planner appended for search ("Stripe payments"), not
part of the entity, so requiring the whole phrase over-demotes on-entity
items that omit the descriptor. Items that never name the brand at all still
miss the head token and stay demoted.
Trade-off: a proper noun with a generic head ("New York Times" -> "new")
under-demotes rather than over-demotes - the safe direction, since the
observed harm was burying real high-engagement signal. Substring (not
word-boundary) matching is likewise deliberate: it catches plurals and
compounds ("stripes"), and vacuous matches from very short heads ("X",
"Go") merely disable the penalty rather than burying good items.
"""
tokens = primary_entity.lower().split()
if not tokens:
return True
return tokens[0] in haystack
def _fallback_tuple(candidate: schema.Candidate, *, primary_entity: str = "") -> tuple[float, str]: def _fallback_tuple(candidate: schema.Candidate, *, primary_entity: str = "") -> tuple[float, str]:
score = ( score = (
(candidate.local_relevance * 100.0 * 0.7) (candidate.local_relevance * 100.0 * 0.7)
@@ -254,17 +277,15 @@ def _fallback_tuple(candidate: schema.Candidate, *, primary_entity: str = "") ->
+ (candidate.source_quality * 100.0 * 0.1) + (candidate.source_quality * 100.0 * 0.1)
) )
reason = "fallback-local-score" reason = "fallback-local-score"
# Entity-grounding demotion: if the primary entity (topic minus intent # Entity-grounding demotion: subtract ENTITY_MISS_PENALTY when the candidate
# modifier) is not present anywhere in the candidate's text surfaces # never mentions the primary entity's head token, across all text surfaces
# (title, snippet, transcript, transcript highlights, top comments, # (title, snippet, transcript, transcript highlights, top comments,
# insights), subtract ENTITY_MISS_PENALTY. Skip for candidates with # insights). Skip for candidates with NO text anywhere (e.g. image-only
# NO text anywhere (e.g., image-only TikToks) to avoid penalizing # TikToks) so thin-text sources aren't penalized unfairly. See
# thin-text sources unfairly. 2026-04-19 Nate Herk "Managed Agents" # _entity_grounded for why grounding keys on the head token, not the phrase.
# video ranked #2 on a Hermes query despite zero Hermes mentions
# because the old haystack only checked title + snippet.
if primary_entity: if primary_entity:
haystack = _candidate_haystack(candidate) haystack = _candidate_haystack(candidate)
if haystack.strip() and primary_entity.lower() not in haystack: if haystack.strip() and not _entity_grounded(haystack, primary_entity):
score -= ENTITY_MISS_PENALTY score -= ENTITY_MISS_PENALTY
reason = "fallback-local-score (entity-miss demotion)" reason = "fallback-local-score (entity-miss demotion)"
return max(0.0, min(100.0, score)), reason return max(0.0, min(100.0, score)), reason
+14 -10
View File
@@ -204,18 +204,22 @@ class TestSlotPriority:
assert posts[4]["url"] in enriched_urls assert posts[4]["url"] in enriched_urls
assert len(enriched_urls) == reddit_keyless.ENRICH_LIMITS["quick"] assert len(enriched_urls) == reddit_keyless.ENRICH_LIMITS["quick"]
def test_multiword_topic_uses_substring_not_token_overlap(self): def test_slot_priority_grounds_on_head_token_not_full_phrase(self):
# "claude tips" clears token overlap for "Claude Code" but rerank # Mirrors rerank's head-token grounding: a post naming the brand head
# demotes it; the partition must mirror rerank's substring test. # ("Stripe") lands in the match tier even without the trailing search
token_only = self._titled(1, "claude tips", score=500) # descriptor ("payments"), so it is not buried under an unrelated
full_entity = self._titled(2, "Claude Code best setup", score=5) # high-upvote post that never names the brand.
out = reddit_keyless._slot_priority("Claude Code", [token_only, full_entity]) head_only = self._titled(1, "Stripe is friendly to 'friendly fraud'", score=5)
assert out[0] is full_entity off_topic = self._titled(2, "PayPal raises dispute fees again", score=900)
assert out[1] is token_only out = reddit_keyless._slot_priority("Stripe payments", [off_topic, head_only])
assert out[0] is head_only
assert out[1] is off_topic
def test_intent_modifier_stripped_from_topic(self): def test_intent_modifier_topic_prioritizes_head_token_match(self):
# Intent-modifier topics still partition by the brand head token: the
# on-entity post wins over a high-upvote post that never names the brand.
on_topic = self._titled(1, "Hermes Agent v0.13 is great", score=1) on_topic = self._titled(1, "Hermes Agent v0.13 is great", score=1)
off_topic = self._titled(2, "Hermes Birkin unboxing", score=900) off_topic = self._titled(2, "LangGraph tutorial walkthrough", score=900)
out = reddit_keyless._slot_priority("Hermes Agent review", [off_topic, on_topic]) out = reddit_keyless._slot_priority("Hermes Agent review", [off_topic, on_topic])
assert out[0] is on_topic assert out[0] is on_topic
+22
View File
@@ -221,6 +221,28 @@ class EntityGroundingTests(unittest.TestCase):
self.assertIn("entity-miss", off_topic.explanation or "") self.assertIn("entity-miss", off_topic.explanation or "")
self.assertEqual(on_topic.explanation, "fallback-local-score") self.assertEqual(on_topic.explanation, "fallback-local-score")
def test_fallback_grounds_on_head_token_not_full_phrase(self):
# Regression: a 323-pt HN thread titled "Stripe is friendly to
# 'friendly fraud'" was demoted to score 0 on a "Stripe payments"
# query because it lacked the trailing word "payments". The brand
# token alone must ground the item - trailing descriptors are search
# hints, not part of the entity.
brand_only = self._candidate(
"Stripe is friendly to 'friendly fraud'", "discussion of chargebacks and disputes"
)
rerank._apply_fallback_scores([brand_only], primary_entity="Stripe payments")
self.assertEqual("fallback-local-score", brand_only.explanation)
self.assertNotIn("entity-miss", brand_only.explanation or "")
def test_fallback_still_demotes_when_head_token_absent_on_multiword_topic(self):
# The fix must not neuter the demotion: an item that never names the
# brand head token stays demoted even on a multi-word topic.
off_topic = self._candidate(
"PayPal raises dispute fees again", "merchants react to the new pricing"
)
rerank._apply_fallback_scores([off_topic], primary_entity="Stripe payments")
self.assertIn("entity-miss", off_topic.explanation or "")
def test_fallback_match_is_case_insensitive(self): def test_fallback_match_is_case_insensitive(self):
on_topic = self._candidate("HERMES agent rocks", "some text") on_topic = self._candidate("HERMES agent rocks", "some text")
rerank._apply_fallback_scores([on_topic], primary_entity="Hermes Agent") rerank._apply_fallback_scores([on_topic], primary_entity="Hermes Agent")