luckiest-co 1.0.47 → 1.0.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (274) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/package.json +1 -1
  4. package/skills/ab-testing/CHANGELOG.md +5 -0
  5. package/skills/ab-testing/SKILL.md +12 -9
  6. package/skills/ab-testing/evals/evals.json +18 -7
  7. package/skills/ab-testing/references/sample-size-guide.md +2 -2
  8. package/skills/ads/CHANGELOG.md +5 -0
  9. package/skills/ads/SKILL.md +4 -4
  10. package/skills/ai-seo/CHANGELOG.md +7 -0
  11. package/skills/ai-seo/SKILL.md +27 -20
  12. package/skills/analytics/CHANGELOG.md +6 -0
  13. package/skills/analytics/SKILL.md +4 -3
  14. package/skills/analytics/evals/evals.json +27 -0
  15. package/skills/analytics/references/ga4-implementation.md +7 -3
  16. package/skills/analytics/references/gtm-implementation.md +8 -2
  17. package/skills/aso/CHANGELOG.md +5 -0
  18. package/skills/aso/SKILL.md +4 -4
  19. package/skills/aso/evals/evals.json +15 -2
  20. package/skills/aso/references/benchmarks.md +4 -2
  21. package/skills/aso/references/google-play-specs.md +5 -1
  22. package/skills/brainstorm/SKILL.md +59 -16
  23. package/skills/brainstorm/builder-check-prompt.md +23 -0
  24. package/skills/churn-prevention/CHANGELOG.md +5 -0
  25. package/skills/churn-prevention/SKILL.md +3 -3
  26. package/skills/churn-prevention/evals/evals.json +14 -0
  27. package/skills/churn-prevention/references/dunning-playbook.md +6 -1
  28. package/skills/coder-brainstorming/CHANGELOG.md +10 -0
  29. package/skills/coder-brainstorming/SKILL.md +62 -20
  30. package/skills/coder-brainstorming/builder-check-prompt.md +23 -0
  31. package/skills/coder-brainstorming/scripts/server.cjs +1 -1
  32. package/skills/coder-debugging/CHANGELOG.md +4 -0
  33. package/skills/coder-debugging/SKILL.md +5 -5
  34. package/skills/coder-requesting-code-review/CHANGELOG.md +4 -0
  35. package/skills/coder-requesting-code-review/SKILL.md +4 -4
  36. package/skills/coder-subagent-driven-development/CHANGELOG.md +4 -0
  37. package/skills/coder-subagent-driven-development/SKILL.md +3 -3
  38. package/skills/coder-subagent-driven-development/scripts/sdd-workspace +3 -1
  39. package/skills/cold-email/CHANGELOG.md +7 -0
  40. package/skills/cold-email/SKILL.md +27 -7
  41. package/skills/cold-email/evals/evals.json +30 -0
  42. package/skills/cold-email/references/benchmarks.md +15 -11
  43. package/skills/cold-email/references/deliverability.md +110 -0
  44. package/skills/cold-email/references/linkedin-outreach.md +91 -0
  45. package/skills/cold-email/references/multichannel-cadence.md +79 -0
  46. package/skills/cold-email/references/outbound-plays.md +88 -0
  47. package/skills/cold-email/references/personalization.md +11 -0
  48. package/skills/cold-email/references/reply-handling.md +65 -0
  49. package/skills/competitor-profiling/CHANGELOG.md +5 -0
  50. package/skills/competitor-profiling/SKILL.md +4 -4
  51. package/skills/content-strategy/CHANGELOG.md +5 -0
  52. package/skills/content-strategy/SKILL.md +5 -3
  53. package/skills/copy-editing/CHANGELOG.md +5 -0
  54. package/skills/copy-editing/SKILL.md +5 -5
  55. package/skills/copy-editing/references/content-refresh.md +33 -0
  56. package/skills/cro/CHANGELOG.md +5 -0
  57. package/skills/cro/SKILL.md +14 -3
  58. package/skills/cro/references/form.md +1 -1
  59. package/skills/directory-submissions/CHANGELOG.md +5 -0
  60. package/skills/directory-submissions/SKILL.md +13 -4
  61. package/skills/directory-submissions/evals/evals.json +15 -1
  62. package/skills/directory-submissions/references/directory-list.md +2 -4
  63. package/skills/emails/CHANGELOG.md +5 -0
  64. package/skills/emails/SKILL.md +14 -4
  65. package/skills/launch/CHANGELOG.md +7 -0
  66. package/skills/launch/SKILL.md +8 -4
  67. package/skills/launch/evals/evals.json +29 -0
  68. package/skills/launch/references/shipped-changes.md +84 -0
  69. package/skills/launch/references/site-launch-qa.md +55 -0
  70. package/skills/lead-magnets/CHANGELOG.md +5 -0
  71. package/skills/lead-magnets/SKILL.md +6 -4
  72. package/skills/lead-magnets/evals/evals.json +18 -0
  73. package/skills/lead-magnets/references/content-to-skill-corpus-design.md +89 -0
  74. package/skills/lead-magnets/references/content-to-skill-release.md +71 -0
  75. package/skills/lead-magnets/references/content-to-skill.md +108 -0
  76. package/skills/marketing-plan/CHANGELOG.md +5 -0
  77. package/skills/marketing-plan/SKILL.md +3 -3
  78. package/skills/marketing-plan/evals/evals.json +13 -0
  79. package/skills/marketing-plan/references/budget-planning.md +26 -17
  80. package/skills/model-router/CHANGELOG.md +6 -0
  81. package/skills/model-router/SKILL.md +3 -3
  82. package/skills/model-router/scripts/route.py +1 -1
  83. package/skills/model-router/scripts/test_route.py +5 -0
  84. package/skills/pricing/CHANGELOG.md +5 -0
  85. package/skills/pricing/SKILL.md +4 -4
  86. package/skills/pricing/evals/evals.json +12 -0
  87. package/skills/pricing/references/research-methods.md +8 -4
  88. package/skills/programmatic-seo/CHANGELOG.md +5 -0
  89. package/skills/programmatic-seo/SKILL.md +8 -4
  90. package/skills/programmatic-seo/evals/evals.json +13 -0
  91. package/skills/programmatic-seo/references/implementation-platforms.md +37 -0
  92. package/skills/prospecting/CHANGELOG.md +7 -0
  93. package/skills/prospecting/SKILL.md +27 -13
  94. package/skills/prospecting/evals/evals.json +41 -0
  95. package/skills/prospecting/references/account-research.md +59 -0
  96. package/skills/prospecting/references/b2b-prospecting.md +1 -1
  97. package/skills/prospecting/references/compliance.md +44 -6
  98. package/skills/prospecting/references/data-sources.md +13 -15
  99. package/skills/prospecting/references/enrichment-playbook.md +61 -0
  100. package/skills/prospecting/references/saas-prospecting.md +1 -1
  101. package/skills/prospecting/references/signal-plays.md +99 -0
  102. package/skills/prospecting/references/sourcing-and-enrichment.md +89 -0
  103. package/skills/referrals/CHANGELOG.md +5 -0
  104. package/skills/referrals/SKILL.md +10 -4
  105. package/skills/referrals/evals/evals.json +99 -0
  106. package/skills/referrals/references/affiliate-operations.md +64 -0
  107. package/skills/referrals/references/affiliate-programs.md +2 -2
  108. package/skills/referrals/references/program-examples.md +14 -6
  109. package/skills/referrals/references/review-platform-rules.md +36 -0
  110. package/skills/referrals/references/review-program-operations.md +62 -0
  111. package/skills/referrals/references/review-programs.md +96 -0
  112. package/skills/revops/CHANGELOG.md +5 -0
  113. package/skills/revops/SKILL.md +10 -6
  114. package/skills/revops/evals/evals.json +13 -0
  115. package/skills/revops/references/automation-playbooks.md +1 -1
  116. package/skills/revops/references/inbound-routing.md +43 -0
  117. package/skills/revops/references/lifecycle-definitions.md +31 -0
  118. package/skills/sales-enablement/CHANGELOG.md +5 -0
  119. package/skills/sales-enablement/SKILL.md +6 -4
  120. package/skills/sales-enablement/evals/evals.json +14 -0
  121. package/skills/sales-enablement/references/cold-call-scripts.md +68 -0
  122. package/skills/schema/CHANGELOG.md +5 -0
  123. package/skills/schema/SKILL.md +20 -8
  124. package/skills/schema/evals/evals.json +40 -0
  125. package/skills/schema/references/schema-examples.md +9 -15
  126. package/skills/seo-audit/CHANGELOG.md +5 -0
  127. package/skills/seo-audit/SKILL.md +10 -16
  128. package/skills/seo-audit/evals/evals.json +68 -0
  129. package/skills/seo-audit/references/international-seo.md +9 -11
  130. package/skills/seo-audit/references/local-seo-pages-and-measurement.md +39 -0
  131. package/skills/seo-audit/references/local-seo-profile-and-citations.md +41 -0
  132. package/skills/seo-audit/references/local-seo.md +86 -0
  133. package/skills/seo-audit/references/rankings-push.md +62 -0
  134. package/skills/seo-audit/references/title-tags.md +89 -0
  135. package/skills/signup/CHANGELOG.md +5 -0
  136. package/skills/signup/SKILL.md +7 -3
  137. package/skills/site-architecture/CHANGELOG.md +5 -0
  138. package/skills/site-architecture/SKILL.md +5 -4
  139. package/skills/site-architecture/evals/evals.json +25 -0
  140. package/skills/site-architecture/references/platforms-and-migration.md +84 -0
  141. package/skills/social/CHANGELOG.md +5 -0
  142. package/skills/social/SKILL.md +4 -4
  143. package/skills/trends/.skillignore +3 -0
  144. package/skills/trends/CHANGELOG.md +20 -0
  145. package/skills/trends/SKILL.md +12 -8
  146. package/skills/trends/references/agent-mode.md +36 -0
  147. package/skills/trends/references/category-peers.md +38 -0
  148. package/skills/trends/references/comparison.md +227 -0
  149. package/skills/trends/references/competitors.md +29 -0
  150. package/skills/trends/references/discovery.md +90 -0
  151. package/skills/trends/references/engine-contract.md +111 -1889
  152. package/skills/trends/references/followups.md +112 -0
  153. package/skills/trends/references/grok-bot-x.md +55 -0
  154. package/skills/trends/references/hiring-signals.md +19 -0
  155. package/skills/trends/references/library-queue.md +33 -0
  156. package/skills/trends/references/recommendations.md +86 -0
  157. package/skills/trends/references/research-runbook.md +781 -0
  158. package/skills/trends/references/runtime.md +165 -0
  159. package/skills/trends/references/save-html-brief.md +39 -20
  160. package/skills/trends/references/setup-wizard.md +253 -0
  161. package/skills/trends/references/synthesis.md +289 -0
  162. package/skills/trends/scripts/box_chrome_login.py +209 -0
  163. package/skills/trends/scripts/briefing.py +9 -17
  164. package/skills/trends/scripts/last30days.py +3247 -280
  165. package/skills/trends/scripts/lib/agentcookie.py +196 -0
  166. package/skills/trends/scripts/lib/amazon.py +877 -0
  167. package/skills/trends/scripts/lib/arxiv.py +42 -8
  168. package/skills/trends/scripts/lib/backends.py +769 -0
  169. package/skills/trends/scripts/lib/bird_x.py +340 -57
  170. package/skills/trends/scripts/lib/bluesky.py +84 -10
  171. package/skills/trends/scripts/lib/bounded_get.py +156 -0
  172. package/skills/trends/scripts/lib/brightdata.py +291 -0
  173. package/skills/trends/scripts/lib/categories.py +7 -1
  174. package/skills/trends/scripts/lib/chrome_cdp.py +455 -0
  175. package/skills/trends/scripts/lib/chrome_cookies.py +132 -39
  176. package/skills/trends/scripts/lib/cluster.py +31 -54
  177. package/skills/trends/scripts/lib/competitors.py +10 -1
  178. package/skills/trends/scripts/lib/cookie_extract.py +221 -44
  179. package/skills/trends/scripts/lib/cookie_paths.py +27 -0
  180. package/skills/trends/scripts/lib/corpus.py +439 -0
  181. package/skills/trends/scripts/lib/devto.py +313 -0
  182. package/skills/trends/scripts/lib/discovery_handoff.py +988 -0
  183. package/skills/trends/scripts/lib/doctor.py +2166 -0
  184. package/skills/trends/scripts/lib/dripstack.py +203 -0
  185. package/skills/trends/scripts/lib/entity_extract.py +37 -0
  186. package/skills/trends/scripts/lib/env.py +1092 -138
  187. package/skills/trends/scripts/lib/fanout.py +11 -1
  188. package/skills/trends/scripts/lib/feed.py +61 -0
  189. package/skills/trends/scripts/lib/fetch_memo.py +135 -0
  190. package/skills/trends/scripts/lib/freshness.py +566 -0
  191. package/skills/trends/scripts/lib/fusion.py +314 -28
  192. package/skills/trends/scripts/lib/github.py +438 -24
  193. package/skills/trends/scripts/lib/grok_x.py +1237 -0
  194. package/skills/trends/scripts/lib/grounding.py +129 -11
  195. package/skills/trends/scripts/lib/hackernews.py +61 -4
  196. package/skills/trends/scripts/lib/health.py +373 -0
  197. package/skills/trends/scripts/lib/hosted.py +333 -0
  198. package/skills/trends/scripts/lib/html_publish.py +34 -0
  199. package/skills/trends/scripts/lib/html_render.py +169 -3
  200. package/skills/trends/scripts/lib/http.py +1215 -70
  201. package/skills/trends/scripts/lib/instagram.py +132 -9
  202. package/skills/trends/scripts/lib/jobs.py +4 -2
  203. package/skills/trends/scripts/lib/library.py +308 -0
  204. package/skills/trends/scripts/lib/library_index.py +477 -0
  205. package/skills/trends/scripts/lib/linkedin.py +271 -24
  206. package/skills/trends/scripts/lib/log.py +5 -2
  207. package/skills/trends/scripts/lib/meta_ads.py +982 -0
  208. package/skills/trends/scripts/lib/normalize.py +382 -28
  209. package/skills/trends/scripts/lib/parallel_mcp.py +234 -0
  210. package/skills/trends/scripts/lib/permission_preflight.py +42 -3
  211. package/skills/trends/scripts/lib/perplexity.py +1080 -468
  212. package/skills/trends/scripts/lib/pipeline.py +5079 -211
  213. package/skills/trends/scripts/lib/planner.py +396 -53
  214. package/skills/trends/scripts/lib/polymarket.py +285 -23
  215. package/skills/trends/scripts/lib/prescriptions.py +432 -0
  216. package/skills/trends/scripts/lib/private_files.py +21 -0
  217. package/skills/trends/scripts/lib/providers.py +231 -10
  218. package/skills/trends/scripts/lib/quality_nudge.py +122 -37
  219. package/skills/trends/scripts/lib/reddit.py +177 -12
  220. package/skills/trends/scripts/lib/reddit_arctic.py +146 -5
  221. package/skills/trends/scripts/lib/reddit_keyless.py +121 -60
  222. package/skills/trends/scripts/lib/reddit_listing.py +215 -14
  223. package/skills/trends/scripts/lib/reddit_public.py +12 -13
  224. package/skills/trends/scripts/lib/reddit_search.py +304 -0
  225. package/skills/trends/scripts/lib/reddit_shreddit.py +51 -7
  226. package/skills/trends/scripts/lib/registers.py +134 -0
  227. package/skills/trends/scripts/lib/relevance.py +17 -0
  228. package/skills/trends/scripts/lib/render.py +2161 -271
  229. package/skills/trends/scripts/lib/rerank.py +623 -55
  230. package/skills/trends/scripts/lib/resolve.py +50 -1
  231. package/skills/trends/scripts/lib/safari_cookies.py +42 -15
  232. package/skills/trends/scripts/lib/schema.py +782 -1
  233. package/skills/trends/scripts/lib/setup_wizard.py +779 -120
  234. package/skills/trends/scripts/lib/signals.py +206 -7
  235. package/skills/trends/scripts/lib/stocktwits.py +374 -0
  236. package/skills/trends/scripts/lib/subproc.py +307 -53
  237. package/skills/trends/scripts/lib/techmeme.py +89 -64
  238. package/skills/trends/scripts/lib/telegram.py +317 -0
  239. package/skills/trends/scripts/lib/tiktok.py +13 -9
  240. package/skills/trends/scripts/lib/topic_shape.py +340 -0
  241. package/skills/trends/scripts/lib/transcribe.py +12 -3
  242. package/skills/trends/scripts/lib/trustpilot.py +256 -5
  243. package/skills/trends/scripts/lib/truthsocial.py +9 -1
  244. package/skills/trends/scripts/lib/ui.py +6 -0
  245. package/skills/trends/scripts/lib/usage.py +132 -0
  246. package/skills/trends/scripts/lib/web_search_keyless.py +63 -4
  247. package/skills/trends/scripts/lib/windows_job.py +138 -0
  248. package/skills/trends/scripts/lib/x_api.py +824 -0
  249. package/skills/trends/scripts/lib/x_envelope.py +733 -0
  250. package/skills/trends/scripts/lib/x_judge.py +292 -0
  251. package/skills/trends/scripts/lib/xai_x.py +26 -3
  252. package/skills/trends/scripts/lib/xquik.py +89 -15
  253. package/skills/trends/scripts/lib/xurl_x.py +284 -91
  254. package/skills/trends/scripts/lib/youtube_yt.py +570 -119
  255. package/skills/trends/scripts/setup-keychain.sh +12 -4
  256. package/skills/trends/scripts/setup-pass.sh +3 -0
  257. package/skills/trends/scripts/store.py +444 -50
  258. package/skills/trends/scripts/watchlist.py +38 -11
  259. package/skills/trends/scripts/x_lane_parity.py +107 -0
  260. package/skills/video-studio/CHANGELOG.md +4 -0
  261. package/skills/video-studio/SKILL.md +4 -4
  262. package/skills/video-studio/references/styles/product-flight.md +45 -0
  263. package/skills/video-studio/references/styles.md +3 -1
  264. package/skills/video-studio-motion/ATTRIBUTION.md +1 -1
  265. package/skills/video-studio-motion/CHANGELOG.md +4 -0
  266. package/skills/video-studio-motion/SKILL.md +8 -5
  267. package/skills/youtube-planner/ATTRIBUTION.md +25 -0
  268. package/skills/youtube-planner/CHANGELOG.md +12 -0
  269. package/skills/youtube-planner/SKILL.md +191 -0
  270. package/skills/youtube-planner/references/plan-template.md +57 -0
  271. package/skills/youtube-planner/references/stages.md +119 -0
  272. package/skills/youtube-planner/scripts/floor_ceiling.py +132 -0
  273. package/skills/coder-brainstorming/spec-document-reviewer-prompt.md +0 -49
  274. package/skills/trends/scripts/lib/reddit_rss.py +0 -224
