How a posted Polymarket link gets auto-analyzed in Telegram (the bet-link reply, which reads the edge-scored pmv2 cohort), how that differs from the /consensus <category> command (a separately-fetched monthly-PNL cohort), and the gotchas that connect — and trip up — the two. Also covers the category-cohort fallback (merged main 22d7697, 2026-06-15, hardened 11d4e9a same day) that appends a consensus section when no high-edge signal exists. CURRENT verified codebase state on main (crates/polymarket-fetch), checked against source 2026-06-15. Line numbers cite main.

For Agents

Two distinct “what does the cohort think of this bet?” surfaces share vocabulary but use different cohorts and different category taxonomies:

  • Bet-link reply (auto, on any posted PM link) → reads pmv2_positionspmv2_traders (the edge-scored cohort). Entry: bot.rs::classify_messageMessageAction::BetLinkrun_score_bet_linkscore_bet_link_body (bot.rs:611) → pmv2::scoring::score_bet (scoring.rs:414).
  • /consensus <category> (explicit command) → fetches a fresh top-N monthly-PNL cohort for one of 9 leaderboard categories, no edge/band. Entry: handle_consensus (bot.rs:866) → cache or category_consensus::run. See category-consensus.
  • Two category taxonomies coexist — the 9 leaderboard categories vs the coarse pmv2_event_category.category bucket (esport/sport/geo/crypto/other). Don’t cross them without the bridge (category_tags + GEO_TAGS/CRYPTO_TAGS).
  • The category-cohort fallback is IMPLEMENTED (merged main 22d7697, hardened 11d4e9a, 2026-06-15): when no high-edge cohort signal exists for a posted bet link, the bot appends a category-consensus section to the same reply. With-signal replies are byte-unchanged. Hardening adds fallback_semaphore, param-matched cache, timeout-lifecycle fix, geo-before-crypto precedence, HTML-safe truncation, and DRY extractions. Orchestrated in pmv2/category_consensus.rs.

When a user posts a Polymarket link in Telegram, the bot replies with a per-side cohort gauge for that bet. The path:

  1. bot.rs::classify_message recognizes a Polymarket URL → returns MessageAction::BetLink.
  2. run_score_bet_linkscore_bet_link_body (bot.rs:611).
  3. pmv2::scoring::score_bet (scoring.rs:414) — parses the selector, then reads cohort holdings via repo::load_bet_holders (joins pmv2_positionspmv2_traders), grouping into BetScores per (condition_id, outcome_index).
  4. format_bet_scores_telegram (scoring.rs:593) renders the per-side gauge for Telegram.

What the bet-link cohort IS

This is the edge-scored pmv2 cohort — the same traders carrying a trader_score / skill band from pmv2-two-tier-trader-scoring. The gauge is skill-weighted exposure (a whale with a negative trader_score drags the side down), explicitly labeled “current cohort EXPOSURE (consensus), not a first-mover signal.” It is NOT the /consensus cohort.

Two distinct “no signal” states in the bet reply

The reply has two different empty-states, and they mean different things:

StateConditionMessageWhere
(a) Nobody holds itscores.is_empty() — no tracked trader holds the bet (or it’s not in the latest snapshot)“No edge-cohort positions match…”scoring.rs:514
(b) Held, but all below floorholders exist, but every side scores below GAUGE_FLOORconsolidate_gauge(...).sides is empty”no high-edge cohort holders…”scoring.rs:472 (rendered), gate at scoring.rs:379

(a) and (b) are NOT the same — read the message

  • (a) = the cohort simply isn’t in this market. Distinguishing “no tracked trader holds it” from “not in the latest snapshot” matters; the message says so and notes it does not verify the market exists.
  • (b) = the cohort does hold it, but all positions fall under GAUGE_FLOOR = 0.005 (scoring.rs:228), so there’s no high-edge side worth surfacing.
  • consolidate_gauge (scoring.rs:379) is empty-safe — it does not panic on scores: &[]; it returns a gauge with empty sides, which the renderer turns into state (b)‘s message.

/consensus <category> — a DIFFERENT cohort

