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:
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user