@@ -3,9 +3,11 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ import math
6
7
  import re
8
+ from datetime import datetime
7
9
 
8
- from . import http, providers, query, schema, signals
10
+ from . import http, providers, relevance, schema, signals
9
11
 
10
12
 
11
13
  # Penalty applied when a candidate does not mention the primary entity
@@ -17,6 +19,18 @@ from . import http, providers, query, schema, signals
17
19
  # Hermes content.
18
20
  ENTITY_MISS_PENALTY = 25.0
19
21
 
22
+ # A fallback entity miss is hidden from synthesized evidence only when it also
23
+ # lacks every stable raw-topic anchor. Explicitly scoped sources such as GitHub
24
+ # project mode carry a high local-relevance floor and therefore escape this
25
+ # visibility gate even when their short title omits the user's wording.
26
+ FALLBACK_ENTITY_MISS_CONFIDENCE_ESCAPE = 0.5
27
+ FALLBACK_ENTITY_MISS_TOPIC_ESCAPE = 0.25
28
+ _FALLBACK_ENTITY_MISS_EXPLANATION = "fallback-local-score (entity-miss demotion)"
29
+ # Explanation stamped on a first-party post whose entity-miss marker was
30
+ # cleared by _apply_first_party_floor. Carries no "entity-miss" substring, so
31
+ # every downstream relevance gate treats the post as grounded.
32
+ _FIRST_PARTY_EXPLANATION = "first-party post (authored by a resolved handle)"
33
+
20
34
  # Small additive credit for a post authored by one of the run's resolved