/consensus <category> [N] [K] is not the bet-link cohort. It fetches, live, the top-N traders by monthly PNL in one of 9 fixed leaderboard categories and surfaces bets where ≥K agree. There is no edge/band — these traders are not in pmv2_traders.

  • The 9 categories (category_consensus.rs:13): politics, sports, crypto, finance, culture, mentions, economics, weather, tech.
  • Core: consensus_from_positions groups fetched positions and keeps groups where ≥K distinct cohort wallets agree.
  • Entry: handle_consensus (bot.rs:866) → serve the recent_category_consensus cache if warm, else tokio::spawn category_consensus::run.
  • Full design in category-consensus; the leaderboard endpoint in category-leaderboard-api.

Gotchas

GOTCHA 1 — consensus cache only warms on 'done'

recent_category_consensus selects runs WHERE status='done' (repo.rs:1601). A run is only readable after complete_category_run(.., 'done') (repo.rs:1542). If you compute + insert the rows but don’t complete the run, it stays 'running' and is invisible to the cache — the next /consensus will recompute from scratch as if nothing was stored.

GOTCHA 2 — no scheduler warms the consensus cache

schedule.rs / scheduled_run.rs have no category-consensus job. The cache is populated only by a user /consensus run, so it is usually cold. The first /consensus <category> (or the first after the 10-min window lapses) pays the full fetch+fan-out cost. Don’t assume warm-cache latency.

GOTCHA 3 — parse_selector returns the LAST path segment (market, not event)

parse_selector (scoring.rs:91) returns the last URL path segment (rsplit('/').next()). For a deep /event/<event>/<market> link it yields the MARKET slug, not the event slug (verified by test scoring.rs:993-995). gamma.event_tags() (polymarket-data/src/client.rs:505) takes EVENT slugs. So deriving an event’s category from parse_selector’s output can silently fail on deep links (the market slug isn’t a valid event slug → no tags → no category). Fix/prefer: use BetScore.event_slug (DB truth) or extract the segment after /event/ — do not feed raw parse_selector output to event_tags().

