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_positions⋈pmv2_traders(the edge-scored cohort). Entry:bot.rs::classify_message→MessageAction::BetLink→run_score_bet_link→score_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 orcategory_consensus::run. See category-consensus.- Two category taxonomies coexist — the 9 leaderboard categories vs the coarse
pmv2_event_category.categorybucket (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
main22d7697, hardened11d4e9a, 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 addsfallback_semaphore, param-matched cache, timeout-lifecycle fix, geo-before-crypto precedence, HTML-safe truncation, and DRY extractions. Orchestrated inpmv2/category_consensus.rs.
Bet-link reply flow (the auto-analyzer)
When a user posts a Polymarket link in Telegram, the bot replies with a per-side cohort gauge for that bet. The path:
bot.rs::classify_messagerecognizes a Polymarket URL → returnsMessageAction::BetLink.run_score_bet_link→score_bet_link_body(bot.rs:611).pmv2::scoring::score_bet(scoring.rs:414) — parses the selector, then reads cohort holdings viarepo::load_bet_holders(joinspmv2_positions⋈pmv2_traders), grouping intoBetScores per(condition_id, outcome_index).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 negativetrader_scoredrags the side down), explicitly labeled “current cohort EXPOSURE (consensus), not a first-mover signal.” It is NOT the/consensuscohort.
Two distinct “no signal” states in the bet reply
The reply has two different empty-states, and they mean different things:
| State | Condition | Message | Where |
|---|---|---|---|
| (a) Nobody holds it | scores.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 floor | holders exist, but every side scores below GAUGE_FLOOR ⇒ consolidate_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 onscores: &[]; it returns a gauge with emptysides, 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_positionsgroups fetched positions and keeps groups where ≥K distinct cohort wallets agree. - Entry:
handle_consensus(bot.rs:866) → serve therecent_category_consensuscache if warm, elsetokio::spawncategory_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_consensusselects runsWHERE status='done'(repo.rs:1601). A run is only readable aftercomplete_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/consensuswill recompute from scratch as if nothing was stored.
GOTCHA 2 — no scheduler warms the consensus cache
schedule.rs/scheduled_run.rshave no category-consensus job. The cache is populated only by a user/consensusrun, 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_selectorreturns 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 testscoring.rs:993-995).gamma.event_tags()(polymarket-data/src/client.rs:505) takes EVENT slugs. So deriving an event’s category fromparse_selector’s output can silently fail on deep links (the market slug isn’t a valid event slug → no tags → no category). Fix/prefer: useBetScore.event_slug(DB truth) or extract the segment after/event/— do not feed rawparse_selectoroutput toevent_tags().
GOTCHA 4 — two category taxonomies coexist (don't cross them blindly)
- 9 leaderboard categories (above) — the
/consensusnamespace.- Coarse event bucket stored in
pmv2_event_category.category∈{esport, sport, geo, crypto, other}, produced bycategory.rs::category_bucket.BetScore.categoryis this coarse bucket, not a leaderboard category. The bridge from gamma tags → leaderboard category iscategory_tags(Category)(category_consensus.rs:42) plusGEO_TAGS(category.rs:57) andCRYPTO_TAGS(category.rs:71).category_bucketresolves in strict precedence esport → sport → geo → crypto → other (esports events are also taggedsports, so esport must win — see pmv2-gamma-tags-categorization). If you want to take aBetScoreand 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:
- Extracts the event slug from
scores[].event_slug(DB truth) or, if empty, callsscoring::event_slug_from_url(strips the market segment from the URL). - Fetches gamma tags via
repo::event_tags_for_slug(DB cache) or, if absent, livegamma.event_tags()(event-slug scoped — avoids the GOTCHA 3 market-slug trap). - Passes tags through
derive_leaderboard_category→ returns acategory_slug(lowercase, matching the/consensuscache key format).
category_consensus_section — produces the reply section:
- Checks
recent_category_consensus(category_slug)— serves from cache if warm. - If cold, calls
run_and_persist(see cache-warm fix below) with a 25s timeout (TIMEOUT = 25s). - Renders output capped at
DISPLAY_ROWS = 8rows. 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-fatal — resolve_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)
| Constant | Value |
|---|---|
TOP_N | 20 |
MIN_OVERLAP | 3 |
TIMEOUT | 25s |
DISPLAY_ROWS | 8 |
CACHE_WINDOW | 600s |
Verification status
Cold-cache live path not unit-tested
The
run_and_persistlive-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
| File | What lives here |
|---|---|
pmv2/bot.rs | Telegram 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.rs | score_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.rs | run/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.rs | category_bucket (esport/sport/geo/crypto/other) + GEO_TAGS (:57) / CRYPTO_TAGS (:71) + event-category sync |
pmv2/repo.rs | category run cache (~1523-1659): insert_category_run / insert_category_rows / complete_category_run (:1542) / recent_category_consensus (:1601) |
polymarket-data/src/client.rs | gamma.event_tags() (:505) — takes EVENT slugs |
Related
- category-consensus — the
/consensus <category>command (the other cohort) in full - pmv2-two-tier-trader-scoring — the
trader_score/ edge-band that the bet-link cohort is weighted by - category-leaderboard-api — the per-category monthly-PNL leaderboard
/consensusfetches - pmv2-gamma-tags-categorization — gamma
event_tagssource + the esport→sport bucket precedence - polymarket-fetch