21
35
  # handles (see rerank_candidates / _fallback_tuple). Deliberately small: the
22
36
  # goal is to stop *burying* first-party posts, not to auto-win the ranking on
@@ -25,6 +39,136 @@ ENTITY_MISS_PENALTY = 25.0
25
39
  # neutral floor so it survives into the visible band.
26
40
  FIRST_PARTY_AUTHOR_CREDIT = 5.0
27
41
 
42
+ _DISCOVERY_ENGAGEMENT_FIELDS = {
43
+ "reddit": ("score", "num_comments"),
44
+ "hackernews": ("points", "comments"),
45
+ "devto": ("reactions", "comments"),
46
+ "digg": ("postCount", "uniqueAuthors"),
47
+ "x": ("likes", "reposts", "replies", "quotes"),
48
+ }
49
+
50
+
51
+ def discovery_engagement_total(item: schema.SourceItem) -> float:
52
+ """Return comparable native interaction counts for discovery evidence."""
53
+ fields = _DISCOVERY_ENGAGEMENT_FIELDS.get(item.source)
54
+ if fields is None:
55
+ fields = tuple(
56
+ field
57
+ for field in item.engagement
58
+ if field.lower() not in {"rank", "rank_score", "upvote_ratio", "rating"}
59
+ )
60
+ return sum(
61
+ float(item.engagement.get(field) or 0)
62
+ for field in fields
63
+ if isinstance(item.engagement.get(field), (int, float))
64
+ and not isinstance(item.engagement.get(field), bool)
65
+ )
66
+
67
+
68
+ def engagement_velocity_score(
69
+ item: schema.SourceItem,
70
+ *,
71
+ as_of_date: str,
72
+ ) -> float:
73
+ """Weight native engagement by age, with an explicit first-week boost."""
74
+ engagement = discovery_engagement_total(item)
75
+ if engagement <= 0:
76
+ return 0.0
77
+ try:
78
+ published = datetime.fromisoformat((item.published_at or "").replace("Z", "+00:00")).date()
79
+ as_of = datetime.fromisoformat(as_of_date.replace("Z", "+00:00")).date()
80
+ age_days = max(0, (as_of - published).days)
81
+ except (TypeError, ValueError):
82
+ age_days = 30
83
+ recency_weight = 1.0 / math.sqrt(age_days + 1)
84
+ if age_days < 7:
85
+ recency_weight *= 1.5
86
+ return round(engagement * recency_weight, 4)
87
+
88
+
89
+ def discovery_velocity_score(
90
+ items: list[schema.SourceItem],
91
+ *,
92
+ as_of_date: str,
93
+ ) -> float:
94
+ """Score a topic cluster and reward independent cross-source confirmation."""
95
+ raw = sum(engagement_velocity_score(item, as_of_date=as_of_date) for item in items)
96
+ source_count = len({item.source for item in items})
97
+ corroboration = 1.0 + (0.15 * max(0, source_count - 1))
98
+ return round(raw * corroboration, 4)
99
+
100
+
101
+ # Discovery confidence floor. The named 2026-07-12 failure mode: quiet feeds
102
+ # left the sweep ranking noise against noise, and it dutifully emitted five
103
+ # 1-like tweets as a "trend list". The floor makes "nothing solid this window"
104
+ # a first-class outcome instead. Constants are deliberately tunable:
105
+ # - FLOOR_MIN_ENGAGEMENT kills absolute junk (a 1-like tweet can never rank).
106
+ # - A topic then clears via EITHER independent cross-source confirmation
107
+ # (>= FLOOR_MIN_SOURCES) OR a genuinely strong single-source spike
108
+ # (>= FLOOR_SINGLE_SOURCE_ENGAGEMENT) - a 1,600-point single-source HN
109
+ # thread is a real story, a 30-upvote single-source meme is not.
110
+ # - Junk-shaped topics (help-me/beginner/musing shapes flagged by the stage-1
111
+ # judge or the topic_shape heuristics) get a stricter read: the
112
+ # single-source engagement bypass is OFF (a 226-comment "help me choose"
113
+ # thread is a busy support thread, not a story), and their
114
+ # FLOOR_MIN_SOURCES corroboration is counted against SEED listing sources
115
+ # when the caller provides that count - a successful enrichment pass pulls
116
+ # a multi-source corpus for almost any topic, so an enriched-count check
117
+ # would never bind.
118
+ FLOOR_MIN_ENGAGEMENT = 25.0
119
+ FLOOR_MIN_SOURCES = 2
120
+ FLOOR_SINGLE_SOURCE_ENGAGEMENT = 200.0
121
+
122
+
123
+ def passes_discovery_floor(
124
+ *,
125
+ source_count: int,
126
+ engagement_total: float,
127
+ item_count: int,
128
+ junk_shape: bool = False,
129
+ seed_source_count: int | None = None,
130
+ ) -> bool:
131
+ """Whether a discovery topic's evidence is strong enough to show a user.
132
+
133
+ Below this floor the honest output is "nothing solid this window", not a
134
+ ranked list of whatever survived the sweep.
135
+
136
+ ``junk_shape=True`` removes the single-source engagement bypass and
137
+ evaluates the corroboration requirement against ``seed_source_count``
138
+ (distinct SEED listing sources) when provided, falling back to
139
+ ``source_count`` otherwise. Non-junk topics are unaffected by both
140
+ parameters.
141
+ """
142
+ if item_count <= 0 or engagement_total < FLOOR_MIN_ENGAGEMENT:
143
+ return False
144
+ if junk_shape:
145
+ corroboration = seed_source_count if seed_source_count is not None else source_count
146
+ return corroboration >= FLOOR_MIN_SOURCES
147
+ if source_count >= FLOOR_MIN_SOURCES:
148
+ return True
149
+ return engagement_total >= FLOOR_SINGLE_SOURCE_ENGAGEMENT
150
+
151
+
152
+ # Stage-1 discovery judge (nominate stage). The top JUDGE_POOL_LIMIT clusters
153
+ # by velocity get ONE batched LLM verdict each (short searchable name, junk
154
+ # flag, 0-100 content-worthiness); clusters beyond the pool keep heuristic
155
+ # names and their velocity-only score. Worthiness blends into the ranking
156
+ # score as
157
+ # blended = velocity * (JUDGE_BLEND_BASE + worthiness / 100)
158
+ # so velocity stays dominant (the multiplier spans 0.5x-1.5x) but a quiet,
159
+ # highly content-worthy cluster can overtake a viral junk one. A missing
160
+ # worthiness (heuristic fallback, judge skipped a row) is neutral at 50 -
161
+ # the multiplier is exactly 1.0, i.e. the plain velocity score.
162
+ JUDGE_POOL_LIMIT = 15
163
+ JUDGE_BLEND_BASE = 0.5
164
+
165
+
166
+ def judge_blended_score(velocity: float, worthiness: float | None) -> float:
167
+ """Velocity-dominant, worthiness-weighted ranking score (constants above)."""
168
+ effective = 50.0 if worthiness is None else max(0.0, min(100.0, worthiness))
169
+ return velocity * (JUDGE_BLEND_BASE + effective / 100.0)
170
+
171
+
28
172
  # Engagement rescue: a high-engagement X post that is on-topic (entity-grounded