GOTCHA 4 — two category taxonomies coexist (don't cross them blindly)

  • 9 leaderboard categories (above) — the /consensus namespace.
  • Coarse event bucket stored in pmv2_event_category.category{esport, sport, geo, crypto, other}, produced by category.rs::category_bucket. BetScore.category is this coarse bucket, not a leaderboard category. The bridge from gamma tags → leaderboard category is category_tags(Category) (category_consensus.rs:42) plus GEO_TAGS (category.rs:57) and CRYPTO_TAGS (category.rs:71). category_bucket resolves in strict precedence esport → sport → geo → crypto → other (esports events are also tagged sports, so esport must win — see pmv2-gamma-tags-categorization). If you want to take a BetScore and ask “which leaderboard cohort cares about this?”, you must route the coarse bucket / gamma tags through that bridge — the two namespaces are not interchangeable strings.

Category-cohort fallback (implemented 22d7697)

Merged to main 2026-06-15. When a posted bet link has no high-edge cohort signal, the bot appends a category-consensus section to the same reply instead of sending a no-signal dead-end. Replies that already have signal are byte-unchanged.

Trigger condition

scoring::has_high_edge_signal(&[BetScore]) = !consolidate_gauge(scores, GAUGE_FLOOR).sides.is_empty().

  • false (no signal) → fallback kicks in.
  • true (signal exists) → reply is rendered normally, fallback is skipped entirely.

This covers both no-signal states from Two distinct “no signal” states in the bet reply — nobody holds it and all-below-floor both trigger the fallback.

Orchestration — pmv2/category_consensus.rs

Two new functions handle the fallback:

resolve_fallback_category — derives the category_slug to query:

  1. Extracts the event slug from scores[].event_slug (DB truth) or, if empty, calls scoring::event_slug_from_url (strips the market segment from the URL).
  2. Fetches gamma tags via repo::event_tags_for_slug (DB cache) or, if absent, live gamma.event_tags() (event-slug scoped — avoids the GOTCHA 3 market-slug trap).
  3. Passes tags through derive_leaderboard_category → returns a category_slug (lowercase, matching the /consensus cache key format).

category_consensus_section — produces the reply section:

  1. Checks recent_category_consensus(category_slug) — serves from cache if warm.
  2. If cold, calls run_and_persist (see cache-warm fix below) with a 25s timeout (TIMEOUT = 25s).
  3. Renders output capped at DISPLAY_ROWS = 8 rows. Top-N fetch = TOP_N = 20, minimum overlap = MIN_OVERLAP = 3.

Cache-warm fix (run_and_persist)

The key seam: run_and_persist was extracted from run (notifier-free variant). It drives the run all the way to status='done' so recent_category_consensus (which selects WHERE status='done') can read it. /consensus’s run() now calls the same run_and_persist seam — ensuring both the fallback path and the command path produce readable cache entries. Cache key parity: category_slug(cat) is the lowercase slug both sides use.

Wiring in bot.rs

score_bet_link_body now accepts chat_id, loading_id, and reply_to so the fallback can edit the interim spinner to “checking top {category} traders…” before the 25s-bounded live compute. All fallback failures are non-fatalresolve_fallback_category and category_consensus_section return None on any error, degrading silently to the existing no-signal reply.

Constants (private, tunable in category_consensus.rs)

ConstantValue
TOP_N20
MIN_OVERLAP3
TIMEOUT25s
DISPLAY_ROWS8
CACHE_WINDOW600s

Verification status

Cold-cache live path not unit-tested

The run_and_persist live-compute path is network-bound. No unit test covers it end-to-end. Needs a real posted-link check against a category the cache hasn’t seen.

Validation-pass hardening (11d4e9a)

Adversarial-review pass merged to main as 11d4e9a. The original 22d7697 fallback was correct in logic but had six structural gaps:

Concurrency. BotState gains a fallback_semaphore (MAX_CONCURRENT_FALLBACKS=3). run_score_bet_link now holds the main 4-slot handler permit only for the fast score_bet_base call and drops it before the ~25s fallback compute. The fallback then acquires the new semaphore for its duration. This prevents the fallback from starving /consensus requests on the shared handler pool.

Timeout/lifecycle. The 25s timeout moved from category_consensus_section into run_and_persist (new Option<Duration> param). On timeout run_and_persist completes the DB run as status='error', so a cancelled fallback no longer leaks a status='running' row that would permanently block future cache reads. /consensus’s own run passes None (untimed, unchanged).

Param-matched cache. repo::recent_category_consensus now accepts and filters by top_n AND min_overlap. The three call sites — handle_consensus, category_consensus_section, and the main.rs CLI path — all pass their own values. This prevents a top_n=20 fallback run from being served to a top_n=50 /consensus read (and vice versa).

Category precedence. derive_leaderboard_category now checks Politics (GEO tags) before Crypto, matching category_bucket’s geo → crypto ordering. Events tagged with both geo and crypto tags now correctly resolve to Politics in the fallback.

HTML-safe reply truncation. The bet-link reply handler truncates at the last newline within Telegram’s 4000-char limit instead of a hard byte cut. A raw byte cut could split an <a> tag mid-way, causing Telegram to reject the entire reply with a parse error.

DRY extractions. polymarket_path (shared by parse_selector and event_slug_from_url) and tags_contain_any (shared by category_bucket and derive_leaderboard_category) were extracted to remove duplication. CACHE_WINDOW is now the shared CONSENSUS_CACHE_WINDOW_SECS constant used by both paths.

Gate result: build + clippy -D warnings + fmt + tests all green.

Deferred (spec §14)

  • Broaden tag→category map beyond currently verified tags.
  • Optional gamma-tag write-back into pmv2_event_category.

Key files

FileWhat lives here
pmv2/bot.rsTelegram handlers — classify_message, run_score_bet_link/score_bet_link_body (:611, now takes chat_id/loading_id/reply_to for fallback spinner), handle_consensus (:866)
pmv2/scoring.rsscore_bet (:414), parse_selector (:91), consolidate_gauge (:379, empty-safe), format_bet_scores_telegram (:593), GAUGE_FLOOR = 0.005 (:228), the two no-signal messages (:514, :472)
pmv2/category_consensus.rsrun/run_inner/consensus_from_positions, the 9 leaderboard categories (:13), category_tags(Category) bridge (:42); fallback: resolve_fallback_category, category_consensus_section, run_and_persist (22d7697)
pmv2/category.rscategory_bucket (esport/sport/geo/crypto/other) + GEO_TAGS (:57) / CRYPTO_TAGS (:71) + event-category sync
pmv2/repo.rscategory run cache (~1523-1659): insert_category_run / insert_category_rows / complete_category_run (:1542) / recent_category_consensus (:1601)
polymarket-data/src/client.rsgamma.event_tags() (:505) — takes EVENT slugs