Compare commits

..

6 Commits

Author SHA1 Message Date
Trevin Chow 2ee0fc5c22 fix(skill): carry pitch-vs-pulse rules into the comparison synthesis path
Design-vs-implementation review found the comparison path missing two
reinforcements the single-topic path has: the windowed-claims rule (no
trend verbs from one 30-day window) and an explicit artifact gate. It
also pointed the beat at a "narrative section" the comparison template
doesn't have. The per-entity template block now carries an explicit
optional pitch-vs-pulse slot (omit entirely when silent - no
placeholder), and the comparison instruction names that slot, the
windowed rule, and the no-fetch-no-pitch gate. Instructions now sit at
the emission point, per the file's own v3.0.6/v3.0.7 lesson about
distance between instruction and output.
2026-06-09 17:33:30 -07:00
Trevin Chow c1ca1a4e9d feat(skill): replace narrative verdict row with evidence-triggered prose beat
Review showed the "Setting the narrative?" verdict compared across
abstraction levels: a homepage tagline is deliberately broad
("financial infrastructure" covers a chargebacks thread), so
tagline-vs-thread alignment verdicts are unfalsifiable and carry no
information. The signal now ships as PROSE in the entity's narrative
section, fires only when the month's evidence directly bears on the
pitch (supports a specific claim, cuts against one, or is squarely
about the pitched ground), and stays SILENT when the pulse is
orthogonal - omission over a manufactured connection. Claims are
tested at matched altitude (specific claim vs specific thread) and
stay windowed (no trend verbs one 30-day window can't support). The
positioning fetch step survives unchanged and now also grounds the
"What it is" row and brand-noise rejection. All scope gating (people
never, ownerless topics excluded, no pitch from memory) carries over.
2026-06-09 17:14:31 -07:00
Trevin Chow 57860aff1c feat(skill): scope narrative lens to first-party entities, gate on fetched positioning
The lens criterion is now explicit: an identifiable first party with a
fetchable public pitch - companies, products, services. People NEVER
qualify, even founders whose companies would (MrBeast the company can
get the lens; Jimmy Donaldson the person cannot; "Garry Tan vs Sam
Altman" gets N/A across the row). Ownerless topics (Bitcoin) fail the
same test - a foundation or fan site is not an authoritative first
party. Comparison-row verdicts are also artifact-gated to match the
single-entity beat: a verdict requires positioning fetched THIS run;
if item 6 couldn't run (no WebSearch), the cell is Unclear - the pitch
is never supplied from memory.
2026-06-09 16:33:29 -07:00
Trevin Chow 4402dd317f docs: changelog, CONCEPTS research-pipeline cluster, grounding solution doc
CHANGELOG covers the narrative lens (Added) and head-token grounding
fix (Fixed). CONCEPTS.md gains a Research pipeline cluster (Primary
entity, Intent modifier, Entity grounding, Keyless path,
Comment-enrichment slots). New docs/solutions/logic-errors/ entry
captures the false-demotion bug, the rejected alternatives, and the
shared-helper prevention rule.
2026-06-09 16:24:24 -07:00
Trevin Chow 6a92f63a56 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.
2026-06-09 16:24:16 -07:00
Trevin Chow fd0e47d99f feat(skill): add "Setting the narrative?" lens for company/product topics
Comparison tables gain a narrative axis judging whether each entity's
community conversation is about what the entity pitches or about
something else (pricing, rivals, an incident). Backed by a new
mandatory Step 0.55 research item that fetches each entity's CURRENT
first-party positioning (RESOLVED_POSITIONING) instead of relying on
memory, and a narrative-check synthesis beat for single-entity company
runs. The mismatch is the signal: companies usually don't control
their own conversation.
2026-06-09 16:24:07 -07:00
10 changed files with 248 additions and 27 deletions
+8
View File
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added
- **First-party positioning research + pitch-vs-pulse synthesis (company / product / service topics).** A new mandatory research step captures each entity's current stated positioning from first-party sources (homepage, docs, pricing) rather than from memory. The fetched pitch grounds `What it is` descriptions (entities described as they pitch themselves today), helps reject unrelated brand-name noise, and feeds an evidence-triggered prose beat: when the month's conversation directly supports a specific claim, cuts against one, or is squarely about the pitched ground, the synthesis says so anchored to the top thread — and stays silent when the pulse is orthogonal to the pitch, because a manufactured connection is worse than omission. Claims are tested at matched altitude (specific claims against specific threads; broad taglines are never graded against individual items), and statements stay windowed to the 30 days — no trend verdicts. Scoped to entities with an identifiable first party: people are always excluded (even founders whose companies qualify), as are events, abstract concepts, and ownerless topics like Bitcoin; the beat requires positioning fetched during the run, never from memory.
### Fixed
- Entity-grounding rerank demotion now keys on the head token of the primary entity instead of requiring the full multi-word phrase as a contiguous substring. A high-engagement on-entity item (e.g. a 323-pt HN thread titled "Stripe is friendly to 'friendly fraud'") is no longer demoted to score 0 on a `Stripe payments` query just because it lacks the trailing search-hint word. The intended demotion still fires for items that never name the brand at all. The keyless Reddit comment-enrichment slot selection (`_slot_priority`), which mirrors this signal, was updated to the same head-token grounding so the two paths stay consistent.
## [3.3.2] - 2026-06-06 ## [3.3.2] - 2026-06-06
### Fixed ### Fixed
+24
View File
@@ -16,6 +16,30 @@ The Python script (`scripts/last30days.py`) the Skill's SKILL.md invokes to do t
The agent runtime that loads Skills and invokes them on the user's behalf. Claude Code is the most common Harness for this Skill but not the only one — Codex, Cursor, GitHub Copilot, Gemini CLI, and the rest of the Agent Skills ecosystem also count. "Multi-harness" describes a Skill that works correctly across every Harness it installs into; features written without multi-harness awareness (e.g., engine flags with no SKILL.md integration, or paths hardcoded to one Harness's install layout) regress on Harnesses other than the one they were tested against. The agent runtime that loads Skills and invokes them on the user's behalf. Claude Code is the most common Harness for this Skill but not the only one — Codex, Cursor, GitHub Copilot, Gemini CLI, and the rest of the Agent Skills ecosystem also count. "Multi-harness" describes a Skill that works correctly across every Harness it installs into; features written without multi-harness awareness (e.g., engine flags with no SKILL.md integration, or paths hardcoded to one Harness's install layout) regress on Harnesses other than the one they were tested against.
## Research pipeline
### Primary entity
The brand or proper-noun core of a research topic — the topic with its Intent modifier stripped. It is what the research is *about*, as distinct from how the user phrased the search.
### Intent modifier
A trailing word or phrase in a topic that expresses what the user wants to know rather than what the topic is ("review", "use cases", "pricing"). Stripped when deriving the Primary entity.
### Entity grounding
The check that a candidate item plausibly mentions the Primary entity before final ranking. Grounding keys on the head token (first word) of the Primary entity rather than the full phrase — trailing words are usually search descriptors, so requiring them falsely demotes on-entity items.
An item that fails grounding receives a decisive entity-miss demotion, designed so engagement cannot rescue off-entity content. Because the demotion is decisive, the grounding bar is deliberately conservative: its failure modes degrade toward "no penalty," never toward burying on-entity signal.
### Keyless path
The research flow available with no API keys: source data is gathered by scraping and RSS rather than authenticated APIs, and ranking falls back to local scoring instead of LLM-based reranking. This is the free tier of the Skill; lexical quality safeguards like Entity grounding matter most here, because no LLM is available to judge relevance semantically.
### Comment-enrichment slots
The small, depth-dependent budget of Reddit posts whose comments get fetched in the Keyless path. Slot selection is relevance-aware: posts that pass Entity grounding claim slots first, so the budget is not spent on high-engagement posts that final ranking will demote anyway.
## Distribution ## Distribution
### Beta channel ### Beta channel
@@ -0,0 +1,117 @@
---
title: Keyless rerank entity grounding required full multi-word phrase, falsely demoting on-entity items
date: 2026-06-09
category: docs/solutions/logic-errors
module: lib/rerank
problem_type: logic_error
component: search_ranking
severity: high
symptoms:
- on-entity, high-engagement items that name the brand but omit the trailing descriptor of a multi-word query are demoted in keyless/fallback rerank results
- observed case is a 323-point HN thread about Stripe scoring 0 on a "Stripe payments" query
- the entity-miss demotion lands twice (ENTITY_MISS_PENALTY on rerank_score plus a secondary final_score penalty), so a false miss guarantees burial regardless of engagement
- reddit keyless comment-enrichment slot selection skips the same on-entity threads via an independently duplicated full-phrase check in _slot_priority
root_cause: logic_error
resolution_type: code_fix
related_components:
- reddit_keyless
- comment_enrichment
tags:
- entity-grounding
- rerank
- keyless-fallback
- multi-word-entity
- substring-match
- false-demotion
- reddit-keyless
- duplicated-logic
---
# Keyless rerank entity grounding required full multi-word phrase, falsely demoting on-entity items
## Problem
The keyless/fallback rerank path's entity-grounding demotion required the FULL multi-word primary-entity phrase as a contiguous substring of the candidate's text (`primary_entity.lower() not in haystack`), so on-entity items that omitted a trailing search descriptor were falsely flagged as entity misses and buried by a deliberately decisive double penalty.
## Symptoms
- On a "Stripe payments" query, a 323-point HN thread titled "Stripe is friendly to 'friendly fraud'" was demoted to score 0 — purely because its text never contained the literal phrase "stripe payments" (the trailing word "payments" was missing).
- The burial is guaranteed by design, not incidental: a flagged entity miss takes 25 `ENTITY_MISS_PENALTY` on `rerank_score` in `_fallback_tuple`, PLUS `ENTITY_MISS_FINAL_PENALTY` applied directly in `_final_score` (added 2026-04-19 after engagement + freshness drowned the diluted penalty). A false positive on the check means confirmed-good signal cannot recover.
- The same over-strict check had been independently re-implemented in `reddit_keyless._slot_priority` (keyless Reddit comment-enrichment slot selection), so scarce comment slots were also steered away from head-token-only posts.
## What Didn't Work
- **Naively relaxing the check** — the full-phrase check existed for a real reason: on 2026-04-19 an off-topic video with zero brand mentions ranked #2 on a Hermes query (documented in the `ENTITY_MISS_FINAL_PENALTY` comment in `skills/last30days/scripts/lib/rerank.py`). Any fix had to keep that demotion firing.
- **Word-boundary matching** — rejected; it re-introduces over-demotion on plurals/possessives/compounds ("stripes", "Stripe's").
- **Graded penalty** (full-phrase = 0, head-only = half, none = full) — rejected; it half-punishes items that are 100% about the entity. Lexical coverage is not topical degree.
- **Any-token grounding** — rejected; "payments" alone would ground completely generic posts.
- **Distinctiveness gate for generic heads** — rejected as complexity to patch a failure mode that is already a safe no-op (see Why This Works).
- **Trusting the docstring** — `reddit_keyless._slot_priority`'s docstring claimed to "mirror rerank's demotion signal," but its inline reimplementation (`entity in _post_text(post).lower()`) had silently drifted from being a mirror into being a second copy of the bug. It was found only by a code-reuse review, not by tests.
## Solution
Ground on the **head token** of the primary entity instead of the full phrase, via one shared helper used by both paths.
**Site 1 — new helper in `skills/last30days/scripts/lib/rerank.py`:**
```python
def _entity_grounded(haystack: str, primary_entity: str) -> bool:
tokens = primary_entity.lower().split()
if not tokens:
return True
return tokens[0] in haystack
```
`_fallback_tuple` switches from the inline phrase check to the helper:
```python
# before
if haystack.strip() and primary_entity.lower() not in haystack:
# after
if haystack.strip() and not _entity_grounded(haystack, primary_entity):
```
**Site 2 — secondary penalty in `_final_score`: no code change needed.** It keys off the explanation string set by site 1, so it inherits the fix automatically:
```python
if candidate.explanation and "entity-miss" in candidate.explanation:
base = max(0.0, base - ENTITY_MISS_FINAL_PENALTY)
```
**Site 3 — `skills/last30days/scripts/lib/reddit_keyless.py` `_slot_priority`:** replace the drifted reimplementation with a call to the shared helper:
```python
# before
return entity in _post_text(post).lower()
# after
return rerank._entity_grounded(_post_text(post).lower(), entity)
```
Tests: `tests/test_rerank_v3.py` gained `test_fallback_grounds_on_head_token_not_full_phrase` (the Stripe regression) and `test_fallback_still_demotes_when_head_token_absent_on_multiword_topic` (guards the 2026-04-19 behavior). `tests/test_reddit_keyless.py`'s two old-contract tests were rewritten as `test_slot_priority_grounds_on_head_token_not_full_phrase` and `test_intent_modifier_topic_prioritizes_head_token_match`.
## Why This Works
- **Root cause:** trailing tokens of a multi-word query ("payments" in "Stripe payments") are usually category descriptors the user/planner appended for search, not part of the entity name. Requiring the whole phrase conflates "doesn't repeat my search phrasing" with "isn't about my entity." The brand head token alone is sufficient grounding; items that never name the brand at all still miss the head token and stay demoted — so the original 2026-04-19 fix keeps firing.
- **Asymmetry argument:** the demotion is engineered to be decisive (double penalty across `rerank_score` and `final_score`), so a false entity-miss is fatal-by-design, while a false grounding merely defers the item to normal relevance/freshness/quality ranking. When the punishment is capital, the conviction standard should be conservative.
- **Substring (not word-boundary) is deliberate:** it catches plurals/possessives/compounds ("stripes", "Stripe's"). Degenerate short heads ("X", "Go", "C") make the check vacuously true, which merely **disables** the penalty — reverting to the pre-grounding baseline — rather than burying good items. Every failure mode of this rule degrades toward "no penalty," never toward "bury good signal."
- **Accepted, bounded limitation:** head-collision with a different famous entity ("Hermes Agent" → a "Hermes Birkin" thread now escapes demotion). This is lexically unfixable — any token rule strong enough to kill the collision re-kills the Stripe case; the discriminator is semantic. The LLM rerank path (which receives the full phrase as prompt guidance and judges semantically) covers this when API keys exist; the keyless path accepts the bounded risk.
## Prevention
- **Shared helper as single source of truth:** when one module's behavior must "mirror" another's signal, it must *call* the same function, not re-implement the check. The `reddit_keyless._slot_priority` drift happened precisely because the mirror was a copy. The fix wires it to `rerank._entity_grounded`, and the docstring now states this explicitly: "keying on the same head token keeps the two paths from diverging."
- **Docstrings record deliberate trade-offs:** `_entity_grounded`'s docstring documents WHY head-token (not phrase), why substring (not word-boundary), and the safe-failure direction. Future readers see the rejected alternatives were considered, not overlooked — and won't "tighten" the check into a regression.
- **Both directions pinned by named tests:**
- `tests/test_rerank_v3.py::test_fallback_grounds_on_head_token_not_full_phrase` — false-demotion regression (the Stripe HN thread must not be flagged).
- `tests/test_rerank_v3.py::test_fallback_still_demotes_when_head_token_absent_on_multiword_topic` — the fix must not neuter the demotion (guards the 2026-04-19 off-topic-video incident).
- `tests/test_reddit_keyless.py::test_slot_priority_grounds_on_head_token_not_full_phrase` and `test_intent_modifier_topic_prioritizes_head_token_match` — the mirrored path asserts the same contract.
- **Audit tests when changing a contract:** tests that encode the old behavior as correct must be rewritten to the new contract, not worked around — the two old `test_reddit_keyless.py` tests would have silently re-blessed the bug.
- **For decisive penalties, route through one flag:** the `_final_score` backstop keys off `"entity-miss" in candidate.explanation` rather than re-running the check — so there was exactly one site to fix and the second penalty inherited it for free. Prefer this signal-propagation pattern over duplicating predicate logic at each penalty site.
## Related Issues
- [PR #484](https://github.com/mvanhorn/last30days-skill/pull/484) — "fix(reddit): relevance-aware comment-enrichment slot selection in keyless path" — introduced the `_slot_priority` mirror this fix reroutes through the shared helper.
- [PR #457](https://github.com/mvanhorn/last30days-skill/pull/457) — "fix(reddit): restore free path via keyless RSS + shreddit scrape" — established the keyless Reddit path.
- [PR #488](https://github.com/mvanhorn/last30days-skill/pull/488) (open) — "fix(reddit): relevance floor + relevance-first ranking" — external PR touching the same ranking surface; coordinate before merging both.
- [Issue #468](https://github.com/mvanhorn/last30days-skill/issues/468) (open) — relevance scoring over-pruning on-topic YouTube items; same symptom family in a different source.
- [../architecture/search-quality-eval-manual-by-default-2026-05-10.md](../architecture/search-quality-eval-manual-by-default-2026-05-10.md) — how to validate ranking/grounding changes like this one (manual eval, not CI-gated).
- [../workflow-issues/release-consistency-test-cascade-2026-05-16.md](../workflow-issues/release-consistency-test-cascade-2026-05-16.md) — sibling prevention pattern: lockstep artifacts drift unless mechanically unified.
+12 -3
View File
@@ -641,9 +641,11 @@ Topic A (the main topic, first in the vs-string) uses outer `--x-handle`, `--x-r
**Then do WebSearch supplements** for: `{TOPIC_A} vs {TOPIC_B} comparison {YEAR}` and `{TOPIC_A} vs {TOPIC_B} which is better` — these catch rivalry articles that per-entity passes might not surface. **Then do WebSearch supplements** for: `{TOPIC_A} vs {TOPIC_B} comparison {YEAR}` and `{TOPIC_A} vs {TOPIC_B} which is better` — these catch rivalry articles that per-entity passes might not surface.
**Use `RESOLVED_POSITIONING` per entity (Step 0.55 item 6) in two ways.** First, ground each entity's `What it is` cell in its CURRENT fetched pitch - describe the entity as it pitches itself today, never from memory. Second, if an entity's month of evidence directly bears on its pitch - SUPPORTS a specific claim, CUTS AGAINST one, or the conversation is squarely ABOUT the pitched ground - say so in ONE prose sentence inside that entity's section of the comparison synthesis (right after the Community Sentiment line - the template marks the slot), anchored to the real item with its engagement. When the pulse is orthogonal to the pitch (on-entity but about something the pitch doesn't speak to), say NOTHING about the pitch: omission is the correct output, and a manufactured connection is worse than silence. Match altitude: test SPECIFIC claims ("zero-config", "fastest", an uptime number) against specific threads; never grade a broad tagline ("financial infrastructure") against an individual thread - it is too broad to hit or miss. Keep claims windowed - "this month's conversation" - never trend verbs like "losing the narrative" that one 30-day window cannot support. If positioning was not actually fetched this run for an entity, skip both uses for that entity - never supply a pitch from memory.
**Skip the normal Step 1 below** - go directly to the comparison synthesis format (see "If QUERY_TYPE = COMPARISON" in the synthesis section). **Skip the normal Step 1 below** - go directly to the comparison synthesis format (see "If QUERY_TYPE = COMPARISON" in the synthesis section).
**COMPARISON TABLE SCAFFOLD (engine-emitted, pass through verbatim):** For comparison topics, the engine's compact output includes a `## Head-to-Head` block with an empty markdown table (columns = entities, rows = axes like "What it is", "Community sentiment", "Trajectory"). Your synthesis MUST include this block verbatim with filled cells, positioned between the narrative and the emoji-tree footer. Keep each cell to 5-15 words. Use ' - ' (hyphen with spaces) not em-dashes inside cells. **COMPARISON TABLE SCAFFOLD (engine-emitted, pass through verbatim):** For comparison topics, the engine's compact output includes a `## Head-to-Head` block with an empty markdown table (columns = entities, rows = axes like "What it is", "Philosophy", "Best for"). Your synthesis MUST include this block verbatim with filled cells, positioned between the narrative and the emoji-tree footer. Keep each cell to 5-15 words. Use ' - ' (hyphen with spaces) not em-dashes inside cells.
### Competitor mode (`--competitors`) ### Competitor mode (`--competitors`)
@@ -748,6 +750,8 @@ Store as `RESOLVED_IG_CREATORS`.
Store as `RESOLVED_YT_QUERIES`. Store as `RESOLVED_YT_QUERIES`.
**6. First-party positioning** - **MANDATORY when WebSearch is available, for company / product / service topics.** If the topic (or, in a vs-run, an entity) is a company, product, or service with a public presence, fetch its CURRENT stated positioning. Do **NOT** rely on memory - homepages and positioning go stale as companies rewrite copy and pivot, and a stale claim produces a false gap. Anchor on first-party sources: the homepage tagline, docs, pricing, or a "compare/why-us" page. Fold this into the per-entity passes above where you can (e.g. add `official site` to a query); otherwise run one focused search per entity (`{TOPIC} official site`, `{TOPIC} pricing`). Capture the one-line value prop and any explicit claims ("zero-config", "fastest", "open source"). Store as `RESOLVED_POSITIONING`. This is what the entity *pitches*; the engine's community data is what people *actually talk about*. Use it three ways: ground `What it is` descriptions (describe the entity as it pitches itself TODAY, not as remembered), help reject unrelated brand-name noise (knowing what the entity is makes off-brand matches obvious), and feed the pitch-vs-pulse synthesis beat - a PROSE note that fires only when the month's evidence directly supports, cuts against, or is squarely about the pitch (see the synthesis section; orthogonal evidence gets silence, not a verdict). Skip (and omit `RESOLVED_POSITIONING`) for people, events, abstract concepts, and ownerless topics - they make no comparable public claim. The test is an identifiable first party with a fetchable pitch, and people NEVER pass it - not even founders/creators whose companies would qualify. The lens can apply to MrBeast (a company) but never to Jimmy Donaldson (a person); a person-vs-person run ("Garry Tan vs Sam Altman") gets no positioning research at all. Ownerless topics fail the same test: Bitcoin has no authoritative first party, and a foundation or fan site does not count.
**Concrete examples:** **Concrete examples:**
| Topic | WebSearches needed | Reddit subs | TikTok hashtags | TikTok creators | IG creators | YT queries | | Topic | WebSearches needed | Reddit subs | TikTok hashtags | TikTok creators | IG creators | YT queries |
@@ -800,9 +804,10 @@ Resolved:
- Reddit: r/{sub1}, r/{sub2}, r/{sub3}, r/{peer1}, r/{peer2} (+ {category_id} peers) - Reddit: r/{sub1}, r/{sub2}, r/{sub3}, r/{peer1}, r/{peer2} (+ {category_id} peers)
- TikTok: #{hashtag1}, #{hashtag2} - TikTok: #{hashtag1}, #{hashtag2}
- YouTube: {query1}, {query2} - YouTube: {query1}, {query2}
- Positioning: "{one-line stated value prop}" (first-party)
``` ```
Only show lines for platforms where something was resolved. Skip empty lines. On the Reddit line, the trailing `(+ {category_id} peers)` annotation appears when Step 0.55 Section 2a added category-peer subs. Omit the annotation when the topic had no matching category. This display replaces the old "Parsed intent" block with something more useful. Only show lines for platforms where something was resolved. Skip empty lines. On the Reddit line, the trailing `(+ {category_id} peers)` annotation appears when Step 0.55 Section 2a added category-peer subs. Omit the annotation when the topic had no matching category. The `Positioning:` line appears for company / product / service topics (from Step 0.55 item 6); omit it for people, events, abstract concepts, and ownerless topics. This display replaces the old "Parsed intent" block with something more useful.
--- ---
@@ -1268,6 +1273,8 @@ Voice contract LAWs 1, 3, 5 apply to comparisons unchanged (no `Sources:` block,
**Community Sentiment:** [Positive / Mixed / Negative / Enthusiastic / Security-concerned / etc.] ({N}+ mentions across {source list}) **Community Sentiment:** [Positive / Mixed / Negative / Enthusiastic / Security-concerned / etc.] ({N}+ mentions across {source list})
[Optional pitch-vs-pulse sentence - ONLY if `RESOLVED_POSITIONING` was captured for this entity AND the month's evidence directly supports a specific claim, cuts against one, or is squarely about the pitched ground: one windowed prose sentence anchored to a real item with engagement. Otherwise omit entirely - silence, not a placeholder.]
**Strengths (what people love)** **Strengths (what people love)**
- [Specific strength with `per <source>` attribution] - [Specific strength with `per <source>` attribution]
- [Specific strength with `per <source>` attribution] - [Specific strength with `per <source>` attribution]
@@ -1299,7 +1306,7 @@ Voice contract LAWs 1, 3, 5 apply to comparisons unchanged (no `Sources:` block,
| Best for | ... | ... | ... | | Best for | ... | ... | ... |
| Install | ... | ... | ... | | Install | ... | ... | ... |
(Engine emits this scaffold; fill the cells with 5-15 words each. If an axis does not apply to the topic class, write "N/A" or a topic-appropriate substitute rather than inventing data.) (Engine emits this scaffold; fill the cells with 5-15 words each. If an axis does not apply to the topic class, write "N/A" or a topic-appropriate substitute rather than inventing data. Ground the `What it is` row in `RESOLVED_POSITIONING` when captured - each entity described as it pitches itself today, fetched this run, never from memory.)
## The Bottom Line ## The Bottom Line
@@ -1441,6 +1448,8 @@ At render time the `@handle`, `r/sub`, and publication-name placeholders become
Headlines should be specific and newsy ("BULLY dropped and it's dominating", "Europe is banning him one country at a time"), not generic ("Album release", "Tour updates"). Headlines should be specific and newsy ("BULLY dropped and it's dominating", "Europe is banning him one country at a time"), not generic ("Album release", "Tour updates").
**Pitch-vs-pulse beat (company / product / service topics).** If you captured `RESOLVED_POSITIONING` in Step 0.55 AND the month's evidence directly bears on it, work in ONE bold-lead-in paragraph saying how. Three cases qualify: the pulse SUPPORTS a specific claim (e.g. `**"Zero-config" is holding up** - this month's top deploy thread is devs praising the no-setup flow, 800 upvotes`), CUTS AGAINST one (e.g. `**Stripe's fraud-fighting pitch took a direct hit** - the loudest thread this month argues it is friendly to "friendly fraud", 323pt HN`), or the conversation is squarely ABOUT the pitched ground. Always anchor to the real top item with its engagement, and keep claims windowed - "this month's conversation" - never trend verbs like "losing the narrative" that one 30-day window cannot support. If the month's conversation is orthogonal to the pitch - on-entity but about something the pitch doesn't speak to - write NOTHING about the pitch: omission is the correct output, and a manufactured connection is worse than silence. Match altitude: test SPECIFIC claims ("zero-config", "fastest", an uptime number) against specific threads; never grade a broad tagline against an individual thread. Keep it a normal newsy bold-lead-in paragraph, NOT a new `##` section (LAW 4 still holds). Skip silently for people (always - the beat can cover MrBeast the company, never Jimmy Donaldson the person), events, abstract concepts, and ownerless topics (Bitcoin), and whenever positioning was not actually fetched this run - never supply a pitch from memory.
**THEN - Quality Nudge (if present in the output):** **THEN - Quality Nudge (if present in the output):**
If the research output contains a `**🔍 Research Coverage:**` block, render it verbatim right before the stats block. This tells the user which core sources are missing and how to unlock them. Do NOT render this block if it is absent from the output (100% coverage = no nudge). If the research output contains a `**🔍 Research Coverage:**` block, render it verbatim right before the stats block. This tells the user which core sources are missing and how to unlock them. Do NOT render this block if it is absent from the output (100% coverage = no nudge).
@@ -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)
+11 -2
View File
@@ -533,7 +533,8 @@ def _render_comparison_scaffold(topic: str) -> list[str]:
Axes match the April 9 launch-video exemplar (9 axes suited to AI-tool Axes match the April 9 launch-video exemplar (9 axes suited to AI-tool
comparisons). For non-AI-tool comparisons, the synthesizer writes N/A comparisons). For non-AI-tool comparisons, the synthesizer writes N/A
or topic-appropriate substitutes in irrelevant rows. or topic-appropriate substitutes in irrelevant rows. The "What it is" row
grounds in first-party positioning fetched during the run when available.
""" """
entities = _parse_comparison_entities(topic) entities = _parse_comparison_entities(topic)
if not entities: if not entities:
@@ -558,10 +559,18 @@ def _render_comparison_scaffold(topic: str) -> list[str]:
] ]
body = [f"| {axis} | " + " | ".join([" "] * len(entities)) + " |" for axis in axes] body = [f"| {axis} | " + " | ".join([" "] * len(entities)) + " |" for axis in axes]
fill_instructions = (
"Fill each cell based on the research above. Keep cells short (5-15 words). "
"Use ' - ' (hyphen with spaces) not em-dashes. Write N/A for axes that do not apply to this topic class. "
"Ground the \"What it is\" row in first-party positioning fetched during this run's research when "
"available - describe each entity as it pitches itself today, never from memory. "
"This scaffold matches the April 9 launch-video exemplar shape."
)
return [ return [
"## Head-to-Head", "## Head-to-Head",
"", "",
"Fill each cell based on the research above. Keep cells short (5-15 words). Use ' - ' (hyphen with spaces) not em-dashes. Write N/A for axes that do not apply to this topic class. This scaffold matches the April 9 launch-video exemplar shape.", fill_instructions,
"", "",
header, header,
separator, separator,
+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
+5
View File
@@ -101,6 +101,11 @@ class RenderComparisonMultiTests(unittest.TestCase):
self.assertIn("## xAI", rendered) self.assertIn("## xAI", rendered)
# Scaffold table header has a column per entity # Scaffold table header has a column per entity
self.assertIn("| Dimension | OpenAI | Anthropic | xAI |", rendered) self.assertIn("| Dimension | OpenAI | Anthropic | xAI |", rendered)
# No verdict row: the pitch-vs-pulse signal ships as synthesis prose,
# not a table axis (early drafts emitted a "Setting the narrative?" row)
self.assertNotIn("Setting the narrative?", rendered)
# "What it is" grounds in positioning fetched this run, never memory
self.assertIn("never from memory", rendered)
# Envelope scaffolding present # Envelope scaffolding present
self.assertIn("EVIDENCE FOR SYNTHESIS", rendered) self.assertIn("EVIDENCE FOR SYNTHESIS", rendered)
self.assertIn("END OF last30days CANONICAL OUTPUT", rendered) self.assertIn("END OF last30days CANONICAL OUTPUT", rendered)
+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")