29
173
  # or first-party) cannot be fully zeroed by the other penalties. The floor is a
30
174
  # function of the post's engagement percentile *within the run's X pool* (so it
@@ -52,21 +196,24 @@ INTERACTION_FLOOR = 35.0
52
196
  # not a win.
53
197
  FIRST_PARTY_FLOOR = 25.0
54
198
 
55
- # Intent modifiers to strip before extracting the primary entity so that,
56
- # for example, "Hermes Agent use cases" yields primary_entity="hermes agent"
57
- # rather than "hermes agent use cases". Kept in sync with
58
- # planner._INTENT_MODIFIER_PATTERNS.
59
- _INTENT_MODIFIER_RE = re.compile(
199
+ # Only strip trailing intent modifiers. A word such as "review" may instead be
200
+ # the subject of a longer topic ("AI code review bottleneck").
201
+ _TRAILING_INTENT_MODIFIER_RE = re.compile(
202
+ r"(?:\s+(?:and|or|&|,)\s*)?"
60
203
  r"\b("
61
204
  r"use cases|use case|workflows|workflow|"
62
205
  r"examples|example|tutorial|tutorials|"
63
206
  r"review|reviews|comparison|applications|"
64
207
  r"in practice|production use|production|"
65
208
  r"how i use"
66
- r")\b",
209
+ r")\b[\s?.,:;!]*$",
67
210
  re.IGNORECASE,
68
211
  )
69
212
 
213
+ _GENERIC_GROUNDING_MIN_TOKENS = 4
214
+ _GROUNDING_ANCHOR_MIN_LENGTH = 6
215
+ _GROUNDING_LOW_SIGNAL_TOKENS = relevance.LOW_SIGNAL_QUERY_TOKENS | {"still", "work"}
216
+
70
217
  INTENT_SCORING_HINTS: dict[str, str] = {
71
218
  "comparison": (
72
219
  "Prefer items that directly compare, contrast, or benchmark the entities"
@@ -119,6 +266,7 @@ def rerank_candidates(
119
266
  model: str | None,
120
267
  shortlist_size: int,
121
268
  resolved_handles: set[str] | None = None,
269
+ primary_handle: str | None = None,
122
270
  ) -> list[schema.Candidate]:
123
271
  """Rerank the fused shortlist, demoting candidates the reranker scored as irrelevant.
124
272
 
@@ -128,10 +276,15 @@ def rerank_candidates(
128
276
  exempted from the entity-miss demotion in ``_fallback_tuple`` (a post almost
129
277
  never repeats its own author's name, so the body-text grounding check would
130
278
  otherwise bury the subject's own highest-signal posts).
279
+
280
+ ``primary_handle`` is the run's ``--x-handle``: the one handle in the
281
+ subject role. Only it can be the topic's own account (_subject_handles);
282
+ ``--x-related`` and discovered handles are first-party but never subjects.
131
283
  """
132
284
  handles = resolved_handles or set()
133
285
  shortlisted = candidates[:shortlist_size]
134
286
  primary_entity = _primary_entity(topic)
287
+ subject = _subject_handles(primary_handle, topic)
135
288
  if provider and model and shortlisted:
136
289
  try:
137
290
  response = provider.generate_json(
@@ -141,17 +294,32 @@ def rerank_candidates(
141
294
  except (ValueError, KeyError, json.JSONDecodeError, OSError, http.HTTPError) as exc:
142
295
  import sys
143
296
  print(f"[Rerank] LLM reranking failed, using local fallback: {type(exc).__name__}: {exc}", file=sys.stderr)
144
- _apply_fallback_scores(shortlisted, primary_entity=primary_entity, resolved_handles=handles)
297
+ _apply_fallback_scores(
298
+ shortlisted, primary_entity=primary_entity, resolved_handles=handles, subject_handles=subject
299
+ )
145
300
  else:
146
- _apply_fallback_scores(shortlisted, primary_entity=primary_entity, resolved_handles=handles)
301
+ _apply_fallback_scores(
302
+ shortlisted, primary_entity=primary_entity, resolved_handles=handles, subject_handles=subject
303
+ )
147
304
 
148
305
  if len(candidates) > shortlist_size:
149
306
  tail = candidates[shortlist_size:]
150
- _apply_fallback_scores(tail, primary_entity=primary_entity, resolved_handles=handles)
307
+ _apply_fallback_scores(
308
+ tail, primary_entity=primary_entity, resolved_handles=handles, subject_handles=subject
309
+ )
151
310
 
152
- _apply_first_party_floor(candidates, resolved_handles=handles)
153
- _apply_engagement_rescue(candidates, primary_entity=primary_entity, resolved_handles=handles)
154
- _apply_interaction_signal(candidates, resolved_handles=handles)
311
+ _apply_first_party_floor(
312
+ candidates, resolved_handles=handles, primary_entity=primary_entity, topic=topic,
313
+ primary_handle=primary_handle,
314
+ )
315
+ _apply_engagement_rescue(
316
+ candidates, primary_entity=primary_entity, resolved_handles=handles, topic=topic,
317
+ primary_handle=primary_handle,
318
+ )
319
+ _apply_interaction_signal(
320
+ candidates, resolved_handles=handles, primary_entity=primary_entity, topic=topic,
321
+ primary_handle=primary_handle,
322
+ )
155
323
 
156
324
  return sorted(
157
325
  candidates,
@@ -171,12 +339,33 @@ def _intent_hint_block(plan: schema.QueryPlan) -> str:
171
339
  return ""
172
340
 
173
341
 
342
+ _UNTRUSTED_FENCE_TAG = re.compile(r"<\s*/?\s*untrusted_content\s*>", re.IGNORECASE)
343
+
344
+
345
+ def _defang_untrusted_fence(value: str) -> str:
346
+ """Scraped content must not be able to terminate the fence that contains it.
347
+
348
+ A title carrying the literal closing tag would otherwise end the block
349
+ early, leaving the rest of the scraped text outside the fence and
350
+ indistinguishable from engine-authored prompt text.
351
+
352
+ Only the tag form is rewritten. A bare ``untrusted_content`` identifier in
353
+ scraped prose or code is left byte-exact: this is a research tool, and
354
+ altering evidence to defend the fence would corrupt what the judge scores.
355
+ Matched case-insensitively and tolerant of inner whitespace because the
356
+ reader is a model, not an XML parser.
357
+ """
358
+ return _UNTRUSTED_FENCE_TAG.sub(
359
+ lambda match: match.group(0).replace("_", "-"), value
360
+ )
361
+
362
+
174
363
  def _fenced_untrusted_content(candidate_block: str) -> str:
175
364
  return (
176
365
  f"{UNTRUSTED_CONTENT_NOTICE}\n\n"
177
366
  "Candidates:\n"
178
367
  "<untrusted_content>\n"
179
- f"{candidate_block}\n"
368
+ f"{_defang_untrusted_fence(candidate_block)}\n"
180
369
  "</untrusted_content>"
181
370
  )
182
371
 
@@ -273,22 +462,36 @@ def _apply_llm_scores(
273
462
  str(row.get("reason") or "").strip() or None,
274
463
  )
275
464
  for candidate in candidates:
276
- rerank_score, reason = scores.get(
277
- candidate.candidate_id, _fallback_tuple(candidate, resolved_handles=handles)
278
- )
465
+ if candidate.candidate_id in scores:
466
+ rerank_score, reason = scores[candidate.candidate_id]
467
+ provenance = RERANK_PROVENANCE_LLM
468
+ else:
469
+ rerank_score, reason = _fallback_tuple(candidate, resolved_handles=handles)
470
+ provenance = RERANK_PROVENANCE_FALLBACK
279
471
  candidate.rerank_score = rerank_score
280
472
  candidate.explanation = reason
473
+ _set_rerank_provenance(candidate, provenance)
281
474
  candidate.final_score = _final_score(candidate)
282
475
 
283
476
 
284
477
  def _apply_fallback_scores(
285
- candidates: list[schema.Candidate], *, primary_entity: str = "", resolved_handles: set[str] | None = None
478
+ candidates: list[schema.Candidate],
479
+ *,
480
+ primary_entity: str = "",
481
+ resolved_handles: set[str] | None = None,
482
+ subject_handles: set[str] | None = None,
286
483
  ) -> None:
287
484
  handles = resolved_handles or set()
288
485
  for candidate in candidates:
289
- rerank_score, reason = _fallback_tuple(candidate, primary_entity=primary_entity, resolved_handles=handles)
486
+ rerank_score, reason = _fallback_tuple(
487
+ candidate,
488
+ primary_entity=primary_entity,
489
+ resolved_handles=handles,
490
+ subject_handles=subject_handles,
491
+ )
290
492
  candidate.rerank_score = rerank_score
291
493
  candidate.explanation = reason
494
+ _set_rerank_provenance(candidate, RERANK_PROVENANCE_FALLBACK)
292
495
  candidate.final_score = _final_score(candidate)
293
496
 
294
497
 
@@ -315,6 +518,112 @@ def _is_first_party(candidate: schema.Candidate, resolved_handles: set[str]) ->
315
518
  return _candidate_author_handle(candidate) in resolved_handles
316
519
 
317
520
 
521
+ def _handle_is_topic(handle: str, topic: str) -> bool:
522
+ """True when the handle names the topic itself, not someone near it.
523
+
524
+ Matches a handle that contains a topic word ("ghostty" for "Ghostty
525
+ terminal", "karpathy" for "Andrej Karpathy") or is spelled from prefixes
526
+ of distinct topic words ("steipete" = stei+pete for "Peter Steinberger",
527
+ "sama" for "Sam Altman"). A founder or commentator handle on a product
528
+ topic ("mitchellh" for "Ghostty terminal") does not match.
529
+ """
530
+ letters = re.sub(r"[^a-z]", "", (handle or "").lower())
531
+ if len(letters) < 3:
532
+ return False
533
+ words = [
534
+ word
535
+ for word in re.findall(r"[a-z]+", (topic or "").lower())
536
+ if len(word) >= 3
537
+ and word not in relevance.STOPWORDS
538
+ and word not in relevance.LOW_SIGNAL_QUERY_TOKENS
539
+ ]
540
+ if any(len(word) >= 4 and (word in letters or letters in word) for word in words):
541
+ return True
542
+ return _spelled_from_word_prefixes(letters, words, frozenset())
543
+
544
+
545
+ def _spelled_from_word_prefixes(rest: str, words: list[str], used: frozenset[int]) -> bool:
546
+ """Whether ``rest`` splits into prefixes of distinct ``words``.
547
+
548
+ Every chunk but the last must be at least 3 letters, and a single chunk
549
+ alone never counts (that case is the containment test above).
550
+ """
551
+ if not rest:
552
+ return len(used) >= 2
553
+ for index, word in enumerate(words):
554
+ if index in used:
555
+ continue
556
+ for size in range(min(len(word), len(rest)), 0, -1):
557
+ if rest[:size] != word[:size]:
558
+ continue
559
+ remaining = rest[size:]
560
+ if size < 3 and remaining:
561
+ continue
562
+ if _spelled_from_word_prefixes(remaining, words, used | {index}):
563
+ return True
564
+ return False
565
+
566
+
567
+ def _subject_handles(primary_handle: str | None, topic: str) -> set[str]:
568
+ """The topic's own account: the primary handle, when it names the topic.
569
+
570
+ Only ``--x-handle`` holds the subject role. A related handle is someone
571
+ near the topic, so it never qualifies, even when its letters happen to sit
572
+ inside a topic word ("art" in "Smart home"). The primary handle must still
573
+ name the topic (_handle_is_topic), so ``--x-handle mitchellh`` on
574
+ "Ghostty terminal" is a related voice, not the subject. Known false
575
+ negative: an alias handle that shares no letters with the name
576
+ ("mrbeast" for "Jimmy Donaldson") is not a subject; its on-topic posts
577
+ still earn the floors through grounding or relevance.
578
+ """
579
+ handle = (primary_handle or "").lstrip("@").strip().lower()
580
+ if handle and _handle_is_topic(handle, topic):
581
+ return {handle}
582
+ return set()
583
+
584
+
585
+ def _is_repo_snapshot_candidate(candidate: schema.Candidate) -> bool:
586
+ """True when every source item is a GitHub repo card (signals.is_repo_snapshot)."""
587
+ return bool(candidate.source_items) and all(
588
+ signals.is_repo_snapshot(item) for item in candidate.source_items
589
+ )
590
+
591
+
592
+ def _first_party_floor_eligible(
593
+ candidate: schema.Candidate,
594
+ *,
595
+ resolved_handles: set[str],
596
+ primary_entity: str = "",
597
+ subject_handles: set[str] | None = None,
598
+ ) -> bool:
599
+ """Whether a first-party post earns the authorship credit and floors.
600
+
601
+ First-party authorship always exempts a post from pruning, but the lift
602
+ into the visible band is reserved for posts that are on-topic (entity
603
+ grounded, or clearing the normal relevance floor) and for the topic's own
604
+ account, whose posts never name their author. A related handle's
605
+ off-topic reply ("@friend Nanit" on a Ghostty run) stays where its own
606
+ score puts it. With no primary entity every post counts as on-topic.
607
+
608
+ Grounding counts only when it is positively established. For a short
609
+ generic topic ("Go") _entity_grounded cannot test a match and passes
610
+ everything, so there the post must clear the relevance floor instead.
611
+ """
612
+ if not _is_first_party(candidate, resolved_handles):
613
+ return False
614
+ # A repo snapshot card's "author" is the repo owner or the --github-user,
615
+ # not someone posting in the window (and a project owner such as "sqlite"
616
+ # can match a topic-token handle), so it never earns the lift.
617
+ if _is_repo_snapshot_candidate(candidate):
618
+ return False
619
+ if subject_handles and _candidate_author_handle(candidate) in subject_handles:
620
+ return True
621
+ return (
622
+ _is_entity_positively_grounded(candidate, primary_entity)
623
+ or candidate.local_relevance >= relevance.RELEVANCE_FLOOR
624
+ )
625
+
626
+
318
627
  def _is_x_candidate(candidate: schema.Candidate) -> bool:
319
628
  """True when the candidate originates from X (top-level or any source item)."""
320
629
  if candidate.source == "x":
@@ -326,6 +635,20 @@ def _candidate_engagement(candidate: schema.Candidate) -> float:
326
635
  return candidate.engagement if candidate.engagement is not None else 0.0
327
636
 
328
637
 
638
+ def _is_entity_positively_grounded(candidate: schema.Candidate, primary_entity: str) -> bool:
639
+ """Like _is_entity_grounded, but False where grounding cannot be tested.
640
+
641
+ _entity_grounded passes every candidate when the entity offers no safe
642
+ anchor (a short generic topic such as "Go"); that keeps it from demoting,
643
+ but it is not evidence that a candidate is on topic. No primary entity
644
+ still counts as grounded, as in _is_entity_grounded.
645
+ """
646
+ if not primary_entity:
647
+ return True
648
+ haystack = _candidate_haystack(candidate)
649
+ return bool(haystack.strip()) and _entity_grounding_verdict(haystack, primary_entity) is True
650
+
651
+
329
652
  def _is_entity_grounded(candidate: schema.Candidate, primary_entity: str) -> bool:
330
653
  """Whether the candidate plausibly mentions the primary entity in its text.
331
654
 
@@ -369,53 +692,102 @@ def _interaction_targets(candidate: schema.Candidate, resolved_handles: set[str]
369
692
 
370
693
 
371
694
  def _apply_interaction_signal(
372
- candidates: list[schema.Candidate], *, resolved_handles: set[str]
695
+ candidates: list[schema.Candidate],
696
+ *,
697
+ resolved_handles: set[str],
698
+ primary_entity: str = "",
699
+ topic: str = "",
700
+ primary_handle: str | None = None,
373
701
  ) -> None:
374
702
  """Float and tag first-party posts directed at another account. The relational
375
703
  tell (the subject personally engaging someone) is invisible to keyword and
376
704
  engagement scoring, so these are floored into the visible band and tagged so
377
- synthesis reads them as signal."""
705
+ synthesis reads them as signal. The floor applies only where
706
+ _first_party_floor_eligible holds; an off-topic reply is tagged, not lifted."""
378
707
  if not resolved_handles:
379
708
  return
709
+ subject = _subject_handles(primary_handle, topic)
380
710
  for c in candidates:
381
711
  targets = _interaction_targets(c, resolved_handles)
382
712
  if not targets:
383
713
  continue
384
714
  c.metadata = {**(c.metadata or {}), "interaction_targets": sorted(targets)}
715
+ if not _first_party_floor_eligible(
716
+ c, resolved_handles=resolved_handles, primary_entity=primary_entity, subject_handles=subject
717
+ ):
718
+ continue
385
719
  if c.final_score < INTERACTION_FLOOR:
386
720
  c.final_score = INTERACTION_FLOOR
387
721
 
388
722
 
389
723
  def _apply_first_party_floor(
390
- candidates: list[schema.Candidate], *, resolved_handles: set[str]
724
+ candidates: list[schema.Candidate],
725
+ *,
726
+ resolved_handles: set[str],
727
+ primary_entity: str = "",
728
+ topic: str = "",
729
+ primary_handle: str | None = None,
391
730
  ) -> None:
392
- """Floor every first-party post above the zero band, on any scoring path.
731
+ """Floor on-topic first-party posts above the zero band, on any scoring path.
393
732
 
394
733
  Backstops the LLM rerank path, where the grounding hint would otherwise cap
395
734
  a first-party post (which never names its own author) at <=30 and re-bury
396
- it. Floor only lifts; it never lowers a post the scorer rated higher.
735
+ it. Floor only lifts; it never lowers a post the scorer rated higher. Only
736
+ posts passing _first_party_floor_eligible are lifted (the topic's own
737
+ account always is); every first-party post still has its entity-miss
738
+ marker cleared below, so none is pruned for omitting its author's name.
397
739
  """
398
740
  if not resolved_handles:
399
741
  return
742
+ subject = _subject_handles(primary_handle, topic)
400
743
  for c in candidates:
401
- if _is_first_party(c, resolved_handles) and c.final_score < FIRST_PARTY_FLOOR:
744
+ if not _is_first_party(c, resolved_handles):
745
+ continue
746
+ if c.final_score < FIRST_PARTY_FLOOR and _first_party_floor_eligible(
747
+ c, resolved_handles=resolved_handles, primary_entity=primary_entity, subject_handles=subject
748
+ ):
402
749
  c.final_score = FIRST_PARTY_FLOOR
750
+ # Clear the entity-miss marker here, at the one site that knows the
751
+ # resolved handles. Downstream relevance gates key on the marker, not
752
+ # on handle knowledge, so neutralizing it once lets the carve-out
753
+ # propagate instead of forcing every gate to re-derive first-party.
754
+ # A first-party post is entity-grounded by authorship: nobody repeats
755
+ # their own name in their own post.
756
+ if c.explanation and "entity-miss" in c.explanation.lower():
757
+ c.explanation = _FIRST_PARTY_EXPLANATION
403
758
 
404
759
 
405
760
  def _apply_engagement_rescue(
406
- candidates: list[schema.Candidate], *, primary_entity: str, resolved_handles: set[str]
761
+ candidates: list[schema.Candidate],
762
+ *,
763
+ primary_entity: str,
764
+ resolved_handles: set[str],
765
+ topic: str = "",
766
+ primary_handle: str | None = None,
407
767
  ) -> None:
408
- """Floor final_score for high-engagement X posts that are first-party or
409
- entity-grounded, so a viral on-topic post can't sit at ~0. Off-topic
410
- (entity-miss) collision posts are excluded, preserving noise suppression.
768
+ """Floor final_score for high-engagement X posts that are entity-grounded
769
+ or floor-eligible first-party (see _first_party_floor_eligible), so a viral
770
+ on-topic post can't sit at ~0. Off-topic (entity-miss) collision posts and
771
+ a related handle's off-topic posts are excluded, preserving noise
772
+ suppression.
411
773
  """
412
774
  x_cands = [c for c in candidates if _is_x_candidate(c)]
413
775
  if len(x_cands) < 2:
414
776
  return
777
+ subject = _subject_handles(primary_handle, topic)
415
778
  engagements = sorted(_candidate_engagement(c) for c in x_cands)
416
779
  n = len(engagements)
417
780
  for c in x_cands:
418
- if not (_is_first_party(c, resolved_handles) or _is_entity_grounded(c, primary_entity)):
781
+ # A first-party post qualifies only through _first_party_floor_eligible
782
+ # (which needs positive grounding); the bare grounding pass below would
783
+ # wave a related handle's off-topic post through on a short topic.
784
+ if _is_first_party(c, resolved_handles):
785
+ qualifies = _first_party_floor_eligible(
786
+ c, resolved_handles=resolved_handles, primary_entity=primary_entity, subject_handles=subject
787
+ )
788
+ else:
789
+ qualifies = _is_entity_grounded(c, primary_entity)
790
+ if not qualifies:
419
791
  continue
420
792
  e = _candidate_engagement(c)
421
793
  # Percentile rank in [0, 1]: fraction of the X pool strictly below e.
@@ -468,26 +840,58 @@ def _entity_grounded(haystack: str, primary_entity: str) -> bool:
468
840
  items that omit the descriptor. Items that never name the brand at all still
469
841
  miss the head token and stay demoted.
470
842
 
471
- Trade-off: a proper noun with a generic head ("New York Times" -> "new")
472
- under-demotes rather than over-demotes - the safe direction, since the
473
- observed harm was burying real high-engagement signal. Substring (not
474
- word-boundary) matching is likewise deliberate: it catches plurals and
475
- compounds ("stripes"), and vacuous matches from very short heads ("X",
476
- "Go") merely disable the penalty rather than burying good items.
843
+ Long natural-language topics with a generic head use stronger trailing
844
+ anchors. Short generic-headed topics remain a safe no-op so entities such as
845
+ "Go" are not falsely demoted.
846
+ """
847
+ verdict = _entity_grounding_verdict(haystack, primary_entity)
848
+ return True if verdict is None else verdict
849
+
850
+
851
+ def _entity_grounding_verdict(haystack: str, primary_entity: str) -> bool | None:
852
+ """_entity_grounded's test, with None where no safe anchor exists.
853
+
854
+ None means the entity cannot be checked (no tokens, a short generic-headed
855
+ topic, or no strong trailing anchor); _entity_grounded treats that as a
856
+ pass so it never demotes, and _is_entity_positively_grounded as a miss.
477
857
  """
478
858
  haystack = haystack.lower()
479
- tokens = primary_entity.lower().split()
859
+ tokens = re.findall(r"\w+", primary_entity.lower())
480
860
  if not tokens:
481
- return True
482
- return tokens[0] in haystack
861
+ return None
862
+ head = tokens[0]
863
+ if len(head) > 3 and head not in relevance.LOW_SIGNAL_QUERY_TOKENS:
864
+ return head in haystack
865
+
866
+ # A long natural-language topic headed by "AI", "how", or another generic
867
+ # word needs a stronger anchor. Short entity-like topics keep the historical
868
+ # safe no-op rather than risking false demotion.
869
+ if len(tokens) < _GENERIC_GROUNDING_MIN_TOKENS:
870
+ return None
871
+ anchors = [
872
+ token
873
+ for token in tokens[1:]
874
+ if len(token) >= _GROUNDING_ANCHOR_MIN_LENGTH
875
+ and token not in relevance.STOPWORDS
876
+ and token not in _GROUNDING_LOW_SIGNAL_TOKENS
877
+ ]
878
+ if not anchors:
879
+ return None
880
+ return any(re.search(rf"\b{re.escape(token)}", haystack) for token in anchors)
483
881
 
484
882
 
485
883
  def _fallback_tuple(
486
- candidate: schema.Candidate, *, primary_entity: str = "", resolved_handles: set[str] | None = None
884
+ candidate: schema.Candidate,
885
+ *,
886
+ primary_entity: str = "",
887
+ resolved_handles: set[str] | None = None,
888
+ subject_handles: set[str] | None = None,
487
889
  ) -> tuple[float, str]:
890
+ # Relevance and source quality only. Freshness enters once, in
891
+ # _final_score; counting it here too gave it ~0.22 of the final score and
892
+ # let a 3-point story from today outrank a 380-point one from last week.
488
893
  score = (
489
894
  (candidate.local_relevance * 100.0 * 0.7)
490
- + (candidate.freshness * 0.2)
491
895
  + (candidate.source_quality * 100.0 * 0.1)
492
896
  )
493
897
  reason = "fallback-local-score"
@@ -500,9 +904,33 @@ def _fallback_tuple(
500
904
  # _final_score's secondary penalty (which greps for it) is also skipped.
501
905
  # A small bounded credit lifts a first-party post just off neutral without
502
906
  # letting authorship alone outrank a genuinely strong on-topic third party.
907
+ # The credit, like the floors, goes only to on-topic posts or the topic's
908
+ # own account (_first_party_floor_eligible); an off-topic reply from a
909
+ # related handle keeps the exemption (so it is not pruned) but earns no lift.
503
910
  if resolved_handles and _is_first_party(candidate, resolved_handles):
504
- score += FIRST_PARTY_AUTHOR_CREDIT
911
+ if _first_party_floor_eligible(
912
+ candidate,
913
+ resolved_handles=resolved_handles,
914
+ primary_entity=primary_entity,
915
+ subject_handles=subject_handles,
916
+ ):
917
+ score += FIRST_PARTY_AUTHOR_CREDIT
918
+ # A repo card keeps the exemption (a person-mode card never names the
919
+ # person) but is not authored in the window, so it is not labeled so.
920
+ if _is_repo_snapshot_candidate(candidate):
921
+ return max(0.0, min(100.0, score)), "fallback-local-score (repo snapshot)"
505
922
  return max(0.0, min(100.0, score)), "fallback-local-score (first-party authorship)"
923
+ # Grounding-exempt evidence (currently Amazon): the adapter gated these
924
+ # against the model-supplied keyword before they existed, so the
925
+ # entity-miss demotion below would punish them for a match they were
926
+ # never going to make -- a "Weber Grills" run legitimately surfaces a
927
+ # product called "Spirit E-325" whose reviews discuss searing, not Weber.
928
+ # Returning here also skips _final_score's secondary penalty, which greps
929
+ # the reason string for "entity-miss": one flag, both paths, per the
930
+ # propagation pattern in
931
+ # docs/solutions/logic-errors/entity-grounding-full-phrase-false-demotion.md
932
+ if _is_grounding_exempt(candidate):
933
+ return max(0.0, min(100.0, score)), "fallback-local-score (grounding-exempt source)"
506
934
  # Entity-grounding demotion: subtract ENTITY_MISS_PENALTY when the candidate
507
935
  # never mentions the primary entity's head token, across all text surfaces
508
936
  # (title, snippet, transcript, transcript highlights, top comments,
@@ -525,12 +953,81 @@ def _primary_entity(topic: str) -> str:
525
953
  string for topics that are all intent modifier with no entity, so
526
954
  callers can skip the grounding check.
527
955
  """
528
- stripped = _INTENT_MODIFIER_RE.sub(" ", topic)
529
- # Also collapse multiple spaces and strip punctuation.
956
+ stripped = topic
957
+ while True:
958
+ shortened = _TRAILING_INTENT_MODIFIER_RE.sub("", stripped, count=1)
959
+ if shortened == stripped:
960
+ break
961
+ stripped = shortened
530
962
  stripped = re.sub(r"\s+", " ", stripped).strip(" \t\r\n?.,:;!")
531
963
  return stripped
532
964
 
533
965
 
966
+ def _is_grounding_exempt(candidate: schema.Candidate) -> bool:
967
+ """True when the candidate carries the relevant-by-construction label.
968
+
969
+ Set by adapters that already gated their results against an explicit
970
+ keyword at retrieval time (see normalize._normalize_amazon). Checked on
971
+ the candidate's own metadata and on any of its source items, since
972
+ clustering can build a candidate from several items.
973
+ """
974
+ metadata = candidate.metadata or {}
975
+ if isinstance(metadata, dict) and metadata.get("grounding_exempt"):
976
+ return True
977
+ return any(
978
+ isinstance(item.metadata, dict) and item.metadata.get("grounding_exempt")
979
+ for item in candidate.source_items
980
+ )
981
+
982
+
983
+ def _is_corpus_candidate(candidate: schema.Candidate) -> bool:
984
+ """True when the candidate carries private corpus evidence."""
985
+ if candidate.source == "corpus":
986
+ return True
987
+ return any(item.source == "corpus" for item in candidate.source_items)
988
+
989
+
990
+ def prune_fallback_entity_misses(
991
+ candidates: list[schema.Candidate],
992
+ *,
993
+ topic: str,
994
+ ) -> list[schema.Candidate]:
995
+ """Hide unanchored, low-confidence fallback misses from visible evidence.
996
+
997
+ Broad recommendation queries can be misread as one long primary entity,
998
+ causing every fallback candidate to receive the entity-miss marker. The
999
+ marker alone is therefore not a safe filter. A candidate is removed only
1000
+ when its stable title and snippet do not clear a meaningful raw-topic
1001
+ relevance floor and it lacks a strong local-relevance signal from an
1002
+ explicitly scoped retrieval path. Comments and transcripts are excluded
1003
+ from this escape because incidental words there do not ground the candidate
1004
+ itself. Private corpus candidates always escape: retrieval already accepted
1005
+ them on body text, and titles are often filenames that omit the head token.
1006
+ Source items remain in the report's diagnostic source dump.
1007
+ """
1008
+ if not topic:
1009
+ return candidates
1010
+
1011
+ kept: list[schema.Candidate] = []
1012
+ for candidate in candidates:
1013
+ if candidate.explanation != _FALLBACK_ENTITY_MISS_EXPLANATION:
1014
+ kept.append(candidate)
1015
+ continue
1016
+ if _is_corpus_candidate(candidate):
1017
+ kept.append(candidate)
1018
+ continue
1019
+ if candidate.local_relevance >= FALLBACK_ENTITY_MISS_CONFIDENCE_ESCAPE:
1020
+ kept.append(candidate)
1021
+ continue
1022
+ primary_text = f"{candidate.title or ''} {candidate.snippet or ''}"
1023
+ if (
1024
+ relevance.token_overlap_relevance(topic, primary_text)
1025
+ >= FALLBACK_ENTITY_MISS_TOPIC_ESCAPE
1026
+ ):
1027
+ kept.append(candidate)
1028
+ return kept
1029
+
1030
+
534
1031
  #: Secondary entity-miss penalty applied directly to final_score (not just
535
1032
  #: rerank_score). The -25 on rerank_score composes to only -15 on final_score
536
1033
  #: via the 0.60 weight, which engagement bonus partially offsets on
@@ -541,35 +1038,82 @@ def _primary_entity(topic: str) -> str:
541
1038
  #: the dilute penalty. This backstop makes the demotion actually decisive.
542
1039
  ENTITY_MISS_FINAL_PENALTY = 20.0
543
1040
 
1041
+ #: Multiplier applied to a candidate whose every dated item falls outside the
1042
+ #: run's window. The tool's whole promise is the window, so a stale item must
1043
+ #: not lead the ranked clusters however relevant it reads — a 2025-10 video
1044
+ #: ranked #1 in a 2026-07 brief, and a 2025-12 one ranked #5, both correctly
1045
+ #: flagged [date:low] and both ranked anyway. Scaling rather than subtracting
1046
+ #: keeps the ordering *among* older items intact, so the "still worth reading"
1047
+ #: signal survives underneath the in-window evidence.
1048
+ OUT_OF_WINDOW_FINAL_MULTIPLIER = 0.35
1049
+
1050
+
1051
+ #: Weight of engagement (0-100, absolute per-source log scale from
1052
+ #: signals.engagement_score) in _final_score. Its full range is worth 15
1053
+ #: points, nearly twice freshness's effective 8 (freshness spans 10-90), so a
1054
+ #: large engagement gap (a 380-point HN story vs a 3-point one, ~78 points ->
1055
+ #: ~12) can outweigh most of a month's age gap (~7), while a modest gap cannot.
1056
+ #: Relevance still dominates: one tenth of local relevance is worth 4.2 points
1057
+ #: through the fallback rerank score alone.
1058
+ ENGAGEMENT_FINAL_WEIGHT = 0.15
1059
+
1060
+
1061
+ #: Candidate.metadata key recording which path produced rerank_score. Set by
1062
+ #: _apply_fallback_scores and _apply_llm_scores; the explanation text is not
1063
+ #: provenance (an LLM reason may start with any word).
1064
+ RERANK_PROVENANCE_KEY = "rerank_provenance"
1065
+ RERANK_PROVENANCE_FALLBACK = "fallback"
1066
+ RERANK_PROVENANCE_LLM = "llm"
1067
+
1068
+
1069
+ def _set_rerank_provenance(candidate: schema.Candidate, provenance: str) -> None:
1070
+ candidate.metadata = {**(candidate.metadata or {}), RERANK_PROVENANCE_KEY: provenance}
1071
+
1072
+
1073
+ def _is_fallback_scored(candidate: schema.Candidate) -> bool:
1074
+ """True when rerank_score came from _fallback_tuple, not an LLM or host.
1075
+
1076
+ Reads the provenance the scoring path recorded. A candidate with none
1077
+ recorded is treated as judge-scored, so the cliff still applies.
1078
+ """
1079
+ return (candidate.metadata or {}).get(RERANK_PROVENANCE_KEY) == RERANK_PROVENANCE_FALLBACK
1080
+
544
1081
 
545
1082
  def _final_score(candidate: schema.Candidate) -> float:
546
1083
  normalized_rrf = _normalized_rrf(candidate.rrf_score)
547
1084
  rerank_score = candidate.rerank_score or 0.0
548
- # Engagement bonus: high-engagement items (viral TikToks, popular YouTube videos)
549
- # get a boost so they aren't buried by lower-engagement but text-relevant items.
550
- # Engagement is log1p-normalized (0-100 range via signals.py), so a 2.5M-view
551
- # TikTok scores ~15 and a 1500-view one scores ~7. The 0.05 weight gives a
552
- # meaningful but not dominant boost.
1085
+ # Engagement is already 0-100 on an absolute per-source log scale
1086
+ # (signals.engagement_score): ~8 for a 3-like X post, ~50 for a 254-upvote
1087
+ # Reddit thread, ~90 for a 380-point HN story.
553
1088
  engagement_val = candidate.engagement if candidate.engagement is not None else 0.0
554
1089
  base = (
555
1090
  0.60 * rerank_score
556
1091
  + 0.20 * normalized_rrf
557
1092
  + 0.10 * candidate.freshness
558
1093
  + 0.05 * (candidate.source_quality * 100.0)
559
- + 0.05 * min(engagement_val * 6.0, 100.0)
1094
+ + ENGAGEMENT_FINAL_WEIGHT * max(0.0, min(engagement_val, 100.0))
560
1095
  )
561
- if candidate.rerank_score is not None and candidate.rerank_score < 20.0:
1096
+ # The cliff demotes items a judge explicitly called irrelevant. A fallback
1097
+ # score below 20 only means weak lexical overlap, which the 0.60 weight
1098
+ # already prices in; cliffing it too buried on-topic threads (a 254-upvote
1099
+ # r/Ghostty post ranked last at score 7).
1100
+ if (
1101
+ candidate.rerank_score is not None
1102
+ and candidate.rerank_score < 20.0
1103
+ and not _is_fallback_scored(candidate)
1104
+ ):
562
1105
  base *= 0.3
563
1106
  # Secondary entity-grounding penalty: when the fallback path flagged
564
1107
  # entity-miss via candidate.explanation, apply an additional penalty
565
1108
  # at final_score level so engagement signal can't mask the demotion.
566
1109
  if candidate.explanation and "entity-miss" in candidate.explanation:
567
1110
  base = max(0.0, base - ENTITY_MISS_FINAL_PENALTY)
1111
+ # Recency contract: out-of-window evidence never leads the ranked output.
1112
+ if schema.candidate_out_of_window(candidate):
1113
+ base *= OUT_OF_WINDOW_FINAL_MULTIPLIER
568
1114
  return base
569
1115
 
570
1116
 
571
-
572
-
573
1117
  def score_fun(
574
1118
  *,
575
1119
  topic: str,
@@ -625,7 +1169,10 @@ def _extract_comment_text(candidate: schema.Candidate) -> str:
625
1169
  parts = []
626
1170
  for item in candidate.source_items:
627
1171
  for comment in item.metadata.get("top_comments", [])[:3]:
628
- body = comment.get("body", "") if isinstance(comment, dict) else str(comment)
1172
+ body = (
1173
+ comment.get("excerpt") or comment.get("body", "")
1174
+ if isinstance(comment, dict) else str(comment)
1175
+ )
629
1176
  if body:
630
1177
  parts.append(body[:150])
631
1178
  for insight in item.metadata.get("comment_insights", [])[:2]:
@@ -644,7 +1191,7 @@ def _extract_comment_text_scored(candidate: schema.Candidate) -> str:
644
1191
  for item in candidate.source_items:
645
1192
  for comment in item.metadata.get("top_comments", [])[:3]:
646
1193
  if isinstance(comment, dict):
647
- body = comment.get("body", "")
1194
+ body = comment.get("excerpt") or comment.get("body", "")
648
1195
  if not body:
649
1196
  continue
650
1197
  score = comment.get("score")
@@ -706,3 +1253,24 @@ def _normalized_rrf(rrf_score: float) -> float:
706
1253
  # Max single-stream RRF at rank 1 is 1/(K+1) ~ 0.016; multi-stream
707
1254
  # accumulation reaches ~0.08.
708
1255
  return max(0.0, min(100.0, (rrf_score / 0.08) * 100.0))
1256
+
1257
+
1258
+ def candidate_relevance_ok(candidate: schema.Candidate) -> bool:
1259
+ """Shared gate: is this candidate topically usable for display surfaces?
1260
+
1261
+ Single owner of the entity-miss demotion test. Render-side surfaces (Best
1262
+ Takes, cluster visibility) must call this rather than re-testing the
1263
+ explanation string themselves -- a second copy of the predicate is how the
1264
+ documented mirrored-predicate drift bug recurs, and it means a carve-out
1265
+ added here silently fails to reach them.
1266
+
1267
+ First-party posts are handled upstream: ``_apply_first_party_floor`` clears
1268
+ their entity-miss marker at the one site that knows the resolved handles,
1269
+ so this predicate needs no handle knowledge.
1270
+ """
1271
+ explanation = (candidate.explanation or "").lower()
1272
+ if "entity-miss" in explanation:
1273
+ return False
1274
+ if (candidate.final_score or 0.0) <= 0.0:
1275
+ return False
1276
+ return True