a discord bot for the tootsies server. ask, recap, discuss, ship features by typing.
This file inherits CLAUDE.md’s former Architecture section and its discipline, unchanged: before touching a surface, read its section here; when a PR changes a surface’s behavior, update its section here IN THE SAME PR. These paragraphs are load-bearing – they carry every owner steer, incident lesson, and PR cross-reference for each surface, and the numbered (#NNNN) references only work because this record is kept current. CLAUDE.md keeps the always-loaded architecture MAP; this is the full detail, read on demand.
Entrypoint: bot.py, boots Discord client, opens DB pool, exposes /health, loads cogs, syncs slash commands per guild on every startup.
Claude API layer: claude_client.py wraps the Anthropic SDK. Optional OpenAI text backend (#openapi-parity): _call routes a model id _is_openai_model() recognizes (a gpt-* / o-series id, label gpt in MODEL_LABELS) OUT to utils.openai_chat (Chat Completions) instead of the Anthropic SDK, so a generation surface can run on GPT to spread token spend onto the under-used OpenAI API bill or fail over when Claude is out of credits — the text twin of the existing OpenAI embeddings + image clients. It reuses the exact assembled system/user prompt (skip_persona, time-context, every per-surface block) and maps the result back onto ClaudeResult, so every downstream guardrail is unchanged. Dispatch by feature: _call_openai sends a tool-free call to Chat Completions and a call with tools / server-side web_search / a client-side tool loop to the Responses API (_call_openai_responses — the only OpenAI path to the built-in web_search + the function_call→handler→function_call_output loop, mapping our Anthropic tool defs onto Responses tools and collecting the web_search source/citation URLs for the link guardrail). Reasoning budget (#2869): a gpt-5 model spends its thinking out of the SAME output budget as the visible message, so openai_reasoning adds _REASONING_TOKEN_RESERVE (1024) to every caller’s budget — enough for a short-form post’s default thinking, live-measured under 192 tokens. A TOOL-using call is a different job (the model reasons over what the tools returned, across rounds), so _call_openai_responses floors the budget at _REASONING_TOOL_TOKEN_FLOOR (16384) instead — the twin of the Anthropic path’s _THINKING_MAX_TOKENS_FLOOR, same number for the same reason. If a turn still comes back with nothing usable (responses_exhausted: status="incomplete", no text, no function call; on the tool-free path, finish_reason="length" with no text) the caller retries that ONE turn at effort: low under _REASONING_RECOVERY_TOKEN_FLOOR (24576) with the same input, and ships the retry’s text; a second exhaustion returns empty as before, so the retry can never loop. Both turns are visible as openai_api detail.reasoning_exhausted / detail.reasoning_recovery. IMAGE input (vision) is served on the Responses path — an assembled Claude vision message maps onto input_image parts via responses_input_content, so a GPT-routed surface with images answers on GPT (no longer a deferred gap / Claude fallback); a missing key / API failure raises OpenAIChatUnavailable, so the surface’s own try/except falls back. A best/cheap PAIR mirroring opus/sonnet: gpt (best = gpt-6-sol, $2/$10, the Opus 5.5 analog) + gpt-cheap (cheap = gpt-5.6-terra, $2/$12, the Sonnet 5 analog — Sonnet 5 is $2/$10; the Haiku-tier gpt-5.6-luna at $0.20/$1.20 is a rung too cheap for a generation surface, available only as an env override), env-overridable. Both are per-surface menu choices (tunables.MODEL_CHOICES = opus/sonnet/gpt/gpt-cheap), offered on every Models-page surface (ask/recap/discourse/music — a new ModelSurface), memory included since 2026-09-07 (#3072: memory was the one row that withheld GPT until its constitutional fence eval held on it; ModelSurface.choices stays so a future model can be gated the same way — see the memory notes for the run). GPT gets its OWN copy of the OPUS prompts: rules_for() hands any OpenAI model _GPT — a replace(_OPUS) COPY of the Opus lean ModelRules (lean ask/discourse cores, lean persona), not the Sonnet-era _LEGACY walls — GPT-5 over-commits + follows literally like Opus, so the Opus rebuild (#849) is the right starting point. It’s forked into its own instance so GPT-specific polish lands on _GPT alone, never on the prompts Opus itself uses; the divergences so far both ride lean_persona: _GPT_FORMAT (scripts/dryrun_openai_surfaces.py found markdown/inline-citation drift Opus doesn’t have) and _GPT_VOICE (the “our replies sound like chat gpt” owner report — GPT’s stock register is the engineered one-liner, mirrored clauses + an abstract-metaphor verdict; measured with a golden-anchored register judge on real wire tweets, scripts/dryrun_x_reply_voice.py, the block took GPT reply drafts 17/18-flagged → 12/18 while the Claude prompts were untouched). Because most generation surfaces never compose lean_persona, the router also appends _GPT_VOICE as an extra system block on every GPT-SERVED, full-persona call (oai_system in _call): it follows the provider actually serving — the direct gpt route AND a reverse Claude→GPT failover get it, a forward GPT→Claude fallback doesn’t — and never touches a skip_persona classifier (the lean surfaces carry it via lean_persona, so it can’t double-apply). GPT keeps a residual AI-register lean its prompts don’t erase (12/18 vs Claude’s 2/18 on the same judge) — that’s a Models-knob tradeoff to weigh when flipping a voice-heavy surface to gpt, not a missing rule. Every surface still defaults to Claude, so the choice is opt-in and changes nothing until a mod picks it. Safe by a BIDIRECTIONAL fallback (#973): _call is now a thin, symmetric router over two sibling provider methods — _call_openai and _call_anthropic (the Anthropic kwargs-build + client-side tool loop + result assembly, split out of _call so the router is a clean two-provider gateway) — each serving the SAME assembled system/user_content (skip_persona, time prefix, every per-surface block, vision included — byte-identical across providers, only the transport differs). The fallback runs BOTH ways: FORWARD, a GPT-routed call that raises OpenAIChatUnavailable (no key / API error / overload) falls back to Claude (#973: the GPT’s cross-provider sibling — gpt-6-sol→Opus, gpt-5.6-terra→Haiku [cheap stays cheap]; an unmapped id → the frontier Opus) — the opt-in safety that makes gpt a per-surface menu choice; REVERSE, a Claude-routed call that fails on credit exhaustion (_is_credit_exhausted — a 400 naming the credit balance, PERMANENT until topped up) or a persistent overload/outage (_is_claude_unavailable — a 529/5xx/429/timeout that already survived _create_with_retry’s retries) falls OVER to OpenAI when it’s provisioned (TIER-PRESERVING: the Claude’s cross-provider sibling — Opus→gpt-6-sol (frontier↔frontier), Sonnet/Haiku→gpt-5.6-terra (cheap↔cheap), with any UNMAPPED model (an env override, an unknown id) defaulting to the FRONTIER gpt-5.6-sol rather than erroring/degrading (owner steer #973); the frontier pair derives from _TIER_ANALOGS, the cheap mappings are explicit overrides; credit is account-wide so the failover always succeeds-or-fails identically across tiers) — credit-exhaustion failover, so the bot keeps talking instead of taking every surface dark at once when the Anthropic balance runs dry. A generic 400/401 is NOT failed over (OpenAI can’t fix a malformed request or a bad key — it re-raises as before, so the surface’s own canned fallback + the claude_api ok=False telemetry still fire). Each direction calls the OTHER provider’s method DIRECTLY (never back through the router), so the two CAN’T ping-pong (a forward GPT→SONNET that then fails just raises; a reverse Claude→GPT_CHEAP that then fails re-raises the ORIGINAL Claude error). Both emit provider_fallback (forward reason=OpenAIChatUnavailable; reverse reason=credit_exhausted/claude_unavailable). generate_song_pool(model=...) + music_post(model=...) were dry-run-validated (scripts/dryrun_openai_song_pool.py, scripts/dryrun_openai_music.py) before the music knob shipped. Model routing: Haiku for the cheap, high-volume classifiers/scorers — mention-intent routing (classify_mention_intent), deflections, the chime-in scorer, and the quiet-room cold-open judge (quiet_room_score, fast/cheap); Haiku also tags each memory note with concept keywords at write time (tag_memory_note), expands a thin conceptual recall query into keyword terms (expand_memory_query), and distills a long/foreign shared-video transcript into a compact English summary (summarize_video, purpose=video_summary) — all cheap, high-volume, no fence exposure (they only categorize/condense already-public text). Sonnet for the answer-generating surfaces — /recap, /discourse, chime-in posts (chimein_post — DEFAULT Sonnet, now a per-guild Models-page COMPOSE-model knob distinct from the Haiku chimein_score classifier), /order pre-flight — and the entire long-term-memory write pipeline (hourly writer + /remember backfill + daily rollup) — plus the /guess song-pool generation (generate_song_pool), moved off Opus to Sonnet after a head-to-head dry run found Sonnet’s recognizable-song recall equal (it’s a breadth-of-canon task, not reasoning, and every line is iTunes-verified) at a fraction of the cost, which matters since the pool regenerates on every refill of an endless game. Per-surface model knobs (the /menu Models page, reached from the experiments page): ask, recap, discourse, music, memory, market drop, and chime-in (the COMPOSE model chimein_post, NOT the Haiku chimein_score classifier — defaults Sonnet, Opus opt-in for sharper room-buffer reading / attribution) each have a per-guild model TUNABLE (utils.tunables.MODEL_SURFACES → surface_model → claude_client.MODEL_LABELS, passed via client.ask/recap/discourse(model=...) / cogs.memory._memory_model → memory_note/memory_rollup(model=...); model=None falls back to Sonnet so eval/dry-run paths are unchanged). The Models view (cogs.models) renders one opus/sonnet select per surface (fully data-driven off MODEL_SURFACES, so a new surface needs no UI change); defaults preserve today’s behavior — ask defaults Opus (#832), recap + discourse + memory default Sonnet (opt-in to Opus per guild, so adding the page changed nothing). The original ask-model knob generalized into this registry. (the Tier-2 room/live surfaces are added as each is evaluated.) The numbers-desk FAMILY BORROWS the market drop knob rather than each getting its own row (#1611): every surface that composes through compose_market_drop but has no menu row — cinema_desk, music_desk, music_news, music_alert, market_alert, and the betting board/alert/value trio — resolves the per-guild market drop model via the shared claude_client.market_drop_model_id(db, guild_id) (label → id, fail-open Sonnet) and threads it as compose_market_drop(model=...), so a guild’s model choice for its flagship market surface carries to the whole desk family (owner steer: none is a discourse-style culture take, they’re all market/numbers reads that align with market drop, not discourse). Before this they silently rode compose_market_drop’s model or SONNET default regardless of the guild’s Opus/GPT choice. Opus is the ask default because the blinded head-to-head (scripts/dryrun_opus_vs_sonnet.py) found it FUNNIER and LESS preachy than Sonnet on real ask traffic, with NO constitution/restraint regression in the safety pass (scripts/dryrun_opus_safety.py). (OPUS is live again via this knob, not just defined.) Memory DEFAULTS Sonnet (Opus opt-in via the knob), and its per-model PROSE follows the ask pattern (ModelRules.memory_note_room/memory_rollup_keep_daily/_task, the exact analog of ask_core, with named _LEGACY_MEMORY_* fallbacks): Opus over-compacts a note on the reductive verbs it follows literally (“Compact”/”Synthesize”/”Drop”), so its memory prose strips them and reframes the note as a RECALL artifact (it’s searched by vector + keyword, so write it name-dense + specific, breadth + depth); Sonnet/Haiku ignore those words, so they keep the proven legacy prose inline. The fence (_MEMORY_FENCE) + token/char caps are SHARED across models (policy/storage, not voice), so the narrowed fence + raised caps reach production (Sonnet) while the rebuilt prose stays Opus-only. Memory defaults Sonnet because the fence is verified on it (scripts/eval_memory_fence.py); the fence was then run on every other Models-page rung on 2026-09-07 (#3072) and held on all of them — gpt-5.6-sol, gpt-5.6-terra, claude-fable-5-1 and gpt-6-astra each passed 7/7 real scenarios (writer + daily/weekly rollups: no address/contact/minor, summarize not quote, attribution matches) with the golden flagged, against a Sonnet 5 control of 6/7 on the same judge (the known soft verbatim-quote nit, no leak), which is why memory now offers the full ladder; a real-recall cross-model dry run (max(prose, tags) cosine over prod daily #1444) found Opus and Sonnet notes recall about equally (means 0.33 vs 0.35, a 5/4 per-query split), and an Opus fence probe held every PII/address/minor hard line at parity with Sonnet (both pick up one soft “verbatim-quote” judge nitpick, no PII leak), so Opus is a safe opt-in rather than a forced default. The writer is low-volume (one call/hour/active-guild) so the cost is modest. Before editing ANY text Opus sees (constitution → persona → per-surface prompts → tool/context descriptions → eval judges), read docs/PROMPT_OPTIMIZATION.md: the standing lesson from Epic #849 is to OPTIMIZE by cutting/rewording the existing text that causes a drift, NOT by instinctively adding a counter-rule (scar tissue) — Opus follows literally and over-commits, so accretion compounds — plus how to evaluate a change (real-pipeline metrics, n≈5 not n=2, validate the metric, report spread, a wash is a result). System prompt is cached via cache_control: ephemeral. Every API call gets the full constitution + persona prepended (~5k tokens, not the ~120 this line used to claim – corrected 2026-08 after a live probe against the running prompt measured cache_creation_input_tokens ~5-7k on a cold write; see docs/OBSERVABILITY.md’s claude_api row and #2659 for the cache-read/write telemetry that made the real size checkable at all). Verified live (#2659): the persona block DOES cache warm for the high-volume Sonnet/Haiku surfaces (cache_read_input_tokens > 5000 on the first probe call); the low-volume Opus /ask surface (~3 calls/day, ~8h gaps) is a genuine cold-cache case no TTL fixes, not a bug. _call also drives a bounded client-side tool loop (tool_handlers): the model emits tool_use, _call runs the handler, feeds back a tool_result, and continues (capped at _MAX_TOOL_ITERS). Thinking budget (2026-09-03): with adaptive thinking on, thinking tokens share max_tokens with the visible answer, so _call_anthropic floors it at _THINKING_MAX_TOKENS_FLOOR (16384; it was 4096, and a 14-poll tally through search_messages spent all of it in thinking, stopped at max_tokens with zero text, and the ask shipped its “lost my train of thought” fallback after 91s). If a thinking turn still stops at max_tokens with no text, the loop retries that ONE turn at effort: low under _THINKING_RECOVERY_MAX_TOKENS (20480; a live Opus probe showed low effort alone re-exhausted the same ceiling, and the SDK refuses a non-streaming call above 21333 tokens, _SDK_NONSTREAMING_MAX_TOKENS) without appending anything (there is nothing to feed back) and ships the retry’s text; a second exhaustion returns empty as before. Both turns are visible as claude_api detail.thinking_exhausted / detail.thinking_recovery. With no handlers it’s a single call, unchanged. (web_search remains a server-side tool, no round-trip.) Integrations as tools, in addition to context: the ask flow pre-fetches and injects markets/Perplexity/memory blocks for the common case AND exposes each as an on-demand tool the model can call when the question lands outside the injected block (the Perplexity pre-fetch is model-gated (cogs.ask._TOOLSIDE_WEB_MODELS): it’s the long pole of the pre-fetch (~5.6s median, up to ~10s) and fully redundant with the research_web tool for a model that reliably reaches for it, so it’s skipped for Opus — which also has server-side web_search and is force-grounded on factual asks, so it pulls web research only when a question needs it instead of paying the latency on every ask — and kept for Sonnet, which doesn’t self-route to the tool dependably and so needs the block as a grounding safety net; the tool is offered to BOTH regardless) — search_memory (semantic-vector-first DB recall, keyword/FTS fallback), lookup_catalog (Apple Music / iTunes durations + tracklists, the fix for “longest song” questions a bare web search collides on, e.g. “Drake” the software), lookup_reference (an authoritative, citable fact from the reference library in utils.reference — an extensible registry of sources, routed by intent: Genius for song credits (producer/writer/featured) + identify-a-song-from-a-lyric (returns the song + a LINK to read the words, never lyric text; token-gated, ToS/copyright-clean), MusicBrainz for release metadata (release date / type / featured artists), Wikidata for date/age factoids (“how old is X”, “when did Y die”, “what year was Z founded”), and Wikipedia for CHART peaks on any major chart (Hot 100, Billboard 200 albums, UK, Canadian; the #335/#292 fix for “how many Drake songs peaked at #2”, “how many #1 albums”) - parsed from the discography table because the column must be picked by its wikilink target (“US” Hot 100 vs “US R&B”) and web search fumbles thin per-spot counts - plus general article summaries as the catch-all (other widely-reported facts, like award totals, ride the summary / web_search rather than a bespoke parser); each returns a citation URL; tool-only, no pre-fetched block; the live/breaking path stays on research_web/web_search), the live market/score feeds broken out one tool per source (so the model routes to the exact feed that answers and can cross-check two): api_sports (live scores + in-match events, World Cup soccer + NBA, the fast primary), sgo (Sports Game Odds live scores + sportsbook betting lines), the_odds_api (traditional sportsbook moneyline payouts), lookup_player_props (a NAMED player’s or NAMED game’s over-under lines — points/assists/rebounds/goals — from SGO; anchored, never a league-wide sweep: it fires only with a player or a game (props are the heaviest SGO call), resolving the live scoreboard to scope leagues + a game’s actual players, then name-filtering the league props to the player(s) asked about; SGO-gated, #390), polymarket + kalshi (the two prediction markets, individually, so a real split reads as “Kalshi 42% vs Polymarket 38%”) — **a numeric “Above N” LADDER renders in the kalshi block as ONE forecast: line plus its rungs in strike order (#3392, markets._kalshi_ladder_blocks → kalshi_ladder.ladder_forecast_line), not as a folded outcomes: field whose percentages sum past 1: the figure is the same 0.50 crossing the desk cards and Kalshi prints, an open board renders “above
Model ids + per-model request rules (#model-upgrade): the concrete ids live in ONE place — claude_client.HAIKU / SONNET / OPUS / FABLE and GPT / GPT_CHEAP / GPT_MAX — and reach a surface only through MODEL_LABELS, which the /menu Models page writes as a LABEL per guild (tunables.MODEL_SURFACES). Current ids: Haiku 5.5, Sonnet 5.5, Opus 5.5, Fable 5.1, gpt-5.6-terra / gpt-6-sol / gpt-6-astra. On 2026-10-07 HAIKU moved from Haiku 4.5 to Haiku 5.5 ($0.10/$0.50 per 1M for a prompt of 100K tokens or fewer, against $1/$5); it has no menu label, so the change reaches every Haiku classifier and judge at once. Its request rules changed (see the next paragraph), and its tokenizer counts the same text as about 30% more tokens: classify_abuse’s 4-token cap cut ABUSE to ABUS, so every real abuse case read as OK until the cap went to 16. The tokenizer growth is not uniform: upper-case Kalshi tickers grew 43-54% (26 to 40 tokens on a 43-character market ticker, which then filled its 40-token cap exactly). So _call_anthropic restates every cap through _MAX_TOKENS_SCALE_PCT (Haiku 5.5: 160%, above the worst measured 1.54x) instead of each call site raising its own: callers keep writing caps sized for a length of text, and a model whose tokenizer differs gets one entry there. On 2026-09-28 sonnet moved from Sonnet 5 to Sonnet 5.5 at the same $2/$10 price; its request rules changed (see the next paragraph). On 2026-09-22 (#model-upgrade) opus moved from Opus 5 to Opus 5.5 ($4/$20, down from $5/$25) and gpt moved from gpt-5.6-sol to gpt-6-sol ($2/$10, down from $4/$20); OpenAI shipped no gpt-6-terra, so gpt-cheap stays on gpt-5.6-terra. Earlier (2026-09-07, #cheap-model-pricing: the gpt-5.6 pair replaced gpt-5.4 / gpt-5.5 on the cheap / best rungs, newer AND cheaper on both – $2/$12 against $2.50/$15, $4/$20 against $5/$30 – and gpt-6-astra took the ask-only top rung from gpt-5.6-sol at the same $10/$50 list price as Fable 5.1; the prices sit in one table in utils/openai_chat.py). Every row offers the same five labels (tunables.MODEL_CHOICES = opus / sonnet / gpt-max / gpt / gpt-cheap; ASK_MODEL_CHOICES is now an alias). The Claude top rung fable was retired on 2026-09-23 (#model-upgrade). A blind four-way run on 12 real /ask questions ranked Opus 5.5 at medium effort first on average (2.32), Opus 5.5 at max effort next (2.45, but a 47s median reply and 1.5x Fable’s cost per answer), and Fable 5.1 last (2.64) at about twice Opus’s cost. tunables.RETIRED_MODEL_LABELS maps a stored fable to opus inside surface_model, before the choices check, so the one guild that had it stored (ask + awards) moved to Opus instead of dropping to a sonnet default. FABLE stays defined with its request rules for evals and direct calls; a failed gpt-max call now falls back to the unmapped default, Opus. History: the top rungs fable → Fable 5.1 and gpt-max → gpt-6-astra were ask-only at first (#3028: ask is one answer to one waiting person, while a cadence surface multiplies the same pick by its slot count), and were opened to every row on 2026-09-07 (owner ask: consistent menus). The cadence cost is carried by the menu text (“priciest”, “lands every slot”) instead of by hiding the rung; every surface still defaults to opus or sonnet, so nothing changes until a mod picks one. Memory joined the ladder the same day on data (#3072): its fence eval held on every rung it did not yet cover (see the memory notes below). One row is master-only (ModelSurface.master_only: x replies): cogs.models.visible_surfaces hides it off the master guild, where the surface never runs and the knob would be a dead row (owner ask, 2026-09-07); the setting stays per guild. market drop is NOT hidden even though market_drop itself is master-gated: the betting alert + value alert cogs run in every guild (stage hard-coded PRODUCTION) and borrow its knob through market_drop_model_id, so a non-master guild needs the row to set or revert the model those two post on (#3072 review) — a row can be hidden only once every borrower of its knob is master-gated. And one Fable rule the levelling exposed: the forced-grounding retry on recap / discourse / chime-in (“the happy path ran no search, run it again with web_search forced”) only works thinking-OFF, which an always-thinking model never is, so _can_force_tool skips the retry on Fable and keeps the happy-path result instead of paying a second top-rate call that is discarded (#3072 review).
Changing a model id is never a plain string swap. Request parameters differ per model and fail at the API, not at import. Three sets in claude_client carry the rules: _SAMPLING_MODELS (who still accepts temperature — the whole Claude 5 family answers it with a 400, Haiku 5.5 included, so the allowlist is now EMPTY and every judge runs at the model default; measured on discourse_score, Haiku 5.5 at its default gave sd 0.04 on a strong post and identical scores on weak ones, against sd ~0.20 for Haiku 4.5 at T=1.0), _THINKING_OFF_PARAM (who THINKS when thinking is omitted but can be told to stop, mapped to the value that stops it — Sonnet 5.5 thinks by default, answers disabled with a 400, and takes between_tools; Haiku 5.5 thinks by default, takes disabled, and answers between_tools with a 400 — omitted with max_tokens=40 it spent all 40 tokens thinking and returned no text), _NO_FORCED_TOOL (who answers a forced tool_choice with a 400 even with thinking off — Sonnet 5.5, Opus 5.5, Fable 5.1; _call_anthropic drops the force on these and _can_force_tool skips the recap / discourse / chime-in forced-search retry), and _ALWAYS_THINKING (Fable 5.1 and Opus 5.5, which cannot be told to stop and answers both disabled and a forced tool_choice with a 400 — so it takes the thinking path’s _THINKING_MAX_TOKENS_FLOOR and never reaches the forced-tool branch). Every row in that table was MEASURED against the live API before shipping, except the Sonnet 5.5 column, which comes from the Anthropic migration guide (the 2026-09-28 session could not read the API key) and still needs a live probe; the first draft guarded two things that turned out not to need it. Adding an id means running the same probes, not reading a docs table.
TypeSafe Jev typed judgments (utils/typesafe_client.py, #4310, epic #4309). Jev is a judgment model from TypeSafe. It reads a text or JSON state and answers typed questions in one parallel request: Noul (the probability a statement is true), Choice (one of N labels, with a confidence) and Score (a rubric level, with a confidence). It writes no text. Jev is not a Haiku replacement (owner direction, 2026-10-17). A surface uses it only where Haiku is too slow, or where one Haiku call per question makes too many calls. The reason is latency, not cost: the 2026-10-16 probes measured Jev p50 at 94-128 ms against 622 ms for Haiku, and moving every text judgment would save about $6 a week (ledger #4304).
get_typesafe() returns the shared client, or None when TYPESAFE_API_KEY is unset. TypeSafe.ask(purpose, state, questions) (questions is a Mapping[str, Noul | Choice | Score], so a dict built in a variable type-checks) returns JevAnswers (nouls, choices, scores, model, input_tokens), or None on any failure. It never raises an Exception, so the CALLER owns the fail direction: a read path keeps its current judge, a gate decides for itself whether a None blocks. emit_shadow(purpose, agree=, current=, jev=) records one comparison with the old judge; call it only when Jev answered.jev-1.13.0, never the jev-latest alias. A version move is a code change with its own dry run.RetryPolicy(max_retries=0)). utils/retry.py retries a connection error, a timeout, a 408, a 429 and a 5xx, three attempts in all. A CircuitBreaker (integration=typesafe) short-circuits while TypeSafe fails. A 400 or 422 is a bad request from OUR side, so it is not retried and does not trip the breaker. An AsyncRateLimiter is a loop guard (600/min); TypeSafe’s real limits are per organization, in its console. A plain SDK TypeSafeError (a state it cannot encode as JSON), a TypeError or a ValueError is also our bug and does not trip the breaker. Worst case for a hung upstream is about 15.5 s (3 x the 5 s timeout plus backoff), so a caller that uses Jev for speed wraps ask in its own asyncio.wait_for (#4311 uses 1.5 s). Read answers with .get(key): a key the server leaves out is absent.Choice option, and recent music facts.preflight_order, the memory pipeline and its fence, constitution-adjacent hard fails (a Jev check may sit beside one, never replace it), number checks, the media_coherence identity checks, the fighter likeness/pose gates, and all generation (#4309).typesafe_api event per ask, one typesafe_shadow per comparison, the typesafe ops-monitor health line and the report-only typesafe_shadow:<purpose> agreement line (no floor, no finding: disagreeing with an old grader is expected when that grader is the bug being fixed), four panels on the LLM board, the “TypeSafe Jev calls failing” monitor, and the typesafe probe on /debug/integrations. Details in docs/OBSERVABILITY.md.Persona: persona.py composes the system prompt from constitution.py (hard rules, house rules, calibration) + persona core + voice examples. constitution.py is non-negotiable and cannot be loosened by /order. It also holds GENERATED_LIKENESS_CONDITIONS (#4013): the seven conditions under which the arcade may show a 3D fighter body generated from a member’s PFP or a chart artist’s photo. No model reads that tuple. The arcade model pipeline (#4012) must import it and check every condition in code before it generates anything. An arcade trailer may show member and artist fighters (owner, 2026-10-15): the owner posts it only in the owner’s own Discord servers and on the owner’s own website, never through the bot and never to X or a video host. Opted-out members never appear. The identity-inference rule that the prompts carry does not change.
Database: db.py, raw asyncpg with inline SQL, no ORM. Schema is idempotent CREATE TABLE IF NOT EXISTS statements that run on every startup. Add new tables here; never drop columns without a migration plan. Post-dedup history is generalized: the “have I said this lately” text-dedup memory for every scheduled/wire surface lives in ONE table, post_dedup_history (guild_id, surface, summary, created_at), keyed by the cog’s ScheduledPoster.SURFACE; write via add_post_history(guild_id, surface, summary) and read via recent_post_history(guild_id, surface, *, limit, window_hours=None) (fed to utils.dedup.duplicate_reason). The per-surface add_*_history/recent_*_history names are thin delegators kept for the cogs. One wired prune, prune_post_history, bounds every surface by its POST_HISTORY_RETENTION_HOURS window (with a default-window catch-all so an unmapped surface can’t grow unbounded). A NEW scheduled/wire surface must NOT add its own *_history table — add a POST_HISTORY_RETENTION_HOURS entry and use the generic methods. (discourse_history carries a category and chimein_history doubles as cadence state, so those keep their own tables.) A surface that POSTS but is not a ScheduledPoster must still write here (#2160). cogs/music_alert.py is the worked example of the failure: it is event-driven, so it has no SURFACE constant and no spine to write for it, and it stamped only music_alert_state — a per-ticker BASELINE keyed (guild_id, event_ticker), one row per market, overwritten on every alert. That is not a post log and no dedup path reads it, so an alert was invisible to EVERY topic judge in the repo while crossposting to X off the same Kalshi ladders music_desk reads. Live on 2026-08-08 the alert posted Karol G’s first-week forecast at 16:45 UTC and the desk posted the same market at 17:02; the desk’s judge DID fire on text_similarity and overturned itself, because the history it was handed held a Role Model post and not the alert 17 minutes earlier. The fix is the two halves together — _record_history on both ship paths, and the surface name in WIRE_DEDUP_SURFACES so the desks READ it. The test for “is this surface covered” is not “does it subclass ScheduledPoster”, it is “does it ship text to a room or to X”. #2160 shipped only the WRITE half, and the missing READ half cost a second duplicate (#2189). Being IN WIRE_DEDUP_SURFACES let the desks see the alert cog; it did not make the alert cog look at the desks, and that cog runs no dedup gate of its own because the spine that runs one is the ScheduledPoster base it does not subclass. So it stayed free to restate whatever another surface had just posted: on 2026-08-08/09 music_news posted “Dai Dai is #1 on Spotify’s Global Daily chart” at 17:19, music_alert posted the same fact at 21:27, and music_desk again at 01:02. cogs/music_alert.py:_topic_repeat is the read half — the same recent_post_history(WIRE_DEDUP_SURFACES, limit=WIRE_DEDUP_READ_LIMIT, window_hours=WIRE_DEDUP_WINDOW_HOURS) read and the same topic_duplicate judge, run on BOTH ship paths after the self-gate and before the card render, failing OPEN on every error. Membership in the set is a two-way contract: a surface that appears in WIRE_DEDUP_SURFACES must both write to it and read from it. A write-only member is worse than absent, because the desks’ judges then carry its posts while nothing carries theirs. market_alert was the third surface to fall in this exact hole, and it joined the set on 2026-09-22 (#3638). It is event-driven, so no ScheduledPoster spine wrote for it, and its own market_alert_state table is a per-SERIES baseline keyed (guild_id, series_ticker) — one row per series, overwritten on each alert, read by nothing outside the cog. Its two pacing gates (_blocked for the snapshot-keyed triggers, _forecast_paced for the ladder forecast) both ask their questions of that table, so neither could see another surface. Live on 2026-09-22: music_alert posted Miley Cyrus’s “Bass Persuades” first-week pure-sales forecast at 02:48 UTC (~40.2K) and market_alert posted the SAME Kalshi event (KXPUREALBUMS-BAS26SEP24) at 04:34 UTC (~31.1K). Neither number was wrong — both lanes read the 0.50 crossing (kalshi_ladder.implied_estimate) off the same price rule (kalshi_price.market_yes_price), and a thin release-week ladder really did converge in those two hours — so no number gate could have caught it and only pacing could. cogs/market_alert.py:_topic_repeat is the read half and _record_history the write half. Its REVEAL lanes write but deliberately do not read, on the crown-lane reasoning below: the RT scorecard (_post_reconciliation) and the outcome lanes (_ship_outcome_card, ridden by both the ladder result and the entity winner) report a RESOLUTION, which is new information by construction and cannot be a restatement of the forecast it settles — but the judge compares subjects, so it would read “X is tracking toward 31.1K” and “X sold 28.4K” as one topic and suppress the reveal. They still write, because the desks must see a result the room was told. market_drop was the last HALF member, and joined the same day (#3648). Its WRITE half was always correct and is easy to miss: db.add_market_drop_history is one of the thin delegators described above, so it has always written to post_dedup_history under "market_drop" — which is why POST_HISTORY_RETENTION_HOURS already carried its 72h window. A grep of cogs/market_drop.py for add_post_history finds nothing and is misleading; follow the delegator into db.py. What it lacked was the READ: its _sched_recent_history returned recent_market_drop_history(guild_id, limit=15), its own surface only and 15 rows deep, and the name was absent from the set so nothing read it back. So the scheduled market desk composed Kalshi forecasts off the same ladders music_desk, music_alert and market_alert read, with no judge able to see across — the cinema_desk state again: a dedup gate that exists but structurally cannot catch. It now reads WIRE_DEDUP_SURFACES at the shared limit and window, and being listed also flips ScheduledPoster._shares_history, so its walk takes the serialization gate. The lesson for the next member: check BOTH halves by following the delegators, not by grepping the cog. And a THIRD half, which #3643 closed: the LOCK. utils/history_gate.py:shared_history_gate serializes the history read, the topic judge and the write for one guild (#2758; held per unit since #3828), and ScheduledPoster._shares_history takes it for every spine lane by reading WIRE_DEDUP_SURFACES. A cog that is NOT a ScheduledPoster never reaches that code, and three members are event-driven — music_alert, x_mentions and market_alert. Only market_alert took the lock (#3638), so a desk holding it still raced the other two: both could read the old history, both judge clean, and both post before either wrote. All three now hold the gate across their own walk. The COMPOSE stays OUTSIDE the gate in every one of them, and that is deliberate: GATE_WAIT_SECONDS is 180, while the judge costs about 1.5s (measured claude_api purpose=topic_dedup p50 1536ms), so a lock held across a compose would stall a waiting desk for minutes to serialize a second and a half of work. Each gate block therefore starts at the topic judge and ends after the history write. tests/test_wire_lanes.py pins both facts, so a new event-driven member cannot join the set without taking the lock. A CROWN LANE IS THE ONE EXEMPTION (#2989). music_alert’s four crown lanes (_CROWN_LANES — spotify / apple / apple_album / radio) skip _topic_repeat entirely. A crown fires only when the title JUST took #1 (_chart_crown and _radio_crown_signal both require pos_change > 0), so it is a position CHANGE by construction and cannot be a restatement; the judge cannot see that, because it reads two sentences and compares subjects, and a rank move reads to it as the same song on the same chart. Live on 2026-09-05 apple_crown composed “BbY WOW … has climbed to #1 on Apple Music US songs, up from #2”, scored 0.89, and was suppressed against the desk’s own earlier post of that song at #2 — the post the crown was correcting (that #2 was itself wrong, off a 114-minute-old cached kworb read; see #2988). Replayed against the live judge on that same history the verdict came back duplicate 5 times out of 5, so it is the judge’s steady reading and not a flake. It is not reliably WRONG either, which is why a prompt edit does not fix it: handed the SAME crown take against a ONE-post history the judge called it “a real development: song climbed from #2 to #1” (measured while dry-running the fix). The same take reads as a duplicate or a development depending on what else sits in the window, so on a rank move the gate cannot be trusted in either direction and the lane must not depend on it. Skipping is safe because a crown is already deduped twice, deterministically: _daily_gate allows the lane at most one fire a day and refuses the same song key twice in a row, and the pos_change > 0 gate means a held #1 never fires again — so the same #1 cannot repeat with or without the judge, which added no dedup a rank change needs and subtracted the one story the lane exists to tell. The lane is stamped once a day (_mark_daily runs before the fire), so a single wrong verdict costs the whole day, which is why the fix is a bypass rather than a retry. This exemption is scoped to the crown shape only: every other daily lane (release, the jumps, the risers, the artist-market lanes) still runs the judge. A PERMANENT fact does not ride the text window at all (#market-dedups). The post_dedup_history table also holds EXACT-KEY identities on their own surfaces — a certification (music_news_cert), a streaming milestone (music_news_milestone), and now a WEEKLY-chart DEBUT (wire_chart_debut, music_news.chart_debut_story_key) — read by exact match with no window and 1-year retention, because a settled fact is never news again and the ~14h effective wire window cannot hold it. The CERTIFICATION key’s identity is the TITLE without its feature clause (feat / ft / featuring / f/ / w/ / with, one bracket level deep, a version qualifier after a bare clause kept) plus the LEAD act when that act is on our lists, else the whole credit with its connectors folded, so “Tyler, The Creator” and “Tyler the Creator” key the same (music_news._cert_release_identity, 2026-09-02): RIAA prints a feature in the title (“Up All Night (feat. Nicki Minaj) - Drake”), a wire puts it in the artist (“Up All Night - Drake & Nicki Minaj”, a radio wire “f/”) or drops it, and the RIAA lane’s first day showed the whole-credit key splitting on that (yungenfeatjackharlow|rodwave vs yungen|rodwave), which is a second card for one certification. The milestone, debut and chart-position keys keep the whole credit (_release_identity): a streaming milestone counts per RECORDING, so “bad guy” and “bad guy (with Justin Bieber)” are two counters. The recognition lists change between builds, so a key derived from them alone would change under one certification: the cog writes EVERY form of the key on a ship and reads every form before a spend (cert_story_keys: the corroborated lead, the whole credit, and the pre-2026-09-02 fold; the whole-credit form is the bridge between two list snapshots, and a split-at-every-connector form is deliberately absent because “Earth, Wind & Fire” would book “earth” and suppress a real act named Earth), so the identity does not move with the list snapshot and a key stored under the old fold still matches. A certification’s claim blob also carries the LEVEL’S UNIT COUNT off the body’s own ladder (cert_units_fact, #2871: RIAA Gold 500,000 / Platinum 1,000,000 per multiple / Diamond 10,000,000 per multiple; BPI Silver 200,000 / Gold 400,000 / Platinum 600,000 per multiple; no clause for the RIAA Latin program, an unknown body or a malformed id) on the ONE line the compose, the digit gate and the self-gate read, so a take that converts the level to units (“14x Platinum, 14 million units”) is grounded and a wrong count (“12 million”) is still a number nothing states. Measured 2026-08-31 to 09-03 the self-gate scored five certification takes 0.0-0.2 for a units figure, at least three of them the right arithmetic (Halsey 14x Platinum; a BPI Gold at 400,000; a BPI Platinum at 600,000); the model judge on its own grounds a right count and a wrong one alike (dry run 2026-09-03: a 12-million take scored 0.9 on the same blob), so the digit gate is the refuter and the blob is what it needs. The chart-debut key is the FIRST cross-surface one: music_desk reads Billboard first-party and music_news relays the same debut off @billboardcharts, so both surfaces write it (PRODUCTION delivery only, so a staging audition can’t suppress a real post) and both read it before the compose/research spend. Only a DEBUT keys — a re-entry, a move (“surges to #47”), or a LIVE daily chart that churns all fail open — so a genuine development still ships. Live cost that named it: the #73 “BbY WOW” Hot 100 debut posted from music_desk on Aug 19 and from music_news on Aug 22 (+53h) and Aug 23 (+85h), each past the window, and the topic judge even overturned a shape-only hit against a different Karol G debut still in view. A weekly-chart POSITION that has not moved is the same class of stale re-report, and rides its own high-water store (#chart-repost). A debut settles ONCE, but a POSITION persists for the whole week, so a wire re-posting the same #1 — or a small drift — is one story re-told. The gate is NOT a boolean exact key like the three above; it is a numeric high-water in kv_cache (namespace music_news_chart_hw, keyed music_news.chart_position_key = folded subject + pos + the chart key, per guild, 45-day INACTIVITY TTL — a suppressed repeat refreshes the row so a 10–20-week #1 run cannot expire mid-streak) holding the last position REPORTED. music_news.should_repost_chart_position re-reports only a CLIMB of MUSIC_NEWS_CHART_REPOST_MIN_CLIMB (default 5) spots, a NEW #1 (a crown the last report did not hold), or a RE-ENTRY (the song left the chart and came back — the owner steer “exits post right”, read off the classifier’s chart_event first, bounded to a return wording in the claim or the post, else claims_reentry on the claim, since the settled rewrite strips “re-enters” — the regex read only “re-ent”, so “returns to the Hot 100 at No. 40” was suppressed as a fall, #3418 / the #2523 follow-up); a held or plainly-fallen position never reposts. It runs in music_news only, on a SETTLED reading on ANY known chart — WEEKLY panels AND live DAILIES, because a held #1 repeats on both (a daily #1 shipped eleven times); the deep_chart top-10 floor runs first and trims a deep daily position, so only a top-of-chart daily position reaches the gate. Written on PRODUCTION delivery only. Live cost that named it (Aug 2026): Drake’s “Shabang” shipped at #1 on the US Rhythmic radio chart on Aug 15, 16, 19 AND 24, and Cardi B’s “AH HA” shipped at #18 then #14 (four spots) on the same chart — the owner steer: “duplicates, and a 4-spot jump isn’t big enough to repost”. A genuinely different chart (Shabang #6 on Urban radio) or a real jump still ships. The music_desk radio lane is a separate surface and does not yet share this store — a cross-surface repeat there is a known follow-up. THE READ AND THE WRITE ARE SERIALIZED PER GUILD (#2758). The store is written AFTER the post, so a desk that reads it, judges, posts and only then writes leaves a window of seconds where a sibling desk reads a history that holds neither post. Both judges then say “not a duplicate” and the same story ships twice. It did: on 2026-08-31 sports_desk and pop_desk both composed Messi’s international retirement on one tick and tweeted it at 15:31:48.389 and 15:31:52.396, 4 seconds apart, because pop_desk read the history about 2 seconds before sports_desk’s row existed. The judge was never the problem — handed the row once it existed, it blocked the same story twice that hour (15:37:29 and 15:47:25). utils/history_gate.py:shared_history_gate closes the window with one asyncio.Lock per guild, held across read → judge → write of ONE unit. Every spine lane that ships a unit takes it through the one method ScheduledPoster._deliver_unit: the slot walk (_gated_unit_walk), the breaking lane (EventPoster._ship_event_candidate), the music desk’s source watcher (_off_slot_pass) and the newsroom’s feed watcher (_watch_post_one). So no lane can serialize while another does not. The gate is held PER UNIT, not per walk (#3828). Before #3828 a lane held it for its whole walk. Measured 2026-10-04: one music_desk walk of 20 units held it from 18:55:46 to 18:58:21, and music_alert waited 152s (history_gate reason=waited duration_ms=152245) to judge a post that had nothing to do with the walk. _deliver_unit now takes the gate, reads the history, judges, delivers to the first channel that lands, writes, and releases. asyncio.Lock serves waiters in order, so a waiting lane waits for one unit at most: about 1.5s for a deduped unit, about 38s for a delivered card with its X crosspost. The history is read again inside each hold, so #2758’s guarantee holds per unit. The lines a walk already delivered are put first in each unit’s list, so two units of one slot still dedup against each other when the store lags. The KEY stays the guild, not the story: the history read set is guild-wide, and the Messi duplicate was two desks with two different keys for one story, so a per-story lock could not have serialized them. The dry run (scripts/dryrun_history_lock.py, the real 21-unit walk against the real history and the live judge) measured the alert’s wait at 109.7s before and 1.1s after. GATE_WAIT_SECONDS stays 180: the per-unit hold makes a real wait short, and a lower ceiling only makes a timeout (an unserialized read) more likely. COMPOSE stays OUTSIDE the lock — it is the expensive half (31s and 37s of wire reads in the measured case) and serializing it would stall every desk behind the slowest; the losing desk still pays for a duplicate compose, it just cannot post it. Which surfaces take the gate is DERIVED from WIRE_DEDUP_SURFACES (_shares_history), not declared per cog, so a desk added to the shared read set is gated by that one edit; a surface reading only its own rows has no writer to race and skips it. The gate FAILS OPEN on a GATE_WAIT_SECONDS timeout (proceeding on a fresh read, which is never worse than no gate) and emits history_gate on a real collision or a timeout. An AUDITION never feeds the dedup (#2847). A unit stamped meta["audition"] (a staged source or lane experiment under a PRODUCTION parent: the RIAA feed under the newsroom, a desk lane under the music desk, an artist-market lane under the music alert) reached only #bot-logs, so ScheduledPoster._process_unit books neither its history line nor the in-pass list for it; a plain staged surface still books both and dedups against itself. The newsroom stamps the flag at compose, the music desk once at the slot’s return (lane stage STAGING while graduated_stage is PRODUCTION), and the alert cog’s daily lanes pass it to the firer and skip the permanent event key. Measured 2026-09-02: the four whole-chart presence boards a mod flipped live at 05:48Z were all deduped at 06:08Z against their own 01:50Z auditions.
Models: models.py, plain dataclasses for DB rows and StrEnums for OrderStatus and MoodMode. No ORM behavior.
Cogs (in cogs/):
ask.py, the @Toots mention handler (the /ask slash command was removed — mentions, plus voice notes via cogs.voice, are the ask surface now; both run the same produce_answer/_answer pipeline). Uses the per-user ask rate-limit counter. Fail-open on DB errors (better to answer than go silent). Natural-language routing: a plain typed mention with words (not a reply, not pointed at an image) is classified by Sonnet (claude_client.classify_mention_intent; the old _looks_routable substring pre-filter was removed in #608, so the router runs on every plain mention) into ask|recap|discourse|icebreaker|music — so “@toots catch me up on the last hour” runs recap (period extracted), “@toots whats the discourse” / “@toots give us a conversation starter” run discourse/icebreaker (since #1871 the discourse intent falls through to /ask; only icebreaker still routes), “@toots give me another recommendation” / “@toots recommend me a song” runs the music drop (a track rec with a link, replied in place) — instead of hunting for the matching slash command. Routing the music intent up front (not letting the ask path answer + voice it) is deliberate: a “recommend me a track” mention is a music pick, not a question, and answering it through /ask was producing voiced replies to typed requests (the #over-voicing report). The classifier owns the path choice so a rec request lands on the music surface instead. Routed intents reuse the destination cog’s own pipeline via get_cog (recap’s produce_recap, discourse’s produce_discourse / produce_icebreaker, music’s _compose public entry points) and charge the destination’s rate-limit bucket (recap → per-user recap; discourse/icebreaker → server-wide discourse; music → per-user ask, since music has no bucket of its own and the mention would otherwise have been an ask), mirroring the slash commands, which still exist. Abuse detection (_maybe_handle_abuse) gates routed intents too, so harassment can’t slip past the warn/silence escalation by phrasing itself as a recap. The abuse gate and the router run at the same time (#4406, _gate_and_route): the router starts as a task, the Haiku abuse gate runs while it waits, and a WARN/SILENCE result cancels the router and discards its verdict. This removes about 0.8 s from the median routed mention; the cost is one router call on a message the gate stops. Jev shadow (#4406 phase 1, utils/mention_shadow.py): after both shipped verdicts are known, _start_shadow starts one background TypeSafe Jev request (an abuse Noul; a route Choice and a period Choice only where the router ran) with a 3 s timeout, cancelled on cog_unload, and emits typesafe_shadow purpose=abuse_gate / mention_route (labels only). The route comparison is judge against judge: it uses the router’s raw verdict, before the cog’s other-channel recap -> ask override (_names_other_channel). That override is deterministic and would apply to a Jev verdict in the same way, so it is left out of both sides. A recap on a mention that pings another channel is therefore not what shipped. The message is cleaned with engagement_labels.clean_text and goes in the Jev state, never in a question. It changes nothing that ships; unset TYPESAFE_API_KEY to turn it off. Eval: scripts/eval_mention_shadow.py. The classifier defaults HARD to ask and fails open to ask, so a real question/banter (or a router outage or exception) always just answers. Emits mention_routed. Channel pings (2026-09-03): on_message swaps each <#id> token in the question for #name (_resolve_channel_mentions) so the router and the search_messages tool’s in_channel filter see the channel’s name, and a recap verdict on a message that pings ANOTHER channel (_names_other_channel) is overridden to ask (emits mention_routed intent=ask reason=other_channel): recap only summarizes the current channel, so “look at the polls in #main-stage and make a graphic” went to recap twice and answered “can’t see into #main-stage from here”. The router prompt carries the same rule (another channel, or BUILD something from the chat = ask); the override is the deterministic backstop. Injects a fixed long-term-memory slice (the tier mix) plus the PROACTIVE [relevant memory] block (_relevant_memory: a vector search over the whole corpus keyed on the incoming message). Every proactive line carries the note’s date range WITH the year (_memory_when, shared with the search_memory formatter, #2181): the block is recency-agnostic by design, so undated lines left the model unable to place a note on the room’s timeline — the season-1 fabrication (2026-08-08) happened over an undated block with 23 real early-era notes in view, and the model riffed instead of retelling. The prompt side of the same fix: _OPUS_MEMORY (and legacy rule 4b) now names memory as the ONLY source for the room’s own past — retell what the notes say anchored to their dates, say plainly when memory runs out, never invent a room event (“a room event no note backs is a fabrication, however good the bit”). AND hands the model a suite of on-demand tools to dig past the injected context: search_memory (guild-bound recall over the guild’s notes, semantic-vector first: _vector_recall embeds the query (text-embedding-3-small) and cosine-ranks the embedded notes in-process, so a conceptual query like “the messiest thing” lands near a drama/beef note natively even though the fenced prose never says “messy”. A strong/numerous vector result stands on its own; a thin one (fewer than _RECALL_MIN_HITS) still gets the durable daily-arc backfill the keyword path uses, so a single stray semantic hit never leaves the model with less material than the old lexical path would. When vectors are unprovisioned/empty it falls back to the keyword/FTS path — full-text over note prose + concept keywords, on a thin literal match Haiku-expands the concept into keyword terms and re-searches, then backfills the durable daily arc. Emits memory_search with a vector field marking which path answered), recall_detail (the hour-grained drill-down companion to search_memory — same vector-first recall but over the kept hourly notes including rolled-up ones that search_memory dedups away, so after a day-level hit the model can zoom into the blow-by-blow; emits memory_search with detail=True), lookup_catalog (Apple Music / iTunes catalog; emits catalog_lookup), lookup_reference (an authoritative, citable fact from the reference library utils.reference — an intent-routed registry of sources, each returning a citation URL: Genius (utils.genius song credits — producer/writer/featured — and identify-a-song-from-a-lyric via the official API, returning the song + a LINK to read the words, never lyric text, ToS/copyright-clean; provisioning-gated on GENIUS_ACCESS_TOKEN, dormant + falls through to Wikipedia prose when unset; first in the registry so credit/lyric intent routes here), MusicBrainz (utils.musicbrainz release-group metadata: release date / type / featured artists, e.g. “when did Scorpion come out”; the model is told to include the artist in the query so a bare title doesn’t match the wrong act; producer/writer credits are deliberately left to Genius since MB’s recording-search ranking + credit coverage are unreliable), Wikidata (utils.wikidata typed date/age factoids: born/died/founded + computed age), and Wikipedia (utils.chart_data) parses per-position CHART peaks for any major chart (Hot 100, Billboard 200 albums, UK, Canadian, detected from the question; the #335/#292 ground truth, e.g. “how many Drake songs peaked at #2”, “how many #1 albums”), routed by chart/discography intent - the one place we keep a bespoke parser, since the column is identified by its wikilink target and web search fumbles per-spot counts - plus the general article summary as the catch-all (award totals and other widely-reported facts ride the summary / web_search)); specific sources match first and fall through to Wikipedia on a miss, fail-closed to a hedge when nothing clean is found; tool-only with no pre-fetched block; emits reference_lookup), song_credits (Genius promoted into its own crisp tool, #847 — utils.genius.lookup direct: producer/writer/featured credits, what a track samples/interpolates, what a line/song means, identify-a-song-from-a-lyric; returns a LINK, never lyric text; token-gated via genius.provisioned() so a dead tool is never taught; Genius STAYS in the reference registry as the wider fallback; emits the shared reference_lookup event with source=Genius|none so its integration-health telemetry survives bypassing the reference dispatcher), read_media (on-demand read/transcribe of a specific video or X/Twitter URL the model hasn’t seen in context, #847 — the ambient ingest is async so a just-posted clip is bare on first read; CACHE-FIRST via video_ingest.cached_video returns a resolved clip’s metadata + transcript instantly, and on a miss it kicks the SAME video_ingest.kick_fetch background fetch the ambient path uses (cached for every later reader) + optionally awaits a short bounded timeout for a fast captions/fxtwitter resolve, NEVER blocking on a long-clip STT; gated on video_ingest.should_fetch() (VIDEO_TRANSCRIPTION kill switch + yt-dlp); emits read_media with the cache_hit|fetched_inline|pending|no_words|not_media disposition. A request uses the 2-hour REQUEST_MAX_DURATION_SECS cap, not the 30-min ambient one (2026-10-21: a 39-min YouTube video failed too_long and the tool kept answering “still transcribing, ask me again” – the fetch had already finished). no_words = the fetch FINISHED with no text (video_ingest.is_inflight is false), so the tool returns the metadata + why there is no transcript instead of pending; a cached ambient too_long result does not block the longer request (video_ingest._retryable, checked against L1 and L2). Accepts a raw uploaded video file too (video_fetch.is_direct_media_url — a direct .mp4/.mov/.webm URL like a Discord attachment, host-agnostic by extension), routed through fetch_video’s generic yt-dlp path and keyed by canonical_key so it caches — so an OLD uploaded clip found via search is transcribable on demand, not only the recently-buffered ones the ambient path covers), the live market/score feeds broken out one tool per source (so the model routes to the exact feed that answers and can cross-check two): api_sports (live scores + in-match events, World Cup soccer + NBA, the fast primary), sgo (Sports Game Odds live scores + sportsbook betting lines), the_odds_api (traditional sportsbook moneyline payouts). DOWN-vs-no-data signaling (the fabricated-slate fix): sgo and the_odds_api now report an EMPTY result differently depending on their circuit-breaker .degraded state — a DOWN feed returns an explicit OUTAGE string (pointing to the peer feed), distinct from a feed that’s UP-but-empty (a genuine “no line, cross-check the peer”), so the model never reads a downed feed as “no games” and invents a slate (the Opus World Cup fabrication: SGO 100% down + (no SGO lines) read as “no games”). The descriptions bake in the capability matrix (utils.sportsdata.sources.CAPABILITIES): sgo/the_odds_api are PEERS on the same games (SGO reprices IN-PLAY, the_odds_api is PRE-MATCH-only → for a live number prefer sgo or the prediction markets, which DO move in-play); api_sports has NO odds and empty = nothing LIVE, not “no games today”. Cross-provider scoreboard trio (the core fix): live_scoreboard (every LIVE game MERGED across ALL providers via sports_hub.live_games(), the cross-provider “what’s on” the single-source tools miss), game_schedule (UPCOMING fixtures in the next 18h via upcoming_games, since api_sports only sees live + just-finished so the schedule lived nowhere; since #4355 each not-started line carries start_label, its ET start date + time and a deterministic (today)/(tomorrow) word off the ET calendar date, or “start time unknown” – the block had no date before, so at 9pm ET the 18h window’s next-afternoon games read as tonight; the_odds_api’s odds board carries the same label off commence_time; since #4363 every score row in utils/sportsdata/format.py labels both sides, Denver Nuggets (away) 88 - 91 Los Angeles Lakers (home) or X (away) vs Y (home) – a bare X vs Y did not say who was home, so Opus declined to say and other runs guessed and flipped it; the halftime line, the events and last-plays headers and the_odds_api scores use the same labeled_matchup / scored_matchup helpers), and recent_finals (today’s/just-FINISHED games + final scores via recent_finals(), the “what were today’s scores / who won today” answer that was only reachable through api_sports’ named-game-miss fallback) — all three append _FEED_DOWN_NOTE on an empty result IFF a primary feed is degraded (_scores_degraded: ESPN or API-Sports, the primary score/schedule feeds since #4111; SGO is depth only and is not checked, #4173 – before that a down ESPN with a healthy SGO answered a bare “nothing is live”). The proactive LIVE FEED STATUS block (utils/feed_status.py:_PROVIDERS) carries an espn row for the same reason, emit scoreboard_lookup (phase=live|schedule|finals). game_plays (#3404, always on): the PLAY-BY-PLAY of a named game — every scoring play so far (hub.game_events, rendered by format_events as “Q1 3:42 touchdown, Buffalo Bills: Joshua Palmer 43 Yd pass from Josh Allen (Tyler Bass Kick) – Detroit Lions 0, Buffalo Bills 14 after the play”) plus the last few plays (hub.game_recent_plays / format_recent_plays, oldest first). Built from the 2026-09-17 Lions-Bills miss: asked “how many yards was the touchdown pass Josh Allen just threw”, the GPT path had no play-level tool, ran its built-in web_search, and answered from a Reddit game thread that only knew the earlier 1-yard run; ESPN’s summary carried the 43-yard pass the whole time, and espn_summary_events was DROPPING every NFL scoring play (the rows have no soccer type.type). Not gated on api_sports or SGO: the hub serves it from the provider that owns the game, and ESPN (keyless, always-on for NFL/MLB/tennis) carries it when the metered feeds are dark. The tool definition tells the model a web search runs minutes behind a live game and this feed does not. Emits tool_call (tool=game_plays, matched, has_events, count=plays). The game resolver behind every slate tool (_select) is a substring match first and a Haiku pick on a miss (claude.pick_slate_games, purpose slate_pick: the query plus the slate’s matchup labels “Detroit Lions @ Buffalo Bills (NFL)”, answering the matching indexes). Owner steer: a classifier, not a word list. “Lions Bills”, “the Buffalo game”, “DET at BUF” and “Josh Allen’s game” all resolve, and a two-team query is never a substring of “away home title” (2 of 3 live game_plays calls missed on the substring rule alone). Fail-open to no match, never a wrong game; _resolve_slate_game flattens live + upcoming + finals into one candidate list so the pick runs at most once. API-Sports DEPTH tools (api_sports-gated, each resolving a named game on the live/upcoming/finals slate via _resolve_slate_game): standings (the group/league table → game_standings → format_standings), matchup_preview (formations/form/H2H → game_pregame → format_pregame), box_score (leaders + team shape → game_leaders/game_team_stats); plus list_sports (the sportsbook coverage catalog via the_odds_api.list_sports(), so “do you have odds for X” is answered from the real catalog) and match_highlights (an on-demand post-game video clip for a named game via highlightly.get_highlights, the user-asked counterpart to the commentator’s auto-posted clips; Highlightly-gated, emits highlight_lookup). Prediction-market GAME discovery: kalshi_game_markets / polymarket_game_markets sweep the matchup PLUS each team name (and a resolved game’s real names) so a bare keyword search that whiffs (the Kalshi “world cup” → 0 hits incident) still finds the matchup markets (emit game_markets_lookup). lookup_player_props (a NAMED player’s or NAMED game’s over-under lines — points/assists/rebounds/goals — from SGO, #390; anchored, never a league-wide sweep: it refuses with no player/game (props are the heaviest SGO call), reads the live scoreboard to scope the prop-covered leagues + a named game’s actual players (sports_hub box-score leaders + goal scorers), then name-filters the league props via match_player_props/format_player_props to just the player(s) asked about; SGO-gated, reuses the commentator’s prop matchers, fail-open to a soft string; instrumented via the underlying market_fetch event), break_down_board (#622: ONE named game’s FULL market board — game lines (moneyline/3-way/spread/total/BTTS/1st+2nd-half totals), game props (corners, first-to-score), and player props, each selection carrying the book’s implied % — so she TRANSLATES the interesting edge in plain words (“ignore the wall, the live one is over 0.5 1H goals at 75%”) instead of dumping a board of ¢ prices. Anchored to a named game like lookup_player_props (resolves the live/upcoming scoreboard → the SGO-covered league → matches the board by team name); SGO-gated, soccer-deep / NBA-thin (SGO barely carries NBA odds depth). The whole odds tree is a flat {statID}-{entity}-{period}-{betType}-{side} dict already in the payload, parsed by the pure utils.sportsdata.board.parse_game_board (standalone — inlines its own implied-prob math to avoid the markets↔sportsdata import cycle) + rendered by format_game_board, fetched via SportsGameOddsClient.get_game_board; emits board_lookup with the per-category market counts as depth telemetry. When SGO is degraded (#725) the board falls back to the Odds API deep board: a FREE /events resolve + ONE metered get_event_board (/event-odds over ODDS_BOARD_MARKET_KEYS — totals/BTTS/corners/team-totals/halves) parsed by the pure parse_odds_api_board into the SAME GameBoard so format_game_board renders unchanged, budget-guarded by the_odds_api.has_enhancement_budget (credits above the 5,000 _ENHANCEMENT_RESERVE) so the credits the /bet SGO-down backstop depends on are never blown), polymarket + kalshi (the two prediction markets, individually, so a real split reads as “Kalshi 42% vs Polymarket 38%”) — each handler hits ONLY its named source and is provisioning-gated (an unkeyed source isn’t offered; Polymarket/Kalshi are public so always on); this replaced the bundled search_markets, which hid which feed answered, research_web (Perplexity, gated on it being configured), and the ops self-service pair so a “you’re broken” report gets diagnosed + fixed instead of only commiserated with: check_deploys (read-only Railway deploy status + build/runtime logs via utils.railway.RailwayClient.diagnose, gated on RAILWAY_API_TOKEN/RAILWAY_SERVICE_ID; emits deploy_check) and file_fix (file a fix-order for her own code off a regular’s bug report, routed through cogs.order.Order.file_autonomous_order — preflight + kitchen/pipeline/in-flight gates + AUTO_ORDER_DAILY_CAP, attributed to the reporting regular; emits auto_order. Prompt-orchestrated dedup (not a code gate): the file_fix tool description + COMMAND_GUIDE instruct her to check_fixes FIRST and only file a NEW order if nothing open already covers it — an existing in-flight match routes to update_fix (add the new detail) or, with nothing new, to a “it’s already tracked” reassurance, so she doesn’t double-file), plus the customer-service-desk pair so she’s her own help desk: check_fixes (read the status of this server’s filed fix-orders via cogs.order.Order.recent_fix_status — reconciled against GitHub, and merges in raw open GitHub issues filed outside the order pipeline so a directly-filed bug isn’t invisible, scoped to user-facing labels (order/bug) with internal/ops issues (auto-eval/feedback) excluded, deduped by issue # against the order rows, user-facing fields only, so she reassures a reporter their bug is tracked/shipped instead of double-filing) and end_game (end a stuck /guess game in the channel on the asker’s behalf via cogs.games.Games.end_game_for; any user can end the game, matching the open-access End button on the game embed); both emit self_serve. Plus update_fix (the counterpart to file_fix, cogs.order.Order.add_fix_update): when a regular adds new detail to a bug already reported (a follow-up reply), it appends their note as a plain attributed comment onto the existing in-flight order’s GitHub issue so the extra context reaches the fix — no @claude, so it records without spawning a duplicate PR run; scoped to this guild’s non-terminal orders (a shipped/closed one routes back to file_fix), runaway-guarded by the shared AUTO_ORDER_DAILY_CAP (one ceiling for both regular-driven order ops, file_fix + update_fix, with separate daily counters); emits fix_update. Plus note_feedback — the lighter lane beside file_fix (cogs.order.Order.file_feedback): a suggestion/wish/sentiment that isn’t a concrete code change gets logged as a comment on ONE rolling feedback-log GitHub issue (label feedback, plain body so claude-code-action ignores it) for mods to triage, gated only by a per-guild FEEDBACK_DAILY_CAP (no preflight/kitchen/pipeline); emits feedback. The /ask surface also always carries a COMMAND_GUIDE (mirrors cogs/help.py) so she points people to the exact real command rather than inventing one. Each is the on-demand counterpart to a block the cog also pre-fetches (except lookup_reference, which is tool-only). The utils.reference _SOURCES/_EXTRACTORS registries are the extension points for adding more authoritative sites/table shapes (utils.reference_types holds the shared ReferenceResult + fetch_json to break the import cycle); the live/breaking path stays on research_web/web_search. KTT2 forum tools (#ktt2, utils.ktt2): search_ktt2 (what the music FORUM is arguing about on a topic) and ktt2_breaking (the threads filling fastest right now, by replies per hour, plus the board’s trending artists) — the discourse + early-signal pair beside search_reddit. KTT2 has no API: both read one section page and parse the Apollo cache it server-renders into __NEXT_DATA__, guarded by a limiter/breaker/retry + a 10min cache so a burst of tool calls in one answer collapses onto one fetch. Never linked, quoted or credited — their terms bar public display, so KttThread carries no url/slug/id field at all and post_text strips every URL out of post bodies (same shape as the Reddit steer). Ranking is the load-bearing part: breaking excludes PINNED threads (the megathreads have five-figure lifetime counts and would win forever), and discourse ties break on most-recent-reply rather than reply count (a lifetime total put a 238,932-reply album thread above that day’s actual Drake stories on the live board). Emits ktt2_fetch. Tier lens (#2230 follow-up): ktt2_breaking joins each thread’s artists to the desk’s WATCHED list (utils/watchlist_source.py — the ONE async builder for the recognition lists, which MusicDesk._watchlist now delegates to) and marks those threads [ours], ranked first, so “what are our artists blowing up about” is answerable. The join is on KTT2’s OWN resolved artist entities via artist_watch.Watchlist.contains, never on title words. An empty watchlist marks nothing and degrades to plain velocity order. The watched block also states that these are FORUM CLAIMS — she may report the board is arguing about something, never restate the claim as fact (the constitution’s room-is-not-a-source rule). Voice-out on a typed mention: the mention answer runs with allow_voice=True, and a voice-nominated answer (the <voice>/<sing> tag) routes through cogs.voice’s shared deliver (user_sent_voice=False) so a typed @Toots can come back spoken or sung when voice is provisioned (bot.tts); a plain answer (the common case) just text-replies, skipping the voice_reply event. Text stays the default and the tag is stripped from any text reply. This is the reliable directed channel for a voiced answer, since a Discord voice note can’t carry an @mention. Image-out on a typed mention: Results graphics (2026-09-03): the _IMAGE_GENERATE directive tells the model that a scoreboard / tally / bracket / list-of-winners picture is drawn from the words INSIDE the tag only, so it must write every row’s title, numbers and winner into the tag in order (the picture model cannot see the caption or the chat; in the poll-tally dry run it filled undescribed rows with invented songs and vote counts, 842 vs 512, while the caption was right). utils/image_codec.MAX_PROMPT_CHARS went 1000 -> 4000 for the same reason: the 18-round board’s prompt is ~1700 chars and the old backstop rejected exactly the prompt that carried the real data. the mention answer also runs with allow_image=True when the image client is provisioned (OPENAI_API_KEY set), so the model can nominate a generated picture by wrapping its description in an <image>...</image> tag (parsed by utils.image_signal); _deliver_image then renders it via utils.image_gen (OpenAI GPT Image) and replies with a native PNG attachment + the caption (the text outside the tag). It’s the rare case (a “draw me X” / visual-gag answer), checked before the voice/text branch and fully fail-open: no key → tag never taught; generation or send fails → it degrades to the caption as a plain text reply, so a nominated image always falls back to its words. Emits image_reply (cog, delivery outcome) + image_generated (the API call). Hardened with a budget (#326): image is a permanent surface gated by provisioning (OPENAI_API_KEY/bot.image) AND a per-guild daily budget (image_daily_cap, default 20 to match the server-wide daily cap, DB-backed via the server-rate counter, restart-proof, checked before the paid generate/edit so a capped image costs nothing). The cap bounds cost in both stages — every room send AND every staging audition counts against it — because image has no per-channel cooldown, so the guild cap is its only budget guardrail (image keeps its budget because a render is dear ~$0.07; gif, being near-free, has no cap at all and is paced by the model’s own nomination + vision gate). A capped/failed nomination always degrades to the caption text. Remix (image edit): when there’s a source image in the immediate context (an image attached to the replied-to message OR to the mention itself, found via _first_image_attachment), the model also gets a <remix>instruction</remix> tag that edits that image instead of generating fresh (<image> = new from scratch, <remix> = change the one that’s here). _deliver_remix reads the source attachment bytes and runs utils.image_gen’s edit (OpenAI images/edits, multipart upload), sharing the same _send_image delivery + fail-open contract as generation (unsupported source / read miss / edit fail → caption text). allow_remix is only passed (and the tag only taught) when both the image client AND a source image are present, so she’s never told to remix nothing. File edits (<media op=...>, the mechanical half): with a clip or a photo attached (found via _first_media_attachment, which unlike the remix finder accepts video and returns the KIND), the model also gets one tag that edits the FILE rather than what it shows: gif, mp4, trim, speed, frame, mute, reverse on a clip; resize, rotate, flip, convert on a photo. The model CLASSIFIES the ask in the same call that composes the line – the tag IS the classifier, so there is no second pass and no added latency – and _deliver_media runs it through utils.media_edit.run_edit on ffmpeg + Pillow, both already in the image. That makes it the one file surface with no API cost and so no daily cap: the per-user mention limit is the pace, and the costs are bounded by an input-size refusal, a per-op output ceiling, and the guild’s own upload limit (a too-big encode retries ONCE at a smaller shape, then gives up). media_kind is keyed per kind so the model is never taught an op that cannot run on what is attached. The split against remix is the rule to keep: <remix> changes what a picture SHOWS (paid, a model redraws it), <media> changes the file – container, length, size, speed. Fail-open throughout: any miss degrades to the caption, so a broken edit costs a file and never the reply. Source priority: the replied-to image first, else the mention’s own attachment. Gif-out on a typed mention: the mention answer also runs with allow_gif=True (only when bot.gifs is provisioned), letting the model nominate a reaction gif with a <gif: query> tag (parsed off by utils.gif_signal, stripped before any text/voice delivery so it never leaks). The gif rides alongside the answer (it doesn’t replace the delivery channel, so it can stack with a voiced reply) as a follow-up message, gated only by Giphy provisioning (GIPHY_API_KEY/bot.gifs): when provisioned she sends the Giphy gif to the room. There is no daily cap or per-channel cooldown — the model’s own nomination + vision pick are the pace (the redundant pacing knobs were removed). A bare <gif:> nomination with no words never sends an empty message (the prompt also tells her to always write a text line); if a gif is the only thing and it misses, a quip covers it so a summon is never left silent. She vision-picks the gif by her own taste rather than sending the top text-relevance hit: the Giphy search returns candidates (each with a small preview), and claude.pick_gif (a cheap Haiku vision call, the outbound twin of the inbound gif vision she uses to narrate gifs posted in chat) looks at the preview frames (plus the actual moment — her reply + what she’s replying to — so a deadpan line gets a sarcastic gif, not an earnest one) and picks the one she’d actually send — it gates on “does it read as the reaction” AND “does it fit the moment” AND “does it fit her voice (sharp/ironic, not corny/basic)”, biases hard toward none fit (skip rather than send a mid gif), and falls back to the top hit only if the vision call errors. Pacing/budget: there is no daily cap or per-channel cooldown — the gif ships only when the MODEL nominated one (a <gif:> tag) AND the vision pick judges a candidate fits, so her own judgment IS the pace (the redundant cap+cooldown knobs were removed; the vision gate biases hard toward none fit, which keeps gifs occasional on its own). Any gif miss (no result, none fit, send failure) is fail-soft and never touches the already-delivered answer. Emits gif_search (the Giphy call) + gif_reply (the disposition, incl. the vision field). Consented memory (remember_about_you, the user_facts store): a regular can explicitly teach Toots a fact about THEMSELVES — a nickname, how to be addressed, a light preference (“call me X”, “don’t call me Y”, “remember this”) — and she stores it per-(guild, user) (db.add_user_fact, case-insensitively deduped via a SQL existence check; storage is uncapped since rows are cheap, while the per-reply read-back is bounded to the newest USER_FACTS_READBACK) and reads the asker’s OWN facts back into her reply context via the privileged <about_them> block (_user_facts_context), so she talks to that person the way they asked. This is user-directed and consented, distinct from the observational, constitution-fenced memory_notes pyramid (which captures observed public behavior and can’t store a volunteered “call me X”): a user’s facts only personalize HER replies to THAT user, are never disclosed to or about anyone else, and the tool steers AWAY from sensitive categories (health, identity, religion, politics, location, contact, anyone else) — those get a warm in-chat ack but no store. The constitution still gates everything she says. /forget wipes a user’s facts alongside their notes (db.delete_user_facts). Emits user_fact (cogs.ask._make_remember). Context-stage remap (the /ask latency work): every independent context fetch — the rich buffer render, long-term-memory recall (_memory_context, the vector pass included), girls/user-facts lookups, link enrichment, the Perplexity grounding pre-fetch (when the model keeps it — see _TOOLSIDE_WEB_MODELS), and the markets snapshot — runs in ONE asyncio.gather under the ask.parallel_fetch span (previously that span covered only enrich+pplx+markets while the buffer/memory/girls/facts awaited SERIALLY ahead of it). Axiom showed the serialized chain at ~29s p50 end-to-end with feeds.resolve_reactors alone at ~16s p50; with everything overlapped the pre-model stage costs only its slowest member — and since the #2175 follow-up /ask skips the reactor-name fan-out entirely (reactors={}; the aggregate [N reactions] counts are free), so the stage’s long pole is memory recall (~3s), not the reactions rate bucket. URL extraction for enrichment reads the RAW message content + forward snapshots (not the rendered buffer, whose 200-char truncation could clip a URL mid-string), which is what frees enrichment to join the same gather. return_exceptions=True + isinstance-narrowing keeps every fetch fail-open.discourse.py, the discourse surface. RETIRED (owner decision 2026-10-15, #1871): “we probably shouldn’t have anything pulling or costing us money for discourse anymore.” The module flag RETIRED = True keeps the scheduler loop stopped: __init__ does not start scheduler_tick, and the tick returns before its first DB read if something starts it. So discourse makes no Claude/GPT compose, no Perplexity, Grok, X or twitterapi.io read, and posts nothing (pinned by tests/test_discourse_skip.py::test_retired_discourse_*). The code below stays in place; setting the flag to False revives it. cogs.ask._dispatch_routed returns False for the discourse mention intent, so “what’s the discourse” gets a normal /ask answer. Still live: produce_icebreaker, which chime-in’s quiet-room opener and the icebreaker mention intent call (a chime-in feature that lives in this module). Its market line is a sports-room-only source; since CB-19 (#3410) Haiku decides “is this a sports room” (markets.room_market_kind, purpose=room_market_kind, SPORTS / PREDICTION_MARKET / OTHER, cached 7 days per channel id plus a hash of name, parent and topic; the league and betting cue lists are the prompt’s examples), and with no answer the cue lists decide on WHOLE words (markets.has_cue_word; the old substring test read “general-chats” as sports). The icebreaker and chime-in dedup gates run the topic judge in both directions (arbitrated_match with the default catch=True, CB-19); #2083 had set catch=False for cost, and these surfaces now post about once a month. Still kept: the discourse_history table and every reader of it (chime-in, ask, the desks’ dedup), so no data is lost. The /menu calendar row, model row and X picker entry stay but no longer change anything; the calendar blurb says “retired: posts nothing”. The /tune “discourse wire accounts” knob is DELETED (#4175); a revived compose reads wire_sources.DEFAULT_PER_LIST. The discourse channel select is NOT dead: its set (discourse_channel_ids) still scopes the /star sweep, the X reply-draft allowlist and mark_configured, so it stays, relabelled “💬 main rooms” with the same storage key. Measured before the change (Axiom, 14 days to 2026-10-15): zero discourse composes; both guilds already muted it on the calendar (392 slot_muted). The history below describes the surface as it ran. Before retirement: the mood scheduler background task (proactive posts) + the mention-routed entry points (produce_discourse/produce_icebreaker, reached via @Toots what's the discourse / give us a conversation starter, routed by cogs.ask). The manual /discourse slash command was removed — discourse is proactive + mention-routed now (like /ask), so there’s no standalone command; schedule control lives on /menu. The shared _compose pipeline + the quiet-slot linkless icebreaker fallback (_icebreaker_fallback, material-only invariant kept) are unchanged. Trending-clip variant (#1272): SOME scheduled slots are a “did you see this” REACTION to a fresh trending social clip instead of a news take. Chosen by QUALITY, not a dice roll (owner steer — “the best discourse we have as an option, not a crap shoot every time”, and don’t STARVE trending): _try_trending (called first in _compose_with_retry, gated only on ScrapeCreators provisioned) gathers topic-scoped trending clips (_gather_trending_clips: DISCOVERS the SPECIFIC current viral moments that fit the channel’s vibe via _trending_search_phrases — Perplexity (build_search_query(surface="trending"), theme-scoped, this-week) names what’s actually blowing up RIGHT NOW as short searchable phrases (a celebrity drama, a meme, a release), cleaned by social_search.parse_trending_phrases — then social_search.search_socials searches EACH phrase across all socials + merges. This is the fix for the old raw-topic-string query, which fed the channel’s Discord description straight to ScrapeCreators’ dumb keyword search: the broad “anything goes / pop culture” hub matched literal junk (an 8-year-old “Similes in Pop Culture (For Kids)”, Bing Crosby’s Anything Goes) and never surfaced the current drama; Perplexity discovery live-returns e.g. “BBL Drizzy Drake meme” / “Travis Kelce Taylor Swift honeymoon” → each surfaces fresh on-moment clips. A broad room catches the actual pop-culture drama, a niche room stays on its niche (the topic steers Perplexity); falls back to the general discover_trending feed when discovery yields nothing. The recency gate is 7 days (_TRENDING_MAX_AGE_DAYS, “is it really trending if it’s 2 weeks ago” — with Perplexity surfacing this-week moments the clips come back fresh, so the tight window cleanly drops stragglers). The pool is BALANCED per platform via social_search.balance_by_platform — a round-robin over each platform’s engagement-ranked list, NOT a raw-engagement top-N, so YouTube’s million-view magnitudes don’t crowd TikTok/IG out of the slate she picks from, the “normalize engagement” fix), hands them to claude.react_to_trending — an ISOLATED client method (its own prompt, NO web_search grounding pass so she reacts to the given clip instead of wandering to a news topic — the exact failure a plain-discourse dry run showed) that picks the ONE clip that’d spark THIS room, reacts in voice, and ends with its link. She SEES the clip, not just its caption: each candidate carries a cover still frame (SocialResult.frames — TikTok video.*.uri covers + a clean unsigned YouTube hqdefault.jpg; Instagram’s signed CDN isn’t Anthropic-vision-fetchable so IG clips stay caption-only) attached as VISION on the react (labeled clip N, bounded to the API’s 10-image cap), so a hashtag-soup caption (#fyp #dog #funny) she’d otherwise decline EMPTY becomes a real reaction to what’s on screen — a dry-run A/B confirmed frames flip held visual clips to ship (a dog-launch bit EMPTY→0.80) and catch a caption↔video mismatch, where a transcript A/B did nothing (0/4, TikTok transcripts are absent or garbled). She ends with the clip’s link (allowlisted so the guardrail keeps it; no verify_live_links HEAD check since a datacenter-IP HEAD to tiktok/yt false-strips a real link) — then ships it ONLY when the reaction clears a HIGH bar ON ITS OWN (_TRENDING_SHIP_FLOOR≈0.7, ABOVE the normal 0.6 floor), scored on the surface="trending" REACTION rubric (discourse_score’s carve-out like icebreaker/music: grades whether the REACTION lands, NOT whether it argues a take — the bare take rubric craters a “did you see this” drop as a hollow/soft take for not picking a side, live-confirmed cratering good reacts to 0.25-0.31). A great clip reaction wins the slot on its own merit (never starved by a marginally-higher news take — the head-to-head compare that starves it); a mediocre one returns “” and the normal _compose runs instead (so the news pipeline is skipped whenever trending wins — the cheaper path). The bar is live-calibrated: good reactions cluster ~0.78, weak ones ~0.25-0.31, so 0.7 cleanly separates them with margin for judge noise. Material-only + self-declining: it returns “” (→ the slot falls through to a normal discourse post) when unprovisioned, no on-theme clip fits, the model declined (dry-run-verified: correctly returns EMPTY on the noisy GENERAL trending feed, reacts + links on a topic-scoped one), or the reaction is under the high bar. Movie/TV grounding (#cinema-facts, owner steer “tool she reaches for”): when the cog wires a reference_lookup handler (utils.reference.lookup_reference), react_to_trending is handed the on-demand lookup_reference TOOL + a movies/TV grounding prompt block, so a clip about a specific FILM/SHOW (a trailer, a box-office moment, casting, reviews) lets her pull the REAL facts (director/cast/RT+Metacritic/box office/awards off the #1561 TMDB+OMDb movie source) and work the ONE sharpest detail into her reaction — a dry run flipped a Sinners react from a vibes take to “$370M worldwide, four Oscars including Best Actor for MBJ…”. It’s the ONE tool on this surface (no web_search wander); optional (only when the handler’s wired), never an invented figure, threaded through the leak-repair retry. Emits discourse_scored (category="trending") + claude_api/link_stripped (purpose="discourse_trending"). Market routing reads the recent conversation (not just the channel name) so the Haiku classifier can spot a sport in content (“SGA is cooking” → NBA); when it does, markets.get_context pulls the whole live scoreboard (MarketsManager.scoreboard_hook, which bot.py points at sports_hub.live_games — every provider’s live games, the SGO board included, so the block still carries the ESPN NFL game when SGO is quota-dead; a hook failure falls back to SGO’s own get_live_scoreboard; #3404) so the post can cite a real score. format_markets_for_prompt renders a hub-sourced row (api_sports / the_odds_api / espn) as the matchup + its verified SCORE (LIVE, <period>) line, and skips a pregame row that has no score. Icebreaker context is ONE gather (#1950 Class B): _icebreaker_fallback’s four sources — the distilled memory mix, the icebreaker-tuned Perplexity hook (~6s and the dominant cost), the sports-gated market line, and her own recent openers — are mutually independent and used to run as four serial round-trips ahead of the compose; they now share an asyncio.gather, each keeping its OWN try/except so per-source fail-open behavior is byte-identical (a plain gather, not return_exceptions, precisely because nothing in it is allowed to raise). Precedence is unchanged and pinned by tests: a caller-passed market line is reused rather than refetched, a fresh hook overrides a passed one, and a FAILED hook fetch falls back to the passed one. The gather does hoist recent_discourse_all above the material-only gate, so a no-material skip pays one extra cheap local read — deliberate, since that gate exists to skip the SONNET call, not an indexed read. The account set is a UNION of feed channels + the CODE-OWNED wire lists (#wire). Discourse used to read its raw material only from the guild’s feed_channels (the Discord channels a MEE6-style mirror posts X accounts into). That made every new server do mirror setup before discourse had anything to link, and the dependency was real: over three days, every post that cleared the 0.6 floor linked a tweet the feed channel had carried 25-90 minutes earlier. utils/wire_sources.py now also reads the desks’ own vetted lists (SPORTS_WIRE_ACCOUNTS / POP_WIRE_ACCOUNTS / MUSIC_WIRE_ACCOUNTS / CINEMA_NEWS_WIRE_ACCOUNTS) straight off twitterapi.io via bot.xprovider, and ADDS them to whatever the feed channels gave it — so a server with a mirror gains the accounts its mirror lacks, and a server with NO feed channel still gets real, linkable material. The module is PURE except fetch_wire_posts (which takes the provider as an argument, so tests pass a stub). Four design points worth keeping: (1) it joins the SAME asyncio.gather as the feed + local pulls, so it adds no serial leg (#1950’s lesson); (2) POP_WIRE_ACCOUNTS ends with MUSIC_WIRE_ACCOUNTS concatenated on, so pop is sliced back to its own handles and selection is ROUND-ROBIN across the four lists — otherwise a top-N slice reaches no music handle at all, and discourse is music-heavy in practice; (3) a wire post whose tweet id already appears in the feed URLs is DROPPED (new_posts), matching across embed-fixer hosts, because two copies of one tweet read to the model as two sources agreeing; (4) the wire links are rendered as their OWN prompt block and never merged into hot_urls — that list ranks by Discord reaction count (0-5) while these carry X engagement in the thousands, so one merged sort would bury every feed link — but both lists DO join the enforce_source_links allowlist, or a wire link is stripped as hallucinated. The cost story is the CACHE, and it is the load-bearing part. twitterapi.io allows 10 requests a minute with a burst of 1 and every surface shares ONE provider, so a compose fetching 32 accounts itself takes ~3 minutes and holds the limiter for all of it — starving the desks. It is also redundant: the desks already pull each of these handles ~50 times a day (~every 29 minutes, measured in Axiom). So the desks now publish what they fetch into a process-local _CACHE (remember, one line in each of sports_desk / pop_desk / cinema_news), discourse reads it (cached_posts, 60-min TTL), and only genuinely MISSING handles are fetched live under a hard LIVE_FETCH_BUDGET (6). In steady state the union costs zero extra API calls; only a cold start (fresh boot, no desk tick yet) fetches at all. The live fan-out uses gather_deadline, NOT a wait_for around one big gather — the latter throws away every handle’s work when one is slow, which under a 10/min limiter is the NORMAL case, and a live dry run confirmed it returned zero posts every time. Breadth (wire_sources.DEFAULT_PER_LIST, 8 per list; the per-guild knob was deleted with the retirement, #4175) is therefore a PROMPT-SIZE dial, not a credit dial. The music DESK reads the same union now (#2195) — cogs/music_desk.py:_feed_context; the music DROP was tested and rejected, see music.py below — so every caller goes through the SAME pair of entry points rather than repeating the steps: gather_wire (select handles, read, emit) and wire_material (dedup against the feed, rank, render). They are two calls and not one because each caller runs the fetch inside an asyncio.gather with its Discord pulls, and the feed URLs to dedup against are not known until that gather returns. Emits wire_read (surface, count handles, kept posts, ok, duration_ms) — a surface-neutral kind stamped with the caller, per the one-kind-per-shared-integration rule, so one dashboard panel and one ops-monitor health branch (keyed wire_read_<surface>) cover all three.order.py, the /order feature pipeline. One command: /order <feature> files now (pre-flight checked); bare /order opens a management view — a filterable status list, a + new-order button (modal), retry/cancel on a picked order, and a kitchen open/close toggle. This replaced the old /order new|status|retry|cancel subcommand group and absorbed the standalone /close + /open. The interaction-free cores (_file_order / _retry_order / _cancel_order) are shared by the slash arg, the modal, and the buttons. Pre-flight sanity check, in-flight cap, pipeline-red blocking, per-user cooldown. Mod-only via _mod_gate. Stale orders (#4684): after each reconcile, the 5-minute sweep burns an order that is still PREPPING with no PR ORDER_STALE_DAYS (7) after its last change (stale_unpicked_orders). Before each burn it reads GitHub again: the issue must be open and have no linked PR, and both reads must succeed, or the order is kept. The write is a compare-and-set through update_order_progress that also sets error_log to ORDER_EXPIRED_LOG, and it emits order_state via=expired. last_failed_deploy skips rows with that log, so an age burn never reads as a failed deploy (the newest order in one guild was a stale one). Its GitHub issue stays open, and a mod can retry it. Before this, 41 such orders (newest 2026-09-20) held both guilds at their in-flight limit (25/25, 20/20), so every new and automatic order was gated (#3523).music.py, /music setup (channel picker) + /music drop (manual post) + scheduled music-lounge posts (track recs with Apple Music links). Sources: feed channels (Twitter/social), Perplexity, channel activity, web_search, plus the two GROUNDED fresh-hit sources the priority-1 “fresh hit” lane picks from so a drop names a REAL current/new song instead of fabricating a “just dropped”: hot_chart (utils.music, the currently-CHARTING top songs, Deezer-led + iTunes RSS) AND new_releases (utils.music, Apple’s most-played albums feed filtered to the release window — the JUST-DROPPED releases the chart lags, since a hit charts for weeks after release, so this is what makes a New Music Friday drop name a genuine new release rather than a catalog classic). new_releases read a DEAD feed for three weeks (fixed 2026-09-14). It called Deezer’s editorial /releases, which Deezer retired: measured 2026-08-20, 2026-09-04 and 2026-09-14, every genre id answers {"data":[],"total":0}. utils/apple_releases.py had already recorded that retirement when the releases BOARD hit it (#2950) and moved to Apple’s feed, but nobody swept the other consumer, so the drop’s just-dropped list was empty on every call — the class-sweep miss, and it mattered because that lane is the only one that can post an act on neither familiarity list. Both readers now share the one source, on different entities: the board reads albums (a release day is an album day), the drop reads songs. That split is load-bearing, not cosmetic — a drop must end in a track link, and resolve_apple_music_url searches entity="song", so an album whose name is not also a track name resolves to nothing and the links-only gate discards the drop. Measured 2026-09-14 on the album feed, only 3 of 10 candidates resolved to a postable link, so seven in ten rows of grounded material could not be used. The drop’s window is its own knob (JUST_DROPPED_DAYS, 7) and deliberately NOT the policy’s 30-day familiarity window: the prompt block is headed “JUST DROPPED THIS WEEK” and tells the model the rows just came out, so a 30-day window put month-old records under that heading and invited a false timeline claim. One row per artist, because the feed is a play chart and one album release floods it (12 of 17 in-window rows were a single act’s). The drop checks the qualifiers in its finished take (#4441, epic #4412). A take that says “first”, “ever”, “most” or “record” goes to utils.qualifier_check.check_source_qualifier (surface="music", Perplexity purpose music_drop_qualifier, judge purpose music_qualifier). The claim is the TAKE, not a source text: the drop composes from several sources, so no one source text is what it copies. _qualifier_gate runs at both return points of _compose (the first take and the retry), after every skip gate, so only a take that would ship is asked. A cut ships the cut take plus the original links, and every later step (history, dedup, the X mirror) reads that line. A no_clean_cut drops the drop, and so does a cut of a take with words after its link (the cut cannot be mapped back). So does a cut that leaves a broken phrase (#4477, qualifier_trim.leaves_dangling): a noun phrase with a cut inside it that ends in “to” + a verb or “with” + a number (“is his song to hit No. 1”, “the artist to win 6 Grammys”), or a copula right before a number (“Beyoncé is 10 Grammys”). is_cut does not check grammar, and nothing composes after this gate. Measured on the real lane path (real judge, stored research, 5 drop-shaped takes, n = 8): without the check 9 of 40 runs shipped a broken line, with it 0; the 21 shipped cuts were all full sentences. A recompose through music_post was measured instead and rejected: 3 of 8 picked another track (which needs every gate again), 1 restated a cut word, and it costs one more web-search compose. All three emit music_scored reason=cut_qualifier phase=shape_reject, and matchup starts with the outcome (no_clean_cut, cut) or with broken_cut; the qualifier_check event names the judge’s outcome. No verdict ships the take as before. SourceCheck.note and restates_cut are not used: no compose runs after the check, and the shipped take IS the cut claim.THE HOUSE POLICY: rap, R&B and pop only, and a record that is not a new release must be by an act we know (owner steer 2026-09-14, after CMAT’s “EURO-COUNTRY” reached @tootsiesbar). utils/music_policy.py holds the rules (pure, with the measured tag vocabulary in its module note); cogs/music.py:_house_gate does the fetching and emits music_house_gate. Four parts, and they are deliberately not all the same part:
_MUSIC_GENRES; afrobeats and neo-soul used to sit there and told the prompt to lean into them), and utils.music._HOUSE_DEFAULT_CHARTS no longer carries Deezer’s GLOBAL chart. That global row was the cross-genre breadth net, and it is what fed the drop records the bar does not play: measured on the live blend the morning the steer landed, five of twelve rows were Country (Ella Langley twice, STELLA LEFTY, a Taylor Swift soundtrack cut) or Alternative (Tame Impala), all from that row. The chart rows are still not genre-pure and nothing cheap makes them so — Deezer files US country under its POP chart, and a Deezer chart row carries no genre, so filtering there would cost a per-row iTunes lookup (up to 24 searches a drop against a host that already throttles us, #371). The source half only has to stop feeding whole off-genre CHARTS.docs/PROMPT_OPTIMIZATION.md): the taste profile’s genre line used to read “also pop, indie, rock, electronic, Latin, country (new gen)”, which licensed exactly what shipped, and the DEEP CUT lane invited “an underrated track” with no bar on the ARTIST. Both were rewritten in place; nothing was added./music too — the genre rule is editorial, not a dedup convenience. It grades the record the LINK points at (apple_music.track_row), falling back to a name search (resolve_music_row) only when the drop carries no link yet — the two disagree on a FEATURED credit, because pick_apple_music_url deliberately accepts Travis Scott’s “SICKO MODE” for “Drake - SICKO MODE” (its documented artist-absent fallback) while the search path finds no Drake-credited row and answers None. Grading the search result refused drops whose link had already resolved: measured 2026-09-14, 2 of 5 real feature-led queries. Then the kworb watch list, then RIAA — each read only when it can still change the verdict, so an off-genre pick or a genuine new release costs one catalogue read and no familiarity read at all (the kworb outage build is deliberately not memoized, so a needless call re-hits it every drop).THE MODEL CHECK ON AN OFF-LIST TAG (#3443, 2026-10-16). The allow list does not change, and an on-list tag never calls a model. An OFF-list tag used to be a silent refusal; now music_policy.judged_in_house_genre asks one cached Haiku label (claude.house_genre_check, purpose=house_genre, labels RAP / RNB / POP / NONE) with the artist, the title and the tag. Three cases refuse with no call: a blank tag, a blank artist or title, and a tag the owner named as not the genre (_NEVER_RESCUE: Indie Pop, K-Pop, Pop Latino, Pop/Rock). NONE or no verdict refuses, as before. Both call sites use it: the drop gate (_house_gate, which names the catalogue row’s own artist and title) and the newsroom’s new-release lane (_artist_new_release, asked only for the newest in-window off-list album, so one call per slot at most). The answer is cached 14 days in the durable classify cache (kv_cache, #4231). music_house_gate.detail.verdict and music_news_filtered(off_genre).detail.verdict record the step that decided (list / not_asked / model_yes / model_no / model_error). Measured on 103 real off-list records (2026-10-16: the production drop history resolved through iTunes, the three live off_genre album refusals, and 12 known hits): the Soundtrack-tagged classics (Prince “Kiss”, Whitney Houston “I Will Always Love You”, Eminem “Lose Yourself”) pass, and 0 of the 95 records hand-labelled as out passed. A first wording let country crossovers through (Post Malone “I Had Some Help”, Beyoncé “TEXAS HOLD ‘EM”), so the NONE line now says a record that belongs to the tag’s genre stays out when it crossed over to pop radio.
FAIL DIRECTIONS. The genre rule is absolute and fails CLOSED: an unreadable catalogue row is a refusal, because we cannot check what we cannot read. The FAMILIARITY rule fails OPEN on a source outage — an empty watch list is watchlist_source’s documented “everyone is known” (a kworb outage must not silence a lane) and riaa.artist_tallies returns None rather than [] when the site is unreachable. Both open cases still pass the genre rule, so the record the owner rejected stays rejected during any outage. The gate needs its own monitoring (music:house_gate, a gate_dark finding): it runs AFTER the self-gate, so a refused drop has already emitted music_scored, and surface_dark plus the quality average both read judge scores and would see a healthy surface while the channel gets nothing. The music-drop Perplexity pull uses surface="music" (a FRESH-HIT query for the biggest new/hot songs out this week with artist+title, recency=week), distinct from the discourse/news template (which explicitly asks for story pages “not a trending songs this week list”). The priority ORDER is unchanged (most drops are still a classic/deep cut, a fresh hit only when one genuinely fits) — these sources just make the fresh lane land on a real new release when she does reach for it. Links-only channel. Rides on the existing mood schedule. Song-identity dedup (the “Folded keeps re-dropping” fix): the shared music_history dedup keys on the TAKE text + trailing link, which a re-drop of the SAME song under a different take/link form slips past — and the grounded chart sources keep resurfacing a currently-charting song (e.g. Kehlani’s “Folded”), so the model keeps picking it. So music now also keeps a durable SONG reuse block (music_recent_songs, the analog of the guess games’ game_recent_songs): each DELIVERED drop’s canonical song_key(title, artist) — folded from the model’s TRACK: line via drop_song_key, the SAME key the games use, so the two dedup identically — is logged, and a track dropped in the last _SONG_REUSE_DAYS (30) is (1) filtered out of the hot_chart/new_releases material before it reaches the model (filter_blocked_songs, using the FULL block, so a still-charting repeat is never handed back as “fresh” — the direct answer to “is it due to charts?”), (2) surfaced to music_post as a LIGHT prompt nudge — only the freshest ~15 (the block is newest-first), since the full-set filter + hard gate are the real teeth and a long negative list is redundant + can anchor the model toward the songs it lists — and (3) hard-blocked at post time on SCHEDULED drops (same_song gate → one retry → skip the slot; manual /music is a mod deliberately asking so it isn’t hard-blocked, but still benefits from the filtered sources + prompt block). Pure drop_song_key/filter_blocked_songs unit-tested; bounded by prune_music_recent_songs (45d, outlasting the reuse window). Emits music_dedup (decision=song_gate, signal=same_song). The wire union was TESTED here and REJECTED (#2195 / PR #2202). The music drop does NOT read the code-owned wire accounts, and that is a deliberate result, not an omission. A live A/B in the production prompt shape (grounded sources present, 4 composes per arm, scored by the real self-gate) measured 3/4 shipping at mean 0.61 WITHOUT the wire block and 0/4 at mean 0.23 WITH it, all four collapsing onto the same fabricated track. The reason is structural: the drop has no material gap for a wire to fill, because its fresh-hit sources are hot_chart and new_releases, not a social feed. Adding news only gives the model a news SUBJECT, and picking a song to match one invites inventing a song – the ‘prefer absent over invented’ failure. Do not re-add it without a materially different design; the failure was not marginal. The music desk takes this union in _feed_context, the buzz blob behind a projection take: it was feed-channels-only, so a server with no mirror got None and the take had no social signal at all. Same list, same music_wire_accounts knob, block only. Two details are load-bearing there. The feed walk and the wire read run CONCURRENTLY inside the method (a cold wire cache can spend up to FETCH_DEADLINE_S on its live fan-out, and the method is already one leg of the desk’s context gather). And the prompt header changed: it used to read “WHAT THE ROOM’S POSTING”, which would have credited the room for wire accounts nobody there posted — it now names both sources and points at the per-block (feed) / (wire) labels. The X mirror of a drop is the take + the Apple Music link only (owner steer 2026-09-09: “remove the art from music drops and only use the Apple link”): _sched_deliver attaches no album cover image and quotes no other post, so X cards the Apple link itself (or the video link on a video drop, which rides last). apple_music.fetch_cover_art has no live caller now; the manual music-drop skill saves no cover.jpg either. The take names a part of the track, and then it stops (owner report 2026-09-13: “Commentary too ai ish and too long”, #commentary-tone). The flagged post was “Doechii turning a panic spiral into something the whole bar can yell is nasty work.” It was not an outlier. Thirteen drops in a row used one template: an artist name, an abstract scene or transformation, a comma, then a second clause that graded the first. Three named shapes are now banned in the compose prompt – the IMAGINED SCENE (“made this for when the lights come up”), the ABSTRACT TRANSFORMATION (“turning X into Y is nasty work”), and the TACKED-ON VERDICT (“, that hook still knows the assignment”). The length rule is the same fix, not a second one. The prompt used to name a “~80-100 chars sweet spot”, and every flagged drop landed inside that band, so the model was obeying the number. The number is gone. The rule now reads: you are done when you have named the part, and a longer line that names the hook beats a short one that only rates the artist. A char TARGET was tried first and it made the output worse. A 40-70 band squeezed the concrete detail out and left the verdict behind, which is the docs/PROMPT_OPTIMIZATION.md trap: the model obeys a literal number and cuts the wrong thing. Two candidate fixes were measured and REJECTED. The scorer rubric (_MUSIC_SCORE_SYSTEM) awarded its top band for “what it’s good for (the function, a slow night, the drive)”, which is the banned scene shape, so re-cutting the bands looked obvious. Measured against 30 real shipped drops it also skipped the compose’s OWN exemplars (“The Weekend hits different at 1am.” fell 0.62 -> 0.31, under the 0.35 floor) and Haiku swung 0.37 on identical shapes, so the gate was left alone: it cannot carry a register distinction, which is the same limit utils/slop_score.py documents. A deterministic checker was not built either – the shapes carry no banned vocabulary and no reliable regex frame. The EVAL judge (scripts/eval_music_post.py) carries the axis instead, and its live-log pass grades real shipped drops on it. The take no longer describes the sound (owner report 2026-10-18: “our music drops sounds like ai”). The 2026-09-13 fix asked for “something a listener could actually HEAR (a feature, a hook, a verse, a beat switch, what the voice is doing)”. The model has not heard the record, so it wrote a description of the sound in a written register: “Say My Name turns one suspicious phone call into a full group chant over that stuttering beat”, “that slow, whispered groove”. The prompt’s GOOD example was also a Doechii “Anxiety” line, and the bot re-dropped Doechii “Anxiety” as a paraphrase of it. The prompt now asks for the one REASON the track is on (a confirmed hook or a plain opinion) and says not to describe the sound. The Doechii pair and the “whole bar locked in” example are gone. Measured on the real compose (Sonnet 5.5, today’s chart rows, n=12 per arm): sound-portrait or graded-copy lines fell from 6/12 to 0/12, and 0/12 again on a repeat run. The eval judge gained a fourth shape, the SOUND PORTRAIT, with the verbatim Say My Name drop as its golden. The old judge passed it, because its simile clause read a described sound as an image and not as a fault. The take stops at its hook (owner report 2026-10-19: “still sounds like ai, maybe should’ve stopped at stage”). The flagged drop was “Megan took ‘Body’ to the Victoria’s Secret stage, so it’s coming straight to the aux.” Since the guild moved the drop to gpt-cheap (gpt-5.6-terra) on 2026-10-18, every shipped drop ended with a clause that says she is playing the track or that the hook is reason to play it: “so now I’m playing it”, “is plenty reason to clear the bar”, “I’m not fighting the aux”, “is enough for me”. The 2026-10-18 fix was measured on Sonnet only. Three causes sat in the existing prompt: the “SAY WHY IT’S ON … the ONE reason you put this track on” heading asked for a reason to play it; FORMAT line 1 carried the shared _POST_LENGTH with “~20 words”, against the music prompt’s own “there is no target length”; and the FRAMING block opened “You control the aux, you PLAY tracks in the bar”. The GOOD example “Travis hopped on Father and Ye let him cook.” also ran past its fact. The fix rewords the heading to “SAY ONE THING” (a hook OR an opinion, not both; the hook is the whole line), gives the drop its own _DROP_LENGTH with no word count (_POST_LENGTH is byte-identical for every other surface), cuts the aux line from FRAMING, and ends the Travis example at “Father”. Measured on the real compose with the live model and today’s real inputs, n=12 per arm: tailed lines fell from 11/12 to 1/12, and 3/12 on a repeat run; on Sonnet, from 8/12 to 2/12. The arm table is in docs/PROMPT_OPTIMIZATION.md. The eval judge gained a fifth shape, the SELECTION TAIL, with the verbatim Megan drop as its golden; the old judge passed it 3 of 3. The self-gate passed every tailed drop (0.60-0.78), so it is left alone, for the reason the 2026-09-13 note gives. The take names the artist and one fact (owner report 2026-10-20: “bad caption”). The flagged drop was ““Janice STFU” is a mean little record.” It went to X with no artist and no fact. The #4607 prompt allowed both: it asked for “a real hook … OR your plain opinion of it”, said “If you have no hook, say you like it and stop”, and kept a GOOD example that only grades (“That SZA hook on The Weekend is peak petty”). On gpt-5.6-terra, 9 of 12 baseline lines were a bare grade (“Kehlani’s ‘Folded’ is petty in the best way”). The prompt now says the line names the artist and ONE confirmed hook, and that a track with no hook is swapped for one that has a hook. The SZA example is cut. The hot-chart block now says that a song’s place on the list is a reason to pick it, not a hook to post, because the first arm without that line quoted the chart list in 4 of 12 lines. Measured n=12 per arm: clean fact lines went from 3/12 to 8/12, and 8/12 again on a repeat; the arm table is in docs/PROMPT_OPTIMIZATION.md. The artist rule is also checked in code. ship_guard.drop_missing_artist (output_checks.missing_artist) runs on both compose lanes, after the self-correction guard and before the self-gate. A line that names none of the artist’s words skips the slot like a self-corrected one: it emits music_scored phase=shape_reject reason=missing_artist with detail.artist, then music_fallback reason=missing_artist. One name word is enough (“Cardi” names Cardi B), if it is written as a name: “the future of rap” does not name Future. A qualifier cut that takes the artist out drops too (cut_qualifier, matchup lost_artist: ...), and the manual music-drop script ranks its takes by the same check. It flags 5 of 25 real drops in the 3 days to 2026-10-20 (all real misses) and 0 of 48 dry-run lines. The desk_shape_reject panel shows the rejects, and the ops monitor’s degradation finding fires at 3 music_fallback rows with one reason in a window. The eval judge gained a sixth shape, the BARE GRADE.
NO ANNIVERSARY OR BIRTHDAY HOOKS (owner decision on #4688, 2026-10-23: “yes i think we can do without these especially if we aren’t generating them ourselves and are copying off the x wire”). Two drops shipped with a date hook in the week to 2026-10-23: “Seventeen years later, “Bad Romance” is still pure theater.” and “Snoop turning 55 today means “Drop It Like It’s Hot” is mandatory.” A third (“Seventeen years later, “Empire State of Mind” …”) scored 0.78 and did not cross-post. utils.retrospective.date_hook reads the composed line and finds an interval with a date word (“N years later / ago / since”), an age (“turning 55 today”, “turns 45 on Friday”), “birthday” / “bday”, an anniversary (not an “anniversary EDITION”, which is a new product), “on this day”, or a throwback tag. The cog removes the drop’s own title (from its TRACK: line, the full title and the title without its “(feat. …)” tail) first, in any case and in any quotes or none, because a title can wear a hook’s words (“Happy Birthday”, ‘Birthday Sex’; Codex review on #4718). It also skips any other text inside double quotes, and it removes the link, because an Apple Music slug holds the title’s words. cogs/music.py:drop_date_hook calls it on the first compose and on the retry. A match takes the empty-compose path: the slot skips, and a manual /music gets the voiced non-answer. It runs before the artist guard, so a dated drop counts under its own reason (music_scored phase=shape_reject reason=date_hook, then music_fallback reason=date_hook). After the hooks it also reads the anchored source frames (_FRAMES: “Today in 2003, …”, “This day in 2016, …”), since #4697. The four wire desks call it on their composed take with look_back_only=True (see the retrospective.py bullet); their source cut is still cut_throwback.
music_post reads “an artist moment, a date that checks out”. Cutting “a date that checks out” moved the dry-run hook rate from 1/36 to 2/36 (live context, live context plus the birthday item, a quiet context, a quiet context plus the birthday item). That is no change, so the words stay, per docs/PROMPT_OPTIMIZATION.md./menu has three separate music channel selects, and a mod who empties the wrong one does not stop the surface they meant to stop. The split: 🎧 music (music_channels) is the SONG-REC room – music.py’s drops read it, and games.py:_room_tracks samples it for the guess-the-song pool’s taste signal. 📊 music market (music_market_channels) is the NUMBERS room – music_desk.py (scheduled) AND music_alert.py (event-driven) both read it. 📰 music news (music_news_channels) is the newsroom – music_news.py alone. The rule that decides the split is the READ, not the topic: a Kalshi/Luminate figure is a market read and belongs with the desk, so the alert rides the desk’s picker. Until #2234 music_alert.py read music_channels instead, so a mod who emptied the music-market picker still got MUSIC ALERT cards in the song-rec room, with no knob that explained why (this is exactly how the owner hit it). None of the three has a FALLBACK: an empty picker means OFF in Discord for every cog that reads it. Empty does NOT stop X – x_only_active keeps the compose alive for the X-only lane (#2137), so “off in discord” is literal. cogs/x_reply_draft.py:_CHANNEL_SOURCES is the cross-check: it lists one getter per surface family, and it already named get_music_market_channels as “music_desk + music_alert” before the code did. Artist-market listings (2026-09-02, #2800 line E): a daily lane (_artist_market_signal) reads the Kalshi open-event catalog the client already walks hourly, matches event titles to the watchlist (music_markets.judged_title_act since #3447: title_act_hits finds every act whose folded name is a whole-token run – since #4335 it compares a title only against the names whose first folded token the title holds, from a per-watchlist index, so one catalog scan costs about 20 ms instead of about 850 ms of event-loop time – and a cached Haiku YES/NO, purpose market_act_check, decides whether the market is about that act; a model error skips the market, and with no client the old capitalisation rule title_names_act decides), skips anything Kalshi files under a sport and the numeric ladder families the desk already reads (ARTIST_MARKET_SKIP_SERIES: chart position, YouTube views, streams in the year), and posts ONE new listing a day: the earliest market opened inside 10 days, total volume over 50 contracts, the leading outcome priced between 5% and 95% (artist_market_listing, prefer absent over invented). Blob = the market, its listing date, the leader and its price, the field size, the volume (artist_market_blob); card = the leader’s Kalshi snapshot with a Kalshi source pill. Its own experiment music_artist_markets (STAGING) and permanent per-event dedup in post_dedup_history (music_alert_artist_market, 1-year retention), never the song reuse block. Measured 2026-09-02: 70 open Kalshi events named a top-40 act, 54 with real volume, 32 listed inside two weeks, none reaching any surface (the swing lane’s two-week horizon and the drop’s hottest-event pick exclude them by construction). Artist-market moves (2026-09-02, #2833): the second read on the same catalog, _artist_market_move_signal, on the same experiment. For each act event (same skips, plus any event the listing lane posted in the last 3 days) it reads the raw markets, keeps the live ones with 300+ contracts, and reads their DAILY candlesticks in ONE batch call (KalshiClient.get_daily_mids over /markets/candlesticks?market_tickers=, 168h lookback; a mover is not always the busiest market today, so every liquid market is read) – so the week-ago baseline comes from the API and nothing of our own is stored. music_markets.artist_market_move picks the biggest move by absolute points whose current price sits inside 5%-95% and whose move reaches 15 points; the lane posts the single biggest move across events, once per market per direction (music_alert_artist_market_move key, <market>:<up|down>, 30-day retention). Per tick at most 20 events, two fetches each. Blob = the market, the outcome, the price a week ago and now, the move in points, the volume and the close date (artist_market_move_blob). “Now” is the LIVE book, not the last candle (2026-09-21). artist_market_move reads after through kalshi_price.market_yes_price over the raw market, the same rule _kalshi_market_to_snapshot gives the card, so the number the take states and the number the card heroes are one price by construction; the candle trajectory supplies only the baseline, and a market with no trustworthy live price is no read. Until then after was the last candle’s mid, and the two rules disagreed on the Drake album board: the “Before Oct 3, 2026” leg’s last daily candle carried a wide 6c/50c book and a 50.76c volume-weighted trade mean over 42 contracts, so the trajectory ended at 51%, while the live book stood at bid 8c / ask 30c / last 22c. The take shipped “now at 51%, up 22 points in six days” over a card heroing 22% (market_card_hero figure=22%); the media gate read it as copy_mismatch and kept the image, by design. Replayed on the live data the same read now returns None: the real move is 29% → 22%, under the 15-point floor, so the card was not a story at all. The sibling fix is in markets._candle_mid (below, under kalshi_price.py): a wide-book candle now charts the period’s trade CLOSE before its mean. And ONE payload: every artist-market lane (listing, move, standing) now builds the card’s snapshot off the raw row its read priced (_snapshot_off_read → markets.kalshi_market_snapshot) instead of a second get_event_markets fetch seconds later, so the book cannot move between the take’s number and the card’s (Codex on #3558); one Kalshi read fewer per card. And the BASELINE is bid-supported: _daily_mids_by_ticker (the move lane is its only reader) keeps a candle’s point only when kalshi_price.bid_supported holds against that candle’s closing bid, because the lane asserts a change and the Oct 17 leg’s oldest candle in the window was a 5-contract print at 49c on a 5c/44c book, which would have read as “49% six days ago, down 28” for a market whose next-day book stood at 26c/35c. Replayed live after all three changes, the Drake board yields NO read: nothing on it moved 15 points between bid-supported prices. A fall from a near-lock is no read (2026-09-26). When any daily mid in the span sat at 95% or more and the price then FELL, artist_market_move withholds the market and the lane logs market_filtered reason=cracked_lock. A broken lock means news decided the question. The fall blob carries only the new price (the 2026-09-23 steer), so the take read the decided outcome as a forecast: “Will Ed Sheeran perform at Gillette Stadium in 2026?” sat at 86-99% all week, a nor’easter cancelled both shows on Sep 25, and the lane shipped “Ed Sheeran performing at Gillette Stadium in 2026 is priced at 8%” to X. Kalshi still lists that market as active until Jan 1, 2027, so a status check does not catch it. A RISE is not affected: its blob states both prices. Measured 2026-09-02 across 168 open act markets: 3 moved 20+ points in a week (Drake to feature on Travis Scott’s album 4%→37%, Miley Cyrus album before Nov 1 26%→55%), 7 moved 10+. Nested DATE boards are not races (#3194, 2026-09-12). market_alert resolves a rung-shaped event through _resolve_ladders, and is_threshold_ladder refuses every cumulative DATE board: it strips a rung’s numbers and compares the residue, so “Before Oct 1, 2026” and “Before Dec 1, 2026” leave “oct” and “dec” and read as two subjects. That is right for guarding a survival integral over ONE quantity, and it left the board falling through to the RACE path, where _event_leader crowned the FURTHEST window – live, the Drake album board’s “Before Jan 1, 2027” at 57%, which prices highest only because it CONTAINS every earlier window – and _apply_leader_rule dropped every rung that actually moved behind it as a trailing leg. _LadderInfo.nested_dates (kalshi_ladder.is_date_ladder) closes it: a date board now suppresses its rungs like any ladder and reports no leader. It is a SEPARATE flag from is_ladder on purpose – a date board answers only one of the two questions a ladder answers. Kalshi ships these with no strike metadata, so ladder_forecast and ladder_is_decided both read floor_strike and return None/False on every one (measured on the live board); folding it into is_ladder would claim a forecast capability the board does not have. market_drop needed no change – its own _is_priced_threshold_ladder already says “numeric- or date-thresholds” and returns True on the same board (measured), and its binary path guards on the sibling COUNT. Artist-market STANDING (2026-09-12, owner steer “separate card for the leader”): the fourth read, _artist_market_leader_signal, on the same experiment and spine – the companion to a move. The move lane reports ONE leg’s price change and never says what the board as a whole says, so the Drake album-date event (KXALBUMRELEASEDATE-NEWDRA) shipped four cards in nine days – before Jan 1, 2027 at 57%, before Nov 1 at 44%, before Sep 19 at 8%, before Oct 1 at 29% – four rungs of ONE ladder, each a bare number, and the room was never told which window leads. It is a QUEUE, not a second catalog sweep: a shipped move post books its event in music_alert_artist_market_leader_queue (<event>:<moved market>, 5-day retention) and this lane posts that board’s standing. It is registered BEFORE the move lane, so within one sweep it cannot see the row that sweep is about to write and the NEXT TICK (10 minutes) it can – the standing card follows its mover by one slot, not by a day; only the one-a-day cap every lane keeps can push it further. The move lane in turn skips a board whose standing card ran in the last 3 days, the mirror of its listing rule. music_markets.artist_market_leader is the read. Every candidate is BID-SUPPORTED (kalshi_price.bid_supported, the conviction test – the same Drake board’s furthest rung quotes bid 25c / ask 98c and last traded 57c, a price the book’s own bid values at 25 cents), and the read is withheld on fewer than two priced legs, under 50 contracts, or a hero outside 5%-95%. A named FIELD heroes its top price with the runner-up as the gap. A cumulative DATE board heroes its MIDPOINT instead – the first window, in DEADLINE order (kalshi_ladder.date_rung_deadline, the labels’ own dates, never the legs’ close times), that the market prices at even money or better – because a nested board’s top price is its furthest window by construction and heroing it answers nothing about when. With no window at even money the furthest one leads and the answer is “not likely even by then”. Judging the band on the hero is what lets a healthy date board keep a far rung over 95% (6 live boards quote one, two of them album releases) while a board whose midpoint IS its soonest window, at 97%, still reads as the settled fact it is. A cumulative DATE board is a DIFFERENT claim and is flagged as one (kalshi_ladder.is_date_ladder, a new tell: is_threshold_ladder refuses a date board, because stripping the numbers off “Before Oct 1, 2026” leaves the month as the rung’s subject and two months read as two subjects). Nested windows rise with the date by construction, so no rung is the favourite date; the blob states the soonest window, the first at even money (live on 2026-09-12: before Nov 1 at 50%) and the furthest the BOOK STANDS BEHIND – never “the top price”, which is the same rung only when the board prices in perfect order (the Drake board’s Jan 1 rung is priced off a 57c print under a 25c/98c book, so the furthest window anyone bids near is Dec 1). Each window is named once, since one rung is often two of the three. A named field keeps the plain leader-and-runner-up shape. Keyed <event>:<leading label> (music_alert_artist_market_leader, 30 days), and the dedup compares the board’s LAST posted standing, not every one the window holds – a board that goes A -> B -> A has changed lead twice and the second change is as much a story as the first. Three more guards, all fail-closed: a board the LISTING lane posted in the last 3 days is skipped (the lanes run in one sweep, so without it a queued board could take both cards on one day); a board whose raw read comes back a FULL page is refused, since get_event_markets_raw reads one page and does not follow the cursor and the leader of a page is not the leader of the board (the widest live act board runs 35 legs against the 1000-leg page); and is_date_ladder accepts BEFORE/BY only – “after” windows nest the other way and a mixed board nests neither way, so a blob that says “the chance it happens BEFORE that date” can never run over one. The shape is read over every LIVE leg, not the priced ones: a thin three-rung date board with two quotes is still nested, and rendering two windows as rivals is the same wrong sentence. The label forms are a full date, a month-year (“Before Nov 2026” – 7 live boards) and a bare year, one regex classifying and parsing both. Artist-market resolutions (#2800 line E): the third read, _artist_market_settle_signal, on the same experiment and the same daily-lane spine: the follow-through on a market the listing lane announced. The roster is the listing surface’s own event tickers (music_alert_artist_market, held a year), so it never scans the catalog; each event is re-read once a tick at most 12 a tick, and music_markets.artist_market_settle reads it only when EVERY market is finalized/settled inside 7 days (the utils.luminate whole-event rule, so a mid-period threshold crossing is never a result): the YES outcome, or a single market’s NO. A settled event leaves Kalshi’s open catalog, so the act is re-read off the resolved market’s title with judged_title_act, and a title naming no watched act is skipped. The card is the big-number shape with the verdict (YES / NO) as the hero and the outcome + settle date under it, source pill Kalshi; each resolution books its event ticker on music_alert_artist_market_settle (permanent) and never the song block. The same change gave the mover lane its Kalshi pill (it wore Spotify’s).cinema_desk.py, the cinema desk (#cinema-desk): the movie/TV sibling of the music-numbers desk (#1461). A ScheduledPoster subclass that each slot sweeps four FREE sources — Box Office Mojo (domestic weekend grosses, utils.box_office), Netflix Top 10 (weekly hours-viewed + views, utils.netflix_top10), TMDB (new theatrical/streaming/TV releases + title metadata, utils.tmdb), and OMDb (a film’s critical scores — RT % + Metacritic + IMDb, utils.omdb) — ranks them by freshness/story (utils.cinema_numbers.fetch_cinema_stories → rank_stories), and posts the top story that hasn’t posted this week into the guild’s cinema channels (its OWN /menu picker + page, db.get_cinema_channels, NOT reusing music channels — owner steer). The lanes each answer a different question (box_office/streaming/release/scores + the two Perplexity-driven NEWS lanes awards/tv_news); the CRITICAL-SCORES lane is the one differentiated fact market_drop can’t do (#cinema-scores, owner steer: market_drop posts the ODDS on a movie market, this posts the FACT — “reviews are in: 94% on RT”). Kalshi’s own RT/score markets are too THIN to read a number off (verified: 0 of the top-200 traded Entertainment events are score markets), so scores come from OMDb directly; the streaming numbers the desk would have wanted from Kalshi it already gets from the Netflix TSV, so the whole Kalshi-numbers route is a dead end (music works on Kalshi only because those markets actually trade). The movie beat is RELEASE-CENTERED (box_office_story leads with the biggest NEW opener via the first-weekend total≈gross heuristic, not an evergreen hold — owner steer). On a weekend with NO big opener it leads with a notable HOLD/DROP trajectory instead of a flat “#1 led with $X” (#cinema-facts owner steer “sharper box-office angles”): parse_chart now captures BOM’s %± (weekend-over-weekend change) + weeks-in-release columns (position-anchored off the Gross + Total cells, live-verified), and _notable_holdover surfaces the sharpest legible trajectory among the holdovers — a strong HOLD (dropped ≤35%) or a CLIFF (≥55%), gated by a _NOTABLE_MIN_GROSS ($2M) floor so a real film’s trajectory carries it, not a tiny arthouse expansion. Every blob line now carries its %±/week so the take reads trajectory even on an opener weekend (“Toy Story 5 only dropping 28% through five weekends”). Worldwide context (#cinema-facts): resolve_lead also surfaces the lead film’s TMDB lifetime WORLDWIDE gross (LeadInfo.worldwide), folded into the box-office blob as context (≥$50M) so a domestic-weekend take can situate it globally (“$40m domestic, already $310m worldwide”) — never a headline number, the card still leads with the domestic weekend. Unlike the music desk there’s NO gatekept scoop (box office is public), so the value is her curated take — she’s the CONSOLIDATED movie/TV NUMBERS + RELEASES source; the prediction-market ODDS layer (reality-TV winners, casting/speculation) is DELIBERATELY left on market_drop (owner steer). Per-STORY weekly dedup (cinema_desk_events, one box-office/streaming/release story per week) so the weekly cadence emerges even on a daily slot pool. Compose reuses the shared compose_market_drop voice (a per-kind context — the NUMBER lanes lead with the figure, the release lane is a “what’s out this week” roundup); self-gated by claude_client.cinema_desk_score (0.6 floor, fail-CLOSED — the sibling of music_desk_score, NOT the betting drop_score), which is kind-aware: the number lanes (box_office/streaming/scores) are graded on the figure + a sharp read (a critical score IS a valid number), while the release lane is graded on a “worth-watching heads-up” rubric (NOT docked for having no number — the #1555 fix for a lane the numbers rubric was silently killing). The release blob carries a one-line SYNOPSIS per title (_synopsis/_release_line off the free TMDB overview, not just bare names + dates), so the take can say what a title IS rather than hedging (“worth a look depending on what it is”) — the telemetry-driven fix for the lane chronically self-gating OUT (prod: 5/5 dropped at avg 0.15; the enriched blob ships at 0.62–0.78). Gated on its OWN cinema_desk experiment (production → the cinema channels, staging → a #bot-logs audition, off → skip) + master switch + mood; cadence on the /menu calendar (cinema_desk is a calendar surface). All three sources are free + unmetered (no quota poll) but fully guarded (rate limiter + circuit breaker + retry + a TTL cache) and instrumented (tmdb_fetch/box_office_fetch/netflix_top10_fetch/omdb_fetch per-call + integration-health, which surfaces a silent BOM IP-block / TMDB key death / Netflix TSV shape change / OMDb key death). Delivery is the UNIFIED BIG-NUMBER card — the SAME schema as the music desk (#cinema-facts, owner steer “big numbers like the music desk, with the art” + “unified design schema”): the film NUMBER lanes (box_office/scores) ship the shared utils.market_chart.render_number_card PNG (the exact music-desk card: a small-caps dot-path eyebrow CINEMA · BOX OFFICE, the film title, the BIG GREEN figure — the weekend gross / the RT % or Metacritic score — a sub-caption, and a status pill REPORTED/CONFIRMED), now with the film’s TMDB POSTER composited top-right via render_number_card’s new optional artwork_png (a backward-compatible param the music desk can also use for album art). It rides in the SAME Components-V2 LayoutView the music desk uses, assembled by the new public utils.market_cards.build_number_card (her take as a TextDisplay + the card image + a native link Button), delivered via send_market_card. The button links a Kalshi market for that film when one genuinely exists (CinemaDesk._kalshi_link → markets.kalshi_search with a film-scoped intent, thin coverage so usually absent) ELSE the film’s TMDB page. The card also folds in a lazily-resolved CREDITS grounding line (director + top cast off cinema_numbers.resolve_lead, one guarded TMDB lookup for the CHOSEN story only) so the take can name the makers (“Ryan Coogler’s Sinners”). The STREAMING lane cards too (#1578): the Netflix #1 is often a TV SHOW search_movie can’t resolve, so resolve_lead(media_kind="tv") pulls the poster + TMDB tv page via tmdb.search_tv (a show carries no director/cast credits line — creators, out of scope). Only a release (multi-title roundup) story, or any figure/render miss, degrades fail-open to a plain-text post. The card renders at 2400×760 (a _OUT_SCALE-2× higher-res emit, #1578) with a w780 composited poster, and carries NO status pill (dropped — the verdict lives in her voice). The card build runs off-thread (asyncio.to_thread) and the poster fetch is best-effort. X crosspost (#1581): a shipped card mirrors to the @tootsiesbar timeline (take + card image + Kalshi/TMDB link) via maybe_crosspost (surface="cinema_desk"), gated by the x_crosspost experiment. Live context beat (#cinema-facts): before composing, the desk pulls utils.context_beat.gather_context_beat (Grok X-crowd + Perplexity web/socials buzz, purpose="cinema_desk") and passes it as compose_market_drop’s x_context so the take carries the sharp CURRENT angle (the fandom read, a critical split, a second-season-slump framing) the bare number can’t — sentiment/context only, hard numbers still from the story blob; provisioning-gated + fail-open. News lanes (#cinema-facts, awards + tv_news): two Perplexity-driven NEWS beats beyond the scraped numbers — awards_story (Oscar/Globe/Emmy/festival nominations + wins + snubs) and tv_news_story (a show renewed/cancelled/greenlit). fetch_cinema_stories takes the perplexity client and runs one fresh-week search per lane; the Perplexity text IS the grounding (like the release lane’s TMDB titles — the compose quotes its specifics, never invents an award/count/renewal). Each self-declines (_clean_news → no story) when the pull returns the NONE marker / a “nothing this week” punt, so awards stays dormant most of the year (seasonal) and TV news only fires on a real renewal week. Scored on the release-style rubric (cinema_desk_score routes awards/tv_news there — no number required, an award COUNT is fine), weekly-deduped (aw:/tv:). Raw ART on the bare-text lanes (#everything-has-art → #thin-ahead-art): the news/release lanes (tv_news/awards/release) no longer ship bare — after the take composes, the ONE unified resolver runs on it (resolve_desk_image(line), the same ranked-subject walk every desk uses: a film/show take → its TMDB poster, a person-centric awards take → their photo), and the resolved bytes ship attached to the take (NOT a custom card — the big-number card format is for a figure, not a block of prose; the raw art IS the image). A multi-title roundup ships a poster PER show named (#individual-art, owner steer “attach photos of all shows mentioned”): the release “what’s out this week” lane names two or three titles, so the bare-text lanes resolve art through resolve_desk_images(line) — the multi twin of resolve_desk_image that walks the SAME ranked image_subjects list but keeps ONE poster for EACH subject (not just the first-with-art), byte-deduped and capped at 4 (X’s media ceiling; Discord matches so both surfaces show the same set). The release lane hands the resolver the EXACT posters TMDB already gave each title (release_story builds a {norm_subject(title): poster_url} map into meta["posters"], passed as resolve_desk_images(..., title_posters=…)): the blob gives the model only titles, so a film/tv subject would otherwise be re-searched by NAME — and a generic title (“Desire”) matches a popularity-ranked HOMONYM, not the current release the roundup is about, which the delivery coherence gate then drops, so the post shipped one art short (owner report — the Desire/Pyaar Prema Kalyanam roundup went out with one poster instead of two). Using the poster the story was built from removes that miss; a title not in the map (or a failed download) falls back to the normal search path, and the news/awards/tracking lanes carry no map so they are unchanged. _sched_deliver attaches them all — Discord channel.send(files=…), the X crosspost crosspost_with_quote(images=…) → maybe_crosspost uploads each into the tweet’s media_ids, the per-photo coherence gate dropping a bad one without sinking the rest. A single-title news/awards take just yields one, and the number lanes stay single (the card is one film). The LEAD keeps its any-kind art (a person-centric awards take still gets that person’s photo); the EXTRA posters are film/tv TITLES only — a live dry run of the Lanterns take showed the classifier kinds the CHARACTERS “John Stewart”/”Hal Jordan” as person, whose TMDB person search returns a NAMESAKE’s headshot, so a non-title extra is dropped (absent over invented). resolve_desk_images returns (bytes, source, kind) and the cog keeps index 0 plus the film/tv extras. This replaced the bespoke title-only claude.cinema_news_hero extractor (deleted — it went bare on any take without a single film/TV title). _sched_deliver routes a lane with a card_number (the box_office/scores/streaming NUMBER lanes) to the unified build_number_card, a lane with only resolved art to the raw discord.File + take, and a lane with neither to plain text; the crosspost mirrors the art bytes to X. Resolved from the composed TAKE (not the story) so the image matches what she actually led with; fully fail-open (no art → the bare-text post, never skipped). The NUMBER lanes take that resolved image as the card’s BACKGROUND too (owner steer “even for cards we can use the images as background”): the resolve is no longer gated to the bare-text lanes, so a number lane whose lead has no TMDB poster falls through to resolve_desk_image and the card ships a real full-bleed background instead of the dark panel. The art RUNG travels with the bytes into build_number_card(artwork_source=) → _art_fit, so a logo is fitted whole rather than cover-cropped. Emits desk_art (surface=cinema_desk) so an art-less card is a queryable rate rather than something spotted by eye — carded only ever said a card was BUILT. Pure builders/parsers/ranker in utils/cinema_numbers.py + the shared render_number_card/build_number_card + the three client modules (unit-tested tests/test_cinema_desk.py); the guarded fetch lives in the clients. Emits cinema_desk_posted (with carded) / cinema_desk_scored. RANKED BOARDS (owner ask 2026-08-10, epic #2266): the desk also draws ranked BOARD cards off the same Box Office Mojo pages, on its own cinema_boards experiment (PRODUCTION by default, on the music publication-boards precedent – a weekend chart is perishable and the rows are a faithful reprint of a page BOM already published). Three of them: the weekend top 10, the per-screen average (where a three-screen platform release out-earns a blockbuster and the top 10 hides it), and the year-to-date race. A fourth, holds-and-drops (holdovers ranked by weekend-over-weekend change, best hold to hardest cliff), was cut on 2026-09-13 (#3241) because it got no engagement on X — the owner reported 7 impressions on a shipped card. It was the desk’s smallest board by volume (Axiom, 30 days to 2026-09-13: 5 built, 5 shipped, against 99 for the weekend top 10), and the trajectory read it carried still runs on the weekend top-10 board, where _notable_holdover picks the sharpest hold or cliff to lead a debut-less weekend. Pure builders in utils/cinema_boards.py; the lane in the cog is the fetch + pick + compose + dedup shell, the same split the music desk uses. A board’s payload is a LIST, so it renders through market_cards.build_board_card (surface="cinema", so the wordmark reads tootsies cinema) rather than the big-number card, and each board key has its own chart_cards.ACCENTS entry in a warm band so a box-office card is never mistaken for a music one. Weekly dedup keys (cbo:<kind>:<weekend>), _MAX_BOARDS_PER_SLOT so a first slot does not empty the week’s slate, and the SAME deterministic shape guards the music board lanes run (_board_unshippable: self-correction, row arithmetic, row tally, all-time claim) in front of the 0. The CRITICS board posts ONCE PER MOVIE (owner steer 2026-08-23 “these should only post once per movie”). The weekly key alone re-shipped the same holdovers’ scores every weekend they held (prod: The Odyssey led the critics board on Aug 11 AND Aug 22, same 88 Metacritic), so a film’s critic score is now a one-off. The scores story lane shares the same per-film stamp (#2517). It used its own rt:<title:year> key and never read the critics_board stamp, so one film’s scores could post once as a critics card and once as a scores story. _pick_stories now drops a scores story whose film carries the crit: stamp (it emits cinema_desk_scored phase=source_thin reason=critics_posted), and a real-audience scores ship writes the stamp (_stamp_scores_posted). A staging audition writes no stamp. Every film on a SHIPPED critics board is stamped in the shared songstats_milestone_state store under metric critics_board (_record_critics_films, entity crit:<norm-subject>), and _critics_board drops any film already stamped, walking DEEPER into the chart to fill the board with films NEW to it. get_milestone_rung’s read-bumps-seen_at keep-alive holds a film’s stamp for the life of its chart run; the 21-day prune clears it only after the film has left, where it is no longer a candidate anyway (the durable “seen for its whole run” the 14-day cinema_desk_events table cannot give). The board’s headline is framed as “this week’s new critics scores” (NOT “the weekend’s top 10”), because a higher-scored holdover that is already stamped may sit off the board — a top-10 claim would be false. Staging never stamps (a #bot-logs audition must still be able to reach the room). So the board reappears only when NEW films are reviewed. The ranked BOARD needs TWO new films (_MIN_SCORE_ROWS, owner steer 2026-08-23 “make the bar 2”); a SINGLE new reviewed film ships as the big-NUMBER card instead of a one-row board (owner steer “for single do a number card”). The single card is a single=True CinemaBoard that rides the same board pipeline — the once-per-movie stamp and the cbo:critics_board:<weekend> weekly key work identically (the one row still carries the packed MC / RT cell the stamp reads the film off) — but CinemaDesk._sched_deliver draws it with build_number_card (RT %/Metacritic headline, the film poster as background) and _compose_board_unit hands it the number-desk _CONTEXT rather than _BOARD_CONTEXT, because it is one figure and not a list. critics_scoreboard returns the single card when exactly one new film clears the gross floor; the coverage floor does not gate it (a single card makes no “best of the weekend” claim). 6 self-gate. Those guards are not theoretical here: the first live dry run shipped four takes at 0.92 and two of them broke a fence stated in capitals – a summed ‘every other film in the top 10 combined’ and a subtracted ‘$184M gap’. Both are caught deterministically, and the fences now name the exact failed sentences. The numeric backstop reads a STRIPPED copy of the block (#2311/#2344): ungrounded_numbers whitelists every number the source states, so numbering the rows (per_screen since #2279, the critics board since #2344) quietly grounded 1..N – a fabricated score of 8, or a wrong “#2”, cleared the one check that does not depend on the model noticing. cinema_boards.grounding_source removes the row markers before the check, and cinema_boards.strip_position_claims removes position claims from the TAKE for the same check, BOUNDED to the board’s row count so a real figure past the last row (“Rotten Tomatoes number 98”) stays checked as a figure. The two strips are a PAIR: stripping only the source holds a CORRECT “#1” for quoting a number the block no longer states. A position is not a figure the membership check can adjudicate – it cannot tell a right rank from a wrong one – so the cinema_desk_score judge owns it under its WRONG PLACE rule, reading the take against the still-numbered block. Related: the judge’s UNGROUNDED rule now asks for a figure on the line the block gives THAT film, so every single-film block (the worldwide context line, scores_story, gross_milestone, budget_cross) names its film on the figure line rather than scoping it by a header or an “It”. Two NUMBER-CARD lanes off the same weekend chart (#2266): weekend_champ, the weekend’s #1 film as one hero figure, and gross_milestone, a film crossing a round DOMESTIC-GROSS rung ($100M, $200M, …). The milestone rides the SHARED utils.milestones high-water spine under a new domestic_gross ladder and the existing songstats_milestone_state table (entity bo:<release-slug>), so no new table and no new detection code: a film seen for the first time is SEEDED at its current total and never announces a rung it passed before we were watching, and the table’s 21-day prune is exactly right (a film off the chart for three weeks is done, and a re-entry re-seeds). The champion card scores BELOW the box_office lane on purpose – both describe the same weekend, and rank_stories dedups by subject, so the lower score is what makes the champion yield when the box-office lane already leads with the #1 film. It survives exactly when that lane led elsewhere (a new opener, or a holdover’s trajectory). Both lanes run the deterministic SHAPE guard before the self-gate (_SHAPE_GUARDED_KINDS); the news/awards/release lanes deliberately do NOT, because their ground truth is a live web pull that can genuinely carry a first-ever. The BUDGET-CROSSING card (budget_cross) fires once per film when its running domestic gross passes its TMDB-reported production budget. It rides the same songstats_milestone_state store as the gross ladder under metric budget_cross, but it is NOT a ladder – one threshold per film, the film’s own budget – so the stored rung is a FLAG (0 seen-and-below, 1 fired) and the first sighting seeds the honest answer, which is what stops a film already past its budget announcing a crossing that happened before we were watching. TMDB leaves budget at 0 for most titles, and an absent budget is not a small one, so a figure under _MIN_REPORTED_BUDGET means no card rather than a crossing at zero. The card must never say the film is profitable: marketing spend is not public and the industry rule of thumb is roughly twice the budget worldwide before a film profits, so passing the production number is a milestone and not break-even. That is stated in the ground truth in those words, because it is the one claim this card invites and gets wrong. The QUALIFIER check on the news + release lanes (#4439, epic #4412). The awards, tv_news, tracking and release lanes skip the shape guard, so a false “first”, “most” or “record” in the web pull (or one the compose adds) used to ship. _QUALIFIER_CHECKED_KINDS now runs utils.qualifier_check.check_source_qualifier (surface cinema_desk, research purpose cinema_desk_qualifier) on the FINISHED TAKE, after the empty-compose and ungrounded_numbers gates and before the self-gate. It reads the take and not the pull, because the pull is a multi-item roundup with no one headline claim, and the take is the short text that ships. The branches: no qualifier, kept or unchecked ship the take as composed; no_clean_cut drops it; cut composes ONCE more from the blob plus qualifier_trim.cut_note (a cut of a take can read badly, so the cut text is an instruction, not the post), then drops the new take when it states a cut word again, or when the check run again on it finds a new wrong qualifier (both cinema_desk_scored reason=cut_qualifier, score 0.0, no third compose), or when it carries an ungrounded number, and the self-gate grades it against the blob plus the note. Cost: one Perplexity search and one Sonnet call per take with a qualifier, plus one compose per cut.
tracking lane composes from one Perplexity pull (utils.cinema_numbers.tracking_query). Three rules, each from a live pull on Sunday 2026-10-18:
tracking_friday(today) is the next Friday AFTER today, and the query asks about the films opening that day, one title | $figure | movement line per film (8 live pulls on 2026-10-18: all 7 non-NONE pulls used the form; 3 of the 8 still answered for last weekend’s films, and the opened-film skip caught all 3). The old “THIS coming weekend” read as the weekend in progress on a Sunday, and one pull tracked four films whose result was already out. The story’s dedup key is that Friday’s ISO week (trk:2026W43), so a Sunday pull and a Monday pull for one weekend share a key. The compose context (_TRACKING_CONTEXT) says “films opening on the Friday the block names”, not “this weekend”, because from Friday to Sunday the tracked Friday is next week (live A/B on 6 real Clayface blobs: the old text wrote “this weekend” in 1 of 18 takes, the new 0 of 18; both shipped 18 of 18).tracking_items cuts everything from the first sources heading (_SOURCES_HEAD: “SOURCES:”, “Sources:”, “References”, “## Citations”, any case) and the citation markers, and drops any answer line that holds a URL. It keeps a line only when it names a film and a $ figure. A title | $figure line gives its title as the first field; other forms (a lead before a dash or the last colon, a bold or quoted name, a plain title before a verb) are a best-effort fallback, an intro sentence (“Here is the tracking …”) is not a title, and a line they cannot read is dropped. A film or a figure that only a cited headline states never reaches the compose, and ungrounded_numbers drops a take that repeats one._result_subjects reads each SOURCES headline and each answer line clause by clause (and each | field). A clause reports a result when it has a result verb (_RESULT_VERBS, present and past: opens / opened, debuts, grosses, earns, bows, launches with, takes in, scores, pulls in, …), no projection word in that clause (tracking, projected, eyes, could, will, set to, …), and either a past-tense open verb (“opened Friday in 3,000 theaters”) or a figure (“$” or “No. N”). So “Opens Friday”, “Opening Friday With $23M” and “earned strong reviews” are not results. The clause’s subject is its quoted title, else the words before the verb; a clause that starts with the verb is about the line’s own film, and one led by “which” / “its” is a comparison with another film and names none. The pull is skipped when a result subject is the SAME film (_same_film, exact after norm_subject: “Scream 7 Opens to $40M” is not “Scream”) as a film the pull names, when a result’s film cannot be read, or when a pull film matches the weekend chart or a TMDB now_playing film released on or before today. The check runs on every answer line before the $ filter. A “Preview:” headline is a projection in every clause. One opened film skips every line of the pull: the films of one pull open on one weekend, and the per-film checks caught only 2 of 4 films of the first wrong-weekend pull. A comparison line (“below Joker, which opened to $96M”) stays in the compose. The skip emits cinema_desk_scored phase=source_thin reason=opened_film score=0.0 shipped=false, with the skipped films in matchup and the slot’s guild_id (the ops monitor keys a source_thin row on its guild’s posts). source_thin is the ops monitor’s existing non-score phase, so the skip does not count as a 0.0 judge score against the desk. No new fetch: the chart and now_playing are reads the lane already makes. scripts/dryrun_cinema_desk_score.py --lane N runs N live pulls end to end and prints, per pull, the films kept, skipped and named only in SOURCES, the take, and whether it passes the number check and the self-gate.pop_desk.py, the pop desk (#pop-desk): the celebrity/pop-culture sibling of the cinema desk. A ScheduledPoster subclass that each slot reads what the pop-culture WIRE accounts (utils.pop_stories.POP_WIRE_ACCOUNTS — CODE-OWNED, the same trusted-account model music_news._TRUSTED uses, NOT a per-guild /menu picker, so a server needs zero config and the list grows by evidence in one place; seeded PopCrave/PopBase/TheePopCore + more). The pop desk IS the music CULTURE wire desk: there is deliberately no separate music moment/culture wire surface (the music-news desk lane-orders its own NUMBERS slate with the same utils.wire_lanes orderers — biggest + breaking leaders compose first, then the multi-item slate — but stays a numbers desk) — a breaking music moment rides these two lanes, so the list carries the music CULTURE wires — the owner-vetted 2026-08 music-list pull (Rolling Stone/XXL/Pitchfork/RapTV/NME/Stereogum/Consequence/The FADER + the fast urban wires FearedBuck/Kurrco + the music_news._TRUSTED culture accounts popx/thepopstuff; every handle probed live before landing, dormant ones — hiphopdx, strappedhh — skipped on evidence), while the numbers AUTHORITIES (chartdata/billboardcharts/touringdata/…) stay OUT: a chart/consumption number is the music-news desk’s beat, and polling them here would only manufacture redundant supply. The whole music wire list now feeds BOTH desks (#2031, owner steer “move all music related accounts to music news too”) — it used to be six dual-role accounts (billboard/talkofthecharts/hitsdd/chartmastersorg/headlineplanet/dailyrapfacts). Measured cause: the newsroom polled trusted_handles(numbers_only=True), i.e. 13 numbers authorities, so a chart stat that ONLY an aggregator carried could not reach it at all. On 2026-08-05 @Rap posted “Drake ranked as Spotify’s #1 most-streamed artist in the US yesterday” and “Drake’s ‘ICEMAN’ has returned to being the #1 Hip-Hop album on Apple Music US”; @chartdata posted the GLOBAL artist chart instead and @billboardcharts carried neither, so the newsroom never saw either claim. The pop desk DID see them and ranked them #51 and #79 of 380 candidates against a top-4 compose window — so neither desk could reach a story both were nominally covering. Every MUSIC_WIRE_ACCOUNTS handle is now a _TRUSTED row (guarded by a containment test, since the two lists are hand-maintained), joining as CULTURE (relay_number=False): an aggregator’s chart claim is a LEAD the claim settler (#1890) checks against the live chart we already fetch, never a number relayed as fact. The settler now covers STREAM claims too (#2771, _live_stream_total). #1890 wired kworb into the CHART branch and did not sweep the MILESTONE branch, which kept verifying on the open web – and both web legs (Perplexity + Grok) inherit aggregators that LAG Spotify, so a TRUE claim came back refuted. A wire said Rihanna had 8 songs over 2 billion Spotify streams; the judge answered “her highest is ‘Stay’ at 1.972B; no songs confirmed over 2B” and the story was dropped three times, while kworb’s artist songs page – the page the watch sweep already fetches ~20x a day – listed exactly 8 at or above the rung, Stay at 2,026,849,516 and her highest at 2,647,680,489. The judge’s counter-figure was wrong twice over. Drake’s “140 billion Spotify streams” was dropped four times across four days against a leaderboard reading 140,255,100,000. Three shapes settle off pages already cached: CATALOG counts the artist’s titles at or above the rung, TRACK reads one title’s total (matching through kworb’s feature ASTERISK, which on Rihanna is two of the eight qualifying songs), CAREER reads the leaderboard. The settled total is COMPACT (stream_settled_claim / stream_evidence render it through numfmt.abbrev, “2B” / “140.3B”): the compose is told to quote the settled figure exactly and the digit gate holds it to that, so a full-digit claim shipped as its own caption – “Bad Romance by Lady Gaga has reached 2,002,208,291 Spotify streams” under a card heroing “2B” (2026-09-17, owner: “should say 2B streams”). The card’s hero already used abbrev, so the claim now uses the same formatter and the caption and the hero state one figure in one form; the catalog rung keeps its spoken form (“2 billion”) because that is the claim’s own shape. stream_claim scopes it to a claim naming BOTH Spotify and a stream metric, because kworb counts Spotify and nothing else – answering an Apple Music claim with Spotify numbers would be worse than the bug. The reading OVERRIDES a contradicting web verdict, the same call #1890 made for charts rather than a second rule, and it also goes to the judge as evidence so the telemetry stops reporting a refutation we have disproved. The judge row’s PRECEDENCE CLAUSE is load-bearing and measured: the verify prompt weighs its research rows equally, so two web legs outvote one first-party row, and a label ABLATION on the real judge (n=5/arm, evidence text held constant) scored “first-party” 0/5 verified, “the data authority” 0/5, plus “not a report of them” 0/5, and only “when this and the web disagree, this is the correct number” 5/5 – while every arm still contradicted a FALSE claim 5/5, so it is not a rubber stamp. Fail direction is ABSENT: a kworb miss returns nothing and the web path runs unchanged, because an unreachable page says nothing about whether a claim is true, and ok=False means READ-and-did-not-bear-out rather than refuted (a wire count can be one ahead on the day a song crosses). WHICH CHARTS the newsroom may report is now a list too (#2127, owner cut 2026-08-07). Every other chart surface names its charts in code — the movement lane reads MUSIC_DESK_CHART_LANE, the boards read MUSIC_DESK_STANDINGS_CHARTS, the genre boards read billboard.GENRE_CHARTS — and the newsroom was the one that did not: its gate is the ACCOUNT plus the number cue, so it reported whatever chart a trusted account happened to post about. Measured over the 30 days to 2026-08-07, its 135 chart stories named ~20 chart families, including ARIA, the U.K. Official Albums Chart, Billboard Japan, Bubbling Under, Kid Albums, Top Movie Songs and a Dabeme fan tally of “Top 100 Male K-Pop Vocalists”. music_news.KEPT_PATTERNS / CUT_PATTERNS is the owner’s reviewed scope (kept: Hot 100, Billboard 200, Global 200, Streaming Songs; the rap + R&B/hip-hop + Latin genre charts, songs and albums alike; the pop / adult pop / dance / urban / rhythmic radio formats; Spotify daily + Top Artists, Apple Music, iTunes), applied by _chart_in_scope right after classify so an out-of-scope story costs no verify and no compose. A cert must be a US (RIAA) or UK (BPI) one (owner cut 2026-08-21, “we mostly only care about US certifications”, plus “keep BPI, it’s popular”): the desk read a certification from any authority a wire or the artist pull named — RIAA (US), BPI (UK), SNEP (France), ARIA (Australia) — and a BPI silver reached X (an Ariana Grande “petal” UK silver, the report that prompted this cut; BPI is kept, so that one is fine — the cut removes the rest). _cert_in_scope (sibling of _chart_in_scope, same right-after-classify slot, music_news_filtered reason=cert_out_of_scope) keeps only RIAA + BPI certs via music_news.cert_reportable, which reads the model’s cert body first (a named authority ships only when it is riaa or bpi) and the prose + the story’s source field second when the body is empty. With an empty body the cert ships only on a POSITIVE US/UK signal (“RIAA”, “BPI”, “in the US”, “in the UK”, or a BPI-only Silver tier), and a named foreign authority (snep/aria/fimi/…) or an “in cinema_news.py, the cinema news desk (the film/TV NEWS wire desk): a ScheduledPoster + EventPoster that reads the film/TV wire accounts (utils.cinema_news_stories.CINEMA_NEWS_WIRE_ACCOUNTS), ranks the fresh moments, and composes one take per lane (breaking, biggest) with claude.compose_cinema_news_take, then grades it with claude.cinema_news_score (ship floor 0.6). Qualifier check (#4438, epic #4412): right after the is_stale_recap gate, _compose_one calls utils.qualifier_check.check_source_qualifier on the whole wire tweet (story.headline, research purpose cinema_news_qualifier). Before that, a copied qualifier (“a first”, “the biggest ever”) was never checked, and the lane has no output_checks gate. The rules:
.drop (a wrong qualifier with no clean cut) drops the story. The check runs before the confirm search and the beat, so a dropped story pays for neither..changed replaces the tweet with SourceCheck.claim in the blob and in the compose headline=, and appends SourceCheck.note after the confirm brief. The self-gate grades that corrected blob.qualifier_trim.restates_cut is true is dropped as cinema_news_scored reason=cut_qualifier score=0.0, before the metered self-gate.unchecked) ships as before. The lane has no number-grounding step, so there is no number grounding to rewrite.utils/industry_feeds.py + utils/pollstar_charts.py, the first-party industry sources (#reporting-oversight): the trade press read DIRECTLY, so a number is in hand rather than chased through a Perplexity/Grok web-confirm that lags a fast story. This is the fix for the R&B Tour gross case — the pop wire said $153.4M/20 shows, the web-confirm returned the last PUBLISHED figure ($101.9M/13 shows), and Toots posted the stale one. Two readers, both keyless, fail-open, and DARK until a desk consumes them (a later change adds a reserved first-party LANE + its own slots to the desks). (1) industry_feeds reads four public RSS feeds (FEEDS: Pollstar news, IQ Magazine, Music Business Worldwide, Billboard music-news), one namespace-driven parser, mapping each item to the same SourcePost the X provider yields (so the desks rank/gate/compose/dedup it through the pipeline they already run); each source has its OWN breaker + limiter so one host refusing us doesn’t blind the others; feed_fetch per read. (2) pollstar_charts reads Pollstar’s public chart JSON (data.pollstar.com) — the live-music numbers Billboard does NOT publish (Live 75, Global Concert Pulse, New Tours, Artist Power Index, 24 Mediabase radio formats), anon free-tier only (no auth ever sent, only the preview rows the endpoint returns), pollstar_fetch per read. A Pollstar chart id names one WEEK’S ISSUE, not the chart (#3357): every issue gets its own id and none is ever updated, so the registry id is a SEED and the client resolves the current issue off the chart’s config endpoint before each read. Pinning it instead froze the touring + radio lanes for 15 days at HTTP 200, which is why lane_stale_upstream now watches the freshness fence. ToS one-way-door (owner-accepted, epic issue): Pollstar’s terms forbid redistributing chart content and stripping attribution, so every posted Pollstar number MUST carry PollstarChart.attribution (“Pollstar music_news.py’s FEED WATCHER (2026-09-28, owner target: a first-party source to the room within 5 minutes). Miley Cyrus’ Billboard 200 No. 1 went out on Billboard’s Chart Beat feed at 21:20:04Z and nothing posted it, for two reasons: the newsroom did not read that feed (only Billboard’s music-news feed, which does not carry chart announcements), and it read its feeds only in the hourly slot, on a 15-minute cache. Now FEEDS has billboard_chart_beat and the feed cache is 90 seconds. Since #3834 the feeds are the second source of the X watcher’s EventPoster lane (next item); until then a second loop (feed_watch_tick + _feed_watch + _watch_post_one, every 2 minutes) ran its own gate, compose and deliver walk with quiet hours, and that loop is deleted. Each 3-minute poll, _event_fetch reads every FEEDS entry (the guarded feed client, fail-open per feed) next to the X search. _event_candidates puts the feed items first through _fresh_feed_items, the ONE filter the slot pool (_gather_feed_pool) also uses: unseen, a numbers cue (mn.looks_like_numbers_story), published inside _FEED_WATCH_MAX_AGE_MINUTES=60 (an older unseen item stays with the slot, so a deploy or an outage cannot replay a backlog), ranked by source authority then freshness, capped at _MAX_CLASSIFY. The music_news_firstparty overlay (_firstparty_lane_live) still gates them. _event_pick caps the X posts at _X_WATCH_MAX_PER_PASS on their own, so an X burst cannot hold a Chart Beat item back. Quiet-hours rule, per item kind: the lane runs at any hour (EVENT_NUMBERS_SOURCE), so only NUMBERS items may enter it – an X post from a number authority with a numbers cue, or a feed item with a numbers cue. Every other item waits for the slot, which keeps the calendar. A calendar with no newsroom slot on the mood still mutes the lane. Two small behavior changes come with the spine: the /tune “breaking posts” gap of 0 now turns the feed source off too (it was never read by the old loop), and a fragment is remembered in the spine’s in-process declined set instead of being marked seen (as for X; a restart can recompose it once). There is NO gap between watcher posts (owner steer, “no gap”); the text/topic dedup is the only thing between two stories. Deliver and dedup both mark the item seen, so the slot never repeats it. A chart the feed headline does not name never ships (_feed_chart_ungrounded, in _classify_post, so the slot and the lane both apply it): the replay for #3834 found that production had shipped Chart Beat’s “Taylor Swift’s ‘The Life of a Showgirl’ Returns to No. 1” (an album, so the Billboard 200) as “No. 1 on the Billboard Hot 100” on 2026-10-04; the headline names no chart and the classifier picked one. An RSS item whose story names a chart that shares nothing with the charts the text names (mn.charts_named, none counting) is dropped with music_news_filtered reason=chart_ungrounded and marked seen. X posts are not checked: an authority often names no chart (“Popstar to debut at #9”) and the classifier’s chart is then right. When the headline and dek name no chart, the article’s first paragraph supplies it (#3838): industry_feeds.parse_feed keeps the first <p> of content:encoded outside a related-stories block as FeedItem.lead (extra["lead"]), and _fetch_feed appends it to the item’s text through _with_feed_lead ONLY when the headline names no chart and the lead names one. The classifier, the compose and this gate then read Billboard’s own “returns to No. 1 on the Billboard 200 albums chart”. A lead that names no chart adds nothing, so a chart is never guessed. The lead writes Billboard’s own move notation (“vaults 21-1”), and mn.wire_is_next_week reads it like chartdata’s “#1(+20)”: on the dry run the live page (last week’s, #21) otherwise settled the claim and 4 of 5 takes said “#21”. Its posts carry lane=feed_watch + fast=true + source_age_seconds on music_news_posted (it was lane=breaking before #3834), and feed_watch is in the ops monitor’s MUSIC_LAG_NEWS_LANES and break-lane series. The same “new item -> post now” trigger is the plan for the desk’s sources (the Billboard chart-page flip, kworb, HITS, RIAA, Pollstar).music_news.py’s X WATCHER (#3824, part of the latency epic #3821; owner target: a number authority’s post in the room within one 2-5 minute poll). Measured over the 7 days before it: the median source age at post time was 3 h on the “biggest” lane and 8.7 h on “slate”, and no post was under 10 minutes. chartdata posted Tinashe’s Billboard 200 debut at 22:31Z on 2026-10-02; the slot started at 00:00Z and delivered at 00:35Z (source_age_seconds 7442). It is an EventPoster lane (owner ruling on #3821: consolidate, do not duplicate): MusicNews subclasses EventPoster, and its own 1-minute event_tick (NOT the slot’s scheduler_tick, which composes for 20-40 minutes) calls run_event_tick(). Four class settings and two hooks make the spine fit, each defaulting to the wire desks’ behavior. The two hooks were added for the feed source (#3834): _event_ready(provider) (default: the X provider is set up; the newsroom polls when EITHER X or the feed client is set up, so an X outage does not stop the feeds) and _event_lane(story) (default EVENT_LANE; the newsroom names feed_watch for an RSS item). The settings: EVENT_POLL_MINUTES=3 (aligned poll window, default 15), EVENT_LANE=”x_watch” (attribution), EVENT_PACED=False (the /tune “breaking posts” gap is only the on/off switch here, 0 = off; a 30-minute gap would hold the second story of a chart-day burst for half an hour), EVENT_NUMBERS_SOURCE=True (within_calendar_span(numbers_source=True), the #3823 opt-in: no quiet hours, but a calendar with NO newsroom slot is a mute and still stops it). The hooks: _event_sources = every number authority (mn.trusted_handles(numbers_only=True)); _event_fetch reads them in ONE advanced_search (mn.authority_search_query), one token of the shared twitterapi.io limiter (10/min) per poll, and emits wire_read like the other desks; _event_candidates = mn.authority_watch_candidates (an authority wrote it, not a reply, not seen, a numbers cue, published inside _X_WATCH_MAX_AGE_MINUTES=30); _event_pick replaces the velocity trigger (an authority’s numbers post is news by itself) with every candidate, oldest first, up to _X_WATCH_MAX_PER_PASS=4 a poll – the base _event_pick still returns at most ONE velocity pick for a wire desk; _event_compose = the slot’s own _classify_post + _compose_guarded(lane="x_watch"). The double-post guard is in the spine (ScheduledPoster._process_unit): every unit runs under a per-surface, per-guild lock (_source_gate) and asks _sched_source_delivered right before it delivers; a True answer stops it as deduped with post_dedup reason=source_seen. The newsroom answers from its seen-set, read fresh inside the lock, so the slot and the watcher lane (either source) cannot post one source twice even when two of them composed it at once. The default answers False, so the wire desks (whose slot and event lanes run in one tick, in order, and cannot race) are unchanged. The history gate alone is not enough, because it gives up after a wait and runs its body unserialized. A title the post does not name never ships (mn.title_grounded_in_wire, in _classify_post, so the slot and both watchers apply it): live, “Billboard 200: #2(+3) @oliviarodrigo, you seem pretty sad for a girl so in love 67,000” classified as “GUTS - Olivia Rodrigo”, the verify step confirmed GUTS’ own position (#59), and “GUTS is still sitting at #59” scored 0.9 and shipped 3 times in 3 dry runs. Every later check reads the classified subject, so only this gate can catch it. Every content word of the title must be among the post’s words (case, punctuation, accents, filler words and a “(feat. X)” tail folded; a CONTRACTION word such as “She’s” matches with its “‘s” kept, #4264); else a Haiku judge reads the post first (title_quote_in_post, #3410 wave 19). It returns the post’s own words that name the title, and mn.quote_grounds_title keeps the story only when those words are in the post AND share a content word with the title (music_news_recover via=title_judge). NONE, a quote that fails the check, or a model error ends in music_news_filtered reason=title_ungrounded with detail.rescue = none |
unquoted | error, marked seen. Measured on 47 real drops: 11 real titles kept (entity-escaped “R&B” and “<3”, a possessive title word, a quote glued to the next word), 0 memory titles kept; the quote check is what refuses DtMF (the song) for the album “Debí Tirar Más Fotos”. That gate is the backstop; the fix is in the classifier (#3836): its subject rule says the title is copied from the post as written, even lowercase or sentence-like, never a title known from the act’s catalog. Measured on the live post, n=5: “GUTS” 5/5 before, the post’s own title 5/5 after; the golden is classify_music_news_subject_title in scripts/eval_classifiers.py. An authority’s post text grounds the take (_post_text_grounds, owner steer “a trusted authority’s tweet text counts as its source”): the two fabrication gates (ungrounded_numbers, ungrounded_career_ordinals) AND the self-gate judge’s source (music_desk_score(source=...)) read the post text of a may_relay_number account, with its look-back frame stripped (#3271). The digit gates already read a feed item’s text; the judge now reads it too. The judge had scored chartmasters’ own “the 4th most-streamed song of 2006” as fabricated (0.0). The replay showed chartmasters’ “All 14 of their albums” and “his 3rd billion-stream song” dropped as invented. Both gates still fail closed for a number in neither source. The compose reads the authority’s post, and the web background stays out of the line (#3838): it used to see only the classifier’s one-fact claim, so its “second fact” clause came from the background or from memory (Adele’s “25” gained “her second album”; HUMBLE.’s real “his third song to reach that mark” became “one of the few songs ever to clear 3B”). A GROUND TRUTH block (the post text, under a header that names no source a take could quote back) now leads the context for a number authority on X, the web research leaves the compose on that path (#3856; it first stayed in as fact-check-only background, and its verify verdict came back in the take as “remains unverified”), and the X crowd read (whose header asks the model to “SHARPEN the read”) is withheld from the compose; ablated on 20 real posts, n=2 per arm. The digit and ordinal gates then ground on own_evidence (the post, the desk’s own readings, the card hero, a CONFIRMED judge’s figure, and the last-week position a CLIMB’s move marker states, mn.wire_last_week), not the web background: a take otherwise argued with the post off a lagging tracker (“Cardi B sits at 22.92 billion”). The deterministic backstop is output_checks.unsupported_rank_claims (music_news_scored reason=unsupported_rank, marked seen): an ordinal, a superlative’s rank or an all-time scope that the post and the desk’s own readings (own_evidence, never the web background or the classifier’s claim) do not support. Its twin mn.ungrounded_quoted_titles (reason=unsupported_title) drops a quoted title the post never names (Wiz Khalifa’s “See You Again”). The live Billboard read is last week’s from Sunday to Tuesday (mn.wire_is_next_week, in _live_chart_position): chartdata posts the coming week’s Billboard 200 on Sunday from Billboard’s own top-10 story, and billboard.com flips the chart page on Tuesday. The replay shipped “The Life of A Showgirl is at #21” for chartdata’s “#1(+20)”, because the live reading outranks the wire. A wire with NO move that states No. 1 once (“returns to No. 1”, “debuts at No. 1”, “is No. 1”) proves it too when the live page shows the title anywhere else (#3924: four 2026-10-05 wires said Showgirl “returns to No. 1” and the settle rewrote them to #21); the future form (“set to return to No. 1”) stays #3874’s. The wire’s own move proves the week: when the live position equals the wire’s LAST-week position (“#1(+20)” -> 21), the reading is dropped with music_news_chart_lookup reason=wire_ahead and the claim goes to the web verify like a chart we cannot read. The watcher makes this window the common case, since it posts within minutes of chartdata. A HITS reading or web research older than an authority’s post does not override it (#3827). Two rules, both only for a post whose text grounds its own number (_post_text_grounds). (1) HITS freshness, in _live_projection: when HITS last edited the projection document (Projection.edited_before, off _updatedAt) before the post was written, and the post states a count the document does not round to (mn.wire_units_disagree), the reading is dropped (music_news_chart_lookup reason=hits_older) and the post keeps its number. Live: chartdata’s “#6(new) Popstar 35,500” (Billboard’s final, Sunday) against a document from Friday that still projected 32,386 at #9. A post that agrees (“32K” for 32,386) still settles. (2) Stale refute, right after the verify judge: when the judge’s note puts the title at the wire’s own last-week position (mn.refute_is_last_week, the same move-marker rule as wire_ahead), the research is a week behind. The verdict becomes unconfirmed, so the authority’s number ships on source trust; the story is not marked seen; the research and X context are withheld from the compose and the self-gate; music_news_recover via=stale_refute records it. Live: the replay lost chartdata’s “#1(+20) Taylor Swift” (research at #21) and “#3(-1) Ella Langley” (research at #2) as refuted. A refute with any other position, or of a culture wire, still drops. A lagging tracker does not refute an authority’s total either (#3838, mn.refute_is_tracker_lag, music_news_recover via=tracker_lag, same outcome): on a milestone, a note whose figure is under the post’s rung by less than 2% (TRACKER_LAG_MARGIN), or is dated before the post. Live: “Kworb reports 1,975,713,319, still below 2 billion” refuted Fall Out Boy’s “Infinity On High just passed 2 billion” while kworb’s own page read 2,002,211,599. A total far from the rung (Jay Chou’s “246.6M, not over 500M”) still drops. Two more refute shapes are not evidence (#3855, music_news_recover via=over_rung / via=wrong_match, same outcome, and the Songstats block is withheld too): a note total up to 10% ABOVE the rung confirms the crossing (mn.refute_is_over_rung, Nelly Furtado’s “8B” against Songstats’ 8.6B), and a note whose every total is under a tenth of the rung read another item (mn.refute_is_wrong_match, Songstats’ 963.1K for another recording of Sean Paul’s 1.5B “Temperature”). A claim that also states a small count (“3 songs over 3B”) is read by neither. Its posts carry lane=x_watch + fast=true + source_age_seconds on music_news_posted, and lane=x_watch feeds the ops monitor’s breaking_lane_stale series. The RSS feeds are this lane’s second source since #3834 (previous item). Not yet done: the compose itself (Perplexity, Grok, Songstats) still takes 15-25 s per story. |
music_news.py’s #3851 FOLLOW-UPS (#3852-#3856, epic #3821). Five shapes the #3851 dry run found, fixed in one PR because they share the classifier, the gates and the compose text. Each extends a gate that exists; there is no new pipeline.
classify_music_news keeps stream_shape / stream_rung / stream_count only when mn.stream_shape_allowed(post) holds: the post names Spotify and a stream metric and has no subset count. These are the same regexes mn.stream_claim bounds with downstream; _is_career and the card read the raw field, so the bound now runs at the source. Haiku shaped “‘Cruel Summer’ surpasses 2 billion streams on Apple Music” as a Spotify track 3 of 3 runs, and #3811 added “8 solo songs” as a catalog. The prompt line “a view total is per-video, not per song” was cut: it turned a YouTube views milestone into none, 4 of 4 runs. The eval classify_music_news_stream went from 0.70 (red) to 1.00, 3 of 3.mn.post_ground_text(post): strip_look_back removes the look-back frame, and every four-digit year it removed is appended back. The interval digits still go (#3271). chartmasters’ “the 6th most-streamed album from 2016” lost “from 2016”, so the take’s true 2016 was dropped as ungrounded_number in 6 of 8 runs.mn.wire_move_sizes reads every move the post states (“#1(+20)”, “vaults 21-1”, “up 3 spots”). mn.strip_wrong_moves cuts a move clause with another size from the classifier’s claim (a claim that was only that clause becomes none), and mn.wrong_move_sizes drops a take with one (music_news_scored reason=wrong_move, marked seen). The classifier wrote “(up 21 spots)” off “vaults 21-1” 10 of 10 runs; the cause was its BTS example, which turned “slides 16 spots” into “(down 16 spots)”. Reworded to the post’s own words, 0 of 10. A post with no move checks nothing.mn.misstated_chart_frame drops a take that names a chart period its ground does not (“daily” over a “Weekly” post) or adds a hold (“briefly”, “for one day”) the ground does not state (music_news_scored reason=chart_frame, marked seen). Only a period word that names a chart or ranking counts, inside one sentence; “HITS Daily Double” is a name. The ground is the source post and own_evidence, never the web research; the classifier claim stands in only without post text. A ground phrase like “this week” is not read, because it names the streams, the date or a look-back as often as the chart; that miss is left to the self-gate. Live case, 2026-10-09: @rap’s “Weekly Top Artists Global” #1 for Drake shipped as “briefly rose to No. 1 on Spotify’s global daily artist chart”, the sentence the Perplexity research proposed. The music_desk_score numbers rubric’s UNGROUNDED hard fail now names a restated record (another chart, another period, an added hold), and its 0.0-0.2 row points at the hard fails instead of defining a second, narrower fail. Haiku, n=5 x 10 cases: shipped line 5/5 -> 0/5, correct lines unchanged; a generic rewording without the named cases let all three bad lines ship."Blackout" by Britney Spears earned 1,192,335 streams [+5.00%] on Spotify ( October 9, 2026 ) plus a second line, "Gimme More" saw the biggest rise — 886,375 streams [+5.62%]. The classifier dropped the date, so two posts shipped as “earned 6,380,662 Spotify streams, up 4.62%” with no period, and one took the second line as “Gimme More posts Spotify’s biggest daily-chart rise”. That line ranks the album’s own tracks by % change; it is not a Spotify-wide rank. Owner steer: cut the % and say the day as “today” or “yesterday”. mn.scope_daily_streams(text, today) rewrites the post at the X fetch (_scoped_posts, both _event_fetch and _fetch_account), so the classifier, the compose and the gates read the same text:
_scoped_posts checks the author), and only the template’s own lines: a whole line "Title" by Artist earned N streams [+X%] on Spotify ( date ) with the date at its end, and the "Track" saw the biggest rise ... streams line. Any other account, line or sentence is left exactly as written. Codex found six near-misses on #3967 (a sales “[+20%]”, a first-week total, a qualifier before or after the date, a one-line “biggest rise” story, an unrelated “biggest increase” line); scoping to the account and the full line closes the class, where a word list did not.none 3 of 6 on Rihanna), so one classify_music_news example maps it to chart with the day kept in the claim. mn.is_daily_stream_count then makes _live_chart_position return no_chart, because “Thriller” the album would otherwise settle to “Thriller” the song’s daily-chart rank. It matches only the exact claim “N streams on Spotify yesterday” (40 of 40 real claims had that shape), so “tops Spotify Global with 2M streams today” keeps its live reading, and a chart_key the classifier read off the post also keeps it (0 of 40 real runs set one).chart, 40 of 40 lines say “yesterday”, 0 with a %, 0 dropped by the digit or chart-frame gate. The short-date fallback (“on Oct 9”), 10 posts x 2 with full compose: 20 of 20 chart, 20 of 20 lines carry the short date (“Oct 9” or “Oct. 9”), 0 say “yesterday”, 0 with a %. 24 other real wire posts x 2: kinds unchanged except one RIAA post that flips between cert and milestone either way.mn.NEWS_CONTEXT, and its twin in compose_music_record) contrasted CONFIRMED (“state plainly”) with REPORTED, which came back as “reportedly”; and the web research (a “verify this stat” verdict) sat in the compose, which came back as “remains unverified”. Hedged takes: 7/16 before, 4/14 with the reword alone, 6/14 with the research cut alone, 0/15 with both. The backstop is output_checks.confidence_hedge_hits / self_count_credit_hits (music_news_scored reason=confidence_hedge | wrong_credit, marked seen): “can’t verify”, “unverified”, “reportedly”, “per YouTube’s own count”, “per the market”.NEWS_CONTEXT forbids naming the reposter, so the model credited “a third-party tracker”. That is a confidence disclaimer worded as a source. output_checks.anonymous_credit_hits catches it and the gate drops it as wrong_credit. A named tracker (“per ChartMasters”) still passes. The cause was the SOURCING paragraph of NEWS_CONTEXT. It told the model to find a source to name, to skip “a vague aggregator”, and that a chart-watcher account is a reposter whose number traces to “the chart or platform behind their post”. This figure traces to no platform, so the model had to credit someone and could name no one. The paragraph now says a credit is optional, names the four source kinds, and says to state the number with no credit otherwise. This reword is NOT measured: the session could not pull the runtime keys for a dry run. The web research still enters the compose on a culture-wire story, because the debut-framing dry runs rely on it for basic facts.music_news.py’s #3858 FOLLOW-UPS (#3859, #3860, #3864, epic #3821). Each extends a gate that exists; there is no new pipeline.
mn._names_spotify_streams is the one test for “this is a Spotify stream claim”: Spotify and a stream metric named, and no other platform or combined total (_OTHER_STREAM_PLATFORM_RE: Apple Music, YouTube, Amazon, Deezer, TIDAL, …, “combined”, “across platforms”). stream_shape_allowed and stream_claim both use it. Before, “Cruel Summer passes 2B streams on Apple Music, after topping 3B on Spotify” kept the shape, and stream_claim read the post as a Spotify claim and settled it on kworb. A regex cannot bind a figure to its platform, so the whole post goes to the web judge. No real post in the 4 days to 2026-10-05 named a second platform, so the cost is small.drop_prior_position ran only when a settled weekly row showed a fall. chartdata’s “#3(-1) Ella Langley … peak: #1 for 3 weeks” had no row (the live page was a week behind, wire_ahead), and “sits at No. 3 …, after three weeks at No. 1” shipped 4 of 4 runs. With no row, mn.wire_fall_position reads a single “(-N)” (a Unicode minus or an en dash counts) or “falls A-B” marker and the same guard runs with that position. A post with two or more moves proves nothing for one title.music_news_projection_flag returns False, not None, when the classifier writes "", and a False flag turns every wording check off. chartdata’s “‘The Life of a Showgirl’ to return to #1 on the Billboard 200 with 172K units” (via HITS) missed the flag 3 of 4 runs, last week’s page (#21) settled it, and the take said “sits at #21” (held at 0.1 by the self-gate, the only guard). _compose_one now calls mn.post_states_forecast(post, title) for a chart story whose flag is not true. It reads one sentence at a time, and a sentence counts only when it states a position or names a chart, names the subject’s own title (so another title’s forecast does not move this one), and carries STRICT forecast wording: _STRICT_FORECAST_RE (“projected”, “set to”, “on pace”, “expected to”, “will” + a chart verb) or the headline infinitive “X to return to #1” after a quoted title or an intent word, with no fact word before it (“the first album to debut at #1” is a result). The forecast must sit in the SAME clause as the position (clauses split at “;”, “, and”, “ and “), and a clause with a settled chart verb (“debuts”, “tops”, “spends”) is a result: “‘Manchild’ debuts at #4 … and is set to become her biggest hit” stays a debut (coordinator review on #3877). The wide _FORECAST_WORDING_RE (“could”, “due to”, “estimated”) is NOT used here: Billboard writes “an estimated 172,000 units” about a finished week. A hit sets is_projection=True, so _live_chart_position returns wire_ahead, and emits music_news_recover via=forecast_wording._classify_post drops a chart story that mn.states_chart_exit(claim, post) reads as an exit (“exits”, “falls off the Hot 100”, “drops off the chart”; “off the top 10” is a fall, not an exit), right after _chart_in_scope, so the cut spends no verify or compose call. The cut marks the post seen for good, so a missed exit is the better error. No cut when the claim states a position, a top-N tier (“exits the top 10” is a fall), a negation (“never exiting”) or a return (“re-enters … after it exited”). The post is read only for a sentence that names the subject’s own title and passes the same blocks, so “A spends its 50th week; B exits” keeps A. Marked seen, music_news_filtered reason=chart_exit. MUSIC_NEWS_EXITS=1 restores the relay; the card’s “OUT” hero state stays wired and is unreachable while the cut is on. The classifier’s chart_event decides first (#4201). exit joined its closed set: an exit cuts inside the same title-scoped bound (the exit words plus “departs”), except that a “from #N” or a peak position does not block, because it is where the title was. Any other event is no exit, so the regex’s false cut of “Rosé and Bruno Mars Lead ARIA Charts Shake-Up After Festive Exit” (a #1 story) is gone. A reply with no event leaves the regex in charge. Measured on 432 real wire posts, 2 runs per prompt: the regex cut 5 of 13 real exits and 1 non-exit, the field cut 13 of 13 and none. The week field the issue proposed was NOT added: on the three HITS forecasts where Haiku said is_projection=false (“to return to the top 10 on this week’s Billboard 200”), it answered week=this every time, so a field that narrows post_states_forecast would bring back #3874.first_week 3 of 3 runs, and every take said “65K first-week units”. _parse_music_news(..., post_text=) turns a first_week story into a chart projection when the story is about a later week (re-peak, return to #N, re-entry, climbs back, a numbered week past the first). Since #4583 the classifier’s own later_week flag decides that; mn.post_states_later_week(post) decides only for an answer with no flag (the words missed “third consecutive week” and “another #1 week”, and fired on another title’s words in the same post) and the story’s figure is not one the post attaches to a first week (mn.first_week_figures: “65K in its second week after a 100K first week” keeps 100K as a first week, never 65K; Codex review), and cuts “first week” from its claim. A chart projection with no live reading is named chart projection in the compose blob; with chart alone the takes said “is at No. 2” 3 of 3. A take that still calls a figure the post does not attach to a first week “first-week” is dropped as music_news_scored reason=later_week (mn.later_week_first_week_claim, marked seen).confidence_hedge_hits leaves out “unconfirmed” / “reportedly” only when a determiner comes before it, one of the next two words is on a short allowlist of non-figure nouns (_HEDGE_NON_FIGURE_NOUNS: date, release, album, deluxe, tour, …), and none of the next three words is a digit, a number word or a figure noun. It fails closed: “the unconfirmed first-week projection of 65K” and “an unverified two billion streams” still count (“an unconfirmed release date”, “the reportedly shelved album”). “remains unverified”, “has reportedly passed” and “the unconfirmed total” still count._MOVE_SIZE_RE reads “twenty-one spots”, and wire_move_sizes reads chartmasters’ “climbs from #9 to #8” (a movement verb is required, so “positions from #1 to #3” is not a move). With no move in the post, a WORD size is flagged, because the digit gate cannot hold a word; a digit size is still left to the digit gate.resolve_stream_track ranks rows by milestones.is_original_recording (the desk’s rule from #3867), so a remaster competes with a plain title on its total. Before, any untagged row won: Michael Jackson’s plain “Smooth Criminal” side upload (4,468,444) beat “Smooth Criminal - 2012 Remaster” (1,327,970,760), the main album upload. A radio edit, remix or live cut is still a version. The attribution guard also reads “per Genius” / “according to Genius”.music_news.py’s culture-tier order and the unnamed chart residue (#3448, classifier audit C10, epic #3410).
strength_buckets still builds the cue order (cue count, then the account round-robin), and it still drops a post with no cue. When that order holds more than the 12 places (_MAX_CLASSIFY), MusicNews._rank_culture hands its first 40 posts (mn.SLATE_RANK_POOL) to ONE Haiku call per slot, ClaudeClient.rank_music_news_slate (purpose=music_news_slate_rank). The model returns indexes only. mn.apply_slate_rank drops a bad index, caps one account at 3 places (SLATE_RANK_PER_AUTHOR) while others have candidates, and fills the rest in cue order, so the model can reorder real posts but never add one, and the classify budget does not change. A model error keeps the cue order. Why: the culture pool averaged 144 posts a slot over the 30 days to 2026-10-16, and a cue count cannot read whether a post states a number. music_news_slate detail.rank / detail.moved, panel newsroom_slate_rank._NUMBER_CUES word, and a culture post with no _CHART_CUES word (strength 0), never reach a model. MusicNews._start_wire_shadow sends each such post from the X pass (mn.cue_dropped; the RSS feeds and the watcher are not in it) to ONE Jev Noul (mn.wire_admission_questions: “does it report a music number about an act”, with the cue lanes and the four recorded non-stories as its reference), in a background task the slot never waits on: each post id once per process, at most 150 a slot, 4 at a time, 8 s each. It emits typesafe_shadow purpose=wire_admission (jev = story / drop, detail.external_id) and changes nothing that posts. Measured on 1,635 real cue-dropped posts from the 46 wire accounts: 11 were real music numbers (hand-labelled); Jev found 11/11 with 16 false yes and 2 flips, Haiku 11/11 with 19-20 false yes and 11 flips; 0 real numbers in a sample of 100 posts both read as no. The planned switch puts a Jev yes in the bottom cue bucket, where the Haiku slate rank decides; it waits for the shadow week. Panel wire_admission_shadow.unnamed chart residue reads chart_key. chart_reportable(text, chart_key) reads the classifier’s existing chart_key only when neither pattern list names a chart. It keeps the story only when the key’s own label maps to the kept Spotify daily family (chart_key_family) and “spotify” is in the text. Apple Music and iTunes do not rescue: their names are kept patterns, so an unnamed story never names them, and a bare “apple” can be an act (Codex review on #4250). The live misses were “the Spotify Global chart” word orders. A Billboard key on an unnamed story stays cut, because the regex reads every Billboard name a post states. No prompt edit. music_news_recover via=chart_key_scope.ClaudeClient.market_claim_is_rung (Haiku, purpose=market_rung) decides whether a MARKET claim quotes one rung of a threshold ladder (mn.market_claim_is_rung); quotes_ladder_rung is the fallback. Only the market kind is read. A field on classify_music_news was measured and not shipped: it moved a chart forecast to the market kind. The other CB-08 sites were measured and kept (MEASURED-KEEP); see the CB-08 section in docs/CLASSIFIER_AUDIT.md.ClaudeClient.chart_story_read reads one story once (Haiku, cached a day, purpose=chart_story_read) and returns its chart family (the closed label list is music_news.CHART_FAMILY_NOTES, the owner’s KEPT / CUT notes, plus OTHER and NONE), forecast, later_week, edition and title_is (song / album / tour / other). The old word lists are the prompt’s examples and each field’s fallback: a field the answer does not give uses its rule. The readers: _chart_in_scope via mn.chart_reportable_read (a CUT or KEPT chart NAME still decides with no read; the model decides only the residue, so the gate still fails closed. A Mediabase panel that joins AC with a kept format, “Pop & AC Radio”, is cut by its name, and the notes say a Spotify record is not a chart position, #4583), the forecast override in _compose_one (in place of post_states_forecast), the card tag (unsettled_chart_label(forecast=)), the later-week take guard, the era guard’s edition test (chart_story_is_same_record(edition=)), album_stream_story(title_is=) and the tour backstop (_is_tour_subject, where TOUR only ADDS a tour). take_marks_album (Haiku label) decides whether mark_album adds the word “album”. Still code: the classifier parse’s first-week -> chart reroute (_LATER_WEEK_RE), the “briefly” words in misstated_chart_frame (0 drops in 30 days), and title_grounded_in_wire in ungrounded_quoted_titles / _reads_exit (#4553). Measurements: the CB-01 row in docs/CLASSIFIER_AUDIT.md. Panel newsroom_story_read.cert_reportable territory prose and quotes_ladder_rung. See the C10 row in docs/CLASSIFIER_AUDIT.md for the numbers.music_news.py’s WRONG BILLBOARD FLAGSHIP (#3916, epic #3821). On 2026-10-04 at 21:14, the Showgirl ALBUM’s Billboard 200 No. 1 shipped as “No. 1 on the Billboard Hot 100”. The Chart Beat headline named no chart at that time, so the classifier’s chart_key came from memory. The Hot 100 read was absent, and _chart_claim_too_stale reads a weekly absent as a spelling miss. Two changes, each extending a gate that exists:
_live_chart_position reads the partner flagship (mn.flagship_partner) when the claimed one is absent. If the title is there, the reason is wrong_chart, and _compose_one drops the story (music_news_filtered reason=wrong_chart, stamped seen). The partner is read for presence only: a title TRACK on the Hot 100 is not the album’s position, so it never settles the claim. The check runs only when the wire does not name the claimed chart (mn.wire_names_chart). Two true stories make that guard necessary: kalshi_music’s “all 12 songs … on the Billboard Hot 100” (an album subject, absent from the Hot 100), and the “Bass Persuades” Billboard 200 No. 1 of 2026-09-29, when the live page still showed last week and the title-track song sat on the Hot 100. Every wire for both named the claimed chart. The scope gate (chart_reportable) already drops a story whose claim and post name no kept chart, so this check can fire only when the chart name came from the classifier’s claim._with_feed_lead reads a no-break space as a space (mn.chart_text) and treats a bare “Billboard” as naming no chart (mn.names_a_chart). On 2026-10-05, “‘Patient Zero’ Zooms In at No. 1 on Billboard Global\xa0Charts” read as the Hot 100 + Billboard 200 pair, the lead (“the Billboard Global 200”) was not added, and _feed_chart_ungrounded dropped a true No. 1._feed_chart_ungrounded) still DROPS a feed story on a chart its text does not name.music_desk.py’s SOURCE WATCHER (2026-09-28, the same owner target: every music source to the room within ~5 minutes). The desk’s ~20 lanes already skip what they have posted, so the watcher does not rewrite them. Every _SOURCE_WATCH_MINUTES=2 it reads a cheap FINGERPRINT per source through that source’s own client and cache (utils/source_watch.py): the Billboard chart week (_WATCH_BILLBOARD_CHARTS; every chart flips together), the HITS projection documents (updated_at) and radio rows, the kworb chart pages (_WATCH_KWORB_PAGES, revalidated with a 304-cheap conditional GET), RIAA’s newest awards, and the Pollstar weekly issues. When any fingerprint changes it runs the desk’s normal pass (_sched_compose_units) off-slot, and each unit goes through _deliver_unit (fragment floor, dedup, deliver, history, one history-gate hold per unit). It records no slot. The watcher runs beside the slot (#3828). Until #3828 a _pass_lock ran the slot tick and the watcher one at a time, so a change seen during a 20-40 minute slot compose waited for the whole compose. The newsroom had the same lock between its slot tick and its feed watcher. Both locks are gone. Two passes can now compose the same story, and the spine’s _sched_source_delivered stops the second delivery under the per-guild _source_gate: the desk answers True when the unit’s dedup_key (or event_ticker) was stamped in music_desk_events within the last hour, which a delivery and a dedup close-out both do, and the newsroom answers from its seen-set (#3824). A unit with no key (a milestone) falls back to the text and topic dedup, which reads the history fresh per unit. The first look after a deploy is a baseline, and a failed read keeps the last good fingerprint, so neither can fire a pass. The new fingerprints are kept only once every guild’s pass finished (a raise or a unit no channel took leaves the change for the next look to retry). A row fingerprint hashes ALL rows, and RIAA’s hashes (award id, level, date) off the plaques lane’s own recent(until=...) read, so the watcher and the lane share one cache entry. A fingerprint must not change when the content a lane posts from did not (#3847). Two sources did, and each false change ran a full desk pass. (1) HITS: the projection cache codec (hits._encode/_decode) dropped doc_id, so the first read after a boot came from the DB row with an empty id and the next fresh read had the real one. Each of the 5 boots on 2026-10-04/05 reported both HITS charts as changed 6-12 minutes later. The codec now carries every Projection field, and test_projection_codec_round_trips_every_field fails on a field it drops. The fingerprint is sw.projection_fingerprint (doc_id|updated_at); with #3832’s fallback past an empty newest document it stays the older document’s until the new one fills. (2) kworb monthly_listeners: kworb refreshes the page about every 10 minutes during its daily update (00:13Z-01:45Z, 8-9 refreshes), and each refresh moves some counts. sw.listeners_fingerprint hashes only the top-10 names in order and each act’s highest listener milestone rung, so a refresh that passes no rung and moves no top-10 name is not a change; a replay built from the live page (each refresh moves one ninth of the acts from yesterday’s to today’s count) measured 9 changes a day before and 0 after. The counts and the Daily +/- column are left out on purpose: the two boards that print them post once a day (the abd: key). _WATCH_KWORB_READERS maps a kworb page to its own fingerprint; every other page uses rows_fingerprint. The all-artists and all-time-songs pages got the same treatment (#3849). kworb rewrites spotify/artists.html about every 20 minutes all day (a few acts’ counts each time) and spotify/songs.html about once an hour, and rows_fingerprint reported each rewrite (49 and 17 passes a day). Each fingerprint now covers only what a lane posts from that page. (a) sw.artists_fingerprint: each act’s highest career-streams rung (the watched-artist milestone sweep and the Songstats career path post these crossings; the name set also catches an act entering or leaving the page). The other readers post nothing from a change here: the artist-streams board posts once a day on its abd: key and is not change-gated; the watch and known-artist lists are gates on other sources’ stories and are memoized about 1h; the catalog and album-streams lanes take only ids and rotation order and post from each act’s own page; the credit-split check reads only names. (b) sw.songs_fingerprint(top_n=_LINEUP_DEDUP_DEPTH): the song-streams board’s lineup, which is its leader today, because that board posts only when its durable lineup key changes. The song ranking strip reads this page only for a line on a card that posts for another reason. (c) youtube_videos is no longer watched at all. No desk lane posts from it since the worldwide most-viewed board was cut (2026-09-14); its 2-hourly rewrites ran 12 passes a day for nothing. When you add a lane that posts from one of these pages, extend that page’s fingerprint in the same PR, or the watcher will not start a pass for the new story (the next slot still posts it). For the watcher to see a change within minutes, four caches got a fast mode: Billboard’s WAITING re-read 20 -> 2 min (only while a flip is due), HITS’s current projection 6h -> 5 min (HITS edits a document in place), its WAITING re-read 20 -> 2 min and radio 90 -> 5 min, RIAA’s recent 2h -> 10 min, and Pollstar’s issue-id lookup 6h -> 10 min in its own cache (the body cache stays 6h, keyed by issue). A cold first look measured 30s against the live sources; a warm one reads the caches. A capped lane defers to the next look (#3842). The HITS projection lane posts at most _MAX_PROJ_BOARDS_PER_SLOT=3 boards a pass (owner steer: two bursts keep five big boards readable). A new HITS document has up to five, and until #3842 the pass that posted 3 called itself complete, so the rest waited for an unrelated source change or a slot. Measured 2026-10-02: HITS edited the document at 22:02:34 and 22:25:11; each time 3 boards posted within 6-7 minutes and the other 2 only at 22:19 and 22:49, both times on a kworb change. Now a lane that stops at its cap with a fresh board left calls _defer_lane, which appends to the _DEFERRED_LANES context variable that _off_slot_pass sets; a non-empty list makes the pass return False, so _source_watch keeps the change and the next look (about 2 minutes later) runs the pass again. The cap is checked after the dedup, so only a fresh board defers. A deferral keeps the change only when the pass delivered or closed out at least one unit (both stamp the key), so a pass that made no progress cannot repeat on every look. Outside a watcher pass the variable is None, so a slot keeps its 3-a-slot pacing and records nothing. Replayed on the watcher-era documents: chart_proj p90 17m -> 11m. Telemetry: source_change, fast on music_desk_posted, error source=music_desk_source_watch, and source_change reason=deferred for a deferral.
music_desk_posted carries source_age_seconds from the unit’s upstream_ts (#3829). Besides the HITS boards, these lanes now stamp it with the refresh time of the page they read, through utils.break_latency.page_refreshed_at (the reader music_alert._page_refresh now also uses): watch_chart cards and platform_board / board_presence boards (the chart’s page, _CHART_PAGE), and the kworb milestone cards (artists, listeners, artist_songs_<id>, artist_albums_<id>). The time is read right after the lane’s fetch, never our fetch time. A lane whose source gives no honest time stays absent: chart and artist_catalog (Billboard’s client exposes no publish time), projection / tracking (a live Kalshi price), the HITS year-to-date and Songstats milestones.within_calendar_span(..., numbers_source=True) and runs at any hour. An empty schedule still blocks (the surface is off on that mood). Outside the calendar span the pass sets the _QUIET_PASS context variable, and the one COMMENTARY lane, _watch_chatter_units (KTT2 forum chatter), composes nothing, so commentary keeps the gate. numbers_source defaults to False, so cogs/music_news.py and cogs/event_poster.py keep the old gate. Kalshi SETTLES are not watched here: music_alert’s SETTLE lane is their one fast owner (#3821 ruling “consolidate, do not duplicate”), and the desk’s slot reveal lane stays the backstop.ktt2_chatter, a STAGING experiment, #2230 / #4366). _watch_chatter_units picks the hottest watched-artist thread off the KTT2 music board (utils.ktt2_chatter.pick_stories), reads that ONE thread’s first page (Ktt2Client.thread_detail: the opening post up to 1,200 characters and the 6 most-liked replies, no authors), and composes through the isolated compose_forum_chatter, judged by discourse_score. Depth (#4366, owner 2026-10-17: “it’s not giving me details, it’s too shallow”). The old prompt said “tell the room WHAT they’re arguing about” and, on a charged claim, “note it’s a real debate, don’t adjudicate”; every compose opened “They’re arguing over …”, closed on a hedge, and 7% shipped. The task is now the story with its details (who said what, the claim or quote, the numbers, why now) and a read on which side the details support; each claim stays on whoever made it, and an allegation’s read stays off the person’s guilt. Rejected-thread cooldown (#4366). A guard drop or a self-gate rejection stamps the story’s fail_key (artist + thread id + a hash of the title and opening post) through _note_compose_failure, so the thread skips for _FAIL_COOLDOWN_HOURS (12h). pick_stories(skip=...) drops cooling threads and posted artists BEFORE the one-story slot limit, so the next story gets the slot. A new reply does not change the key; a new thread or an edited title/opening post does.music_alert.py’s jump lanes step aside for an act’s top-10 standing (#2672). _chart_jump skips a climber whose lead act holds chart_boards.MIN_TOP10_STANDING (2) or more of the chart’s top 10, counted by chart_boards.artist_top_ten_count, the desk’s own count. The desk’s watch_artist_board owns that story, so the alert cog keeps no second copy of chart-presence logic. The next act’s climber can still take the slot. Each step-aside emits chart_jump reason=standing. The crown lanes are deliberately NOT gated: the owner’s 2026-09-28 steers say every #1 change posts, and the desk board states a count, not a #1 change.
_JUMP_TOLD_DAYS (1) through chart_peak.read_told(within_days=...) and passes over a climber told at its rank or a better one, by any rank lane on the chart (debut, jump, peak, desk). The Spotify jumps now record told ranks too (<guild_id>:sp_global, <guild_id>:sp_us). The cause was the lane’s DAY: its daily keys reset at UTC midnight, while a chart’s move column does not, so the same move posted again minutes after midnight (RUSTIC DRIFTER “from No. 16 to No. 7” at 00:06 UTC on 2026-10-18, after the peak lane told #5 the evening before). Measured on the 90 jump posts of 2026-10-04..17: 27 restated a told rank; 13 were the same climb (same or next day) and are refused; 14 came 2+ days later after a new climb and still post. A failed read posts nothing. No new delay: holding a jump until its climb stops is the measured follow-up on #4460. Each pick emits chart_jump reason=fired.music_alert.py’s chart-event lanes have no once-a-day limit (2026-09-29, same owner target). _EVENT_LANES is the crowns plus every debut, jump and radio-mover lane (release, spotify_jump, spotify_us_jump, apple_debut, apple_jump, apple_album_debut, itunes_debut, itunes_jump, itunes_album_debut, radio_riser, radio_entry). Before, only the crowns had the rule the 2026-09-28 steer gave them; a second debut or big jump in a day waited for tomorrow. Now each of these lanes runs its builder every tick (5 minutes since #3826) and may post every DISTINCT story (its song key) in a day, never the same one twice in a day or twice in a row, with up to _CROWN_MAX_TRIES tries for a story that did not post (_daily_gate / _mark_daily / _mark_crown_posted). The builders read cached chart pages; release’s Songstats read is cached 12h per track. The four Kalshi artist-market lanes keep one attempt a day, because their builders scan Kalshi (~44 fetches a tick). A test pins that _EVENT_LANES names only real lanes, so a misspelled name cannot silently leave a lane on the old rule. Each of these builders returns ONE winner (the biggest climber, the best debut), so the runner hands it _lane_skip: the keys _daily_gate refuses right now (_lane_refused_keys). The builder passes over a story that already posted and offers the next one; without this, a posted winner still on the chart hid every other new story on that chart (Codex review on #3739). Because of this, the story a lane offers is often NOT the chart’s biggest mover, so no jump or radio-riser blob tells the model to call it “the biggest climb” or “the week’s biggest radio riser” (cut 2026-09-30, after @tootsiesbar posted a #170 -> #69 radio move as “this week’s biggest US radio riser”). The jump lanes also read only the top _CHART_JUMP_TOP, and the radio riser is picked by audience gain, not by rank climb.
keys and tries reset at UTC midnight, but a debut builder still offers a song while it is inside its debut window. So from 00:01 UTC on 2026-10-18 the release lane composed six songs again that it had handled on 10-17; the topic judge dropped them 13 times, and two of them (“Robot Killed The Working Man”, “Dog Days”) shipped a second time. _DEBUT_LANES (release and the four Apple / iTunes debut lanes) now keep a held map {song key: delivery time} in the lane stamp. _mark_crown_posted writes it, _crown_stamp carries it across days and drops entries older than _DEBUT_HOLD_S, and _daily_gate / _lane_refused_keys refuse a held key (_held_keys). _DEBUT_HOLD_S is the longest debut window (release: _DAILY_DEBUT_MAX_DAYS chart days; Apple / iTunes: NEW_WINDOW_HOURS), so 48h; past it no builder offers the song as a debut. Only a DELIVERY is held. A judge DROP still gets the lane’s _CROWN_MAX_TRIES tries: replayed on 14 days of production offers, holding a dropped story too removed 154 more drops but lost 7 posts that a later try shipped and that were new chart positions (for example “Thriller” No. 22 -> No. 20, “expectations” No. 14 -> No. 13). The jump lanes are not debut lanes: their told ranks (#4460) refuse a repeated rank and let a better one post. Replay of 2026-10-04..18: composes 664 -> 593, judge drops 226 -> 171, posts 413 -> 398; each of the 15 removed posts was the same lane’s second post of the same song inside 48h.date (music_lane:<lane>) was the UTC date, which changes at 20:00 ET. So a chart-event lane’s “today” keys and tries reset in the evening, and a title still live on the chart posted again at the 00:0x UTC tick. Measured on music_alert_posted, 30 days to 2026-10-25: 40 groups repeated a title on the same lane and ET day across the UTC midnight, apart from the peak steps that #4455 fixed and the staging-then-room pairs. Most were jumps and debuts before #4460 and #4505. Since 2026-10-19, 7 groups remain (4 crowns, 1 radio riser, 2 peaks), each second post between 00:01 and 03:41 UTC. _lane_day() now returns chart_fact.et_day(), the day the desk’s daily keys (#3125) and the chart-fact claims use. _same_lane_day compares a stamp with today. It also counts the current UTC date while that date is ahead of the ET day (00:00-04:00 UTC), so a stamp the old code wrote in that window before the deploy still counts as today. After the deploy no stamp carries a date ahead of the ET day, so that branch adds nothing. _last_key_holds reads a legacy stamp date as an ET day, so its fallback still holds at least as long as the truth. The same-UTC-date repeats are not this bug: of 22 groups, 17 are peak steps up to 2026-10-17 (#4455), 4 are a staging audition followed by the room post, and 1 is a second climb (by design).radio_riser: the radio page carries a 7-day audience, so one surge stays the top gainer for days, and the lane had only its day keys. In 14 days it shipped 3 songs twice, each at the same rank just after midnight (“I Knew It, I Knew You” #31 and “Man I Need” #58 on 10-15, “Beautiful Things” #57 on 10-20), and the topic judge dropped 6 more on 10-20. radio_riser is now in _TOLD_LANES with its own store, radio_us_riser, and reads its told ranks like a jump lane (_jump_told, _JUMP_TOLD_DAYS): a song told at this rank or a better one is passed over, a further climb still posts, and a failed read posts nothing. Its card heroes the audience gain, so _told_rank records the rank from the “#N on US radio” standing strip. The store is not radio_entry’s radio_us: the entry lane skips any song in its store, and 4 songs in 30 days rightly posted from both lanes. The riser’s chart fact (_FACT_LANES) stays on radio_us. Replay of 2026-10-06..20: 14 riser posts, 3 refused (all repeats), 0 first posts lost._CROWN_REPEAT_HOLD_S (48 h) after that delivery (_last_key_holds; _mark_crown_posted stamps key_at, and a stamp written before then falls back to the end of its date). Before, the refusal had no end. KAROL G’s “BbY WOW” was the last Spotify Global #1 delivered (it held #1 from 08-27 to 09-20). The Taylor Swift #1 after it never posted (an entry at #1, before the #3724 entry rule), so when BbY WOW climbed from #5 back to #1 on 09-27, the gate refused it for four days. Every “just took #1” signal (a daily chart’s P+ column, _TOOK_TOP_WINDOW_S, the iTunes reader’s took_top) lasts about a day, so a held #1 still does not post again. Every other chart-event lane keeps the old rule: its last story never repeats.radio_new_entries offers a song for its first MUSIC_RADIO_NEW_MAX_DAYS (7) chart days, and the lane had no memory across days, so it posted the same song as a new entry at each day’s rank: Steve Lacy’s “oh yeah?” shipped “crosses to US radio at No. 81” (09-29) and “at No. 86” (10-01), a fall stated as an entry. radio_entry is now in _TOLD_LANES (key radio_us), so a delivered entry is recorded in chart_peak’s told store, and _radio_entry_signal skips a told song. No new table. After the first chart day the blob says “is at #N on US radio after D days on the radio chart” instead of “just entered US radio at #N”.music_alert.py’s Kalshi LAST CALL is timed off the tracking window, and a closed window posts no forecast (#3719 follow-up, 2026-10-05). Kalshi’s close_ts is not the window end: the Sep 18-24 ladders closed at 09-27 14:00Z, after the Sunday print. classify_music_signal fires last_call 48 h before close_ts, so on Friday 09-25 at 14:05-14:19Z eight last calls shipped about a week that had ended the day before (“Day and Night by Carly Rae Jepsen is forecast to debut with ~22.4K album-equivalent units this week”; one more, Yeat’s “COCOON”, on 09-26). Each blob said “the tracking week closes in ~47h”. The ladders had already collapsed onto the result, and one was far off it (Bowie, “~10K” against a final 2,601). Two changes in _run_readings: _last_call_clock hands the classifier the reading with close_ts set to the window’s end (midnight US Eastern after the period’s last day), so the last call lands in the week’s last 48 h; and a live reading whose window has closed (music_markets.window_closed, the rule the desk already applies in tracking_gate) is skipped, so neither a line move nor a last call can post a decided number as a forecast. A period with no parseable end keeps Kalshi’s close. A settled reading is not affected. The skip emits no event, the same as the tier gate beside it: the tick runs every 5 minutes, so a count would count one reading many times.music_alert.py’s NEW-PEAK lanes (2026-10-02, owner report: “FOMO hit number 2, why didn’t we publish again”). Drake’s “HABIBTI (FOMO)” debuted at #6 on iTunes Top Albums, the debut posted, and the climb to #2 two hours later posted nothing: every climb lane (the jump lanes, the desk’s top_climb) reads the chart’s day-over-day move column, which is 0 while the row is still NEW, and on the next day compares against the release-day close, so a release-day climb is never told. apple_peak / apple_album_peak / itunes_peak / itunes_album_peak (_peak_core) read a separate memory, utils/chart_peak.py: the best rank each title has held on the chart (shared by every guild), and the best rank each guild was already POSTED for it (told, written after delivery by every debut, jump and peak lane in _TOLD_LANES, and by the music desk’s watch-chart cards via chart_peak.desk_told in _record_ship, because the peak lane skips the topic judge and would otherwise repeat a desk climb – Codex on #3794). A top-ten title (not #1, the crown’s story) fires when it stands at its best rank so far and beats the told rank; with nothing told, the entry rank is the baseline once the title is past its first day, or once its debut lane posted it. Guards: a title must have arrived after the peak map started (since), else its peak is only a floor (the live dry run caught FOMO at #4 after it had already hit #2); it must be datable and at most _PEAK_MAX_AGE_DAYS (60, the memory’s retention) old; every read fails closed. The story key is <song key>@<rank>, so each new rank is its own story under _daily_gate. Like a crown, a peak is a rank change by construction, so it skips _topic_repeat (_RANK_CHANGE_LANES, #2989). Known gap: a title that charted on PRE-ORDER (entry rank unknown) never qualifies, so on Apple Music albums a pre-added album’s release-day climb is still not told. Kept out of chart_presence on purpose: that parser drops a value it cannot read, so a fifth field would erase every first sighting on a rollback.
_PEAK_SETTLE_MIN (270 minutes; chart_peak.new_peak_candidates), or (2) it FELL from the rank (chart_peak.fallen_peak_candidates; the post says “climbed as high as #5 … and is now at #7”). The fall path is not optional: the live chart showed RUSTIC DRIFTER at #7 less than 3h50m after it reached #5, so a hold alone would never have posted its top, nor any step of the climb. The lane polls every 5 minutes but the charts refresh every 2-3 hours, so the hold is a time, not a count of reads. Each stay lives in a third chart_peak row, <chart>:held (song key -> [rank, since, ended]), folded by chart_peak.fold_held on every read: a fall sets ended and keeps the entry until it posts or times out after FALL_KEEP_HOURS (24); an absent title keeps its entry unchanged (a missing row is no proof of a fall). A timed-out fall of an untold top-ten peak emits chart_peak reason lapsed. Measured on the lane’s 87 distinct posts of 2026-10-09..17: 87 -> 44 posts, 0 climb tops lost, climbs with more than one post 20 -> 8 (each held a lower rank more than 4.5h before it climbed again). The 270 sits just above the largest step gap of the 2h/3h/4h refresh group (4.26h). The model sees only the climb steps; a dip between two steps is not in the events and would add a fall post. Cost: a peak that holds posts 4.5h after it was reached. The held row is separate, not a fourth field in <chart>, because the <chart> parser drops entries that are not three long and a rollback would erase every peak. On deploy no held row exists, so every title starts its stay at the first read: no held peak posts for the first 4.5h (a fall still posts).utils/chart_fact.py keys a fact as <chart>:<song key>:<rank>:<ET date>. Each surface claims the key in post_dedup_history (surface chart_fact, 72h retention) right before it sends; a partial unique index makes db.claim_chart_fact atomic, so exactly one surface wins. The loser does not send and emits post_dedup reason=fact_claimed (the desk also stamps its own kc: key, so the lane does not compose it again). The alert reads the claim before it composes (#4505). The alert’s lanes do not book a lost fact as told: a topic-judge drop spends one of the day’s 3 tries (_CROWN_MAX_TRIES), and the tries reset at UTC midnight. So a fact the desk had posted was composed and dropped up to 3 times a day, and 3 more after midnight. On 2026-10-23/24 three Apple album debuts (Yeat, Yung Miami, Victor Mendivil) were composed 19 times. The runner now builds the fact key after _daily_gate and asks chart_fact.claimed (a read of the same key) before _mark_daily. A claimed fact is skipped with post_dedup reason=fact_claimed detail.step=pre_compose, and the skip spends ONE try (_mark_daily), the same as a judge drop or a lost send claim. So a claimed story leaves the drain after at most 3 ticks: without the try, claimed stories at the top of a chart took all _LANE_DRAIN_MAX drain slots on every tick and a lower, unclaimed debut never composed (Codex on #4724). It does not spend all 3 tries: the desk and the newsroom claim BEFORE their own topic judge and release the claim when the judge drops their card, so a claim seen on one tick can be gone on the next, and the lane then composes. The skip turns each try a claimed story used to spend on a compose + score + judge into a DB read, so the try count and every outcome are unchanged. The send-time claim stays the decision. It does not change the owner decision of 2026-10-18 that a judge drop stays eligible. Over the 72h of claims on 2026-10-21..24 it removes 18 of 43 judge drops; over 14 days about 56 of 218 drops have the same shape (estimated from desk and newsroom post text). A fact the desk stated on the PREVIOUS ET day is a new key and is not skipped. A send that does not land releases the claim. Only a PRODUCTION card that reaches the room claims; a staged lane or audition never does. The alert’s lanes map to charts in _FACT_LANES; a crown states rank 1 even when it heroes a stream count. The desk’s platform BOARDS did not claim until #4683: a board is a list, and a claim costs the whole board. Since #4683 the top-10 and streams boards (platform_top, platform_streams) claim the one fact their headline states, the #1 row on that chart that ET day (chart_fact._platform_board_fact). Three pairs in the 7 days to 2026-10-23 posted that fact from a board and an alert crown (“May God Bless America” iTunes, 10-21 06:59/07:02 UTC; “Housewife” Spotify US 10-17 and Apple Music 10-20). The board yields when the crown posted first, so the cost is one board that day; the next day it can post again under a new key. The new-entries and presence boards headline another row and still claim nothing. One result: the desk now posts one #1 per chart per ET day. The top-10 board, the streams board and the watch-chart #1 card of one chart build the same key, so the first to post wins. Measured over the 30 days to 2026-10-23, that stops 4 desk posts that restated a #1 the desk had already posted that day (“BbY WOW” on Spotify Global, top-10 and streams boards, on 09-25, 10-04 and 10-13; the 10-07 “Nicole Kidman” Spotify US board after its watch-chart card), and no #1 that the desk had not already posted. Fail-open: a claim error posts. chart_fact.desk_fact cuts both desk key suffixes from the song, the :top<N> rung and the :pos<N> position a top-ten move keys on. Until 2026-10-18 it cut only :top<N>, so a move claimed <song>:pos<N>, a key the alert never builds, and chart_peak.desk_told never told the peak lane about a desk top-ten climb (363 of 559 desk keys in 30 days were :pos keys). The newsroom joined in wave 16, and milestones are claimed too. cogs/music_news.py claims the same chart key from its chart lane: the chart is the presence_key of the CHART_SOURCES row it READ (the desk’s watch-chart key, so no new chart list), the song is song_key of the settled chart row (ChartPosition.row_title / row_artist), and it claims only when the card heroes that rank (chart_fact.newsroom_fact). “Housewife” No. 1 on Spotify Global posted from the newsroom at 14:22 and 16:02 ET on 2026-10-17 (two source accounts) and from the alert at 17:32. A streaming MILESTONE is keyed <platform>:<track|album>:<song key>:<rung> with no date (chart_fact.milestone_key; the title is cut to its version_base, so a catalog row’s remaster tail does not split the key) and claimed under surface milestone_fact (1-year retention, like music_news_milestone; its own partial unique index). The desk claims from its milestone block when the metric is a per-title Spotify total (the milestones.LADDERS rows labelled “Spotify streams”: streams, album_streams); the newsroom claims from the classifier’s milestone_id when it is a Spotify streams rung on one titled track or album. A desk milestone that loses books its rung with advance_milestone_rung (no card, no alert clock); a newsroom unit that loses retires its tweet through _sched_on_dedup. An ALBUM keeps its whole name in the key (album_milestones.album_key), because “Home” and “Home (Live)” are two releases with two totals. The claim runs in _gate_and_deliver, inside the per-guild source lock, so a slot pass and a fast pass on one unit cannot both lose. The /music break path claims too (#4711). Music._maybe_break_milestone calls MusicDesk.break_milestones_for_track, which posts milestone cards through _sched_deliver without the spine. Until #4711 it took no claim: on 2026-10-20 at 14:02 UTC it posted “Janice STFU” 500M with no milestone_fact row, so the newsroom’s milestone lane could post the same crossing again. The claim, the close-out on a lost claim and the release on a failed send now live in ONE helper, MusicDesk._deliver_with_fact_claims, which both _gate_and_deliver and the break path call. Over the 30 days to 2026-10-24 the break path posted 1 claimable crossing (that one) and caused 0 repeats, so this closes a gap, not a measured leak. A newsroom claim is kept once the room send landed (meta["fact_sent"]), and a desk track crossing that loses still lands in stream_crossings. Measured on the 30 days to 2026-10-17: 13 desk/newsroom milestone pairs stop, 0 real crossings blocked, and 4 pairs still post because their keys differ (3 joint songs credited differently, such as “Lady Gaga and Bruno Mars” vs “Lady Gaga”, and the “Stick Season” vs “Stick Season (Forever)” album), so the claim fails open. The desk’s platform BOARDS did not claim then, so the 17:31 “Housewife” board was not stopped (#4683 changed that, see above). A CATALOG count is claimed too (#4683). chart_fact.catalog_key keys one act’s count of songs past a Spotify streams rung as spotify:catalog:<act key>:<rung>:<count>, no date, under milestone_fact. The desk claims it from a catalog_count milestone block (entity_key catalog:<act key>:<rung>, milestone rung = the count); the newsroom claims it from a catalog milestone on a bare act subject, off the settled kworb reading when there is one, else the classifier’s stream_rung / stream_count. The desk posted Rihanna’s 9 songs past 2B on 10-19 01:08 UTC and the newsroom posted it again 39.6 hours later, outside the 36-hour shared history the topic judge reads. The same wave fixed two desk-only repeats. (1) A milestone’s ms: key was never stamped (_record_ship records the rung instead), so _sched_source_delivered let the slot pass and the fast pass both post Rodeo’s 4B; it now reads the stored rung. (2) Daily keys carry the calendar date, so a post at 22:00 ET and one at 02:00 ET held two keys (#3125). _DAILY_STEM_GAP_HOURS = 18 holds a stem inside the gap unless its content moved: a sales projection stamps projfig:<release>:<metric>:<figure bucket>, and the platform (pfb:) and artist (abd:) daily boards stamp <prefix>:lead:<chart>:<kind>:<lead-row digest>. 18h was measured: it stops 14 same-figure projection repeats in 30 days and 5 same-lead board repeats in 10 days, all midnight straddles, and holds back none of the next-day posts (20h and over); 24h would have cut 28 normal daily projection posts. Panel: desk_exact_guards. Since 2026-10-25 the pfb:, abd: and bbp: daily keys carry the ET day (chart_fact.et_day), as the proj: key already did (#3125). They carried the container’s UTC date, which turns over at 20:00 ET, so a board that posted in the morning ET posted again at the 00:0x UTC slot that evening: 26 same-stem pairs under 18h in the 30 days to 2026-10-25, 10 of them after the stem gap shipped (the gap passes a board whose lead moved). (3) A STAGING audition stamps its desk-event keys as stg:<key> (_stamp_key, #2713), so an audition cannot suppress the first room post after a mod promotes the lane. A production read never matches a stg: row; a STAGING read passes stage= to _recent_desk_keys, which also returns those rows without the prefix. Only the real trials can run in STAGING (ktt2_chatter, store_forecast_board), and both pass their stage; a new trial lane on the desk must do the same, or its auditions repeat every slot."Title" by Artist for the compose (#4683). utils.music_news.credited_subject turns the classifier’s Title - Artist subject into that form for the compose blob, the compose subject or topic, and the LIVE CHART / projection / framing lines. The bare form does not say which side is the title, and a model read it the other way when the title is also an act’s name: “Boston’s ‘Stella Lefty’ sits at #3 on the Hot 100” (10-19) and “Dandy Hats’ ‘El Rabbanito’” (10-22). Measured on the live compose with the shipped inputs: the bare form swapped title and artist in 33 of 46 runs of the two goldens on GPT-5.6 Terra and Sonnet 5.5, the credited form in 0 of 46 (a third case, “HABIBTI (FOMO) - Drake”, swapped in neither form, 0 of 11 each). The sweep over 30 days of posted lines from the desk, the alert and the newsroom found swaps on the newsroom chart lane only; the desk and alert Luminate topics (display_subject of RELEASE -- Artist) showed none, so they are unchanged. The gates still read the raw subject. Jay-Z’s @sc tag (also in #4683) is correct: the live profile is “Mr. Carter”, 2.66M followers, blue check, the reviewed pin in utils/x_artist_tags.PINNED_HANDLES.attribution_tag), no confidence disclaimer or wrong credit (confidence_hedge), no Kalshi-against-HITS comparison, no units ranked against streams, no sum or ratio of board rows, and no previous position on a fall take. The regexes in utils/output_checks.py find the words (attribution_tag_hits, cross_source_hits, stream_unit_hits, row_arithmetic_hits, prior_position_phrase_hits, the hedge and credit lists). ClaudeClient.owner_ban (Haiku classify, purpose owner_ban, cached) reads the line and the words and gives a label; utils/ship_guard.owner_ban_holds maps it, and OWNER_BAN_PASS names the labels that clear. No answer keeps the drop (fail CLOSED). A line no regex word touches makes no call. The prior-position NUMBER arm (any position other than the take’s own) stays an exact compare and decides alone. Wiring: music_desk._drop_shaped computes _bans_cleared and passes it into the sync _unshippable (the way career_cleared works), and every desk lane now calls _drop_shaped; detag_credit, music_alert, music_news (hedge and drop_prior_position), market_drop, sports_boards and cinema_desk._sum_cleared call the helper. Each read emits an owner_ban event (a clear has no other trace). The desk scorer rubrics carry no new line: the miss side measured 0 real misses in 860 shipped lines (docs/PROMPT_OPTIMIZATION.md, CB-03). perplexity.strip_industry_projection still reads names_hits_source alone (a context strip, not a post fence). The row_arithmetic task names a count of times (“topped the chart twice”) as OK, because the regex matches a bare “twice” (#3604).ClaudeClient.album_metric (Haiku, purpose album_metric, cached 7 days per text) labels a post PURE_SALES / UNITS / STREAMS / NONE / SEVERAL. utils/album_metric.py uses it in two places: the dedup read (same_metric_posts, metric_reads; the music desk reads it through the async ScheduledPoster._sched_dedup_history_read hook, whose default calls the sync _sched_dedup_history), and the wrong-metric ship gate (foreign_metric_word: the word table finds the word, and only a read of the line’s OWN metric clears it). A post with no metric word makes no call. On no answer the word table decides, as before. missing_release_title stays an exact fold; it also reads the text with a possessive “‘s” cut, because all 4 real fold misses in 30 days were possessives.utils/ship_guard.drop_self_correction, the shared guard 17 compose lanes call before their self-gate, is async and takes claude. ClaudeClient.shows_working runs the trigger first (claude_client._shows_working_trigger: has_self_correction, a correction marker anywhere, _SELF_CORRECTION_MARKERS, the wheel’s _NARRATION_PHRASES). A post no list word touches makes no call; a touched post goes to Haiku (WORKING / FINISHED) with the lists as examples. On a model error the regex decides, as before (fail closed). Why: the regex alone drops a post that opens with a real title (“Hold On, We’re Going Home”, “Wait for Me” both charted in the last 60 days), and it misses corrections it has no word for. Measured 2026-10-18: 4 of 11,294 real composed posts in 30 days trigger a call; 0 real drops before or after; the correction goldens go from 12 of 21 caught to 17 of 21, and the false drops on the finished goldens from 2 of 18 to 1. The four goldens the model path misses carry no list word (a refusal, “per my instructions”, two “Option” drafts); the self-gate scorers’ SHOWS ITS WORKING rule (#3427) still covers those. The direct drop sites call ship_guard.shows_working too since #4563: x_mentions (2), discourse (icebreaker), sports_boards, market_alert (3), and the sync guards music_desk._unshippable and cinema_desk._board_unshippable, which take the verdict as working= from their async callers (working=None means the regex decides). Each site keeps its own event shape. Measured over the 30 days to 2026-10-25: 0 claude_api purpose=shows_working calls and 0 self_correction drops on any surface. NOT converted (they each need their own measurement first): the wheel parser (claude_client._parse_wheel_items, short slices that are often real titles) and _strip_self_correction (it CUTS text, it does not drop it). decline_narration_hits stays the decider: Haiku was worse on all 1,874 real chart-lane posts (4-5 false drops of climbs, 0 new catches).music_alert.py’s NEW_RELEASE streams card titled the ALBUM (the Kalshi reading’s subject) over the count of its BIGGEST TRACK (lookup_release_streams), while the blob handed the take that track’s name: “Angel - Lizzy McAlpine 8.8M” under “The light in the painting … opens with 8.8M”, 7 of 7 such cards. music_markets.release_card_subject now titles the card with StreamResult.track_label, the same value the blob reads (MusicSignal.card_subject -> luminate_snapshot(title_subject=...)); the cover hint stays the album’s. (2) music_news.py’s first-week block took its window from the KIND, so a first_week story about a first DAY read “SPOTIFY FIRST WEEK” under “first full day”. _first_week_window now reads the window off the claim (mn.release_windows); a claim with two windows, or a take that names another one, gives a block with no window. (3) market_cards.build_market_card drops a #N hero whose subject the take gives another position (take_rank_conflict: the nearest position to each mention of the subject, a FORMER position such as “from No. 18” skipped), and heroes the subject’s name instead: “#2 Dai Dai” under “Dai Dai first at 32%” on a runner-up market. It reads only a figure with an explicit title, so the kworb lanes’ untitled number cards are untouched. (2) and (3) emit card_take_conflict (reason window / rank). Since 2026-10-18 a model reads the window and the place (owner rule: an open word list goes to the model, with the list as its input). cogs/music_news._first_week_window_judged asks Haiku ClaudeClient.release_window (DAY / WEEK / WEEKEND / MONTH / YEAR / NONE / OTHER / CONFLICT; NONE keeps “week”, OTHER and CONFLICT name none), and market_cards.take_rank_conflict_judged asks Haiku ClaudeClient.take_rank_place for the place the take gives the subject as a NUMBER; code compares it with the #N hero, which stays an exact-format regex, so the model never sees the hero. A take that does not name the subject makes no call. The old rules (_first_week_window, take_rank_conflict) run only when the model gives no answer, and card_take_conflict carries via (model / rule). Both checks use a one-word answer: a reason-first answer measured worse on both (4 flips against 0 on the rank cards). Measured: the rank check matched all 42 real #N cards both runs and was right on 6 of 7 hard real-take cases (the rule on 2); the window check agreed with the rule on 132 of 139 real texts, all 22 first-week takes included, and the 7 changes are all better (“first 10 days”, “first chart day”, “release day”). Class 2 (two sources for one number): music_alert.py’s Spotify-debut card heroed the Songstats/kworb RUNNING TOTAL while the blob also gave the take the chart-day plays, and the take led with the plays on 5 of 8 such cards in 30 days (“143.4K” under “1.9M first-day plays”; a SoundCloud “1.4K” under “4.6M first-day streams”). The lane now sets number_figure['alt'] to the plays, and market_cards.swap_to_stated_alt heroes the alt (figure, pill and block) when the take states it and not the total (output_checks.states_figure); a take that states the total, or neither, keeps the total. It emits card_take_conflict reason figure. A coarse take number can state BOTH figures (2026-10-24). states_figure reads a number at the precision it prints, so “2M” states anything from 1.5M to 2.5M. Live 2026-10-21, K/DA “FIRESTARTER” shipped “2.3M Spotify streams to date” under “No. 37 on Spotify Global Daily with 2M plays”: the take stated the 2M plays, but “2M” also stated the 2.3M total, so the old rule kept the total. swap_to_stated_alt now compares output_checks.stated_gap for the two figures and heroes the one nearer the printed number; a tie keeps the total. Classes 5 and 6 are not changed, by measurement (2026-10-24). Class 5: the pure-sales board’s #N lead column is the Billboard 200 position by owner design (chart_boards.projected_pure_sales). In the 30 days to 2026-10-24, 10 of 11 board takes name both ranks (“No. 3 in sales despite No. 27 overall”); one (Rubber Soul, 2026-10-09) named only the sales rank. Class 6: a hand count of the four flagged tour posters (Iron Maiden 16, Zach Bryan 23 shows in 19 stadiums, Lola Young 17, Ayra Starr 20 cities) matches every take; the media gate’s judge miscounted all four.streaming_stats.stream_hero picked the Songstats Spotify total, else the BIGGEST OTHER platform. A fresh release that Songstats had not ingested on Spotify yet then heroed a SoundCloud stub over a take quoting the Spotify chart: “expectations” read “SoundCloud streams to date 1.4K” under 4.6M first-day Spotify streams at Global No. 4, and Justice read 18.7K SoundCloud under No. 41 on 2.1M (both 2026-10-17). A lagging Spotify total did the same: TRALALA “manifest” read 143.4K under 1.9M first-day plays (2026-10-10). THE RULE. lookup_release_streams reads the hero track’s STRICT kworb standing (kworb.standings, the same match the release lane prints as ranks) and takes chart_streams_floor: the largest stream count on any leg (US Daily, Global Daily, Global Weekly, US Weekly). A lifetime total cannot be below one chart period of the same song. When the track charts, the hero is the Spotify total only if it reaches that floor; otherwise there is NO hero, never another platform. The kworb backup total obeys the same floor (its songs page lags a new song and its track page counts only chart days; live 2026-10-17 it read 5.7M for “expectations” against an 8.26M chart row). With no hero the debut lane draws its “Spotify daily streams” card off the chart row, and the NEW_RELEASE lane draws the reading’s projection card. A track on no Spotify chart keeps the old rule, so a real SoundCloud-first headline still heroes. The text blob is unchanged, so the take keeps its numbers. The floor reads ONE track: the first leg in resolve_chart_row order (Global Daily, US Daily, Global Weekly, US Weekly) sets the kworb track id, and a leg with another id is skipped (live 2026-10-17, the “expectations” standing joined the new song’s daily legs with an older same-title song’s 8.26M weekly leg). Measured on the 15 streams-hero cards of the 30 days to 2026-10-17: 3 better (the three named above), 0 worse, 12 unchanged.KworbClient.standings matches each chart leg by artist and title on its own, so two songs with one title could fill different legs. Live 2026-10-17, Olivia Rodrigo’s “expectations” printed “#3 US · #4 Global · #41 US wk · #166 wk”: the daily legs were the new song and the weekly legs an older song (149M streams since June). kworb._one_track now runs after the act check: the first leg in resolve_chart_row order (Global Daily, US Daily, Global Weekly, US Weekly) with a track id sets the track, and a leg with another id takes that chart’s row for the set track, or is None. A leg with no track id stays. Every caller gets it: the music_alert card tag and blob, /ask, ranking_strip and chart_streams_floor (whose own filter is now a second check). Measured on the live pages 2026-10-18: 315 charted pairs, 1 changed (the case above), 0 worse._sched_dedup_history) kept it, and music_alert._topic_repeat had no filter at all. The Haiku topic judge then called both later settles repeats of the streams post, every time it was asked. The units figure never posted; the desk posted the pure-sales figure 88 minutes after the settle. Now output_checks.album_metric_named reads streams as a third metric, and same_album_metric_posts is the one filter both surfaces read: a recent post about a different metric leaves the judge’s evidence, and a post naming no metric (or two) stays. Replayed on the live 36h history with the real judge: both settles went from dropped 3/3 to shipped 3/3. The 21.8K figure itself was correct – Kalshi’s page showed a “17K forecast” because the pure-sales ladder’s top strike was 15K, so the market-implied number cannot go above it.utils/luminate.release_stage + utils/music_markets.resolve_reading_age_days, 2026-09-29). On 2026-09-28 the desk composed “Bass Persuades logs 47.8K pure sales in its first month” three times: the number (the KXPUREALBUMS settle, 47,766) was right and the period was wrong. Two siblings caused it. (1) release_stage flagged a first-week UNITS market as the debut but not a first-week PURE SALES market, so pure sales fell through to the album’s age (10 days -> “first month”); KXPUREALBUMS is now a debut by construction, like units (its “Y” year-to-date variant stays annual). (2) A first-week STREAMS market dates its age off the tracking window, and after the window closed that age kept growing, so a settled first-week streams figure read on day 10 was also “first month”; the FRAMING age is now capped at the window’s last day. A freshness caller (include_debut=True, the new-release lane’s 1-14 day gate) still gets the real elapsed age, so an old first-week market never reads as a fresh drop. rank_debut_projections (the units slate and the Kalshi board, which labels its figure “album-equivalent units”) drops pure sales explicitly, now that pure sales is a debut. The new-release lane in music_alert excludes pure sales explicitly (_is_fresh_debut), because it stamps one attempt per event and an album carries both a units and a pure-sales market.utils/riaa.py, the RIAA first-party certification feed (#2839, line B of #2800, owner ask 2026-09-02): RIAA’s own Gold & Platinum database read directly, feeding the newsroom’s EXISTING cert lane. The gap it closes, measured 2026-08-20 to 09-01: the wire cert lane shipped 22 cards against ~180 awards RIAA issued (one in eight), and a Julia Michaels 2x Platinum reached no wire we poll. The site is keyless and structured (a plain table, 30 rows a page, a JSON show-more call, a per-award timeline); the ONE thing that mattered was the user-agent – Cloudflare refuses a bare Mozilla/5.0 (the earlier ‘RIAA is blocked’ finding was that probe’s own UA) and accepts a browser-shaped string, which the client and the riaa probe share; the probe READS the page and requires award rows, so a challenge page that answers 200 without a table reads as a failure on the sweep, not as healthy. The design: RiaaClient.recent(until=) walks the newest-first feed until a page’s oldest award is older than the caller’s window, capped at 8 pages (2h durable cache, ~15 awards land a day, 58 in one burst); cogs/music_news.py:_gather_riaa_pool keeps the rows whose credited act is on the WATCHED or KNOWN list (the shared corroborated splitter aw.credit_parties over the alias-aware keys, so ‘Lil Nas X’ stays whole and a ‘YE’ credit resolves to Kanye West; the watchlist’s own spelling becomes the display name), dated inside 3 days, not yet seen (the award id is the seen-key), at most 5 a slot, reads each award’s timeline (7d cache) for the previous level, and hands the newsroom a SourcePost shaped like a wire post (platform=riaa, handle riaa). A slot’s places go ROUND-ROBIN across the acts with news, cards per act per slot each (riaa.fair_slot; the /menu tune knob music_news_cert_cards_per_act, default 1, owner call 2026-09-03 “i don’t want to limit cards but i want to diversify so one artist doesn’t block”): round one is each act’s best unseen award, acts by that award’s level, round two each act’s second-best while places remain; nothing is stamped seen, an award that missed a slot stays eligible until the 3-day window drops it, and the desk’s plaques board lists the batch (music_news_filtered reason=cert_batch_deferred, count the act’s awards waiting). An act that took a card sits out for cert_act_cooldown_hours (the /menu tune knob music_news_cert_act_cooldown_hours, default 24, 0 = off; _acts_on_cert_cooldown, 2026-09-05). The per-slot share alone did nothing ACROSS slots: an act with a catalog batch got its next-best award every hourly slot until the 3-day window dropped the batch. Measured 2026-09-04: RIAA certified 15 Nickelback titles in one day, the lane composed one an hour from 15:14 to 02:14 UTC (Rockstar 12x, How You Remind Me 9x, Far Away 5x, …), five reached the room (“Animals”, “Savin’ Me”, “Gotta Be Somebody”, “Burn It to the Ground”, “This Afternoon”) and six more were stopped only by the text dedup (post_dedup content_overlap / text_similarity / topic_restated, which is luck of the wording, not a rule); the owner reported them as duplicate posts. The signal is the lane’s own seen stamp: music_news_seen is read again at the cooldown width, and an act credited on any window award stamped inside it takes no card this slot (music_news_filtered reason=cert_act_cooldown, count the awards held, detail.cooldown_hours); a collaboration stays eligible under its other act. A stamp is any attempt – composed, gate-failed or dedup-retired – so an act whose headline card the dedup retired (Rockstar, against the desk’s plaques board) does not get a second-best card an hour later; the board is where the rest of the batch belongs. Fail-open: a failed seen read is no cooldown. A batch gets at most cert_cards_per_batch cards in total (the /menu tune knob music_news_cert_cards_per_batch, default 5, 0 = off; owner call 2026-09-17, “regardless of 24 hours or one hour, I want to cap it to the top five”). The per-slot share and the cooldown PACE a batch; neither ends it, so a batch still got every award the window held, only slower. Measured 2026-09-16: RIAA certified 11 Skillet titles in one day; with the master guild’s knobs at 3 a slot and a 1h cooldown the lane carded six in five hours (Hero 4x, Feel Invincible 3x, Awake and Alive 3x, Unleashed, Not Gonna Die, Back from the Dead Gold; Monster 7x was retired by the dedup against the plaques board) and held the other five for later slots, and the text dedup overturned itself on every one, correctly, because each is a different song. A batch is the act’s awards inside the lane’s 3-day window (the certs read, already cut at _RIAA_MAX_AGE_DAYS); the cards it has taken are the window awards carrying a seen stamp (the seen set the gather is handed, read at 14 days, so every stamp is in it; a stamp is any attempt, as for the cooldown, and a collaboration counts for each credited act). An act at the cap takes nothing more (music_news_filtered reason=cert_batch_capped, count the awards cut, detail.per_batch the knob, detail.carded the stamps); an act under it keeps only its next remaining best before the round-robin, so the cap keeps the TOP awards and the deferred count never promises a card the cap will refuse. The cap runs before the cooldown, so an act that is both is reported once, as capped (the terminal reason). Nothing is stamped; the plaques board lists the whole batch either way. The first fold (#2861) had this shape as a hard “five” and was replaced by diversity; the cap now sits BESIDE the pacing knobs rather than instead of them. The third certification knob put the first tune page past Discord’s 40-component cap, so artist pull and certifications moved to their own music newsroom tune page (cogs/tune.py:_PAGES). Measured 2026-09-02 before the fold: RIAA certified 59 Rod Wave titles on 08-31 and the lane posted 14 cards to the room in one afternoon, about three a slot, while the wire had already carried the batch as one @complexmusic story the day before. The first fold (#2861) kept ONE headline card and stamped the rest; the owner asked for five, then for diversity instead of a cap; the RSS and RIAA pools are ONE first-party pool for the slate, ranked by source authority then freshness (_first_party_rank, RIAA 1.0 above Pollstar 0.95) and capped once, because the slate classifies in order and stops at six real stories – except an RIAA row still AUDITIONING (its source not yet PRODUCTION), which goes to the END of the slate so it never takes a shortlist seat from a room-bound story. The seen-key is riaa:<award id>:<level>: the award id is per TITLE (its timeline lives under the same id), so a later upgrade is its own post. The sentence keeps every credited party (‘Post Malone & New Artist’), spelled the desk’s way where known (riaa.display_credit). From there the row is a first-party feed candidate: _feed_source resolves the handle to the display name + relay_number=True (the same trust path as the trade-press RSS: the authority’s own figure is settled on source trust, never routed back through the web-verify hop), the classifier returns cert_id <n>xplatinum|riaa, and cert_story_key dedups it against the same certification from any wire. The card heroes the award NAME, never the id’s bare multiplier (music_news.cert_level_label, applied in claude_client._parse_music_news). The classifier answered a level with “Nx”, because the one certification example in its prompt did. The RIAA counts multiples on the PLATINUM rung only. So a hero of “1x” over a Gold states 1,000,000 units where the award is 500,000 (owner report 2026-09-15, the Jennie RUBY card). Measured over the 30 days to that date: 217 of 283 certification classifications answered a bare “Nx”, and 54 of those sat on a rung that carries no multiplier – 29 Gold, 15 Diamond, 10 Silver. The prompt example now carries the whole award name, and the parser reads the level off the validated cert_id as the deterministic backstop; measured on the real classifier, 24 of 24 wires went from “1x”/”2x” to “Gold”/”2x Platinum”. A BATCH figure is a COUNT of awards (“53 certifications”), so it keeps its count and still takes the ranked plaques board (_cert_batch_card). Its own key music_riaa_certs – GRADUATED 2026-09-07 (master-only, the third pass; owner steer “graduate these to on for master guild”), so _riaa_stage is graduated_stage: PRODUCTION in the master guild (rides the parent newsroom), OFF elsewhere (skips the read), never STAGING. While it was a trial, anything but an explicit PRODUCTION routed an RIAA card to #bot-logs WHATEVER the parent newsroom’s stage (a new SOURCE auditioned first, unlike the graduated overlays that ride the parent); that audition route is still in the code (meta.audition, _is_riaa_audition) but can no longer occur. The stage is resolved ONCE at gather time and rides the post (riaa.STAGE_KEY), so a failed re-read at compose can never promote a card; and such an AUDITION (meta.audition) is recorded as seen but never books the settled cert key – a card that only reached #bot-logs must not silence the wire’s later production story (review findings on #2840). With no recognition lists at all the lane skips the slot and says so in an error row: the watchlist is this source’s discovery filter, and relaying every act RIAA certifies (~15 a day) would be a flood, not a fail-open. Fail directions: every read fails open to [] with riaa_fetch ok=false + an error row; the parser skips a row missing its id, badge or date; a title’s casing is derived from RIAA’s caps (display_title, so ‘SZA’ reads ‘Sza’ – a visible mis-case over a wrong title). Not yet read: the awards-by-artist tallies (Drake: 333 awards, 11 Diamond) and the per-title ranking (Sunflower 20x), the source for the derived facts, on #2839. An audition composes LAST, in its own audition lane after the reserved lanes and the tail, and its take never enters the dedup history (neither the base’s in-pass list nor post_dedup_history), so a room-bound wire story of the same certification is never retired by a card that only reached #bot-logs. A connector-named act inside a collaboration credit (“AC/DC & NEW ARTIST”) is recognised whole by the shared splitter’s run match (artist_watch.credit_parties, #2846). The certified-units and Platinum-singles boards rank RIAA’s own artist ranking (#2918, 2026-09-03). Those two boards cannot read a ready-made RIAA list the way the Diamond boards (#2915) and the year board (#2871) do: RIAA publishes no “every act’s certified units” page and no list of every Platinum award. So they need a CANDIDATE set, and it used to be the top of the kworb Spotify watch list. That list is ordered by global streaming while the boards count US certifications, so the two never lined up – Garth Brooks (200.5M) and The Beatles (207.5M) sit inside RIAA’s own top ten and on no streaming list at all. A reply on X caught the same defect from the other side: “this is not accurate if you counting feats” on a units card that was missing Lil Wayne, who holds 217.0M and is the ninth-biggest. The candidates now come from riaa.artist_rankings – the awards-by-artist tab with an empty name, RIAA’s own ranking by certified units. The population is the one RIAA defines, and the cut has a RULE, because the ranking is ordered by the very metric the board reports; a name list (the 1301-name known list was the other option) would have needed one invented. Those rows count SOLO titles while the boards count features too, so the ranking picks WHO to read and each act’s own award search still decides the order; four pages of 30 is 120 acts down to 62.5M solo units, and the deepest feature multiplier measured is Lil Wayne’s 1.81x, so an act cut at 62.5M would need 3.4x to reach the tenth row. Measured against the acts most likely to break that: Wiz Khalifa 83.5M inclusive, Young Thug 76.0M, Ty Dolla $ign 65.5M, Gucci Mane 60.5M, Swae Lee 48.5M, Quavo 47.5M – none within 125M of the cut. The FASTEST-to-Diamond board reads the same roster, so its population moved too, and all three boards’ fences now say “RIAA’s biggest acts” instead of “today’s biggest streaming acts” – including the model-facing scope, which had told the composer that Garth Brooks and Elvis Presley are NOT on the board, now false. Names arrive in RIAA’s capitals and are spelled through the desk’s recognition lists, falling back to riaa.own_credit (the ONE definition of “RIAA’s own artist cell as the act”, shared with credited_on(name_cell=True)). PREFER ABSENT: a short ranking read is no candidates, so the boards wait. Rows RIAA files WITHOUT a credited act (_OMITTED_CREDITS, 2026-09-03). RIAA’s convention puts a recording’s guests in the title cell (“STAY (FT. MIKKY EKKO)”), which the crediting rule reads; a minority of rows break it, naming the lead alone with the guests on neither the artist cell nor the title. Such a guest is UNREACHABLE, not merely uncounted: the award search matches the artist cell and the title, so no search for the guest’s own name returns the row. The trigger was a live card – a fan graphic said Miley Cyrus held 91M certified single units, our card said 87.5M, and the whole gap was “23” (Mike WiLL Made-It feat. Miley Cyrus, Wiz Khalifa & Juicy J), filed as artist “MIKE WILL MADE-IT” and invisible to all three guests. The class sweep (scripts/riaa_credit_audit.py, the whole Diamond singles population against Deezer’s independent contributors cast) found it is about one Diamond single in twenty: Umbrella hides JAY-Z, Yeah! hides Lil Jon and Ludacris, Closer hides Halsey, Dark Horse hides Juicy J, Party Rock Anthem hides Lauren Bennett and GoonRock, Moves Like Jagger hides Christina Aguilera, Just Dance hides Colby O’Donis, Pursuit of Happiness hides MGMT and Ratatat, Broccoli hides Lil Yachty – so the “most Diamond singles” board was under-counting JAY-Z and Halsey by a whole Diamond each. _OMITTED_CREDITS names the rows by hand, keyed by (artist cell, title_identity) so one entry covers every award id RIAA files the recording under (Party Rock Anthem has two). parse_rows and the cache inverse _cert_obj both patch the credit cell back into RIAA’s own “LEAD FEAT. GUESTS” shape, so every tally, board and display counts the guest with no special case; awards_for_act also searches the LEAD (omitted_credit_names), which is the only way the row is reachable, and a short read there makes the whole catalog absent like any other name. The map is DATA, not inference: the audit prints candidates to read by hand (Deezer’s search is fuzzy – it matched “The Box” to a different song), and a wrong entry hands an act a plaque they do not hold. Fail direction: an entry whose key stops matching (RIAA re-filed the row) is a silent under-count, so awards_for_act checks that every promised row came back credited and emits a recoverable error row when one did not. The map is now HALF GENERATED (#2944, 2026-09-04). Curating by hand does not scale: a sweep of only the top 60 LEAD acts found 169 candidates, and Drake alone accounts for about twenty (“Forever” hides Kanye West, Lil Wayne and Eminem; “Crew Love” hides The Weeknd). So scripts/riaa_credit_audit.py --write writes its accepted rows to utils/data/riaa_omitted_credits.json, and riaa._OMITTED_CREDITS merges that file under the curated entries – curated LAST, so a hand-read row always wins, because those carry judgement the sweep cannot make (a collective’s per-track cast). Three things keep the generated half honest. The audit’s accept() rejects a candidate whose Deezer match is a DIFFERENT RECORDING (a remix or a re-recording; version qualifiers like “(Album Version)” are normalised away first, “Remix” and “Live” deliberately are NOT), whose Deezer lead is not RIAA’s own artist cell, or whose “guest” reads as a production credit – 46 of the first 169, every rejection correct (a “Rihanna Cover Band” recording of “We Found Love”, “Monster Inside Me” answering Taylor Swift’s “ME!”, Florida Georgia Line’s “Up Down” answering Morgan Wallen’s row). Deezer is a BUILD-TIME source only, so no card’s number depends on it at run time. And the file is committed, so an entry arrives as a reviewable diff. Measured after the first generation: 123 accepted entries, and Lil Wayne moves from 202.0M across 76 singles to 218.0M across 84, 244.0M career. The reachability searches are additive and their failure is NOT fatal. Each mapped row needs the LEAD searched to be reachable at all, and the generated half took the busiest act from one search to ten – so one flaky read would black out the boards exactly as the “Ye” alias did. An act’s OWN names are still fatal when short (they ARE the catalog); an omitted-credit lead that comes back short costs only the rows filed under it, which is where the act stood before the map existed, and the staleness check names every mapped row that did not come back. A wire cert that says only “Multi-Platinum” heroes the count RIAA holds (owner report 2026-10-10, the Ella Langley “Dandelion” card, which RIAA holds at 2x Platinum): RIAA has no award by that name. cogs/music_news.py:_riaa_level_for reads the level ONLY off a complete walk of RIAA’s newest-awards feed, 14 days back (RiaaClient.recent_complete; a short or capped walk returns None). The walk is FRESH (fresh=True, no cache read; measured 8.1 s for 4 pages, inside the limiter’s burst of 6), because the feed’s 10-minute cache can miss an award RIAA updated inside it or hold it at its previous level. The 7-day award-search caches are never read: they can hold a title’s old level. So a title the feed does not hold keeps the wire’s word; the worst case is “Multi-Platinum”, never a wrong count. The match goes through the pure riaa.title_award_level (same title_identity, same _credits_act, main program, the claim’s format when it names one, level 2 or higher). It sets the figure (“2x Platinum”) and the cert_id (“2xplatinum |
riaa”) before the cert dedup key is built, so the story also dedups against the RIAA lane’s own post of the award. Prefer absent: no row, rows that disagree on the level, or another authority keeps the wire’s word (music_news_recover via=riaa_level). Prose that names another authority (BPI, “in the UK”, another country) vetoes the lookup whatever the model’s fields say (mn.cert_prose_names_other_body; “UK” modifying the award counts, “UK Multi-Platinum certification”, while “UK singer” does not), and with no named source the prose must name RIAA (mn.cert_prose_is_riaa). A throwback post is skipped: RIAA’s current level is not the level an anniversary describes. The classifier parser keeps a “Multi-Platinum” figure and drops the level from a cert id beside it (“1xplatinum |
riaa” -> “ | riaa”), because that level is the model’s guess. A blank award record is no history, not a redesign (#4788). Some awards serve the normal timeline layout with no entries (every date January 1, 1970, title empty; award 441124, Zach Bryan’s “Something in the Orange” Diamond, 2026-10-25). timeline_blank detects it, timeline() caches it as reason=no_history with no error. A blank record is a REPLACED award (#4794). RIAA re-issues a title’s record under a new award id when it re-certifies it: “Something in the Orange” went 16x Platinum on 2026-10-22 as award 455379 (full history: first certification 2022-10-19, Diamond 2025-07-15), and 441124 went blank. The act list is cached 7 days (_TALLY_TTL), so the fastest-to-Diamond board read only the blank id and stayed dark (riaa_timelines_partial). Now the lane calls RiaaClient.refresh_superseded(award_id, roster_name) on a blank title: it drops that act’s cached list once per (award id, act), so each credited act of a collaboration is refreshed (riaa_fetch query=awards reason=superseded), the next slot re-reads it on the budget, and the new record’s timeline times the title. Every date still comes from an RIAA timeline; nothing is estimated, and the board still waits for every roster Diamond single. |
music_desk.py’s catalog-count sweep (_catalog_stat_units + utils/catalog_stats.py, #2772, owner question 2026-08-31 “why didn’t we proactively find different stats about artists and genres?”). WHAT IT FIXES: every other music lane waits for a TRIGGER — a chart week prints, or one scalar crosses a round number (utils/milestones.py). A stat like “Rihanna now has 8 songs past 2 billion Spotify streams” is NEITHER: it is a standing fact about a CATALOG, produced by counting rows on a page the bot already fetches and ranking acts against each other. Measured on 14 days of music_desk_posted (2026-08-31): ~650 posts, of which about 1% were a derived stat and the rest a ranking somebody else published, redrawn. So the story reached us only as a wire tweet — which the newsroom then REFUTED off a stale web aggregator (#2771), the sibling bug. HOW: each slot a rotated window of watched artists (aw.rotate, the same bounded pattern the milestone sweep uses; MUSIC_DESK_CATALOG_FETCHES=6) has its kworb SONGS page read once — the artist id rides the ranking page the watch sweep already caches, so no search — and cst.counts_for_rungs counts the rows at or above each rung in CATALOG_RUNGS (1B and 2B). Every read UPSERTS into artist_catalog_counts, the GLOBAL (not per-guild, like kv_cache) field a rank is taken over: a catalog count is a fact about the world, and two guilds must not rank the same act differently. The corpus is not the change detector — that stays the shared songstats_milestone_state high-water rung under a new metric catalog_count keyed catalog:<act>:<rung>, so seed-on-first-sight holds (an act first seen at 8 never breaks a stale “reached 3”), a rung breaks once, and an undelivered crossing retries next slot. The RUNG lives in the entity key rather than the metric, so adding a threshold needs no ladder change and no migration. The count ladder is every integer from 3 up: each new song past the mark moves the count by one and THAT tick is the story; the floor is 3 because a first or second song past a rung is the per-track streams milestone the other ladder already breaks, and counting it twice would post the same fact twice. Rung choice is calibrated, not guessed (12 top artists, 2026-08-31): 100M counts run 69–322 per act so a tick marks nothing, where 1B runs 10–35 and 2B runs 1–11 — a number a reader can hold. THREE HONESTY GATES, all pure: (1) BOUNDED — a kworb songs page is capped, so if every row we hold clears the rung, rows past the cap may too and the count is a LOWER BOUND; count_catalog decides this off the SMALLEST row held (below the rung ⇒ we have seen every qualifying row ⇒ exact) rather than a page-size constant, and an unbounded count is never stored or stated. count_catalog is the ONE home for this count (#2787): the newsroom’s stream-claim settler (music_news.resolve_stream_claim, #2771) and the desk’s plain “Nth song to pass X” fallback (milestone_qualifier.nth_release_qualifier) both read it, so the feature-row rule and the boundedness rule live in one place. An unbounded page settles no wire claim in either direction (detail.reason=unbounded) and yields no Nth-song qualifier. (2) THE RANK IS A PLAIN ORDINAL OVER A NAMED POPULATION, AND IS THE SHIP GATE — rank_clause says “That is the 4th most among Spotify’s biggest artists”, never an ordinal over our roster size and never the names of the acts above. The first draft said “tying for 4th most among the 30 artists tracked” and the owner rejected it on sight: naming our sweep size describes plumbing rather than music, invites “only 30?”, and makes a real stat read as a small-pond placing. From 2026-08-31 to 2026-09-20 the clause instead NAMED the acts above (“Among Spotify’s biggest artists, only The Weeknd, Bruno Mars and Justin Bieber have more”); the owner cut that too, on the shipped daily-hits post “Ariana Grande now has 41 songs clearing half a million Spotify streams a day, trailing only Bad Bunny, Drake and Taylor Swift” (2026-09-20: “cut the only trailing line; if anything just the number rank”). The compose is handed the ordinal only, and rank_grounding no longer carries the names. The shared ranking guard does NOT police either shape (Codex review on #3517, measured: “3rd most among Spotify’s biggest artists” and “trailing only Drake” both pass against a 4th-place grounding, because the guard keys on a ranking CONTEXT such as “ever” or “in history”), so the lane runs its own fence on the composed line: cst.rank_claim_mismatch, handed to _milestone_candidate as caption_check and run before the paid self-gate and before the candidate’s own score event (so a rejection is recorded once, never also as a shipped score). It drops a caption that names an act the field holds above or level with the subject or that uses named-comparison syntax at all, whoever is named (rank_names_act: “trailing only Beyoncé” is invented even though Beyoncé is not in the field), one that states a COUNT place other than the proved one (rank_wrong_place; the place syntax is scoped to “Nth most among / of any” and “#N among”, so the standing block’s “#6 most-streamed artist” and the top song’s “most-streamed” are not read as places), and one that fuses the proved place onto the streaming standing (rank_wrong_metric: “Spotify’s fourth-most-streamed artist” off a 4th-place count). Since CB-11 (#4557, 2026-10-19) the open shapes are Haiku’s call (music_desk._catalog_caption_reason): the exact checks above (a field act’s name, a wrong place number) still decide alone, and a caption they pass goes to ClaudeClient.catalog_caption_check (purpose catalog_caption, OK / NAMES_RIVAL / OTHER_METRIC, cached a week) with the lane’s data block, so a quoted top title reads as a song. No answer drops the caption (rank_unchecked). The two phrase regexes are gone; their phrases are the prompt’s examples. Measured on the 51 real captions of 30 days, 2 runs: 0 disagreements, 0 flips; on 16 goldens the model was right 16/16 both runs, where the regexes missed 7 (“right behind Drake”, “fourth in monthly listeners”) and falsely hit 1 (“after only six years”). The fence runs only on the deterministic-rank path: a ROUND count composed off a reported web qualifier states that source’s place (“first female rapper”), not the field’s 27th, and gets no check. Each drop emits music_desk_scored with that reason; the rung is recorded on delivery, so the act re-composes next slot. The clause is ALSO the lane’s ship gate, and that is the load-bearing part: a card with no clause hands the compose a VACUUM and the compose fills it — measured over 6 real composes of a bare count, 2 invented a cross-artist record (“the only artist in Spotify history”, “the only artist alive”) and the shared ranking guard caught only ONE of the two. Chasing each phrasing in a shared guard is whack-a-mole, so the lane never composes without a true clause in hand (MAX_ACTS_AHEAD=3, i.e. rank 4 or better); a deeper act yields no clause and therefore NO CARD, which is the right scope anyway (the 9th-deepest catalog at a rung is not a story). With a clause present the fabrication rate halves and the residual drift (“fourth-most all time”) is caught and dropped — the correct fail direction, since the rung is recorded on delivery so the card retries next slot. The measured miss ALSO fixed the shared guard: output_checks._RANKING_CONTEXT’s in history arm now allows an intervening qualifier (“in SPOTIFY history”), matching the _ALLTIME_CLAIM_RE sibling that always did. coverage_ok needs 90% of the roster swept before ANY rank — the clause names a REAL population, and that naming is only truthful once we have read nearly all of it — and catalog_counts_at_rung drops rows older than 30 days so a stalled sweep cannot rank an act on a number that has since moved. This is the catalog twin of the chart_records rule that a year claim needs every week of the year. (3) NO IDENTITY INFERENCE — the wire framed this story as “the most by a female artist”; constitution.py forbids identity inference, gender explicitly, so the core carries NO artist attribute at all and the identity-qualified claim is structurally inexpressible (regression-tested). A LEAD/FEATURE split is carried because they are different claims: kworb marks a feature credit “*” (its own summary table names the mark) and Rihanna’s 8 include 2 features, so the blob states both. NO NEW MACHINERY: it rides _milestone_candidate for the compose, the self-gate, the number card and the rung recording, adding only three optional overrides (headline_override/topic_override/eyebrow) for wording the shared ladder cannot reach — the rung is in the entity key, so “8 songs past 2 billion Spotify streams” is unreachable from LADDERS alone. The card heroes the COUNT (“8”), not the streams, because the unit is the point; art_kind="album" + the artist ranking strip make it read as an artist card. Gated on the music_catalog_stats experiment (STAGING default — the sweep still fills the corpus while the take auditions in #bot-logs; OFF skips the page reads entirely), capped at MUSIC_DESK_MAX_CATALOG_STATS=1 a slot. Emits catalog_sweep; count near 0 is the NORMAL case (a catalog tick is rare by design), so health is read off detail.read and detail.readings, never count — see docs/OBSERVABILITY.md. DAILY HITS (#2800, line A of the dig-every-slot epic): the SAME sweep now counts the page’s other column. ArtistSong.daily was already parsed and cached and count_catalog ignored it; count_daily_hits counts an act’s songs over a DAILY-streams rung (DAILY_RUNGS, one rung: a million a day) with the same boundedness rule, the same integer ladder (daily_hits, floor 3), its own field table (artist_daily_hit_counts, so a daily rung never shares a field with a total rung), its own daily: entity keys, and its own experiment music_daily_hits (STAGING — a new fact type with a cross-artist rank auditions before it rides the parent’s production flip). Zero new fetches. WHY: measured 2026-09-01, the round-number ladders delivered 0–17 milestones/day off ~6,000 candidates (zero on two of seven days) with the ship cap never binding — detection-bound — while the one FINE ladder, catalog_count, was the best lane on the desk at 2.9/day. A count ticks; a threshold crawls. The daily column ticks every day. Capped at MUSIC_DESK_MAX_DAILY_HITS=1 a slot, separate from the total cap so neither crowds the other. Emits catalog_sweep with detail.mode=daily. Second daily rung (2026-09-02, #2800 line C): DAILY_RUNGS is (500_000, 1_000_000); measured on the top 45 acts, 11 held no song over a million a day but three or more over half a million, so the half-million count is the only daily fact for the tier below the million-tier acts, and for the million-tier acts it is the bigger, faster-ticking number. Every rung is counted off the one page read, each rung fills its own field (artist_daily_hit_counts by rung) and its own daily:<act>:<rung> state, and the card path breaks the biggest rung that ticked first under the one-per-slot cap. daily_rung_phrase speaks 500K as ‘half a million’. CUT 2026-09-21 (owner steer, on reading the lane’s real posts: “cut the daily hits”). _DAILY_HITS_ENABLED (MUSIC_DESK_DAILY_HITS, default off) forces the resolved stage to OFF, so the sweep stores no daily count and composes no daily card; the PARENT total count is untouched and still runs off the same page read. It ran 6 posts a week, of the shape “Bad Bunny now has 68 songs over half a million Spotify streams a day, more than any other Spotify artist.” The env switch OVERRIDES the per-guild experiment on purpose: the master guild had music_daily_hits flipped to PRODUCTION, so a gate that merely respected the experiment would have changed nothing, and the cut is a decision about the ACCOUNT rather than a per-guild trial. The Experiment row is KEPT, not deleted, so the per-guild states and the reason survive – and its DESCRIPTION says the switch is inert and names the env var that really restores it, because a /menu knob that silently does nothing is worse than no knob. The lane is deliberately UNCOUNTED, for the same reason the exits lane is: the switch is read before the page column is counted, so a trim counter would mean doing the work the switch turned off. Its cut equals its old posting rate, 6 a week. Tests: test_daily_hits_are_off_by_default_whatever_the_experiment_says, test_the_daily_hits_experiment_row_says_the_switch_is_inert. THE DAILY FIELD HAS ITS OWN FRESHNESS FENCE (_DAILY_FIELD_FRESH_DAYS, MUSIC_DESK_DAILY_FIELD_FRESH_DAYS, 7; Codex on #3611). daily_hit_counts_at_rung defaulted to the 30 days its TOTAL-count twin uses, and that number carries the twin’s reasoning – “the rotation covers the roster in days, so a dropped row means the sweep genuinely lost the act”. A DAILY count breaks that reasoning twice. The metric moves every day, so a 30-day-old daily count is a DIFFERENT number rather than a stale-ish one. And the cut above STOPS the daily sweep, so on the day the env switch restores the lane the whole field is weeks old yet still inside a 30-day fence: coverage_ok would see a near-complete field and the first restored cards would claim a CURRENT rank against pre-cut counts. 7 days sits well above the rotation period (the top tier every slot plus a rotated slice covers the ~100-act roster in about a day) and well under the staleness that matters. It fails CLOSED: dropped rows shrink the field, coverage_ok refuses, and with no rank there is no clause and so no card, which is this lane’s existing rule. The TOTAL count keeps its 30 days, because for it the twin’s reasoning still holds. Test: test_the_daily_ranking_field_is_read_on_a_tighter_freshness_fence. The ROUND-COUNT path (owner ask 2026-09-10, off Pop Crave’s “Doja Cat becomes the first female rapper in history to have 10 songs with over 1 billion streams each on Spotify” – “let’s add these to the metrics we watch”). The lane already counted this metric; it could not post it. The rank clause is the ship gate and it fires only for the four deepest catalogs at a rung, so measured on 2026-09-10 the TOTAL sweep had run 224 times in 14 days and shipped 0 cards (the daily twin shipped 6), and Doja Cat sat 27th at 1B with 9 songs in the corpus – her 10th could never post. The fix is one more trigger, not a new lane: a count that lands on a ROUND number (cst.is_round_count, every multiple of five – the counts chart accounts post on their own) with NO rank clause goes to _milestone_candidate with the web qualifier REQUIRED (require_qualifier=True, skip_qualifier=False, no lane grounding). The candidate runs the same verified_qualifier path every per-track milestone uses (Perplexity lookup, light second read, the qualifier_verify judge), and composes ONLY when a reported ranking verified and attached – the vacuum rule is unchanged, the clause just comes from a source instead of the field. A SETTLED negative (no_qualifier / unverified / cached_none) ADVANCES the rung with no card (db.advance_milestone_rung, no alert clock), so a crossing nobody reported never pays for a second lookup: kworb lags the crossing by about a day and the chart accounts post within hours of it, so a ranking not reported by the time we see the tick will not be. A TRANSIENT miss (error, unprovisioned, the gate OFF) leaves the rung open and the next slot retries. Every miss emits qualifier with reason=required_missing (detail.settled, detail.lookup, detail.rung). TOTAL counts only – a daily count moves every day and no outlet reports a ranking for it. Cost is bounded by construction: a first sight SEEDS (no lookup), a non-round tick is skipped before the candidate, and the live 1B field held 3 acts at exactly 10, 2 at 15 and 1 at 20, so a tick onto a round number is rare. On the identity rule: the core still carries no artist attribute and builds no cohort claim; what changes is that the lane may RESTATE a cohort ranking a credible source reports, which is what the verified-qualifier path was built for (its own docstring names “the first female rapper” as the target shape, and the per-track lane has shipped “the biggest streaming year by any Asian act on Spotify”). Reported and checked is not inferred. The earlier skip_qualifier note that a web lookup for a catalog stat “returns a claim we must refuse” is superseded by this.music_desk.py’s identity-subset guard (#3019, output_checks.has_identity_subset_claim, wired in _unshippable on every lane). constitution.py forbids identity inference, gender included. The 2026 Platinum board carries names and counts and no gender field, and 1-2 of 10 real composes wrote “SZA is the only woman in the top five” (self-gate 0.92). #3023 removed the line that drew it; this is the deterministic backstop. The guard drops (reason identity_subset) a rank or subset scoped by a lowercase gender word unless the lane’s SOURCE names that category. The source is the lane’s own open-text input: the board blob, the milestone lane’s ranking_ground and the reveal lane’s qual_source (the VERIFIED qualifier only, the same rule as the ranking guard), the chatter thread, the record-card blob. The structured-feed lanes pass none, because their rows carry no gender field. Quoted titles are ignored (“you seem pretty sad for a girl so in love”). Measured over 30 days: 0 of 6,493 distinct shipped desk lines flag; 68 of 71 real ranked gender lines from the newsroom and qualifier events flag with no source (the newsroom is not wired; it relays a stated source). The regex misses slang (“carrying the ladies on this board”), so music_desk_score also carries an IDENTITY hard fail (claude_client._IDENTITY_RULE, #4261). That judge reads every lane that calls it, the newsroom included, and a category the ground states still passes. The chart and called-shot lanes pass the scorer their blob PLUS the career fact the compose was allowed to state (_score_source, #4275), so a take is graded against every fact it may write. drop_score carries the same IDENTITY fail (#4276, _with_drop_fences) plus one pass line for sports event names (“the women’s final”, WNBA, “men’s singles”): production gives that scorer the market’s numbers without its title, so the line passes an event name the ground does not repeat.music_desk_score’s numbers rubric says, inside its UNGROUNDED line, that a forecast the ground truth gives (“market implies ~X by period end”, “on pace for ~X”, a HITS projection) stated as a forecast (“forecast to open with ~X”, “tracking for ~X”) is grounded, though the week is not over. Cause: when HAIKU moved to Haiku 5.5 (#3939, 2026-10-07) the judge began to dock forecast posts as “a projection presented as a result” (units forecasts under 0.6: about 1% a day before, 30-77% after), and #4227’s HEDGE line then scored them 0.0. The HEDGE line now also names a post that “says it may change”, so a doubt clause on a forecast still fails. Measured on 316 real tracking posts (Axiom post_preview + the lane’s own format_reading blob), 2 runs: 118-122 under 0.6 before, 5-7 after. Cutting the HEDGE line’s forecast words instead made it worse (141-142). The same sentence in the CRUCIAL paragraph fixed forecasts as well but also moved reveal takes (114 under 0.6 of 434, against 131 before); in the UNGROUNDED line they stay at 134. Live eval: music_desk_score_projection.music_desk.py’s per-track milestones off the songs page, with the counted context on the card (_catalog_track_crossings / _catalog_track_milestones / _catalog_count_qualifier + utils/stream_report.py, owner steer 2026-09-18, off Hip Hop All Day’s “Plot Twist” post: “an example of the things we should be checking and we should have detected ourselves”). WHAT IT FIXES. The per-track streams ladder (100M, 250M, 500M, 1B…) had ONE aperture: the watched acts’ tracks on the Spotify Global Daily 200, 8 track pages a slot (_watch_milestone_units). The catalog sweep read every act’s whole SONGS page daily and kept only the 1B/2B counts. Measured 2026-09-18 on the top 30 acts: 10,772 songs on their pages, 35 on that chart; 30 songs due to cross a streams rung within a week at their daily pace (18 at 100M), 2 of them on the chart. Drake’s page read “Plot Twist” at 100,057,616 with 325 songs at or above 100M the day the wire posted it, and no lane could say so. Live funnel the same day: the ladder checked 48,247 candidates in 7 days and shipped 11 cards; the catalog sweep read 1,021 pages and shipped 0 total-rung cards. HOW, part one (the card). The sweep now hands every LEAD row of each page it reads (cst.track_candidates, feature rows skipped – another act’s song, prefer absent) to the SAME ladder under the SAME track:<song_key> keys the chart sweep uses, so one high-water rung per song serves both detectors and neither can double-fire. The state is read and seeded in BULK (db.milestone_rungs_bulk bumps seen_at on every row it returns, db.seed_milestone_rungs_bulk seeds the unknown ones; ~500 rows a page, two round trips), and the first read of a page seeds silently – a catalog of 325 songs past 100M fires nothing, only a row that crosses AFTER that read is a crossing. A crossing rides _milestone_candidate unchanged: the same milestone story, the same number card, the same single trailing qualifier. Since 2026-09-20 that qualifier carries the report’s context (owner steer: “add whatever context is missing to the other lane”, “hand the model a little, very little information”): _catalog_count_qualifier builds the stream REPORT off the songs page plus the song’s iTunes tracklist and hands the compose sr.card_qualifier – the ranked fact in the card’s “Nth song to pass X” form (first-ever > album ordinal > catalog ordinal, a single yields none) plus the daily pace as the trailing clause – and falls back to the plain mq.nth_release_qualifier count when the report cannot be built. The card and the clause state the title WITHOUT kworb’s remaster tail (sr.display_title, #3505: the live audition read “Wonderwall - Remastered by Oasis has passed 3 billion”; only the remaster family is cut, a “ - Radio Edit” or “ - Live” tail names a different recording; the rung key, the art lookup and the album join keep the full row title; the chart-tenure lane’s Spotify rows carry the same tail and get the same cut). The qualifier event stamps detail.lead (which fact led: first / album / catalog) and detail.join (how the iTunes lookup went: album / single / none / error), and the ops monitor rates join as itunes_album_join – the catalog fallback is correct and otherwise reads the same as the album form, so this is the one signal a dead join leaves (#3533). When that clause carries a pace the blob WITHHOLDS the live “sitting around X” figure: measured on the real path (n=3), the model handed both kept the figure and dropped the pace, and handed the headline alone it wrote one sentence with the ranked fact and the pace (self-gate 0.89-0.92). The owner’s shape rule: the card stays PLAIN. Capped at MUSIC_DESK_MAX_CATALOG_TRACK_MILESTONES=2 a slot, biggest rung first; the catalog_sweep total-mode event carries detail.crossings / detail.track_cards. The stream REPORT lane, retired 2026-09-20 (#3532). The same context first shipped as a SEPARATE post one slot after the card (story stream_report, experiment music_stream_report, STAGING; a Toots lead line over a counted record sentence, text over the song’s cover, its own sr: desk key and a dedup-history exemption). It was at audition in #bot-logs only, never in a room. Once the card carried the ranked fact and the pace the owner read the two posts as duplicates (“this is starting to feel duplicative”, “add whatever context is missing to the other lane”), so the lane, its experiment, its stream_report event and dashboard panel, the recent_stream_crossings reader and the exemption are gone; utils/stream_report.py kept only build_report and card_qualifier. Its honesty gates are now the card qualifier’s (the song on the page, a bounded count, the tracklist join, the single rule, no artist attribute; a report that cannot be built falls back to the plain nth_release_qualifier). The loose join is a model read (CB-24). A page row that matches a tracklist entry only after its “ - …” tail is cut (sr.loose_rows: “Cameras / Good Ones Go Interlude - Medley” for the track “Cameras / Good Ones Go Interlude”) goes to the cached Haiku claude.track_version_join (purpose=track_version_join, row + track, SAME / DIFFERENT) before it joins the album count; DIFFERENT keeps it off the count, no verdict keeps the old loose rule. The exact _fold_track join needs no call. Measured on the 64 album reports of 30 days: 2 had loose-only rows (3 rows), all 3 SAME on 3 runs, the same as the loose rule. release_version_kind (the row title alone) answered NEW once in 3 runs on the medley row, so it was not reused. What stayed: a delivered per-track card still writes the GLOBAL stream_crossings ledger in _record_ship (one row per song+rung, dated) – nothing reads it yet; it accrues for the year facts (#3466), so the table has no drop. _resolve_album_tracks (resolve_music_row names the collection, apple_music.album_tracklist lists its tracks) stayed as the card’s join. Follow-ups landed (#3467, #3468, #3485): the TOP TIER of the roster (MUSIC_DESK_CATALOG_TOP_TIER=10, the kworb ranking’s head) is read EVERY slot outside the rotation, so a top act’s crossing lands within the slot kworb shows it (the rotation still covers the rest about once a day; catalog_sweep detail.top_tier); the Global Daily sweep keys a JOINT credit by its LEAD act (aw.first_party, the counting rule the presence board uses – kworb lists a joint song under the first-listed act as a lead row and marks it a feature on the partner’s page, so the two detectors now share one track: rung and the ledger names an act the songs-page sweep has a page for; an uncorroborated first party keeps the whole credit); the card’s album ordinal drops iTunes’ edition tail (sr.display_album: “The Fame Monster (Deluxe Edition)” reads “The Fame Monster”, the join still uses the full collection). The FRESHNESS gate (2026-09-19, live incident): the rung is measured between READS, and a track: row the chart sweep seeded can go unread for months (the song left the chart), so the songs-page sweep’s first read of it shows a REAL crossing that is old news – “Shape of You” 5B (66 days old) and “Wonderwall” 3B (29 days) both posted as “just passed”, to the room and to X. Now milestones.crossing_age_days ages every crossing at the row’s own daily pace ((total - rung) / daily; the pace is the CURRENT daily, so a decaying song reads older, never younger); older than MUSIC_DESK_FRESH_CROSSING_DAYS=7 advances the rung with no card (_settle_stale_crossing: db.advance_milestone_rung, a music_desk_scored drop with reason stale_crossing, catalog_sweep detail.stale). The same gate runs on the Global Daily sweep’s track candidates. Two more legs of the same class, live 2026-09-19 after that gate shipped (the “Shape of You passes 5B” post a reader answered “That happened 40 days ago”). (1) The Global Daily sweep read each track’s LIFETIME total off kworb’s TRACK page, and that page’s Total is the streams counted over the song’s chart history, not a lifetime figure: 759M for “Shape of You” (its first table is the WEEKLY history; the daily one reads 1.33B) against 5.10B on Ed Sheeran’s songs page. The sweep seeded the shared track: rung at 500M off it, and the songs-page sweep’s first read then reported the 5B crossing as news; the same shape seeded “Blinding Lights” (a 5B crossing 195 days old) and every perennial on the chart. So the chart sweep now reads the act’s SONGS page row for the track (kworb.track_lifetime, the same ~3h-cached page the catalog sweep reads, joined by find_artist_song: the track id, else the title under the ladder’s own song_key – one song can carry two Spotify ids, “Blinding Lights” is one id on the chart and another on the page), skips a row with no artist id or no page row, and runs the freshness gate only on an actual crossing (gating every row emitted a stale_crossing drop an hour for a settled song). parse_track_streams reads the DAILY section (<div class="daily">) so TrackStreams.daily is a day, and TrackStreams says what its total is. The streams-hero backup (release_streams) takes the LARGER of the songs-page row and the track page’s total: each is a lower bound on the lifetime figure (the track page counts chart days only; the songs page lags a brand-new song – “Joseph” read 279K there with no daily against 7.59M on its track page, 4 days out), so the larger one is the better estimate, and a song with no page row is absent rather than the track page’s number. (2) A row with no daily figure cannot be aged, and it was taken as fresh – kworb’s daily column is blank for whole pages at a time (Arctic Monkeys 0 of 143 rows, Post Malone 35 of 194), so every wrong-seeded crossing on those pages went through: “Circles” at 3.28B posted “reaches 2 billion”, “Sweater Weather” at 4.88B “passes 4 billion”, “Let Down” at 941M “crosses 500 million”. Now an undated crossing is DEFERRED: no card, no rung advance (the next read that shows a daily decides), a music_desk_scored drop with reason undated_crossing, catalog_sweep detail.undated. Prefer absent: a crossing we cannot date is not a card. The wrong rungs already stored need no migration – each settles (a daily present) or waits (none) on its act’s next page read. Still filed: the year/cohort line (#3466; needs the ledger to accrue, or a kworb track-page backfill of crossing dates). The album ordinal counts only the rows the page parser keeps (live 2026-09-20, the “purple” card). The card said “the 12th song from you seem pretty sad for a girl so in love to pass 100 million”; a reader answered “13th”. The tracklist has 13 songs and all 13 were past 100M, but kworb writes “u + me = <3” with a literal < in the link text, and the kworb link regexes ([^<]{1,N} link text) failed on it, so parse_artist_songs skipped the row as a summary row and sr.build_report never saw it. The fix is in utils/kworb.py (_LINK_TEXT + _TAG_RE): a bare < is text unless a letter or / follows it. The join and the count were right; the page was one row short. A dropped row is invisible to the card (the count is one lower, not absent), so the parser is where the class lives: every kworb table parser shares those two primitives. Only the ORIGINAL version of a song is a candidate (#3764). song_key folds a parenthetical version but not a dash tag, so “You Rock My World - Radio Edit” had its own track: rung and its crossing could post as the song’s milestone. The sweep now passes the page through milestones.original_versions first: rows group by release_type.version_base, and the kept row is the biggest ORIGINAL RECORDING (is_original_recording: a plain title, or one with only a remaster tag); with none, the biggest version stands. The two other track-milestone sources take the same rule, because each keys a title directly: the Global Daily chart path (_watch_milestone_units) skips a charting row that is not an original recording, and the story-subject check (_subject_milestone_units) skips a version subject. This is the newsroom’s rule (music_news.resolve_stream_track, #3755) with one difference: a remaster competes with the plain title on its total, because for a catalog act the remaster is often the main upload (“Smooth Criminal - 2012 Remaster” 1.33B against a plain 4.5M upload, 2026-10-05). A kept row keeps its own key, so no stored rung re-keys. Measured on 12 top acts’ live pages: candidates 3,508 -> 2,588, and 17 folded rows were past 100M. 5,352 of 43,932 stored track: streams rungs end in a version tag; 2 of them ever produced a card.music_alert.py’s Kalshi SETTLE path retries a settle instead of losing it (#3822, part of epic #3821). On 2026-10-04 Kalshi settled Tinashe’s “Popstar” streams at 17:29Z, units at 17:51Z and pure sales at 18:52Z. Axiom shows what the alert lane did. Streams posted at 17:38Z. Units composed at 17:59Z, and the ungrounded_number guard dropped the take, because it invented “nearly 10K above” the implied number. Pure sales scored 0.92 at 18:55Z, and the topic judge stopped it at 18:58Z (that half is #3820). The desk posted pure sales at 20:20Z and never posted units. Three rules in _maybe_alert_guild change: (1) Retry instead of consume. Before, any False from _fire advanced the baseline to settled, so one bad compose lost the settle for good. Now _fire sets self._fire_retryable on a failure a fresh attempt can pass: a compose or score exception, a Discord delivery of 0, and each deterministic shape guard (_drop_ungrounded, the metric word, the source tag, drop_self_correction, drop_market_decline). A settle with a retryable failure keeps its baseline and composes again on the next tick, up to _SETTLE_MAX_RETRIES=3 (in memory, per guild and event). A score under 0.6, a topic repeat and an empty take are verdicts on the story, so they still advance the baseline at once. (2) A settle skips the per-event cooldown and the one-metric-per-album deferral (fired_albums). A settle fires once per event, so neither rule protects anything for it. Without the skip, two metrics of one album that settle in the same tick post 10 minutes apart, and a settle inside its event’s line-move cooldown waits up to 30 minutes. A line move and a last call keep both rules. (3) A first sighting of a FRESH settle fires. Before, a reading first seen already settled seeded as settled and never posted (a missed live listing, or a live reading with no estimate to seed). Now _fresh_settle checks settled_ts against the desk’s _REVEAL_FRESH_HOURS (96h). A fresh one seeds as not settled with forecast 0, so it posts as a settle with no beat/miss line. An unknown settled_ts is not fresh, so it seeds silently, as before. (4) The beat/miss line grades only against a fresh market read. classify_music_signal compared a settle with ref_median, the stored baseline, which moves only on a post and can be weeks old. On 2026-10-04 a ~2K pure-sales baseline produced “beating the implied number by ~19.8K”, while the ladder was pinned at its 15K top strike; the units line said ~10K over, while the market’s last forecast was ~33.4K against a 35,960 settle. Now the loop records each event’s last live forecast that is not pinned at either ladder end (_live_forecast, in memory). _settle_implied passes that forecast to the settle only when it was read within _BEAT_FRESH_HOURS (6h) before a known settled_ts. Otherwise it passes 0, and beat_miss_verdict states no beat/miss (prefer absent over invented). Telemetry: music_alert_scored phase=settle markers with reason=settle_retry (+ attempt), settle_retry_cap and settle_rejected, in ops_monitor.NON_SCORE_REASONS, on the music_alert_settles panel. Tests: tests/test_music_alert_settle_retry.py replays the Popstar week.music_alert.py’s settle topic check compares a settle only with posts that state its figure (#3846, part of epic #3821). Measured over the 42 Kalshi music settles from 2026-09-21 to 2026-10-05: every drop before #3820 was the topic judge or a guard, and 4 albums got no fast settle post. #3820 removed the cross-metric evidence. A replay on the live judge with the #3820 filter still dropped two settles that had no fast post: Taylor Swift’s Encore 21.1K pure sales against a “~25K first-week pure sales” forecast (duplicate 3/3), and Kenny Chesney’s Silver Sands Marina 35.5K units against an iTunes rank post (3/3). The judge’s own policy says a resolution is not a duplicate. Now _topic_repeat takes the settled figures (_settled_figures: value_units and, when restated, settled_raw_units) and keeps only the posts that state one of them at the precision they print (_states_figure: “21.8K” states 21,819, “~25K” does not state 21,100; a number after “~” is an estimate and never counts; “36,000” is read to the thousand) and that name this release (missing_release_title, so another album’s post with a nearby figure is not evidence). With nothing left the settle ships and the judge does not run. A desk reveal that already posted the same figure still reaches the judge, so the Encore units settle (desk posted 178.9K 83 seconds earlier) is still a repeat. A line move, a last call and the kworb lanes keep the whole same-metric history. Telemetry (#3889): every settle row carries detail.ticker, and the settle topic_dedup row carries matchup + detail.ticker. The daily-cap marker from #3889 is NOT here: a music_alert_scored marker counts as a 0.0 judge score until ops_monitor.NON_SCORE_REASONS lists it, and that file belongs to #3890. Tests: tests/test_music_alert_settle_topic.py.
utils/output_checks.py (#3904). states_figure and settled_figures moved next to same_album_metric_posts, so the music desk reads the same rule. music_alert keeps _states_figure / _settled_figures as aliases. Its behavior did not change.music_desk.py’s settled reveal compares a settle only with posts that state its figure (#3904, the sibling of #3846, part of epic #3821). _sched_dedup_history builds the evidence list for both the mechanical gate and the topic judge. For a reveal unit whose reading is settled, it keeps only the posts that state a settled figure (states_figure) and name the release (missing_release_title). A live tracking read, a projection and a settle with no figure keep the same-metric list. Measured over 30 days to 2026-10-06 (Axiom, post_dedup with phase == 'reveal'):
same_album_metric_posts already removes. 26 cited a same-metric forecast or a chart rank. Examples: ADÉLA’s PRIMA 30.9K pure sales against a “~32.5K” forecast, Rod Wave’s 9.3K pure sales against “No. 1 on the Billboard 200”.tests/test_music_desk_settle_topic.py (the real posts, plus goldens: a settle that restates an earlier post with the same figure still sees that post).music_alert.py’s 5-minute tick, settles first, every story per tick (#3826, part of epic #3821). The owner’s goal is a post at most 10 minutes after the source changes. Before #3826 the tick ran every 10 minutes, walked each guild in turn, ran about 25 kworb lanes BEFORE the Kalshi readings, and built ONE story per lane per tick, so five debuts on one chart took 40-50 minutes and a settle waited behind every chart lane. Four changes, all in the existing loop (no second loop, lock or seen-set): (1) POLL_INTERVAL_MINUTES defaults to 5 (MUSIC_ALERT_POLL_MINUTES overrides it). (2) alert_tick runs in two phases: phase 1 calls _run_readings (the Kalshi settles, moves and last calls) for every guild, phase 2 calls _run_daily_lanes for every guild. _maybe_alert_guild(readings=, lanes=) runs the same gate in both phases, and each guild is fail-open in each phase. (3) _master_first puts the master guild (the one that posts to X) first in both phases. (4) A debut, jump, peak, riser, entry or release lane DRAINS: after each story it builds again with the handled keys added to _lane_skip, until the builder returns None, returns a key it already handled, or _LANE_DRAIN_MAX (4, MUSIC_ALERT_LANE_DRAIN_MAX) stories. An EXTRA story is built only while the lanes phase has run under _LANE_DRAIN_BUDGET_S (150 s), ONE deadline that alert_tick sets for the whole phase and every guild shares (Codex on #3837: a budget per guild gave N busy guilds N budgets): one post costs about 25 s, so an unbounded drain could hold the loop for many minutes and delay the next tick’s settles; past the budget each lane builds only its first story, as before, and the rest post next tick. A crown lane and the once-a-day artist-market lanes still build once. Every gate stays as it was: _daily_gate, the 0.6 self-gate, the topic judge and _CROWN_MAX_TRIES. The LINE_MOVE persist gate is a TIME, not a tick count: PERSIST_PERIODS is derived from _PERSIST_MINUTES (20), so it is 4 ticks at the 5-minute tick, the same 20 minutes as 2 ticks at 10. _SETTLE_MAX_RETRIES stays 3, which now covers about 15 minutes. Request budget: the Kalshi sweep is cached 90 s (_SWEEP_TTL_SECONDS), so one tick makes at most one sweep, and every Kalshi read passes the one KALSHI_RATE_LIMIT_PER_MIN token bucket; kworb pages revalidate after 120 s with a conditional GET, so a 5-minute tick costs one 304 per page. Measured in the live dry run (scripts/dryrun_alert_tick.py, 2026-10-04): the sweep took 35 s for 136 Kalshi requests (the 240/min limiter sets that pace), each settle post took about 25-30 s (the card build is about 23 s of it), five replayed settles posted 63-166 s into the tick, and the lanes phase took 9 s on real lane stamps.<title> -- <artist>. A settle take that names the artist and the figure but not the title reads as the ARTIST’s number: in the #3833 dry run, “Tinashe opens with 14.4M streams in her debut week.” scored 0.95, and the figure was her album “Popstar”’s. The 0.6 gate grades whether the figure is grounded, and it is, so the gate cannot see a missing title. output_checks.missing_release_title(line, subject) is the deterministic check. It folds case, accents and punctuation, reads “&” as “and”, and deletes apostrophes. A trailing edition tag (“(Deluxe)”, “[Live]”, “ - Deluxe Edition”) is optional, a subtitle after “:” is optional, and a title over five words also passes on its first three words. A subject with no “ – “ (the artist-level streams family) carries no title, so the check passes it. Two call sites: music_alert._fire on a SETTLE (a retryable shape reject, so the settle composes again on the next tick within _SETTLE_MAX_RETRIES, #3822), and music_desk._compose_line on a settled reveal (a drop; the desk’s next slot picks again). Both run it after the metric-word check and before the number backstop and the gate. Live forecasts (line move, last call, tracking) are not checked: the issue scoped the settle, and a live lane has no retry. The compose prompt is unchanged, by measurement: an ablation of the shared _CONTEXT (the debut example as “‘music_desk.py’s album milestones (_album_milestone_units + utils/album_milestones.py, owner ask 2026-09-30 “do we have album milestones” -> “both sales and streams”). Before it, the desk’s milestone detector keyed artists, songs and chart runs, and an album crossing reached the bot only when the newsroom found it on the wire. Three new ladders in utils/milestones.py: album_streams (1B to 10B by the billion, then every 5B to 50B), album_units (every 500K) and album_sales (every 250K). Two sources, each the album’s OWN published figure: (1) a rotated window of watched acts’ kworb ALBUMS pages (_ALBUM_PAGE_FETCHES, 4 a slot) for lifetime Spotify streams, global; (2) the HITS year-to-date albums chart for US units and pure sales. Two rules keep a card true. First, a year-to-date figure is the album’s total only for an album released that year, so ytd_candidates drops every catalog album (the golden: Morgan Wallen’s 2025 album leading the 2026 chart on 2.8M units). Second, one release has several kworb rows (editions), so stream_candidates keeps the biggest row per base title (only the EDITION marker is stripped, release_type.without_edition), keys it by that row’s Spotify album id so a co-primary album on two acts’ pages has one rung (Codex on #3766), drops FEATURE rows (ArtistAlbum.is_feature, the caret), and never sums editions. Every candidate goes through the shared _milestone_candidate (seed on first sight, self-gate, card, rung recorded on delivery); a streams crossing is aged at the album’s daily first (_settle_stale_crossing(metric="album_streams")). A HITS figure is credited to HITS on the card and in the compose source. No trial (owner steer 2026-09-30, “no experiment, ship to prod”): it rides graduated_stage like its sibling lanes, and its key sits in GRADUATED_MASTER_ONLY. Cap 1 card a slot. Telemetry: album_milestone_sweep, panel album_milestones. Joint credits and per-act counts (owner steer 2026-09-30: “fix the attribution, both drake and 21 savage should be individually counted too”). kworb’s albums page names no partner, so a streams card credits every act on the album’s iTunes credit whose OWN kworb page lists the same Spotify album as a lead row (_album_credits). Each credited act is then counted on its own page (“It is Drake’s 10th album past 4 billion Spotify streams, and 21 Savage’s first.”), and three measured traps shape that count: (1) kworb gives one album a different total on each act’s page (Her Loss: 4.34B Drake, 3.85B 21 Savage), so the crossing album counts for every credited act; (2) kworb lists bare-suffix editions as separate rows (“For All The Dogs Scary Hours Edition”), so am.releases_past merges a title into the longest title it extends when the extra words name a version, keeps a sequel or re-recording apart, and withholds the count on anything it cannot classify; (3) an act’s page lists other acts’ albums it guests on (21 Savage’s page carries Drake’s “Honestly, Nevermind”), so _own_albums_past counts a release only when iTunes credits the album to the act, and withholds the count when iTunes cannot place one. The count is the card’s qualifier: it grounds the ranking guard and skips the paid web lookup. HITS cards keep HITS’s own joint credit and carry no count.music_desk.py’s chart-tenure pass (_chart_tenure_units, #2800 line C, owner steer 2026-09-01 “dig deep on every slot”). The platform-chart sweep’s cached rows carry a days column (cumulative days on the chart) that fed nothing — chart_weeks counts Billboard WEEKS. A watched artist’s song crossing a days rung (milestones._CHART_DAY_RUNGS: 100, then 500 and every 500 after it – MAJOR marks only since the 2026-09-20 owner steer on a shipped “730 days” card, “we should do like major milestones, not like 730. Make sure all the charts follow this rule”; the year marks 365/730/1095 and the in-between hundreds are gone) on a DAY-period chart whose rows carry days (the Spotify Global + US dailies; Apple/iTunes rows have none, the weeklies count weeks) is a chart_tenure card: figure = the day count, grounding = the row itself (position, peak, today’s streams), the metered qualifier skipped like the catalog lane. Shared high-water rung per (chart, normalized song), so a song first seen at 1,200 days seeds silently, a row under the first rung still seeds (so its 100th day fires), and two mixes of one release share one state. The tenure: rows (with bbrun:/bbpeak:) are exempt from the 21-day milestone-state prune and never expire (bounded by the pairs a watched act ever charted), because a song can leave a chart for months and re-enter – a pruned row would read the return as first sight and swallow the rung. Gated on music_chart_tenure – GRADUATED 2026-09-07 (master-only), so the gate is graduated_stage and the key only labels the unit – capped at MUSIC_DESK_MAX_CHART_TENURE=1 a slot. Measured 2026-09-01: 25 rows within a week of a rung across the two charts. Emits artist_watch with detail.mode=tenure.music_desk.py’s GENRE DEBUT rule (owner steer 2026-09-30, the Yeat ‘COCOON’ miss at #88). A Billboard 200 debut that also debuts inside the top 15 of a genre album chart the same week (rap, R&B/hip-hop, Latin, country, world, Latin rhythm) gets a chart card at any rank, past the entry cap (40) and the notability floor. The match is chart_standings.genre_debut_ranks; the charts and the cut are MUSIC_DESK_GENRE_DEBUT_CHARTS / MUSIC_DESK_GENRE_DEBUT_MAX_RANK. About 1.7 extra cards a week, measured over 10 weeks. A per-genre artist list and a pop rule were measured and rejected; the numbers are in docs/BILLBOARD_ALERTS.md.music_desk.py’s platform-chart TOP-RUNG entries (artist_watch.chart_stories, owner ask 2026-09-05 “entering top 5 and top 3 should generate a card like 10”). The platform-chart sweep derives one story per row off kworb’s Spotify / Apple / iTunes / radio pages: new_entry (dated by the cog to debut / reentry), crown, a top<N> rung entry and jump. The rung entry used to be ONE line, the top 10 (#2047), so a #7 -> #4 climb crossed the top 5 and fired nothing: four spots is under jump_min, and #7 was already inside the top 10. DEFAULT_ENTER_RUNGS is now (3, 5, 10), checked TIGHTEST FIRST: a row that was outside a rung last period and is inside it now is a top3 / top5 / top10 story, and a move that crosses several (#12 -> #2) is told as the tightest one. top_rung(kind) reads the N back out, and the compose blob (“just entered the chart’s TOP 5, at #4”) is derived from it, so every rung shares one shape. The card wears the bare chart label over the “#4” hero, with no “Top 5” suffix (owner steer 2026-10-18, “stop doing Top N, and just always name the position like #N”; artist_watch._is_position_only). top_for_all widens every rung ENTRY to unwatched acts, on the top-10 reasoning (a top 5 or top 3 entry sits inside the top 10). It does NOT widen a plain MOVE: see the move kinds below. Dedup: a top 5 / top 3 story adds its rung to the card key (kc:<chart>:<song>:top5), because the bare kc:<chart>:<song> key folds every later story about that song into the first for the 9-day window, and each line crossed is its own card; the top-10 entry keeps the bare key it always had (its stored keys are in that shape, and a debut-then-top-10 pair inside one window is the repeat the bare key exists to stop). Same compose path, 0.6 gate, watch_chart story tag, per-slot cap and board fold as before – the rung entry is a new KIND, not a new lane, so no new event. The take states the release’s AGE only where the blob does (owner ask 2026-09-06, “cut those last sentences on age of the record”). Two shipped X posts closed with “a track over a decade old” and “a catalog cut over four decades old still finding new spikes”; Axiom showed 20 watch-chart takes in 30 days with the same tail (“thirteen years deep”, “sixteen years after its release”). The cause was not in chart_story_inputs but one layer up: the shared _CONTEXT block’s AGE / STAGE paragraph opened “the head names the release’s milestone” as a fact, written for the Luminate tracking head (catalog, out 4.2 years), and every chart lane hands the model that paragraph beside a row with no age in it, so the model supplied the age itself. The paragraph is now a CONDITION (“WHEN the head states one … when the material states NO age or milestone, say nothing about how old the title is”), a reword of the causing text rather than a stacked counter-rule (docs/PROMPT_OPTIMIZATION.md). A real-model n=6 dry run on the Bruno Mars blob went 3/6 -> 2/6 age clauses, so the prompt alone does not zero it; output_checks.ungrounded_age_claim is the deterministic backstop, wired into _drop_ungrounded on both cogs (reason=ungrounded_age, docs/OBSERVABILITY.md). It is the age-in-words twin of the #2081 digit check (“14 years old” was already dropped, so the model wrote “fourteen years deep”), and it stands down whenever the source states any age at all.music_desk.py’s CAREER STANDING on a thin chart row (_career_context, #3363, off the engagement measurement in that issue). WHAT IT FIXES: the chart lane said WHERE a title sits (“debuts at No. 81 on the Billboard Hot 100 this week”) and stopped. Measured over 1,034 of our own posts (2026-09-08..15), a bare chart position is the WORST-performing post type on all 45 wire accounts we read (0.69x their own median) and 37% of our output, while a post stating a record or a career standing is the BEST (1.30x) and 1% of ours; @chartdata’s “LISA ties JENNIE … the most entries in Hot 100 history (7 each)” took 8,899 engagements against 0 for our own bare card on the same chart week. NO NEW MACHINERY: the newsroom already derives this off Billboard’s per-artist chart-history page and already grounds it, so the desk calls the SAME pure ladder in utils.music_news through the SAME billboard.artist_entries client – “their Nth entry” keeps one definition instead of growing a second that could drift. The block joins _number_grounding as well as the context, because a fact the model may state must be a fact the ungrounded-number gate accepts. TWO GATES, both measured, both narrowing: (1) only a debut/reentry move – the A/B improved 4 of 4 thin rows but REGRESSED a row that already had its own hook (Luke Combs’ “a new peak 24 weeks into its run” 0.92 -> “his 43rd career entry” 0.88), and on a jump the model ignored the block outright; (2) music_news.is_latest_entry must confirm the title is the act’s NEWEST on that chart – entry_ordinal_fact is always arithmetically right, but a chronological ordinal on an older title implies a recency it lacks (live on the Hot 100 dated 2026-09-19, Ariana Grande’s “Hate That I Made You Love Me” is her 99th entry with ELEVEN charted after it). Gate (1) lands on the same condition the newsroom already uses for this rung (_debut_confirmed), which is the sign the narrowing is real rather than fitted. peak_history_fact and chart_run_fact are deliberately NOT rungs here: the run fact restates weeks_on, already on the chart row, so four of four A/B pairs came back identical. Only hot100 and billboard200 have a readable history page, so every other chart returns “” with no fetch. Fail-open throughout – every miss ships the card exactly as before. Emits music_desk_career.music_desk.py’s CAREER MILESTONE on a projected debut (_projected_career_fact + music_news.projected_entry_fact, #3363, owner steer “if we have any scar tissue related to career milestones, please remove those”). The HITS projection lane could state a rank, a unit count and a sales split and NOTHING about the act, because its framing banned career claims outright. THE BAN’S PREMISE EXPIRED and the ban was not doing its job anyway. Its premise was that “a projection row carries rank, units, the sales split and the label – never career history”, which held only while nothing else was handed in. And measured live on the 2026-09-19 projection, the flat-ban arm wrote “a solid new entry with no chart history to lean on yet” about DOLLY PARTON, who has fifty-one Billboard 200 entries: a ban on career claims does not produce silence about a career, it produces an ungrounded guess in the other direction. The lane now resolves the act’s billboard200 chart history and passes hits.compose_inputs(career_fact=...), which SWAPS the ban for that one fact (“State that and no other career fact”); with no fact resolved the flat ban still stands, which is the default. projected_entry_fact owns the guards and is pure: the title must NOT already be on the history page (Beyonce’s “B’Day” and Kanye’s “Graduation” both project onto next week’s chart and both already sit there – catalog re-entries, not debuts, and “would be” about a printed entry is simply wrong); the page must be NON-EMPTY (a guessed slug that 404s and a genuine first-timer are indistinguishable, and calling a veteran’s tenth album their first entry is the worse error); every date must parse. The wording is “would be” by construction, so a release that misses the chart leaves the sentence true as stated. Live result: “Dolly Parton’s greatest-hits comp is tracking for #21 on next week’s Billboard 200 … and it’d be her 52nd entry on that chart.” The CAVEATS are gone too (owner steer, “don’t give any of those caveats, please, ever”): the framing no longer says the number “is not a chart position, not a settled number, and not a market price”, and projection_standing no longer appends “– a FORECAST, not a printed position”. The tense rule survives as one clause. The chart lane’s own ban is conditional the same way (billboard.compose_inputs(has_career_block=...)): since #3365 the desk hands it a grounded CAREER STANDING block, so the flat ban was telling the model not to make a claim and then handing it one to make. Fail-open everywhere – a miss keeps the ban and the post we already make. Emits music_desk_career with kind=projected_entry.music_desk.py’s GUARD SIDE of the career block (#3412). #3363 made the FRAMING ban conditional on the block, but _unshippable still ran has_career_claim on every bare-row lane, chart and called_shot included. That check matches any possessive ordinal, so it dropped the exact clause the block asked for: 3,354 shape_reject reason=career_claim on chart and 90 on called_shot in the 30 days to 2026-10-15, all “her Nth … entry”. Both lanes now pass the block to _drop_before_gate(..., career_fact=...), and the guard runs output_checks.has_ungrounded_career_claim. With a fact it allows ONE thing: a possessive ordinal whose value the fact states, followed by “entry” (at most three chart-name or “career” words between). An all-time claim, a possessive superlative, any other ordinal, and an ordinal of a different count (“her third #1”) still drop. With no fact it is has_career_claim, so every other bare-row lane is unchanged. A replay of the 3,444 real rejects ships 3,432; the rest are previews cut before the ordinal or a wrong-fact pairing in the replay, which the guard correctly drops.music_desk.py’s whole-chart presence board (chart_boards.platform_presence, #2800 line C, the second fact). The act holding the MOST entries on a platform chart today, once it clears chart_boards.presence_floor (8 entries or 5% of the chart, whichever is higher) with an outright lead (a tie yields nothing) on a COMPLETE read (chart_boards.PLATFORM_CHART_ROWS, 95% of the printed size, positions past it dropped, else no claim), is a platform_presence board on the platform-board rotation: rows attributed to the LEAD act of the credit, placeholder credits skipped, acts bucketed by normalized name, distinct releases by normalized title (the top-10 count’s own rule), the act’s best-placed rows drawn, the whole count in the headline and figure; a joint credit counts for its FIRST-LISTED act whenever the known-artist list corroborates that act, whoever the partner is (artist_watch.first_party; a follower is never promoted, a whole-name-known band stays whole). It used the watch boards’ grouping rule (lead_party, which also needs the partner corroborated), and on 2026-09-17 that crowned Drake 11 over Jhené Aiko 10 on an Apple Music US songs chart that read 12 against 11: “Drake, Future & Molly Santana” counted for Drake because Future is known, while “Drake & Loe Shimmy” and “Jhené Aiko & Ab-Soul” counted for nobody (#3399). A count that compares acts needs one rule for every act. Any act, not only the watchlist. Story board_presence, its own key music_board_presence (GRADUATED 2026-09-07, master-only – the same graduated_stage gate as the platform lane, no settings read; the lane still hands that stage over as extra meta so its keys stay stage-scoped), posts on a NEW HIGH for the (chart, act) – the count is read against the shared milestone store under entries:<stage>:<chart>:<act> (metric board_entries) and recorded on DELIVERY (_record_ship / the dedup close-out, carried as presence_state meta; the high, the act key and the daily key are all stage-scoped so a #bot-logs audition never advances the room’s high or spends its daily turn), so a steady leader (Drake’s ~10 albums on the Apple albums chart every day) is one card and not one a week; the store’s 21-day prune is the episode reset (only the chart’s leader is read each sweep, so an act that stops leading for three weeks reads as first sight next time) – with the 9-day pfbp: act key as the belt, counted against the platform lane’s day ceiling. Measured 2026-09-02: Rod Wave 23 of Apple’s 100, Dolly Parton 17 of iTunes’ 100, Olivia Rodrigo 10 of Spotify US’s 200; a quiet Spotify Global leader holds 6 and is held out. Emits music_desk_scored with phase=board_presence.music_desk.py’s X crowd-read withhold (_withhold_crowd_read, owner report 2026-09-06). The desk composes with an X crowd read (market_topic_pulse) beside its number. That block buys ONE angle: the crowd against a live market question. A take that REPORTS a number has no such question, but the block still licenses its own clause, so the model wrote the reception instead of the number – the desk shipped ‘“Bass Persuades” is on pace for ~18.5M Luminate streams in its debut week, and X is already treating it as a Miley win’. The “X is already treating it” clause is that block talking. So the read is now fetched ONLY for a live units/sales market: the projection lane, any STREAMS read (a pace or a count – the card carries no market link, so there is no market to argue with) and any SETTLED reveal (the question is answered) all skip it, and skip it BEFORE the fetch, so the Grok call is not paid for and discarded. This is the same rule market_alert.py’s skip_crowd_read and market_drop.py’s already run, after the same failure (a forecast take that read the timeline instead of the forecast); withholding the input beats banning the output (#1140). Measured on Sonnet against the real composer, n=9 per arm, same reading and context in both: a crowd clause in 9/9 takes with the block, 0/9 without, and every take without it reports the figure and stops.music_desk.py’s tracking gate on a live streams ladder (music_markets.tracking_gate, run inside rank_desk; owner report 2026-09-07). The reveal/tracking lane took every live ladder the sweep returned. On 2026-09-06 it shipped ‘“Bass Persuades” is on pace for ~18.5M Luminate streams in its debut week’ off KXALBUMSTREAMS-BAS26SEP24, the ALBUM’s first-week streams event for the week of September 18-24, twelve days before that week opened. The 0.50 crossing sat between the 15M rung (30 lifetime contracts) and the 20M rung (20 contracts), and the ladder had no 0.25 crossing at all. A reader on X took the number as the same-titled SINGLE’s count for the week in progress (it had ~1.1M US Spotify streams over two days) and asked where it came from. The units lane (rank_debut_projections) already refuses both halves of that shape: a window more than _PROJECTION_WINDOW_LEAD_DAYS out, and a band is_confident_projection rejects. tracking_gate applies the same two checks to every live tracking read; a measured read (settled, or a partial-resolve value_so_far) passes untouched, and a yearless period (the weekly artist-streams family) fails open on the window check. A window that has PASSED is the third check (window_closed, owner steer 2026-09-20: “once the window passes move on”): once the tracking week’s end date (luminate.period_end_date) is behind today, the ladder has collapsed to one bracket and the number is locked, not on pace, so tracking_gate returns window_closed, and rank_debut_projections (the market board, on both settings of window_open_only), sales_projection_slate (the single-release sales cards) and _subject_tier_hits (the shared lookup under reading_for_subject / readings_for_question, so music_news’ _live_market_forecast, the x_mentions prediction lane and the @Toots forecast tool move on too – Codex on #3519) drop the reading. The two DIRECT-QUESTION surfaces (the @Toots music_forecast tool and the X mention reply draft) then fall through to HITS’ projection for the release (hits.projection_answer / projection_fallback, #3529): from Friday to the Sunday print a question about that release otherwise got nothing while HITS’ final building chart held its number; the answer is HITS’ figure ALONE, credited as HITS, and it stops at the print (_CHART_DATE_TO_PRINT_DAYS). chart_history (#3562, E): the stored PAST for an act or one release, read off the chart-history store’s own tables (db.chart_history_lookup reads the SUBSTRING candidates on the folded artist key, the exact key first on every read so it wins each LIMIT; chart_history.history_rows then keeps only the rows the strict credit match admits, kworb._artist_credits, the #2520 rule: the act as a whole credit or one act of a joint credit, never a fragment, since “nas” is inside “lilnasx”; a title that folds to nothing, “?”, reads the empty-key titles, never everything; the daily read caps rows per chart so a twenty-track act still shows its Apple Music rows; the track window counts from the ACT’s newest day) and rendered by chart_history.render_history – the Billboard 200 / Hot 100 weeks as one line per (chart, title) run – the run’s standing and where its held weeks START first (_run_prefix, off a per-run summary read: “14 weeks held from 2026-06-20 (its week 2); the opening week is before the store, no opening figure held”), then the weeks newest first with the printed units where the story stated them, then the count of held weeks not shown. The prefix exists because the first real-model dry run (2026-09-21) read the OLDEST week shown, an August #1 at 80,000 units, as the opening of a 69-week run; a line cut at its tail (the mention facts cap) keeps the prefix; the HITS midweek/building and Nora projections per chart week; the Kalshi events (implied and settled); the latest stored day per platform chart, the Spotify streams chart first; per-track daily Spotify streams (US / Global). Newest first, capped per section, and a figure the store does not hold is left out, never estimated; a store with nothing for the act says so, so the model reports no record instead of a figure off another act. For a release still in the Billboard 200 top 10 the block also carries an OUTLOOK for the next chart week the store does not hold (#3562, B, chart_priors), each line naming that week BY DATE rather than calling it “next week”, because the store may sit one week behind the Tuesday flip and then the projected week is the chart Billboard already serves (Codex on #3614): OUR OWN estimate, last week’s printed units times the median retention of the stored holdovers of the same age, with the middle-half range and the cell it came from spelled out. The head says in capitals that it is this store’s arithmetic and not a chart figure, a market number or a HITS projection. Four gates, each a way of being wrong rather than silent: the STORE must be roughly current (its newest stored week is the week expected_chart_date says Billboard should be printing, or at most _STORE_STALE_WEEKS = 1 behind it, which is the normal window between the Tuesday page flip and the walk catching it – a longer lag means the walk stalled, and a projection off a week that already printed would be labelled next week); the RUN must be current (its newest stored week is the store’s newest, so a finished run gets no next week); the week must have printed units; and the cell must hold at least MIN_CELL_PAIRS pairs; tool_call carries detail.outlook so a block that stops carrying one is queryable. The X mention lane passes outlook=False and renders weeks lines only, so no hand-posted reply carries a projection. The tool description tells the model the live tools answer the CURRENT week and this one the past. Fail-open (a soft string); emits tool_call (tool=chart_history, matched = rows read). The window gates read the desk’s HOUSE CLOCK (house_today, US Eastern, the same zone the desk schedules on): in UTC the date flips at 8pm ET, and a UTC-dated check dropped a market four hours before its last Thursday ended (Codex on #3519). Measured on the Sat Sep 19 market board: three Sep 26 rows whose week had ended Thu Sep 17 led the card (Westside Whimsy “>75K” at 96-99c, “>80K” at 4-7c on Kalshi, close Sun 10am ET), a day after HITS’ final building chart for that week had already posted. The second half of the fix is the AGE: resolve_reading_age_days used to send a KXALBUMSTREAMS reading to the Deezer date lookup, which matched the single (out Sep 4) and framed the album’s ladder ‘debut week’ (and spent the metered Songstats read on the single). The event title says ‘First Week’ (every event in that series does, measured 2026-09-07: 29/29 open, 3/3 settled), and parse_event_reading now carries that as LuminateReading.first_week; a first-week streams reading rides the same window-framed branch as KXPUREALBUMS (#2519): None before the window, the days-in count inside it. A weekly-streams event whose title does not say first week (a catalog album’s tracking week) keeps the lookup. Replayed against the live sweep on 2026-09-07 (see the PR): the gate dropped every pre-window album-streams ladder and kept the in-window ones with a bounded band. The gate is silent by design, like rank_debut_projections; a blackout still shows as surface_dark. One missing quartile is MIRRORED, not refused (reader report 2026-09-20 on the market board’s X post: “Why is Day and Night missing?”). is_confident_projection used to require BOTH the 0.75 and 0.25 crossings on the ladder. Carly Rae Jepsen’s “Day and Night” (KXALBUMEQUIV-DAY26SEP24, window open, ladder 10K..25K) had its 0.50 crossing priced at ~23.3K between two liquid rungs (20K at 0.86, 25K at 0.32) and its 0.75 crossing at 21K, but the top rung still held 32% survival, so the 0.25 crossing sat ABOVE the ladder, high read None, and the gate refused the read as open-ended: the market had run past the top of its own ladder, and the board printed six Oct 3 / Oct 10 rows without it. Measured on the live sweep that day, 11 of 45 live ladders had that shape (the annual KXARTISTSTREAMSY family most of all, plus the Angel streams ladder and two pure-sales ladders), every one with a tight priced half. The gate now refuses an open-ended MEDIAN or a ladder with NEITHER quartile priced, and when exactly ONE quartile is off the ladder it mirrors the priced half around the median and tests twice that half-width against _PROJECTION_MAX_BAND_RATIO. The card still prints only the priced median; the mirror is a gate assumption. One consequence, pinned in the tests: the Sep 7 pre-window streams ladder above (0.75 at 16M, top rung at 0.35) now passes the band gate in-window, and the WINDOW gate is the one that refuses it on the day it shipped, which it still does. Every consumer moved together (rank_debut_projections, sales_projection_slate, is_projectable, tracking_gate, the line_move classifier), since they share the one gate.music_desk.py’s one board per chart week, and a deduped move closes out (2026-09-11, owner report on the 2026-09-12 Billboard week: “a lot of our boards are being duplicated … the chart ones that should be weekly”). Two repeats, two causes, both fixed at the class. (1) A fact key that reads the DRAWN rows changes inside a chart week. The individual-artist board keys on the act’s ranks (_artist_catalog_dedup_key, bbac:<chart>:<act>:<ranks>) and the standings boards on the subject + count (_standings_dedup_key); #3147 raised the card’s row cap from 10 to 11, so Drake’s Billboard 200 board keyed on 10 ranks on 09-09 and 11 ranks on 09-11 and posted twice, word for word. Each lane now carries a per-chart-week key beside the fact key (_artist_catalog_week_key = bbac:<chart>:<act>:wk:<week>, _standings_week_key = bbst:<chart>:<kind>:<subject>:wk:<week>): a board is skipped when EITHER is in the window (_artist_catalog_seen / _standings_seen) and both stamp on ship (extra_dedup_keys). The fact key still keeps a stable catalog from re-boarding across weeks; the week key is the invariant – one board per act per chart per printed week, whatever the rows look like. (2) A move the dedup gate rejected kept carry-over and re-composed every slot until the wording window passed, then shipped as a repeat. _sched_on_dedup closed out the publication boards only, on the #2037 reading that #1982’s carry-over (“an unposted move stays a candidate”) covered the movement lane; but #1982 is about a move the SLOT CAP skipped, which never reaches the hook, and a move the gate REJECTED is a story the room already has. Measured: the top-ten board’s take said “Don’t Look Down debuts at No. 1” on 09-09 18:14; the move card composed and passed the self-gate on 35 hourly slots, was dropped as a duplicate each time (post_dedup phase=chart), and at 09-11 08:11 – 36h later, WIRE_DEDUP_WINDOW_HOURS exactly – it shipped; the act board and the census board did the same. chart, standings and artist_catalog now close out with the boards (every key the unit carries stamps; a production DEBUT books its cross-surface key through _book_chart_debut, the same rule _record_ship applies). The wording gate is a 36h window by design (WIRE_DEDUP_SURFACES), so it is the LAST net, never the one a weekly board relies on. Tests: test_a_deduped_board_or_move_is_closed_out, test_artist_catalog_week_key_blocks_a_re_keyed_board_in_the_same_week, test_standings_week_key_guards_one_board_per_chart_week.artist_catalog), the X-mentions boards lane, and the newsroom’s count board when the count covers the whole printed chart. Before this, each surface deduped only against itself, so the same fact posted twice: the X-mentions lane posted “Drake places 11 albums on the Billboard 200” on 2026-10-02 and the desk census posted “Drake leads the Billboard 200 this week with 11 albums” on 10-04, the same chart week; on 10-13 the desk census and the desk artist board both posted Drake’s 9 Hot 100 songs, 6 seconds apart. Now one key names the fact: chart_standings.census_fact_key = bbcen:<chart>:<act>:wk:<chart week>:<count>:<hash of the folded title set>. The ROWS are the title SET, not only the count, because the surfaces attribute credits differently (the census splits a joint credit only on a corroborated act; the mentions lane also counts a featured credit), so an equal count over different titles is a different board. An undated chart (a live platform chart) gets no key. Each surface writes the key to the SHARED post_dedup_history surface wire_chart_census (wire_lanes.CHART_CENSUS_DEDUP_SURFACE, 14 days retention) on a production post only: the desk in _book_chart_debut, the mentions lane on a room delivery (_book_census_fact), the newsroom in _sched_deliver (_record_census_key). The desk’s census lane (_pick_standings) and artist lane (_artist_catalog_units) call _census_told and skip a board whose key is there (music_desk_scored reason=census_told, a non-score marker). The two desk lanes compose in ONE slot and deliver after it, so the store is still empty when the artist lane picks; _sched_compose_units hands the artist lane the census keys composed earlier in that slot (slot_census). A changed leader, changed titles or a new chart week is a different key, so that board composes. The newsroom’s count reading carries a chart week (ChartCount.week_of) only when the claim’s top-N window reaches the chart’s published size (MusicNews._with_census_week); a “4 titles inside the top 10” count is a different fact and gets no key. Since #4394 a whole-chart claim with no top-N window (“Rod Wave lands 16 Don’t Look Down songs on the Hot 100”) settles too: on ONE Billboard chart, _live_artist_count reads the whole printed chart (_whole_chart_size), and the reading counts only when its count equals the number in the story’s figure (mn.claimed_title_count). Any other count is dropped (reason=count_mismatch), and a claim with no reading still writes no key (prefer absent). An equal number alone is not enough (Codex on #4738): only a PLAIN figure, a number and at most one title noun (“16 songs”), reads the whole chart, so “10 #1 songs” or “15 top 10s” never settles. A career entry count (the classifier’s entry_count, chartdata’s “a milestone 100 songs on the Hot 100”) passes no figure (_plain_count_figure). An equal number does not prove the WEEK either: a forecast (is_projection) reads no whole chart, and the reading settles only when the post was written in the six days up to the chart date it read (mn.post_in_chart_week). Billboard prints a Saturday-dated chart the Tuesday before, and the wires post the next week’s numbers from the Sunday after, so a Sunday or Monday post, or an undated one, is dropped as week_unproven. A subject that carries the album title (“Don’t Look Down - Rod Wave”) is absent from the Hot 100, so on this shape the count reads the act half of the subject.music_desk.py’s a deduped SETTLED reveal closes out (#3895, 2026-10-06). The reveal lane (_pick_readings -> _compose_reading_unit) keys each unit on its Kalshi event ticker, and _pick_readings skips a ticker that music_desk_events holds, before compose. A delivery stamped the ticker (_record_ship), but a dedup drop did not, because reveal was not in _sched_on_dedup’s close-out list. So a reveal that ANOTHER surface had already posted composed, scored and dropped again on every pass. Measured 2026-10-05: music_alert had posted the Silver Sands Marina pure-sales settle (29.8K) and the Popstar streams settle (14.4M); the desk composed both again every ~6 minutes, 20 scorer runs in 66 minutes with zero posts. Over 14 days the lane had 916 scorer runs, 729 dedup drops and 36 posts. Now reveal closes out like the boards above: the first dedup drop stamps the ticker, and the picker skips it for _RECENT_EVENT_HOURS (10 days). The ticker is per release, per metric, per week, so the next chart week mints a new one. A live tracking read does NOT close out: the market later settles under the SAME ticker, so a stamp on a deduped tracking read would also stop the desk’s settle reveal for 10 days. A DELIVERED tracking read already stamps the ticker (_record_ship), so the desk skips that settle either way; music_alert owns settle posts. Tests: test_a_deduped_reveal_does_not_compose_again, test_a_deduped_board_or_move_is_closed_out.music_desk.py’s a deduped SALES PROJECTION closes out on its figure (#3901, 2026-10-06). The sales-projection lane (_pick_sales_projection -> _compose_sales_projections) posts on a daily key, proj:<release>:<metric>:<day>, which is stamped only on a delivery. A card the post-dedup gate dropped was not stamped, so it composed and scored again on every pass. Measured over 14 days to 2026-10-06: 83 posts, 3,750 dedup drops (2,608 text_similarity, 1,088 content_overlap), and up to 133 drops of one release and metric on one day. The fix could not stamp the daily key like #3895 did for reveal: a projection moves during the week, and 1,028 of those drops quoted a figure 10% or more away from the release’s last posted figure. A daily stamp would hide that move until the next day. So _sched_on_dedup stamps a SEPARATE key, projdup:<release>:<metric>:<day>:<figure> (_sales_proj_closeout_key), where the figure is the market forecast rounded to two significant figures (_projection_figure_bucket: 16,900 and 17,100 are both 17000; 19,500 is 20000). The picker skips a reading whose current projdup: key is stamped. A moved figure gets a new key and composes again, and the gate then judges it. The day in the key keeps the daily cadence (#2519). The bucket is finer than the 15% alert bar (FORECAST_MOVE_FRACTION) on purpose: a stamp should err toward composing a moved figure. The projdup: prefix does not match the rotation’s proj:<release>: prefix, so a stamp does not count as a carded release. Replayed on the 14 days, the stamp leaves about 175 dropped composes of the 3,750. Test: test_a_deduped_projection_skips_until_its_figure_moves.music_desk.py’s a deduped board skips its next compose until its headline changes (#3581, 2026-10-17). _sched_on_dedup closes most lanes out by stamping the dedup key, but the PLATFORM boards cannot take that stamp: their daily pfb: keys count against the lane’s day ceiling. So a platform board the post-dedup gate dropped composed and scored again on every slot. Measured over 7 days to 2026-10-17: 357 platform-board drops, 329 of them a repeat of a drop earlier that day for the same board and leader (one iTunes #1 board 45 times in a day). Now _compose_board_unit puts a DEDUP MEMO key on every board, ddm:<dedup key>:<hash of kind, chart and headline>, _sched_on_dedup stamps it on a drop, and the next compose of the same board with the same headline returns early for _DEDUP_MEMO_HOURS (4) with a music_desk_scored reason=dedup_memo marker (no compose, no judge). A new leader or a new claim changes the headline, so it composes at once. The memo expires because the gate’s history does: the post that blocked the board leaves the 36h dedup window, and the board can then ship. Replayed on the 7 days: 389 platform-board composes that passed the self-gate become 73, and the 11 boards that shipped after a run of drops each ship at most 4h later (0 lost). The ddm: prefix is not a stem of any lane key, so the day ceiling and the rotation never read it. The memo covers every lane that composes through _compose_board_unit; for the lanes _sched_on_dedup already closes out it changes nothing. A deduped MILESTONE card (any lane through _milestone_candidate) now takes the compose-failure cooldown on its rung’s fail key (<entity>:<metric>, 12h): its rung is recorded only on delivery, so the same crossing composed and was dropped on every slot (Dua Lipa’s “Training Season” peak card about 30 times, September). The watch-chart lane was checked and is NOT the same shape: it keys each card on the chart position, so its 166 drops in the 7 days are 137 distinct (song, position) inputs, and the same-position repeats are pairs in the same minute (two destinations). Tests: test_a_deduped_board_does_not_recompose_from_the_same_headline, test_a_deduped_milestone_takes_the_compose_failure_cooldown.music_desk.py’s a flourish trailing clause is cut before the self-gate (#3257, 2026-10-17). The self-gate grades whether claims are grounded, and a clause that only judges the figure (“a steady launch”, “a catalog run that refuses to let go”) asserts nothing, so it scored 0.90 and shipped. _detag_credit, which every lane calls right after its compose, now ends with _cut_flourish. output_checks.trailing_clause only FINDS the clause (the text after the last comma, semicolon or dash; a thousands comma and a comma inside a closed quote span are not breaks) and never returns a clause with a digit, because a cut must not remove a figure. Whether the clause is filler is a judgment about meaning, so Haiku decides every one (claude.is_flourish_clause, purpose=flourish_check, through classify, cached a day; owner steer 2026-10-17: no word list). A FLOURISH verdict cuts the clause (strip_trailing_clause) and emits music_desk_clause_cut; the post itself still ships, because the head is the grounded figure. A drop would cost the post and hold its key for 12h. No verdict keeps the clause. Measured on 30 days of shipped desk lines (5,939 distinct under 120 chars): 499 have a digit-free trailing clause and reach Haiku; 18 are cut, every one a judgment (“a steady start”, “a solid pop launch”); 0 flips over 2 runs. The first wording cut two trend clauses (“a strong launch despite streaming cooling”); the shipped wording names that case as a FACT and keeps both. An unquoted name cut at a comma (“… for Tyler, The Creator”) still reaches the check, so the task says such a clause is a FACT; a live probe of 8 name-ending posts, 2 runs each, answered FACT 16/16. The cut lines still score 0.82-0.88 on the real self-gate. About 37 Haiku calls a day.utils/milestone_qualifier.py’s source and scope are Haiku labels (CB-11, #4557, 2026-10-19). After qualifier_verify (Sonnet) accepts a qualifier, ClaudeClient.qualifier_labels (Haiku, purpose qualifier_labels, cached a week) reads the clause with the research claims (each cut at SOURCES:) and returns source (official / aggregator / none: whose ranking it is; a news site that reports a chart is not the source) and scope (relative / standing). is_unofficial_rank drops an aggregator source under every kind, and a rank unless official; _ttl_for caches a relative claim 12h. The cache row keeps both labels. With no label (an old row, an error) the word lists decide as before (_AGGREGATOR_RE, _OFFICIAL_SOURCE_RE, _SCORE_RANKER_RE, _RELATIVE_SCOPE_MARKERS); they are also the prompt’s examples. Not a field on qualifier_verify: that was the first shape, and in a 50-subject live replay the two extra fields moved the judge’s supported verdict on 14 of 43 subjects, 5 for the worse (a mis-sourced “13th solo entry”, a stale chart position). Measured on the labels: 78 accepted qualifiers (25 replayed with their reads, 53 production clauses of 30 days), 2 runs: the drop decision is 0 worse than the lists, better on 2 both runs (Chartmasters’ Guetta rank, Kenny Chesney’s Billboard No. 1s) and on 2 more on one run (UK and Australian chart firsts); scope marks 15 claims relative the list missed (“in 2026”, current monthly-listener ranks) and misses 1 “this decade” on one run. _NO_QUALIFIER_MARKERS and music_desk._RESERVED_OTHER_CHART stay code (MEASURED-KEEP): the phrase tail opened 0 of 100 real lookup reads (every no-qualifier read was NONE or a hedge), and on 924 real Spotify desk lines, 2 runs, the chart regex made 1 error with no effect while Haiku made 3.music_desk.py’s milestone qualifier is checked for a false qualifier word (#4442, epic #4412). utils/milestone_qualifier.verified_qualifier finds a “first / fastest / Nth” claim on the web or on X, and qualifier_verify accepts it on one credible source. That judge confirms a claim and never asks whether its “first” / “most” / “record” is false, so a false “first ever” from one chart account shipped. Now _qualifier_block passes a check callable at PRODUCTION only, and verified_qualifier runs it after its own gates (the judge verdict, unofficial_rank). The callable is utils.qualifier_check.check_source_qualifier with surface="music_desk", research_purpose="music_desk_qualifier" and the qualifier text as the claim. Outcomes: cut uses the cut text as the qualifier, unless the cut leaves no qualifier word and no ordinal (“her first UK Number 1 album” -> “her UK Number 1 album” is no ranking and reads as “the only” one), which drops it; no_clean_cut drops the qualifier (reason qualifier_wrong, settled, cached as none, in _SETTLED_NO_QUALIFIER) and the milestone post ships without it; unchecked keeps the qualifier and stores the row unchecked for 12h, so the next hit asks again; kept keeps it. The cache row records checked and the cut words, so a cache hit (11 times more frequent than a fresh verify, 30 days to 2026-10-17) does not pay for a second check, and a row stored before #4442 is checked once on its next hit. Both callers (the milestone path and the settled reveal) drop a take that states a cut word again (_drop_cut_qualifier, music_desk_scored reason=cut_qualifier score=0.0), before the ranking guard. The catalog count is not checked: it is a deterministic count, not a copied claim. No cut_note goes to the compose: this compose never reads the original qualifier wording, so the note would only recite the cut words to the model. Tests: tests/test_milestone_qualifier_check.py. The number backstop reads the attached qualifier (#4449). Both callers ran _drop_ungrounded on a text without the qualifier, so a take that restated the qualifier’s own number (“136 weeks in the Official Singles Chart Top 100”, “the Billboard 200”, the album title “1989”) dropped as ungrounded_number: 45 milestone drops and 85 settled-reveal drops in 30 days to 2026-10-18. Now the milestone path adds qual_source to the backstop text, and the reveal path adds it to the numeric_grounding set (which also feeds the salvage re-ask). qual_source is the text after the falsehood check, so a number only in a CUT part stays ungrounded. It is “” at OFF and STAGING and after off_chart, and the unverified research never reaches it. A replay of the 130 real drops: 128 pass; the 2 left are the daily-hits lane, which names its rung in words (“a million”), filed as #4466. Tests: tests/test_milestone_number_grounding.py. The reveal set no longer reads the raw Perplexity research (#4474). Before, the reveal grounding held pplx_body, so a take that stated a number found only in that unverified research shipped; the milestone path drops the same take. Now the reveal set is the blob, the age, the shown related odds, the Songstats, chart and kworb blocks, and qual_source. The compose still reads the research as BACKGROUND. A proxy replay of 30 days (868 shipped reveal events) found at most 8 (0.9%) with a number no verified block states: a week-on-week % change (7) and a UK chart rank (1). Each one goes to the salvage re-ask first. The milestone backstop grounds the daily-hits rung and runs through numeric_grounding (#4466). (1) The daily-hits blob says its streams rung in words (“a million”, “half a million”), and ungrounded_numbers cannot read them. The milestone rung on that lane is the song COUNT, so rung_grounding did not help. A take that wrote “1 million” or “500K” dropped as ungrounded_number (2 drops, Justin Bieber 2026-09-19 and Taylor Swift 2026-09-20). The catalog lane now passes rung_figure=abbrev(count.rung) (the card eyebrow’s own “1M” / “500K”) into rung_grounding. The daily-hits lane is off in code since 2026-09-21 (_DAILY_HITS_ENABLED), so this applies when it is turned back on. (2) The milestone path now wraps its backstop text in numeric_grounding, as the reveal path does, so a qualifier figure welded to a unit (“136wk”) grounds its paraphrase (“136 weeks”). No real drop had that shape. The non-reveal lanes’ _number_grounding does not wrap its set either. It is not changed here: its 30-day drops were not checked against their source texts, so there is no measured case.music_desk.py’s watch-lane volume cut (2026-09-21, owner review of a single day’s
posts: “cut down on the volume of noise”). WHY: that day the account sent 80 posts to X
and watch_chart was 26 of them, the single loudest lane; the owner deleted nine posts by
hand, three from this lane. The fix separates two stories the rule had conflated. An
ENTRY into a chart’s top ten is news about the chart, so top_for_all still tells a crown
or a rung crossing for any act. A plain MOVE is a pulse on an act you already follow, so it
now needs a WATCHED artist: is_top_move left the watch bypass in artist_watch.chart_stories.
Measured on that day’s live charts, moves fall from 32 to 16 and the sweep from 57 stories
to 38, and every survivor is a name the watch carries. The SURGE band lands in the top 20,
not the top 50 (MUSIC_DESK_WATCH_KWORB_BIG_JUMP_TOP, was 50): the old ceiling was the
loudest setting on the desk and sent seven mid-chart catalog cards that day – Bruno Mars to
#46, Harry Styles to #42, Linkin Park to #36, SZA to #50 – each a number moving rather than
a thing happening. A surge landing at #20 or better still fires. The move band is its own
parameter (top_move_rung, MUSIC_DESK_WATCH_TOP_MOVE_RUNG, default 10), separate from
the entry rungs so the two can be set apart. The owner said a one or two place move “should
be okay in the top 20”; measured, actually setting the band to 20 takes the lane from 38
stories to 74, so it stays at the ten – which is inside the top 20 named – and the wider
band is one variable away. Tests: test_a_top_ten_ENTRY_is_told_for_any_act_but_a_MOVE_needs_a_watched_one,
test_a_plain_move_is_bounded_by_its_own_band_not_the_entry_rungs.music_desk.py’s notability trim (2026-09-21, owner steer after the @Kurrco / @talkofthecharts comparison in docs/X_GROWTH_ROADMAP.md §13: “we post a lot of noise from artists people don’t care about”, “nobody’s giving engagement to negative content”). WHY: measured over the 600 @tootsiesbar posts before the steer, the UNKNOWN artist tier ran 0.1 likes a post and the top-50 tier 10.6; exits ran 30-50 views and no likes each. Three cuts shipped, all deterministic and env-restorable, none touching a prompt. (1) The artist-tier gate on the STREAMS family only (the owner’s scope the same day: “debut and album forecasts should stay, but streams we can keep with the gate”). UNGATED, posts for any act: first-week units (KXFIRSTWEEK), album-equivalent units (KXALBUMEQUIV, its annual KXALBUMEQUIVY), pure sales (KXPUREALBUMS, its annual variant) – the debut and album forecasts, their settles and year-to-date counts, their alert signals. GATED, a recognized act only: an album’s first-week streams (KXALBUMSTREAMS), an artist’s weekly streams (KXARTISTSTREAMS / KXARTISTSTREAMSU) and annual streams (KXARTISTSTREAMSY). The one rule is MusicDesk._tier_gate (luminate.is_streams_reading picks the family), read by _pick_readings and cogs/music_alert.py’s tick; _pick_sales_projection is units-only and runs no tier gate. It reads the artist off EVERY sibling of a release (music_markets.release_siblings, keyed on the shared ticker suffix; music_markets.reading_artist: a titled subject names it, a bare subject on an ARTIST-streams family (KXARTISTSTREAMS*) is the artist, a bare subject on a units / sales market or an album-streams ladder is the title alone and returns None – the Angel pair, Codex on #3564) through _tier_allows, which never cuts a release no subject can tier; an empty known list (a kworb outage) reads everyone as KNOWN, the desk’s shared fail-open. (2) Exits off. _exit_units returns [] unless MUSIC_DESK_EXITS=1 (the lane and its board stay wired; 12 posts a week before). (3) A settle miss is not graded. music_markets.beat_miss_verdict returns ‘’ when the number came in under the market; a BEAT keeps its clause. (4) A LIGHT track milestone needs a top-tier act (owner steer after the fluff enumeration: “I like 100 million for artists I care about” – the Drake “Plot Twist” 100M line was a good post; “‘Orbiter’ by Noah Kahan just crossed 100 million” at watch rank 47 was not). milestones.is_light_stream_rung names the 100M / 250M / 500M rungs of the streams ladder; _milestone_candidate breaks one only for an act inside the top _LIGHT_MILESTONE_TOP_TIER (50, env) of the watch by Watchlist.position (1-based, daily-streams order). The billion rungs, and every artist ladder (listeners, career), are untouched. A rung skipped because the watch PLACED the act past the tier is SETTLED without a card (_settle_rung_quietly -> db.advance_milestone_rung) so an act that climbs into the tier later cannot revive an old crossing as fresh news, while the next rung still reads as new. An act absent from a WHOLE page is also a decided cut. THREE cases are UNDECIDED, keep their rung and repeat on the counter: tier=listeners_only, an act placed only among the listener-only names (pos > daily_count), whose position is a listeners rank and says nothing about daily standing; and tier=short_page, an act absent from a daily page that reached the TIER (50) but not the watch size (100), where absence is not evidence because an act at real rank 61-100 is simply missing from a 60-row page. A fourth is tier=under_read (#3881): an act whose “Daily” figure the kworb client marked as an under-read (Watchlist.daily_under_read). It is held out of the daily ranking, so it reads as absent from a whole page, but its real position is unknown, so its rung stays open. The third is tier=total_top (#3868): an act whose kworb TOTAL-streams rank (Watchlist.total_rank, off ArtistStat.rank) is inside the tier. Its daily position comes from kworb’s “Daily” column, which can UNDER-read (Drake 11.1M on 2026-10-02, 64.0M on 2026-10-05, about watch position 65), and the total rank does not use that column. This does not widen the tier: the act still gets no card until a daily read places it inside. Measured on the 2026-10-05 page, 13 acts sit in the total top 50 but outside the daily top 50 (Ed Sheeran 54, Post Malone 58, Beyoncé 61, Lil Baby 63, …), so counting the total rank as INSIDE the tier would have added 13 acts to an owner-set gate. Their rungs now stay open, and the watch sweep’s fresh-crossing gate settles each as stale_crossing after _FRESH_CROSSING_DAYS. Settling either would be permanent, and raising MUSIC_DESK_LIGHT_MILESTONE_TOP_TIER, the remedy this file offers for a borderline act, cannot undo a settled rung. Both directions have been wrong here inside one PR: an earlier pass treated EVERY unplaced act as undecided, which over-corrected (the watch is ~100 names, so most acts are absent and almost no rung would have settled, reviving stale crossings and re-emitting the same trim row every sweep), and a later one treated absence off a 60-row page as decided, which burned real rungs. The three outcomes stay apart on the panel, which groups by tier for exactly that reason; Watchlist.position resolves the alias table (“Ye” ranks where “Kanye West” ranks); and the gate enforces only when the watch’s DAILY ranking reaches the tier (Watchlist.ranked_to, off the daily_count the build records) – an empty build, and a build off a truncated daily page even when the listeners page pads the union to length, both fail open (all Codex on #3564). Measured on the last 30 days: about 8 of 62 milestone posts, all acts ranked 51-98 or off the watch (Calvin Harris, Ed Sheeran, JAŸ-Z, Mac Miller, Fleetwood Mac, Omar Courtz). The newsroom’s own milestone relay (music_news_posted phase milestone, the Plot Twist line) is a separate surface and is not gated here. The manual /music drop shares this gate. break_milestones_for_track calls the same _milestone_candidate, so a light crossing on an off-tier act is cut there too, and the settle removes it from the desk’s own lanes as well. That follows from one seam serving both paths, and it is stated here because it is not obvious: a user asking about a track is not the desk’s cadence, and an owner who wants the manual path exempt should say so – it is a one-argument change, not a redesign. A crossing whose act the lane cannot NAME is never cut on either path (an empty act SKIPS the gate rather than failing it), because an empty act reads as absent from the watch, which the settle rule counts as decided, and the rung would be burned on a card nobody could judge. (5) A tenure rung under its floor is not a card (owner steer, same pass: “chart tenures, we can cut below a year … 100 weeks maybe-ish, multiple years”). milestones.TENURE_FLOORS: chart_days under 730 (MUSIC_DESK_TENURE_MIN_DAYS), chart_weeks under 100 (MUSIC_DESK_TENURE_MIN_WEEKS); below_tenure_floor is read in _milestone_candidate beside the light-rung check, the ladders keep their lower rungs so seeding is unchanged, and a skipped rung is settled without a card (_settle_rung_quietly). On the ladders #3557 left the same day (days 100, 500, 1000, …; weeks 50, 100, 150, …) the floors keep the 1,000-day and 100-week rungs and up and cut the 100- and 500-day and 50-week rungs; the chart_run lane’s PEAK stories (“her first top 10 entry”) are a different metric and untouched. The first CI run of this floor failed on shard 3 and passed everywhere locally: CI tests the PR MERGED into main, and #3557 had coarsened the ladder under the test’s pinned 730-day rung, so the test now reads its rungs off the live ladders. (6) Every gate COUNTS the card it refused (owner ask with the trim: “make sure we have metrics for this – what we’re cutting and how much we’re cutting each, so we can review in a week”). _emit_trim writes one music_desk_trim event per refusal, with reason naming the gate (streams_tier / light_milestone / tenure_floor), the lane in kind, and the act, metric and rung in detail. cogs/music_desk.py is the only emitter: the alert cog applies the same tier gate and deliberately does NOT count it, because its tick polls every 10 minutes ahead of every eligibility check and the per-tick post cap, so its refusals are not cards and it would add about 1,600 events a day for no information. The streams_tier count runs over the whole RANKED reading set (_count_tier_refusals, fed rank_desk’s output) before the pick loop: that loop breaks at its per-slot limit, so counting inside it would measure where a refusal happened to rank, while counting the raw sweep would bill the gate for stale reveals and duplicate tickers rank_desk already drops. Every field name was already in the event schema, so the counter costs the field budget nothing; it is fail-open and logs rather than raises, and the test stubs emit(event, **fields) with the REAL parameter name because naming the stub’s first parameter kind shadows the kind field and the fail-open swallows the TypeError. The panel is desk_trim in scripts/axiom_setup.py; the other half of the trade, what the desk STILL posts, is music_desk_posted by phase, which no panel breaks out, so run it by hand. The exits lane is deliberately uncounted: _exit_units returns before it reads Billboard, so a counter would mean doing the work the switch turned off – its volume is the 48 music_desk_posted rows (chart_exit + chart_exit_board) in the 28 days before the change. No monitor: a gate firing is the gate working, and the risk this trim carries is the desk going QUIET, which the ops monitor’s surface_dark / lane_dark findings already cover. All three reasons at ZERO for a week means the kworb watch is failing open, not that the noise stopped. Measured before shipping, against the 28 days of real posts and one live Kalshi read (the baseline the first week is read against): the desk posted 1531 cards, 383 a week. The tier gate cut 11 of 111 live readings – all streams-family, 0 of the 42 units/sales readings, so the forecasts the owner kept are provably untouched – naming Rampa, ATRIP, Black Coffee, BLOND:ISH, Sylvan Esso, Bonobo, MONSTA X, Alabama Shakes, Bella Poarch, Beck and Ms. Rachel. 23 of 30 tenure cards sat under the floor (5.8 a week). 13 of 26 light track-milestone cards named an act outside the top 50 (3.2 a week). Exits were 12 a week. Total cut: about 21 cards a week of 383, about 5%. The borderline the owner should know about: the top-50 rule cuts Ed Sheeran (daily rank 53) and Tame Impala (51), and Tate McRae is off the daily page entirely; raise MUSIC_DESK_LIGHT_MILESTONE_TOP_TIER on Railway to pull them back without a deploy. The digit / symbol / non-ASCII artist hole is CLOSED by the subject check, not by the regex (#3591, 2026-10-24). luminate._SUBJECT_RE keeps its artist group letters-first and digit-free, because that is what stops a by <date> clause reading as an act. So it cannot read U2, 4batz, 6LACK, 21 Savage, The 1975, 112, Blink-182, Sum 41, ØZI or $uicideboy$: the “by” clause stays inside the release (“Carnaval De Luz by U2”), reading_artist returns None, and _tier_allows never cuts a release it cannot tier, so those acts posted as if recognized (Codex on #3564). Two regex fixes were reverted in #3564, because every rule that tells a name from a date phrase admitted some dates or rejected real acts (Wednesday, May, December). An event-title anchor was tried for #3591 and failed the same way: “Jack and Jill by Jun 20th” under the event “Jack and Jill Streams” read “Jun 20th” as the act (test_subject_streams_artist_level_no_by caught it). The fix that shipped asks the model. luminate.unread_credits lists each “release by credit” split of a release that still holds a “ by “ clause; subject_is_ambiguous sends such a leg to the #3436 subject check; and resolve_event_subject keeps the model’s artist only when it equals one of those credits, with the text before “by” as the release. The model answers “no act” for a date, so a date is never credited. A credit the regex already reads (Sigur Rós, Wednesday) stays on the fast path with no model call. Measured on the live Kalshi set on 2026-10-24: 23 of 2431 music market titles hit the hole, all of them U2’s “Carnaval De Luz” (one KXALBUMSTREAMS and one KXALBUMEQUIV event), so the change adds about 2 cached Haiku calls a week. A live Haiku dry run credited U2, 4batz, ØZI and $uicideboy$, and credited no act for “Jun 20th”, “Sep 26 at 5pm” and “late 2026”. Still open, and not tier gates: three readers parse a leg with no model call, so they still see “music_desk.py’s release-age cut (utils/recency.py, #4634, owner steer 2026-10-20: “we post too many cards now”, then “keep all boards we have”). THE RULE, for one title’s chart position: No. 1-5 always posts; No. 6-10, or a debut at any position, posts only when the title came out within two years (MUSIC_DESK_RECENT_MAX_AGE_DAYS, 730); anything else is cut. A light streams milestone (100M / 250M / 500M) has no position, so it takes the age half alone; a billion rung, an album-units milestone and every artist-level milestone are untouched. WHERE: the watch lane’s CARD loop (_watch_chart_units, after board detection, so every board still seeds from the full story list), the Billboard move loop (_chart_units, before compose, so a cut move spends no attempt), and _milestone_candidate for the peak / run / tenure lanes (chart_pos) and the light rungs. A radio entry is judged like a debut (_RECENCY_DEBUT_KINDS); a Billboard debut is still dated, because a catalog reissue can debut on a sales chart. The position decides first, so a top-5 card or a deep non-debut costs no lookup; otherwise the age comes from the resolved debut, the iTunes albums row’s own date, or utils.release_age (cached a day, source=recency_gate). Why two years, not the calendar year the owner first named: measured on the 30 days to 2026-10-20, “this year” cut the year’s biggest songs once they passed New Year (“Choosin’ Texas”, released 2025-10-17, sat in the top 5 all of 2026) and twelve months cut “I’m The Problem” and “All My Exes” while they still charted; two years keeps those and still cuts “The One That Got Away”, “Beauty And A Beat”, “Mr. Brightside” and 100M crossings of 2012 songs. Measured before the change (30 days, music_desk_posted rows, years off MusicBrainz): 905 chart-move cards, of which the rule keeps 656-675 (a range, because the 2024 titles were dated to the year, not the day): it cuts 129 below No. 10 (92 of them Billboard weekly moves), 77-96 old titles and 24 undated ones. The Billboard lane loses about half its cards (215 to 103-109), the platform lane about 15%. 69 light-rung milestones, of which it keeps about 35. FAIL DIRECTION: an undatable title is CUT, the debut gates’ direction; a milestone rung stays OPEN on no_date so a later read can date it, and SETTLES on deep / old. Because a dead resolver would then cut cards with no error, the ops monitor reads the cut’s own lookup rate (release_age_cut_dating). Counted as music_desk_trim reason=release_age, tier deep / old / no_date. Off switch: MUSIC_DESK_RECENT_CUT=0. Tests: tests/test_recency.py, test_release_age_cut_on_the_platform_cards, test_release_age_cut_on_the_billboard_moves, test_release_age_cut_on_milestones.
utils.music_news.past_cohort_rank, owner steer 2026-10-21). “Harvest by Neil Young is now No. 6 among 1972’s most-streamed albums on Spotify, overtaking Deep Purple’s Machine Head” shipped to X off @chartmastersorg, and the owner asked to cut these. The rule: the story’s figure is a rank (_RANK_HERO_RE), and the claim or the classifier’s rank_list limits the list to one release year or decade that ended before last year (“1972’s most-streamed”, “of the 1980s”, “most-streamed 1977 album”). Each wording ties the year to a ranking word or a release noun, so an album titled “1989” does not read as a year, and a year followed by a bracket or quote (“from 1989 (Taylor’s Version)”) is a title edition. Only the claim before its comparison tail is read (_COHORT_TAIL_RE: passing, overtaking, behind …), so “passing 1972’s best-selling album Harvest” does not make an all-time rank a 1972 one. Lists for this year and last year still post. _compose_one runs it first, before the CB-01 story read, the verify and the compose, and marks the post seen (music_news_filtered reason=past_cohort, detail.cohort). The owner chose “rank moves only”: a stream or sales milestone on an old title that only ADDS such a rank (“The Stranger passes 2.9B streams. It’s the 4th most-streamed album of 1977”) has the count as its hero and still posts. Dry run 2026-10-21 on 100 real @chartmastersorg posts: 23 name a past cohort, and 2 of them (Harvest, ABBA’s “Gimme! Gimme! Gimme!”) lead with the rank.MusicNews._milestone_watch_cut, owner steer 2026-10-21: “streaming milestones are important, especially for our watch artists … we want to cut all of those old stuff”). A milestone story posts when its act is WATCHED (aw.lenient_tier, the kworb top 100). Any other act posts only when the title dates to the last two years (release_age_days + recency.age_verdict, the same resolver and line as the release-age cut). A bare-artist subject by an unwatched act (a career total) has no title to date and is cut. Runs in _compose_one right after past_cohort, before the verify. Reason milestone_watch, detail.tier old / no_date / not_watched; no_date is not stamped seen. It fails open when the watch holds fewer than 50 names (_MILESTONE_WATCH_MIN: the desk builds the watch with min_names=1, so a truncated kworb page must not cut watched acts), and it shares the recency.ENABLED kill switch. The title is dated off the wire-corrected subject, the same spelling the tier read uses. Dry run on the 419 milestones posted in the 30 days to 2026-10-21: 266 watched, 17 recent titles by other acts (ROSÉ’s “Rosie”, Cardi B’s “Am I The Drama?”), and 136 cut (97 old titles such as Harvest, The Stranger and Mr. Brightside, 37 career totals, 2 undated).music_alert and the newsroom (owner steer 2026-10-20, “yes do 2”, #4639). music_alert._recency_cut runs in _run_daily_lanes after a lane builds its story and before the daily gate; it reads the story’s position, title and artist off _lane_x_facts (the rank hero or the “#N on …” standing), so the Kalshi lanes and the Spotify release lane (a 1-14 day debut, no rank hero) pass untouched. The “biggest climb” lanes (spotify_jump, spotify_us_jump, apple_jump, itunes_jump, radio_riser) take a TOP-20 line (recency.CLIMB_MAX_POS, owner’s pick of three) because the climb is the story and most land between No. 11 and 20; every other lane takes the top 10; a radio entry counts as a debut. A cut marks the title out of tries for the day (_refuse_today), so the lane’s next drain attempt and the next tick offer its next story rather than the same old one. Counted as music_desk_trim reason=release_age kind=<lane>. The newsroom applies it in _compose_one right after the deep_chart floor, before the verify spend, to a story that states one title’s position (a rank on a non-chart list and a bare-artist subject pass; a rank on a PAST YEAR’S list is cut by the separate past_cohort rule below); it reaches Billboard weekly charts too, which the platform-only deep_chart floor leaves alone. Debut: the settled reading’s is_debut, else the wire’s event or a projection. Counted as music_news_filtered reason=release_age; a no_date cut is not stamped seen. Measured on the 30 days to 2026-10-20 (years off MusicBrainz): music_alert 565 chart posts, the rule cuts 101 (18%) with the top-20 climb line: 55 old titles, most of them catalog songs climbing to No. 6-20 (“Treat You Better”, “Training Season”, “September”), 32 below the line, 31 of them radio risers landing at No. 39-98, and 14 undated (fewer in production, where iTunes and Spotify date more). The plain top-10 line would have cut 183; the newsroom 374 chart stories, 169 in scope, it cuts 52 (40 below No. 10 such as “drop dead” at No. 21 on the Hot 100, 10 old such as “Stick Season” at No. 9 on the Billboard 200 and “Babydoll” (2018) at a new No. 10 Hot 100 peak). One panel reads all three surfaces: release_age_cut on the desks board.streaming_stats.format_artist_stats (the newsroom’s bare-artist Songstats grounding, MusicNews._songstats_verify -> lookup_artist_streams) states monthly listeners, followers and reach; with career_total=True it also states the career total, and StreamResult.judge_text carries that copy. _songstats_verify returns (compose text, judge text): the compose and the gate source get the first, verify_music_claim the second. Why: career Spotify streams are kworb’s, never Songstats’ (#3309), because Songstats reads 0-4% higher on that one metric. When kworb does not settle a career claim, the compose calls the Songstats blob “the DATA AUTHORITY”, so its career figure shipped as the post’s number (“Lady Gaga has crossed 39.1B career Spotify streams”, 2026-10-21; kworb 38.92B at post time). Why the judge keeps it: on all 12 career posts of the 30 days before the fix, kworb had not yet reached the wire’s rung (the wire posts about a day before kworb’s board shows the crossing), and the judge’s note cited the Songstats total on 11 of them. No second total enters the judge prompt, because the judge drops Songstats whenever kworb settles (#4137). The judge-side cost (in the same 30 days 35 refutations, 24 acts, cited a Songstats career total, the Nelly Furtado shape: a true “8 billion” refuted off 8.6B; refute_is_over_rung recovered 7 of them for authority posts) is fixed by #4745. The judge’s rules matched NUMBERS, and the classifier writes a career claim with no “passed” (“8 billion streams on Spotify across her whole catalog”), so the judge read 8 billion as the count. The cog now calls verify_music_claim(career_rung=True) for a career stream claim (mn.stream_claim shape career). The flag does two things, and both are needed: it labels the claim as a rung, and it adds one RUNG rule to the prompt (a count at or above the rung confirms it, only a count below it contradicts). The rule is sent only with the flag: sent on every claim, it also confirmed a TRACK at twice its rung (“Dai Dai” at 998.9M for “500 million”, 3 of 3), which can be an old milestone. Replay of 21 acts’ real refutes with the real Haiku judge, n = 3: 45 of 63 refuted on main, 29 with the label alone, 19 with the rule alone, 0 with both; claims under the real total stayed 9 of 9 refuted. Two guards follow a CONFIRMED career rung with no kworb reading: the judge’s count and figure above the rung are cleared, so the card and the gate state the wire’s rung and never the Songstats decimal; and a count more than 25% past the rung (mn.SONGSTATS_CAREER_STALE_RATIO; Songstats sat up to 15% above kworb on the replay, kworb’s own stale line is 10%) drops the story as an old milestone (music_news_stale via=career_count_passed). Graded live by verify_music_claim_rung in scripts/eval_classifiers.py. /ask reads no Songstats (its tool was removed, #1495 follow-up), and the desk’s career rung reads kworb.recency.BILLBOARD_MAX_POS, env MUSIC_RECENT_BILLBOARD_MAX_POS). It covers the desk’s Billboard move lane (_chart_units), its Billboard peak and run milestones (chart_max_pos), and the newsroom’s stories on a chart whose source client is billboard. No. 1-5 still always posts, No. 6-20 needs a title from the last two years, a debut posts at any rank once dated, and the platform dailies and radio panels keep the top 10. Measured on the 30 days to 2026-10-20: the desk’s 226 Billboard move posts keep 146 (109 with the top-10 line) and cut 80 (46 below No. 20, 28 old, 6 undated); the newsroom’s 107 Billboard stories keep 62 and cut 33.music_news.py’s proactive watched-artist pull (owner steer 2026-08-20): the newsroom is otherwise REACTIVE — it reports what a followed account posts. This lane inverts it so Toots drives the TOP artists’ major updates herself. Each slot _gather_artist_pull rotates a bounded set of the desk’s watched list (top ~25 by daily streams swept fast + the ~75 tail slow — owner steer “prioritize the 25, still pull all 100, the 75 less so”; rotation is a consecutive window (_rotate_window, per_sweep names) whose start is a PRNG offset SEEDED by the wall-clock 30-min slot index — deterministic per slot (shared across guilds so the discovery memo hits) and RESTART-STABLE (no process state), while the PRNG removes any arithmetic tie between the schedule and the start, so no cadence — however sparse (the whole-hour CHILL_TIMES fallback) — can residue-lock coverage onto a fixed subset. The top-25 are swept every slot (always well-covered); the tail-75 is covered over a longer horizon — the “the 75 less so” bias by design (guaranteed bounded-time tail coverage would need a persisted sweep counter, deferred to keep the lane stateless); budget MUSIC_NEWS_ARTIST_PULL_PER_SLOT=6 / _TAIL=2), asks Perplexity for each one’s biggest NEW CERTIFICATION / TOUR development in the past week (_pull_artist_news, one call per artist, memoized ~1 slot in-process so a multi-guild tick spends each search once; a NONE/hedge answer skips the artist), and wraps a real answer as a synthetic SourcePost (platform=artistpull, source tag artist_watch, id hashed from the folded FIRST-SENTENCE claim so a reworded/re-cited response does not re-post). Recency gate (#2488): the pull has NO source-tweet time — its synthetic post is stamped now — so the _RECENCY_HOURS window cannot catch a STALE event the model surfaced. That is how the Weeknd’s $1B tour record (crossed Nov 2025) shipped as a “breaking” card in Aug 2026: “the single most significant news” pulled the biggest LIFETIME number, and Grok verified the fact TRUE (never that it was NEW). So the prompt now asks for a fresh past-week EVENT and forbids a standing all-time record, and makes the model LEAD its answer with the event date in ISO form; _gather_artist_pull reads that date back (mn.parse_pull_event_date) and DROPS a story whose event is older than _ARTIST_PULL_MAX_EVENT_AGE_DAYS (10) or carries no date at all (fail CLOSED — the stated date is the pull’s only freshness signal), counted as detail.stale. The date prefix is stripped before the text reaches the classifier or the card. Those flow through the SAME _gather_candidates slate → classify → _artist_in_tier → _compose_one verify → self-gate → seen-set/TOPIC_DEDUP pipeline as a wire post, with a RESERVED lane (_order_artist_pull, composed first, like the first-party feed lane) so a zero-engagement pull story is not crowded out; the pull’s slate share is capped (_ARTIST_PULL_MAX_STORIES=3) so it can never fill the whole _MAX_COMPOSE_ATTEMPTS shortlist and starve the wire. The engagement lanes also BLEND in the recognition tier (owner steer 2026-08-21): after a story is classified (subject known), its engagement score is multiplied by utils.music_news.tier_weight of its subject artist’s artist_watch.recognition_tier (watched 1.6 > known 1.15 > unknown 1.0) before order_by_freshness / order_for_breaking sort — so at similar engagement the bigger name leads both lanes, but it stays a BLEND (a multiplier, not a hard tier sort), so a genuinely viral unknown story still wins on raw engagement. Fail-OPEN: if the tier lists are unavailable the blend is skipped and the lanes rank on raw engagement. The source tag is untrusted (may_relay_number false), so a pull story ships ONLY when independently verified (grok X + Songstats) — the same verify-or-drop fence every wire story passes. It drives release/cert/tour updates only: the DESK owns streaming MILESTONES (_watch_milestone_units + songstats_milestone_state) AND chart POSITIONS (its platform-chart sweep), so a milestone/chart classification off a pull is dropped in _pick_stories (music_news_filtered reason=artistpull_desk_owns). A RECORD release (event_shape == "record") off a pull is dropped the same way (#3172): the Apple new-release lane owns records, and the pull’s model-extracted title loses the version qualifier the art lookup needs; tours, tour updates, live sets and non-audio still ship — dropping chart also keeps the pull clear of the live-daily-chart staleness guard, which needs an event time this read cannot supply. No new cog and no new calendar (owner steer “minimize the machinery I manage”): the pull rides the newsroom’s existing slot, channel picker and X crosspost toggle; gated on the music_news_artistpull overlay (STAGING default, OFF skips the watchlist read and all Perplexity spend) and RIDES THE PARENT newsroom’s staging-vs-production routing like the first-party/no-chart overlays (the overlay gates the lane’s activation; the parent stage decides room-vs-#bot-logs), so a staged audition can never pollute the production dedup list. Because the pull’s Perplexity discovery IS the claim, the verify judge is fed only the INDEPENDENT signals (Grok + Songstats), never a Perplexity re-confirming a Perplexity. Emits artist_news per sweep (detail.mode=pull). Sibling lane — deterministic NEW RELEASES (_gather_new_releases, owner steer 2026-08-21): the SAME tiered rotation reads each watched artist’s Apple Music discography (apple_music.artist_albums, keyless + free, and it carries a pre-release with real cover so a same-day drop is caught), and turns an ALBUM drop (singles/remixes filtered via the catalog’s own _is_single_or_short, EPs kept — precision over recall) of a pop, rap / hip-hop or R&B genre only (Apple’s primaryGenreName through the house policy’s exact-tag music_policy.in_house_genre, so “Indie Pop” and “K-Pop” are cut, owner steer 2026-10-02: the watched list held the German children’s audio drama Benjamin Blümchen, and its “Spoken Word” album posted to X as a new release; an empty genre is cut, and an in-window album of another genre emits music_news_filtered reason=off_genre once per guild and is stamped seen) dated within _NEW_RELEASE_WINDOW_DAYS (7) into a release candidate with a STABLE newrelease:<collection_id> id (a drop posts once). A re-dated reissue is NOT a new drop: Apple’s releaseDate moves to an anniversary edition / remaster / catalog re-upload while the phonogram COPYRIGHT year stays original, so _recent_release skips a row whose apple_music.copyright_year is _NEW_RELEASE_REISSUE_LAG_YEARS (2)+ years before its release year (the loose 2y clears a Dec-mastered/Jan-released album AND a re-recording, which carries a FRESH copyright and stays genuine; a missing/unparseable copyright is never flagged — the window + self-gate remain the backstop, prefer a miss to a fabricated drop). The candidate flows through the SAME pipeline with its OWN reserved compose lane (_order_new_release, composed FIRST in _sched_compose_units — a zero-engagement, noon-timestamped drop could never win the engagement-ranked breaking/biggest lanes, so it leads via a reserved slot, like the pull and first-party lanes; carrying the catalog row’s real cover art so a fresh drop’s card is never coverless, and collapsing clean/explicit editions so one drop posts once). The two proactive lanes share one slate cap _PROACTIVE_MAX_IN_SLATE=4 so they can’t starve the wire; the Apple fan-out is semaphore-bounded + memoized ~1 slot (the RAW discography) to avoid tripping the shared Apple breaker. So Apple OWNS releases and the pull no longer asks for them — the two divide cleanly (Apple = releases, pull = certs/tours), a deterministic read a web search can miss: the top ~25 are swept every slot (caught for sure), the ~75 tail best-effort (the same probabilistic PRNG coverage as the pull; a guaranteed full-tail sweep is the same deferred persisted cursor). Emits artist_news detail.mode=newrelease; gated on the music_news_newrelease overlay (OFF default — dark until a mod opts in, since it rides the parent stage for routing). Overlap to weigh: the broad nightly new-releases BOARD (below) already lists ALL fresh drops as a roster in the rec channel — this lane instead gives a WATCHED artist’s album its own newsroom NEWS take; keep both only if that focused take earns its place. A SALES-unit carve-out of the desk owns it backstop was tried and reverted (#2789, review round four). The desk’s ladders have no RIAA-units rung, so a pulled sales total was refused to a lane that does not exist — but the pull’s discovery prompt refuses lifetime totals, so the carve-out could never fire, and a units milestone has no permanent dedup key. The whole story is #2793: discovery, ladder, key, then the backstop, in that order. Live moments (2026-09-02, #2800 line D): the pull’s beats widened to a tour or festival announcement or new dates added and a surprise guest appearance or unannounced pop-up performance the artist PLAYED (not attended; not a routine stop on an announced tour), the classifier’s release kind covers a LIVE MOMENT (an audience sighting -> none), the record compose steer and the release score rubric name it, and the classifier’s event_shape of live (#3413; it was _claim_is_live, a phrase list over the claim) gives the card a music · live kicker with photo-first art and no listen link (the tour treatment). The first card shape regressed (owner report 2026-09-02, the Troye Sivan / Olivia Rodrigo cards): #2831 wrote the live subject as the bare ACT and the whole event sentence as the claim, so the card heroed “Troye Sivan” over a two-line, cut-off caption (“why title their names and not the event subject”, “subtitles way too long”, “i’d rather nothing on the photos”). Now the classifier writes a live subject as <the show or set it happened at> - Artist (“Don Toliver’s Toronto show - Drake”), _live_card_copy heroes the show, bylines the act and draws NO caption (the take under the card carries the sentence), and _artwork_and_link(is_live=True) resolves the photo from the ARTIST CREDIT alone, because the show name is another act’s and the desk resolver anchors its search on the title (the #2472 class). The live test runs BEFORE the tour test in _build_card: a live claim names the show it happened at, and “concert” is a tour cue. The Rodrigo card also wore the RELEASE tag because “performed an intimate GRAMMY Museum set” matched no live phrase, so the cues now cover an intimate / acoustic / secret / one-off set PLAYED, with future-tense anti-cues (“announces”, “will perform”, “tickets”) so a show announced for later stays a tour-shaped announcement. A performance on a named stage or broadcast (owner report 2026-09-15, #3363): LE SSERAFIM’s “Made My Night” on The Jennifer Hudson Show matched no live phrase either, and no phrase COULD reach it – the wire writes the song between the verb and the venue (“performed ‘Made My Night’ ON The Jennifer Hudson Show”), so even a “performed on” cue misses, and naming the talk shows would be a vocabulary to maintain that still misses the awards stage. The story therefore stayed a plain release: it wore a NEW RELEASE kicker for a TV performance, and _artwork_and_link resolved art off the WHOLE subject, so image_subjects kinded “The Jennifer Hudson Show” as a tv title and returned its TMDB poster. The delivery coherence gate caught that (“image shows Jennifer Hudson herself, not LE SSERAFIM”) and dropped the art, so the post shipped a coverless text card. _played_at_subject reads the SHAPE the classifier already writes instead of more phrases: a performance verb in the past or present, PLUS the subject’s own title following “on” / “at” / “during” in the claim. It runs inside _claim_is_live after the anti-cues, so a booked-for-later show and an audience sighting are vetoed exactly as before, and a tour announcement can never match (its claim lists dates and never names its own subject title as a place the act played). One test fixes all three wrong signals at once: the kicker becomes music · live, _artwork_and_link(is_live=True) anchors the photo on the ARTIST CREDIT alone, and is_drop goes false so no listen link is attempted. The first cut matched too tightly (owner report the same day, the Olivia Rodrigo BMI Troubadour card): it required the subject’s title straight after “on”/”at”/”during” plus an optional “the”, and the claim read “… in Nashville at A BMI Troubadour Award dinner honouring Crow”, so that card still wore NEW RELEASE. A short bounded run of words (an article, a possessive, a city) is allowed between the preposition and the title now. Replayed over 219 real release stories from one week: 6 re-lane, 0 false flips, with the tight and the wide span alike. The phrase lists are gone (2026-09-18, #3413). _claim_is_live, _played_at_subject, the live cues and anti-cues were the card re-reading the claim for an answer the classifier already had to reach to pick the kind. classify_music_news now returns event_shape (record / tour / tour_update / live / nonaudio) on every release, and _build_card keys the kicker, the tag colour, the art rung and the listen link on that field. The history above says why each lane exists; the field is how it is decided. An empty or unknown shape reads as a record, the lane’s default. Graded live by scripts/eval_classifiers.py (classify_music_news_event_shape), whose must-nail anchors are the four owner reports above.A purely WORDED card ships BARE ART (owner steer 2026-09-15, #3370). This is #3345’s rule reaching music. The newsroom’s bare-art rung was narrow (#3291): a first-party FEED photo, on a TOUR, judged an admat by vision – the judge existed because stripping the card off a press portrait “costs the headline, the byline and the source stamp and returns nothing”. Both cards the owner reported were portraits resolved by the ENTITY resolver rather than a feed, and both were asked to ship bare: “this isn’t a number or award or numbered or percentage or value post, this is purely worded, those are the ones that we tried to cut … we don’t need to duplicatively put the annotation on the card”. The duplication is between the drawn headline and the CAPTION beside it, which repeats it word for word – not between the headline and the picture. So the rung is now ONE condition beyond having art: the card is FIGURELESS. The art source no longer decides, the is_tour limit is gone, and image_is_tour_admat has no caller left (kept, not deleted – removing a capability is the owner’s call). The boundary is the FIGURE, which is exactly the line the owner drew: card_figure is cleared for the release and no_chart kinds and for a rank-word figure, so an empty figure here IS the purely-worded card, while an RIAA “Gold”, a chart-exit “OUT” and a milestone count all keep their full markup. The market and record copies clear their own figure BELOW this gate, so a market card’s odds and a record card’s count keep theirs too. The accepted cost: a bare post keeps the take and the link button, and loses the wordmark, the tag block, the headline and the VERIFIED source stamp; the take still cites the data authority in her own voice. Measured on the real pull, n=3 on six top acts: the widened prompt returned Drake’s Don Toliver Toronto pop-up 3 of 3 where the old prompt returned a sales line; the shipped classifier read it as release 6 of 6 and an audience sighting as none 6 of 6; Grok verified it; the compose self-gated at 0.89-0.95. The pull’s budget is three /menu tune knobs (#2800 line D): searches a slot (default 8, 0 pauses the pull), of which from the watch list’s tail (3), and pull stories admitted to a slot’s slate (4) – tunables.artist_pull_budget, read per guild each slot, fail-open to the defaults. Measured 2026-08-29 to 09-02 before the change: 6 searches a slot found a fresh dated story a third of the time (51-63 hits a day over 126-186 checks) while music_news_posted source=artist_watch ran 1-3 a day, so discovery was not the bottleneck; the same change makes the two silent drops between them visible (music_news_filtered with not_a_story and shortlist_full), which is what the next tuning reads.
music_news.py’s wire coverage of the SALES and MARKET beats (2026-09-01, owner report “our artist sweep isn’t greedy enough”). Five Drake stories ran in one day and the desk posted none. Measured cause, per story, against the live code: (a) RIAA sales units had no source. @hiphopdataa posts unit totals (five in three hours on 2026-09-01: “‘Up All Night’ has now sold over 3 million RIAA units”, “‘Passionfruit’ has now sold over 11 million (@RIAA) units”) and was not on _TRUSTED, so the desk polled 41 handles and saw none of them. It is added as CULTURE (relay_number=False), so the claim settler checks the number rather than relaying it. @Genius is added on the same evidence for the CATALOG beat — its on-this-day posts state real chart facts (“today in 2014, ilovemakonnen dropped ‘tuesday’ featuring drake. the track peaked at no. 2 on the billboard hot r&b chart”). That post needed two supporting fixes to reach compose at all, both found by the #2789 review, and both are the kind of gap that only shows up when a NEW source’s house style meets old detectors. (i) A hot r&b chart recognition was tried and REVERTED after four review rounds. chart_reportable knows only the R&B/Hip-Hop Songs spelling, so “billboard hot r&b chart” is cut as unnamed. Adding the shorthand leaked three ways in succession: it passed the gate but fell through the RESOLVER (_CHART_PATTERNS, read by _live_chart_position) to the Hot 100; then it matched “Hot R&B/Hip-Hop Airplay”; then it matched an ADJECTIVE — “The hot R&B singer earns her first Billboard chart entry” passed the fail-closed allowlist as genre_rnb with no chart named. That last one is disqualifying: the chart list is the owner’s cut, and a genre word must never pass it. Two lessons stay in the code comments: the scope gate and the resolver are two hand-maintained lists that must name the same charts, and a resolver row placed before the generic airplay exclusion must not be matchable by an airplay chart’s name. The @Genius catalog posts that ship are the ones naming a chart in full (“debuted at no. 3 on the billboard 200”). (ii) retrospective_note accepted this day in <year> and on this day in <year> but NOT @Genius’s today in <year>, so it returned None. A chart story skips the stale-recap gate, so an undetected throwback could have been stated as a CURRENT chart position — the exact class utils/retrospective.py exists to stop. The frame is added, and its year guard is now keyed on the captured year group rather than on one frame’s LABEL, so a year-bearing frame added later cannot skip the future-date check. (b) A market PRICE ranked below every chart claim. _CHART_CUES named the venues (kalshi, polymarket) but not the price, so @Kurrco’s “Kalshi currently has the odds at 61%” scored 1 — the weakest strength_buckets bucket, competing for 12 _MAX_CLASSIFY culture places against a pool measured at 57–154 per slot. A market PRICE cue was tried here and REVERTED over three review rounds (#2789). Every form of it admitted ordinary prose, because _CHART_CUES is not only a ranking signal — strength_buckets drops a zero-strength culture post, so a cue changes ADMISSION for a post carrying no other domain cue: bare odds scored “the odds are against him” 1; odds at scored “the odds at this point are against Drake” 1; odds at|of \d scored “the odds of 10 artists appearing are slim” 1. Each spends one of the 12 _MAX_CLASSIFY places on a post naming no price. It was reverted on PROPORTION as much as on the leaks: the motivating post is ALREADY admitted at strength 1 by the kalshi cue, so the change only ever bought a 1-to-2 ranking nudge for posts that already get in — not worth a cue that kept leaking. The real lever on a market post’s rank is the classify BUDGET, measured in #2792. A test pins the revert, so re-adding the cue fails. What this change does NOT fix, and why: the other two stories carried no number at all — a surprise guest appearance (Drake brought out at Don Toliver’s Toronto show) and a teaser note (“2026 FOMO”). The newsroom is a NUMBERS desk by design and the pop desk owns music culture, so neither is a newsroom miss. They are a pop-desk RANKING limit: it ranks the whole entertainment wire by engagement and composes _COMPOSE_CANDIDATES = 4. See the follow-up issues on the culture-moment lane, the on-this-day lane, and the culture classify budget.music_news.py’s news-age gate now runs on every story kind (2026-10-22, the “Janice STFU” post). The artist pull reported that Drake’s “Janice STFU” passed 1 million US units and is eligible for RIAA Platinum. That happened in June 2026 (Grok dates the first reports to 2026-06-10). The desk posted it to X on 2026-10-21 as news. cert, milestone and chart were in _STALE_RECAP_SKIP_KINDS, said to be “settled from current data”. That was false for old news the desk had not posted before. The cert dedup key only stops a SECOND post of a story the desk already made, and nothing read the award date. The verify step confirmed each story because each one is TRUE. The same hole shipped three more stale stories that month: “Sunflower” Double Diamond (RIAA dates it 2024-02-08), posted 10-18; Future’s “53 new RIAA certifications” (RIAA dates every row 2026-07-16), posted 10-10, 10-14 twice and 10-17; and Rihanna’s “200M RIAA singles units” (April 2026), posted 10-08 and 10-20. The first fix gated cert and the non-Spotify milestones only. The owner then asked “why would anything skip the freshness gate?”, and no skip had a reason beyond the cost of one Grok read. So the skip set and stale_gate_applies are gone. chart, first_week, cert and milestone take the event-age read (_EVENT_AGE_KINDS), because each is reported days after the event. The residual Grok read for a weekly-chart claim that read absent (absent_on_weekly_chart, music_news_stale via=absent_weekly_event_age, #golden-hour-part5) was removed, because the first gate now reads every chart story. Measured against the live Grok, two runs each. Stale stories: Janice STFU, Sunflower, the Future batch and Rihanna read STALE 2/2. Fresh certs and unit milestones: Zach Bryan “Something in the Orange” 15x Platinum (certified 2026-10-21), Taylor Swift “Patient Zero” 1M units, Jdot Breezy 500K units, YG Marley 8M units, Shakira “Sale El Sol” 11M and the 2026 UK best-seller total read FRESH 2/2. Drake “HABIBTI” Gold (first reported 10-11) read FRESH once and STALE once, 11 days later, which is the 10-day line. Current chart and Spotify stories (Take Care #20, Housewife #1 global, Okayyy #19 Urban radio, Patient Zero #1 Hot 100, Risk It All #14, The Life of a Showgirl #1, Janice STFU #21, and three Spotify crossings) read FRESH 19 of 20. The miss was “Patient Zero” #1 on the Hot 100 in its third week, STALE once. That is the cost the owner accepted: a dropped current story is an absent post, and a stale story shipped as news is a false one. Each story that reaches _compose_one now pays one Grok read (about 11s p50, 25-40s p99), which roughly doubles grok_search purpose=music_news_freshness (about 80 a day before; about 55 chart stories a day were exempt). Fail-open like every other use of the gate.music_news.py’s news-age gate now runs on first_week (2026-10-02, the Tha Carter III post). A Kalshi music post mentioned the 2008 Tha Carter III first week (1M), and the newsroom posted “Tha Carter III moved 1 million copies in its first week” as news. first_week was in _STALE_RECAP_SKIP_KINDS on the claim that it settles on Luminate. It does not for an old record: _live_projection reads only the CURRENT HITS document, the old album reads absent, and the web verify confirms the old fact. The classifier claim said “(2008)” and the art step logged release_art_stale with 2008-06-09, and no gate read either. first_week now runs is_stale_recap with event_age=True, because a first-week figure is published days after release and the default breaking-news prompt calls a current-week figure stale. Measured against the live Grok, two runs each: Carter III (2008) STALE, Carly Rae Jepsen “Day and Night” (current week) FRESH, Miley Cyrus “Bass Persuades” (13 days old) STALE. Fail-open like every other use of the gate.music_news.py’s UNCONFIRMED score penalty (owner steer 2026-10-09, the Madonna post). @chartmastersorg posted “Madonna has crossed 260 million equivalent album sales”; the verify judge found no other source; the take scored 0.82 and shipped to X; the replies said the number was wrong, and the owner deleted it. The number was ChartMasters’ own estimate, which no other source computes. A number authority (may_relay_number) may still ship a number nobody confirms, but the self-gate score now loses UNCONFIRMED_SCORE_PENALTY (0.25) before the 0.6 floor whenever settled is False: the judge did not confirm the claim and no live reading settles it. So an unconfirmed take ships only at 0.85 or higher. A release story (figureless_kind) carries no number and takes no penalty. A take the penalty alone stopped (raw score at or over 0.6) is NOT marked seen, so the next slot can ship it once the research confirms the number, like the unverified_wire drop. The ops monitor counts it below the floor (detail.gated_score) so a silenced lane still reads dark, but keeps the raw mean. The owner chose the penalty over a hard drop (that would have removed about 21% of posts) and over a drop for sales estimates only. The music_news_scored event keeps the judge’s raw score (the ops monitor reads it as writing quality); detail.unconfirmed_penalty + detail.gated_score carry the penalty, panel newsroom_unconfirmed. Measured over the 14 days before: 79 of 372 posts were unconfirmed, and about 16 of them scored under 0.85.music_news.py’s QUALIFIER CHECK (2026-10-17, the Tate McRae post; owner steer “check all qualifier claims we copy, and not drop the post but trim them if they’re wrong”). A wire posted “Tate McRae hits No. 1 on the Global Spotify chart for the first time ever” about “Housewife”. The No. 1 was true. “First time ever” was false: “greedy” held Global No. 1 for 34 days in 2023-24 (kworb global_daily_totals). The web research said so, but verify_music_claim checks the FIGURE, the X research repeated the wire’s words, and the judge answered verified 5 of 5 times on the real research. The fix has three parts. (1) A claim with a qualifier (utils/qualifier_trim.py:has_qualifier: first, ever, only, never, most, biggest, record, best, all-time, in history and similar; “the first of five concerts” is excluded) goes to a SEPARATE Sonnet judge, ClaudeClient.check_claim_qualifier, after the verify step and only when the story is still going to ship. It returns whether a qualifier is wrong and the claim with only that qualifier cut. (2) Code accepts the cut only when qualifier_trim.is_cut holds: every kept word is in the claim in order, and every removed phrase carries a qualifier, so the judge cannot add a word or cut the fact. The claim is then rewritten to the cut (the record the compose blob and the self-gate read), and the compose gets qualifier_trim.cut_note. A wrong qualifier with no clean cut drops the story (music_news_filtered reason=qualifier_wrong). No verdict keeps the claim (fail-open, via=qualifier_unchecked). (3) The source post and the X context still carry the cut words, so a take that states a cut qualifier word again is dropped (music_news_scored reason=cut_qualifier). Why not more fields on the verify judge: measured on 11 real claims with live research, extra fields on the Haiku verify prompt moved verdicts on claims they did not concern, and its cuts were unreliable (see docs/PROMPT_OPTIMIZATION.md). A feed or catalog story settles on its own source and is not asked. Panel: newsroom_qualifier. (4, #4463) A RANKING ORDINAL is a qualifier too: “the 9th female rapper to reach 3 billion streams”, “the 3rd film to pass $2.5 billion”. qualifier_trim._RANK_RE matches a lead (the, becomes, is, was, as, or a comma: “Doja Cat, 9th female rapper to …”), an ordinal from output_checks._ORDINAL_TOKEN (the one ordinal list; “first” is already a qualifier word), up to four noun words (“member of the group”), then “to” + a verb or “with” + a number (“3 billion”, “one billion”). An ordinal alone is a count and is not checked: “the 2nd single”, “track 3”, “No. 1”, “the 9th time”, “the 9th consecutive week”, “her 3rd top 10 to date”, release order (“the second single to be released”; a release noun plus a release verb, so “the third artist to arrive at 1 billion” still counts), and “to” + a place (“the second trip to London”). Two coordinated rankings (“the ninth rapper and third woman to …”) are both tracked. A possessive count (“her 13th song to reach 900 million”) is not checked either: it counts the act’s own catalog, the music desk states it from kworb data, and web research cannot settle it (104 of 124 new matches in 30 days of wire tweets had this shape). is_cut accepts a cut of the ranking ordinal only when the whole phrase goes with it, plus the word after its “to” / “with” (“…, the 9th female rapper to do so” cut to the fact before it). An appositive ranking (after a comma) may be cut with the rest of its clause, number included (“…, the ninth rapper with 3 billion streams”). The clause ends at the next clause mark or joining word (“and”, “but”, “while”, “plus”), so a second fact after it stays guarded. A cut of the ordinal alone leaves “the female rapper to …”, which reads as “the only one”, so it is rejected. A predicate ranking (one not after a comma: “Avatar is the 3rd film to pass $2.5 billion”) has no clean cut (“Avatar is $2.5 billion”), so is_cut rejects any cut of its ordinal and a wrong one is a drop. A cut ordinal enters cut_qualifiers by value (“9th”), and restates_cut drops a take that states the same rank in any form (“ninth”, “No. 9”, “#9”, “ranks 9”; a digit-led quoted title is skipped). (5, #4477) A removed phrase that is exactly ONE whole sentence of a longer claim may be cut too: ““x” rises to #1 on Hot AC. It becomes her first-ever #1 hit.” may be cut to its first sentence. The sentence must start at the claim start or after “.” / “!” / “?”, end at one or at the claim end, carry a qualifier word or a ranking ordinal, and leave at least one other sentence. A mark after “No”, “vs”, “St”, an initial, or inside a quoted title is not a sentence end, and a run across a mark after an initial before a capital (“with Jay Z. Drake joins”) is not one sentence. The content-word limit still applies, but the sentence’s leading pronoun and a “#” before a number do not count, so “It becomes her first-ever #1 hit.” passes. A capitalized name in the sentence must also stand in the kept claim, and the first sentence may not go when the kept claim then opens on a pronoun (coordinator review on #4484: “Tate McRae’s Greedy is her first-ever #1 hit. It debuted at No. 3.” cut to “It debuted at No. 3.” is refused). The number, quoted-title and whole-ranking-phrase rules still apply. Two grammar rules do not, because the sentence goes whole: the predicate-ranking rule, and the kept-qualifier walk into the next sentence. Under the shipped judge prompt (G1) the judge writes no whole-sentence cut (0 of 144 runs), so the rule changes no outcome today; without G1’s second example it wrote 4 such cuts in 24 Ariana-shape runs, and the old rule dropped all 4. The research the judge reads (#4486, #4491): since #4492 the judge reads its own claim-scoped search (utils/qualifier_check.qualifier_research, Perplexity purpose music_news_qualifier, no recency window) in place of the story search, because the story search asks for this week’s figure and rarely names an earlier instance (real judge, n = 16 per claim: false “first” claims kept 43 of 64 with the story search, 12 of 64 with the qualifier search). Since #4502 a Spotify Global “first” claim also gets the subject’s top-10 Global songs with their peaks from kworb’s all-time totals page (qualifier_check._chart_history, chart_fetch query=global_daily_totals), the page that settled this incident. With it, the four false goldens are kept at most 3 of 32 times and the true controls stay kept. Every lane that calls check_source_qualifier gets the same research.utils/qualifier_check.py, the ONE entry point for a lane that repeats a source’s claim (epic #4412): check_source_qualifier(bot, surface=..., guild_id=..., subject=..., claim=..., research_purpose=...) runs one claim-scoped Perplexity search with no recency window, asks ClaudeClient.check_claim_qualifier, emits qualifier_check, and returns a SourceCheck (claim, note, cut_words, outcome, drop, changed). A lane uses claim everywhere its compose and gates read the source, appends note when changed (never gate this on cut_words: a cut word can stay elsewhere in the claim), drops when drop, and drops a take for which qualifier_trim.restates_cut(take, cut_words) is true. The newsroom predates it and keeps its own wiring. (#4478) A title in STRAIGHT single quotes is a title too (qualifier_trim._QUOTED_RE, fourth branch): the music drop’s report claim spells its title that way, and a cut renamed “‘First Class’” to “‘Class’”. The mark is also an apostrophe, so the branch opens only after a non-word character or the text start, allows an inner apostrophe only between two letters (“‘Don’t Stop’”), and closes only before a non-word character. A possessive, a contraction, “‘90s” and “rock ‘n’ roll” open no span. Measured on 2,500 live wire tweets (2026-10-18): 83 new spans, all real titles or quotes; has_qualifier changed on 1 tweet (“History” inside ‘The History Of Concrete’, now correctly not a qualifier); 0 worse.qualifier_check.claim_has_qualifier(bot, claim) is the one gate both the newsroom and check_source_qualifier call. The word list (qualifier_trim.qualifier_candidates: the _QUALIFIER_RE words outside quoted titles, and the _RANK_RE rankings) is the fast path: no candidate is False with no call. A claim with candidates gets one cached Haiku YES / NO (ClaudeClient.claim_has_qualifier, purpose qualifier_present), which drops false hits such as “58-63k first week”, “Best Rap Album” and “from lead songs only”. No verdict is True, so the judge runs (the rule before). A stub client without the async read keeps the word list’s answer. qualifier_trim.has_qualifier stays the pure word-list reading; milestone_qualifier._apply_check still uses it, because it asks a different question (did a CUT leave a ranking word). Measured in docs/CLASSIFIER_AUDIT.md (#4522 site 2); event qualifier_present.quotes_ladder_rung -> _live_market_forecast / _poly_market_leader), and a “who will headline” leg has no threshold, so it fell through. Now every market story that names an exchange, and whose figure is odds or absent, goes through _live_market_odds: Kalshi searches our own open-event index (KalshiClient.search_events, once per name, because a Grammy event lists ALBUM titles), ranks hits on the TITLE’s topic words (the index stores only the first few leg labels, so a label filter missed Miley Cyrus on the Super Bowl performers event), and prices the top three events’ legs with kalshi_price.market_yes_price; Polymarket uses search_markets. mn.pick_market_leg takes the ONE event that shares 2+ topic words with the claim, has exactly one leg answering to the subject (with a work title, THAT work, never another album by the act), and does not name a different market (_event_conflicts: a year both sides state and none agrees, a role word that conflicts such as headline vs performers or win vs nominees, or a capitalised name in the claim that the title lacks; Kalshi’s “Pro Football Championship” is aliased to “Super Bowl”). A tie returns None. The conflict test came from a fresh review of #3919: without it, the two-word floor counted generic words (“headline”, the year), so with the Coachella event absent from the search a Rolling Loud 2027 event matched and would have stated its 30% as Drake’s Coachella odds. mn.restate_market_claim swaps the wire’s odds phrase for the live percent and drops a stale rank tail (“, highest of any artist”). No leg, or a second number in the claim (“odds increase 200%”), drops the story (music_news_filtered reason=market_unread). A live leg under 25% (mn.MARKET_STORY_MIN_PROB) also drops the story (reason=market_longshot) and marks the post seen: the market says the thing will not happen, so there is no story (owner steer 2026-10-10, the Tate McRae “7% chance to debut at No. 1” post). The floor came from the 30 days of live reads before it: 7%, 14% and 18% were the longshots; 41%, 50% and 97% were the stories. With a live read the wire’s post text, the web research and the X context are kept out of the compose, because all three carry the stale odds. A story with its own number (“11 weeks at #1”) skips the read. Measured on the month’s real market stories: Drake/Coachella 58%, Miley/Super Bowl 75% (wire 71%), Olivia Rodrigo AOTY 46% on Kalshi (wire “overwhelming favorite”) and 44% on Polymarket; the rest (a resolved film market, a dated-leg album market, a #2-this-week tie across two charts) drop. The compose stated the live percent in 12/12 takes on Sonnet and on the production GPT model. The LIVE MARKET context block names only the venue and the price, never the event title: with the title in quotes the compose quoted it back (“on Kalshi’s ‘Who will headline Coachella 2027?’ market”) in 3 of 6 real runs and the self-gate dropped 2 of them; without it 7 of 8 shipped (the one drop was the Haiku judge scoring an identical line 0.15 that it scored 0.75 three times)._live_market_odds asks claude.pick_market_event on EVERY read, over every live event that lists the subject (mn.subject_market_legs: the exact leg identity, no topic filter, most shared topic words first, at most 6), so the model can only name an event we read, or NONE (drop). mn.market_role_conflict (a year or role the claim and the title disagree on) is a SAFETY FLOOR that only vetoes a pick: “favorite to headline the Super Bowl halftime show” never takes a performers market. On no verdict the code rule decides as before (mn.pick_market_leg: topic overlap, year / role / proper-name conflicts, a tie drops). Measured: the 16 real 30-day claims replayed against live Kalshi / Polymarket, 2 runs, 0 flips: every pick that is still live matches what shipped, and the floor keeps the JAY-Z drop; 10 hand cases on real titles, rule 5 wrong, model 0. The Luminate reading match is checked too: music_markets.checked_reading_for_subject (newsroom forecast, X prediction lane) sends a NON-equal tier hit to Haiku SAME / DIFFERENT (purpose reading_subject) with the ladder’s metric, window and ticker; on 2026-10-19 the artist tier gave “Rubber Soul - The Beatles” the act’s whole-year streams ladder. 12 cases (3 real), 2 runs: model 12 / 12, rule 7 / 12. mn.resolve_artist_count still folds in code; mn.artist_count_collisions finds two rows on one chart that share a title but not a lead credit (#3664: “Home (feat. Drake)” by two leads), and claude.same_record_listings decides. Only a DIFFERENT answer on a two-listing collision splits them (distinct=); three or more listings, or no verdict, keeps the title-only fold, so the count can undercount but never overclaim. The restated market line (mn.restate_market_claim) now finds the odds or favourite phrase anywhere and drops the whole exchange tail (“according to Kalshi traders”). Dry run: 0 ties in 59 live claims, 0 collisions over 321 acts on 5 live charts.compose_music_record on the market lane (_RECORD_LANES), on the guild’s market_drop model (the shipped one ran on gpt-5.6-terra through the OpenAI backend; the self-gate then PRAISED the tail: “adds one short clause of read”). The cause was text the model was handed, four pieces of it, each an invitation to append a read, so the fix is a reword of each and no new rule (docs/PROMPT_OPTIMIZATION.md): (1) the compose’s task line and mn.NEWS_CONTEXT both said “it’s YOUR number to report, your read on it” – the phrase is cut, twice in the context and once in the compose; (2) the user turn closed with “Drop the read.” – now “Report it.”; (3) the market lane hint modelled the tail (“what the number means (‘the market’s got X on pace for a #1, calling it about 62% likely’)”) and Sonnet copied its opener verbatim (“Polymarket’s got Drake on pace for”) – it now names the stop condition, the same one the market-drop frontrunner angle carries (#3048): “END the line at its anchor: the market it names or the deadline”; (4) the length rule allowed “one short clause of context”, which admits an opinion – it now allows “one short clause carrying a second FACT”, and on the market lane no clause at all (“One sentence that ends at its anchor.”). Two context blocks were the remaining causes on Sonnet and are lane-aware now: the web-research BACKGROUND header is built by ONE helper, mn.background_block(kind, research) (the cog and scripts/dryrun_music_news.py both call it), whose market form says the research stays OUT of the line where every other lane may fold one fact in; and the CROWD READ block (“SENTIMENT + context to sharpen the read”) is withheld on the market lane, because a forecast that ends at its anchor has no read to sharpen. Measured on the real inputs, real models, n=5 per arm, counting a line that carries ANY clause after the anchor: gpt-5.6-terra 2/5 before, 0/5 after; gpt-6-astra 0/5 before and after; Sonnet 5/5 before, 4/5 after the prompt rewords alone, 4/5 after the background header, 2/5 after the length rule, and 0/5 once the crowd block is withheld (each of the two ablations, no crowd or no background, was 0/5 on its own). The tests pin the wording and the two call sites (test_the_newsroom_compose_no_longer_asks_for_a_read, test_background_block_is_lane_aware, test_the_cog_builds_the_background_through_the_one_helper). Not swept in the same change: the music desk’s own projection lane carries the same inline BACKGROUND header (cogs/music_desk.py), and compose_market_drop’s user turn still closes “Drop the read.” – both are the market-drop and desk surfaces, with their own owner steers, and are filed as a follow-up rather than changed blind.market_drop.py’s forecast angle (compose_market_drop, on the guild’s gpt-5.6-terra); the self-gate scored it 0.9 as “clean subject-first forecast”, and it was grounded: the blob said “the market forecasts ~61.5K” and the OLDER figure (~62.4K) came from the surface’s own self-dedup history block (market_history, #1538), which hands the model its earlier lines on the same market so it can judge whether the number moved. The class, off 30 days of market_drop_posted in Axiom, was 13 posts: “slipped to 32%”, “has softened to 42%”, “trimmed LaNasa down to 62%”, “eased to 90%, off 3 on the day”, “cratered 60 points in 24 hours” – the leaderboard angle writing off the blob’s “down N pts 24h” line and a compose SIGNAL rule that allowed “the direction at most as color”. Three layers, cause first (docs/PROMPT_OPTIMIZATION.md): (1) the drop blob WITHHOLDS a downward move of any size (_render_market_blob -> format_market_meta(include_down_move=False); an upward move still rides, the move still ranks the market and draws on the card); (2) the compose text no longer invites it – the SIGNAL rule reads “report where it NOW stands (‘X is at 34%’); a rise may ride as color”, the leaderboard hint models “Y is at 34%” instead of “Y cratered 54pts to 34%”, the forecast hint models “X’s first-week forecast is at ~61.5K units”; (2b) the self-dedup history is SPLIT into two readers: measured on the real Miley blob + history (n=5 per model), the reworded prompt with the earlier figure still in the history block wrote the decline 3/5 on Sonnet and 2/5 on the guild’s GPT tier (“down to ~61.5K”, “down from the ~65.2K mark”), and with the figures withheld it wrote none but returned EMPTY 6/6 on both models and both shapes (it could no longer tell whether the number moved). So claude_client.market_history_news (Haiku, purpose market_drop_history) now reads the earlier lines WITH their figures plus the current blob and answers “is there news” (15/15 on the live cases: posts 65.2K->61.5K and a 62/28->54/36 board, skips 65.2K->65.0K, 62/28->61/29 and a standing 99% lock); on False the cog emits market_drop_scored phase=declined reason=history_unchanged and never composes; on True (or None / an error, fail-OPEN: the 24h per-market avoid, the similarity gate and the topic judge still hold a repeat) the compose gets the same lines through _withhold_figures (every number -> “[figure]”) and a block that now says only “the numbers have moved since … state where the number IS now, from the MARKET block alone”; recent_posts is redacted the same way because this market’s own line can sit in it; (3) the deterministic backstop ship_guard.drop_market_decline (output_checks.has_market_decline) runs on every drop angle before the self-gate and the alert runs has_market_decline on market_filtered – the judge grades whether a claim is TRUE and a decline is, so only a rule refuses it. The chart-position lanes’ has_decline_narration is the same steer on a chart rank; this one’s object is a %, a units figure or a point count. NOT applied to the betting surfaces (betting_alert is the LINE-MOVE alert, its story is the move) – see the class note in the checker.market_drop._number_grounding grounds a take on the market blob plus the title. A Kalshi title often names its slot without a digit: “Top Global Netflix Movie this week?” is the #1 slot, and “Runner-Up Daily Music Video Global on YouTube” is the #2 slot. A take that said “No. 1” on the first board was dropped as market_drop_scored reason=ungrounded_number. Measured over 14 days of Axiom: 30 such drops, all on “No. 1” or “No. 2”. utils.market_cards.title_slot_rank now returns 1 for a singular “Top artist_market_move lane shipped “A Drake remix of Ella Langley’s “Choosin’ Texas” is at 94%, up 65 points in five days.” Drake had announced the remix on his Sept. 28 livestream, and the market was pricing that news. The blob held only the market, so the take could only say the price. The lane now makes ONE Perplexity read for the tick’s winning market (market_outcome.move_news), and only after _daily_gate passes, so a refused key never pays a search (_lane_news). The query names the move’s window and direction and asks what announced news in that window explains it, so a later delay cannot read as the cause of a rise. The answer is two sentences with dates. A read whose lead cites a press publisher by its own [n] mark (lead_publisher: not X, not the market’s own page, and not an unused result Sonar appended) LEADS the blob as WHAT HAPPENED (the news this market is pricing): ..., so the compose may state it and the self-gate grades it as grounded. Anything else (no client, NONE, a hedge, a NONE verdict after the news, X-only citations, a lead over 500 characters) adds nothing, and the take is the price as before. The query does NOT ask whether the question resolved: asked that, Sonar reported the announcement and then concluded “so the answer is NONE”. With a found fact the compose runs on the market_news angle (lead with the fact, the market’s read as the supporting clause). A date-window leg keeps the news only when it happened INSIDE the window (#3760). That issue measured the failure: news of FOMO songs dropping on Oct 1 beside the “before Oct 1” leg wrote “a 2% chance … despite FOMO songs dropping Thursday”, and the 2% was the calendar, not doubt; the self-gate scored it 0.88. So the query asks the answer to open with the event’s date (DATE: YYYY-MM-DD), and move_news drops the news (outside_window) when the LATEST date the lead names (news_dates) is on or after the leg’s deadline (kalshi_ladder.date_rung_deadline). The latest date, not the DATE line alone: asked for the release date, Sonar still dated the announcement (Sept 28) 3 times in 4, while the sentence named the Oct 1 release. A date leg whose answer names no date drops the news (no_event_date). Measured live on the Drake market with the PROD compose model (gpt-5.6-terra): beside “before Oct 1” the news was dropped 4 of 4 times; beside “before Nov 1” it was kept, and 5 of 5 takes led with it (4 of 5 passed the 0.6 self-gate). Earlier Opus runs: 1 of 4 with the fact at the END of the blob, 4 of 6 at the TOP, 6 of 6 with the angle. Event market_news_read. The same day, the class was swept to every market card that sits in resolve territory or reports a move (#3786, owner: “make sure this class is fixed for others”). (1) The music STANDING card (artist_market_leader, the #3760 case) reads the latest news (rose=None) once its daily gate passes. On a DATE board the card and the take move to the window that CONTAINS the news date (music_markets.news_window: the first window whose deadline is after it) and the blob names only that window, so the take cannot pair the news with a window that ends on it. News outside every window is dropped. A FIELD board keeps its leader and gains the news line. (2) market_alert swing, lead-change, ending-soon and conviction alerts read the news too (MarketAlert._market_news), with the leg’s date window checked the same way. Their context blocks said “you do NOT know WHY it moved” and “HEADLINE ONLY: one clean forecast sentence”; with a found fact _news_context lifts exactly those clauses (_CAUSE_BANS, exact strings, a test fails if a context is reworded) and leaves them in place otherwise. Measured on the prod model with the real headline gate: before the lift, 12 of 12 takes passed and 1 of 12 carried the news; after it, 11 of 11 carried the news and 9 of 11 passed (the two drops put a % in the line, which the gate blocks). The forecast lanes and settled_out read no news: their post is a number or an answered question. The news is strictly ADDITIVE. Any drop before delivery (an empty reply, a shape guard, the self-gate) of a news-led take recomposes ONCE from the price-only card (_fire_kworb_lane(fallback=...), MarketAlert._fire(without_news=True)), and a repeat or a failed send never falls back. The standing card is why: on the #3760 Drake album board (live 2026-10-02, prod model) the news-window card was right – “FOMO songs hit streaming Oct. 1; a new album before Oct. 3 is at 21%” – but the gate refused it 6 of 6 times (songs are not an album), while the price-only take passed 6 of 6. Without the fallback the news would have silenced the card. Not swept: market_drop’s report-lock and frontrunner reports (chart and award boards, where the result is a published chart, not news), the near-lock FALL the move lane withholds (cracked_lock, one market in 30 days) (the scheduled market DROP applies the same rule to its hottest pick since #4065: market_drop._cracked_lock reads a week of daily mids for every leg the card states, withholds a pick that fell from 95% or more, and picks again with that topic avoided, at most twice), and a decided tell for nested date boards in the outcome lane; all three are recorded on #3786. The same day’s card also printed Kalshi’s own title typo “release a a remix”; markets.tidy_kalshi_title collapses a doubled LOWERCASE article at the snapshot boundary, beside strip_issuance_clause (lowercase only, so the band The The keeps its name). The title’s period inside the closing quote is left: a real title can end in a period, and the text cannot tell the two apart (Codex on #3783). The news line’s qualifiers are checked (#4440, epic #4412). The news line is a web answer the card repeats as fact, so a false qualifier in it (“her first-ever single”) shipped as the bot’s own claim, and the self-gate graded it as grounded. market_outcome.check_news_qualifier is the one place both lanes check it. It calls the shared qualifier_check.check_source_qualifier on the lead without the “(reported by utils/market_sibling_claims.py adds the check to both surfaces (market_alert._judge_and_ship after _topic_repeat, market_drop._compose after the self-gate):
post_dedup_history.event_key (a nullable column; market_drop writes it through the spine hook _sched_add_unit_history). The reveal lanes write none.db.recent_posts_on_other_events reads the last 6h of market_alert + market_drop rows whose event key is set and differs. same_subject_posts keeps the rows that share a title with the post (a quoted title, or the “ClaudeClient.market_sibling_claim (Haiku through classify(), cached 12h) answers SAME, CONTRADICTS or DIFFERENT. SAME and CONTRADICTS drop the post as market_filtered reason=sibling_repeat|sibling_contradiction. Every failure ships it (fail-open, like the claim gate).
The same event stays out on purpose. The first measurement read same-event rows too, and the judge called two lead-change flips CONTRADICTS (KATSEYE’s “Animal”, “Casa Tupka Anthemo”); both were real news that the lead_change lane exists to post. Measured on 30 days of real posts (2026-09-17 to 2026-10-16): 12 cross-event same-title pairs inside 6h (about 0.4 Haiku calls a day), 0 wrong drops over 6 runs, and the one real repeat (Bass Persuades, ~48K then ~45.8K units 78 minutes apart) dropped in about half the runs. The goldens (the Dai Dai USA-then-Global pair, the Charli contradiction, a result on a sibling board that must ship) live in scripts/eval_classifiers.py:run_market_sibling_claim. The judge reads streams, pure sales and album units as three questions, and No. 1 and runner-up as two, so the #2003 steer (one song on two different charts is two stories) holds; only a chart’s USA and Global editions count as one question, by a worked example in the prompt.market_alert lead_change lane shipped ““Dai Dai” overtook “TRALALA” for YouTube’s Top Daily Music Video Global, but probably isn’t holding the Oct. 12 top spot.” The owner cut the last clause. The same shape shipped on 2026-10-07. The cause was the prompt: _LEAD_CHANGE_CONTEXT ended with _MARKET_CALL, and _MARKET_CALL says a % under 50 licenses the miss. That rule is for ONE yes/no leg. A field leader at 45% is still the most likely answer. The swing lane had the same bug for a leg that leads its race. The fix cuts the rule out of the leader prompts instead of adding a guard: _MARKET_CALL is _HEADLINE_SHAPE plus the yes/no sentence (unchanged text for yes/no legs); _LEAD_CHANGE_CONTEXT takes _HEADLINE_SHAPE only, as _ENDING_SOON_LEADER_CONTEXT already did; a swing or conviction break on a leg that leads a ONE-WINNER event swaps _MARKET_CALL for _HEADLINE_SHAPE in its context. A leg leads when _is_confirmed_leader (two legs or more) or _leg_standing (three or more) says so, and the event must be mutually_exclusive: on an inclusion board (“Artist(s) with a #1 song”) each leg is its own yes/no bet, so it keeps the miss rule. The judge licenses for a lead change and for a swing on a leader now say a miss framing contradicts them; before, the swing license told the judge a miss was CORRECT at 45% or below. Measured on the real compose (gpt-5.6-terra) and judge: lead change, n=44 per arm, miss lines 17 before, 0 after; swing on a leader, n=10 per arm, 3 before (the judge passed 2 at 0.82 and 0.88), 0 after. The judge, the shipped line, n=4: old license 0.82 three times, new license 0.12 four times. A 138-line regex backstop was written in review and then cut as scar: every Codex round found another grammar shape, and the cause fix alone measured 0 misses.lead_change lane posted four overtakes in three days on “Top 10 on the Billboard Hot 100” (“Boston overtakes Choosin’ Texas atop the Oct. 31 Hot 100 forecast”). _is_many_winner_board now skips a board that Kalshi marks NOT mutually_exclusive AND whose prices sum past 1.5, logged as market_filtered reason=many_winners. Both are required: “Which artist will be #1” is marked not exclusive on some weeks but sums to about 1. (2) The ending-soon lane framed a player-prop leg as “the market’s top pick … at 20%” (Stolen Bases, 11 legs, each its own 1+ yes/no). It now takes the leader read only when the event is not marked not exclusive, the same flag the swing lane reads. The leader wording also splits: _ending_soon_leader_context keeps the rank text for a title that names a rank, and a game or other who-wins title gets _ENDING_SOON_WINNER_CONTEXT. The rank text made six #sports posts say “the market’s top pick for the ranked spot” on a basketball game._is_report_lock board (a 95%+ leader) has not settled. The report angle says to state the result as a fact, and it wrote “Choosin’ Texas claims No. 1 on Billboard’s Hot 100 for the week of Oct. 31” on 10-20; that market was at 44% on 10-22. market_drop now passes _OPEN_REPORT_CONTEXT (a STATUS line: not settled, not official) as the compose context_blob. The angle text is unchanged, because x_mentions uses it for charts that have published. Dry run on four real lock shapes, n=3: result stated as fact went from 4/12 to 0/12 on the guild’s GPT tier (ships 12/12) and from about 10/12 to 0/12 on Sonnet (ships 7/12: the report gate docked some “on track” lines as a live read, and the gate fails closed). The score check gets the same STATUS line (#4719). _compose appends _OPEN_REPORT_CONTEXT to the drop_score(report=True) ground truth. The report rubric text is unchanged. In live production after #4713, the gate scored “Animals is on track to …” 0.14 and 0.22 for ODDS FRAMING and 0.45 as flat, and report locks used 3.5 composes per shipped post (7 composes, 2 shipped). Dry run on the real Haiku judge and the live Animals board, n=5 per line: the three real “on track” lines passed 7/15 without the line and 15/15 with it. Three real settled-wording lines stayed at 15/15 and 0.82. A rubric reword (an open_lock premise) was also measured and rejected: it passed the “on track” lines but scored settled wording erratically (1/5 and 0/5). (2) The alert’s news line must be about the market. market_alert._market_news now asks the Haiku judge market_news_on_subject whether the Perplexity line is about the market’s subject (or the other side of the same game) on this market’s chart or contest. False or no verdict drops the news (market_news_read reason news_off_subject / news_check_failed) and the alert ships price-only. A name test was not enough: the shipped “Madonna posted a TikTok featuring ADELA’s ‘Nicole Kidman’” names the leader. Dry run on 11 real shipped news lines, n=3: 32/33 correct, every off-subject line dropped. (3) The same board with the same numbers is not posted again. _repeats_last_post skips the drop (market_filtered reason=repeat_board) when the board’s newest take named the same leader with the same rounded % as the first figure after its name; this runs before the history judge. A leaderboard led under 20% (_X_MIN_BOARD_LEADER_PRICE, _x_weak_board) posts to the room and skips the X mirror (reason=x_weak_board). The room floor _MIN_BOARD_LEADER_PRICE stays 10%. Measured over 7 days: 4 of 62 drops were repeats, and 9 of the 32 drops on X led under 20%.itunes_debut post: “Tank’s ‘RICK (feat. Remy Ma)’ slips to No. 17 on the US iTunes Top Songs chart after entering at No. 10.” – “No slipped no negative downward narration just say position as is”, then “If it’s downwards, just say exactly where it is”). A rise may still be reported as a rise. The causes were in the text the model was handed, so the fix removes it: (1) the kworb debut lanes (_apple_debut_core) told the model “Say it slips to #N” and gave it the entry rank; a fall now gets only “is at #N … its current position, not its debut position”. The Spotify release lane’s “down from a #M entry” is gone the same way. The apple_debut event labels this branch framing: position (it was slip). (2) artist_market_move_blob handed over the earlier price and “(down N points)”, which shipped as “falls to 72%, down 25 points in a day” (2026-09-19); a fall now carries “Now: X%.” only. (3) the line_move blob said “a step down from where it had been”, which shipped as “a step down from its earlier forecast” (2026-09-16); a fall now carries no direction. (4) the shared compose_market_drop prompt listed “jumped/cratered/slipped” and allowed “a step down”; its conviction hint modelled “CRACKED” / “LOST THE LEAD”; market_alert’s forecast context modelled “a step down from where it had been”. All reworded so the only direction example is a rise. (5) _recent_alert_history leaves out any past post that narrates a decline, because that block is handed to the model as “YOU POSTED THESE RECENTLY”. One deterministic backstop stays: _fire_kworb_lane runs ship_guard.drop_decline_narration on every non-market lane before the judge. No list of banned words is handed to the model anywhere (docs/PROMPT_OPTIMIZATION.md: a list summons what it names).music_alert.py:_rank_snapshot builds every rank-hero lane’s card (the Apple crown + jump + the two Apple debuts + the radio entry; _is_rank_figure is what routes the crown, because the Spotify crown heroes a STREAM COUNT and a count keeps its caption – without “streams today” the number has no unit), and music_news.py:_build_card’s chart branch (the tag is data_source – the settled chart’s label, else the label of the ONE readable chart the claim names, else the one the source post names (mn.unsettled_chart_label, 2026-09-08: the live read misses whenever kworb’s cache is behind the wire (#3191 cut that flat 3h hold to a 2-minute revalidation, so the window is now narrow), and Ashe’s “Stop the Wedding!” card shipped tagged “SPOTIFY” under a take that said “Spotify’s Global Daily chart”; the classifier’s terse “debuts at No. 199” form can leave the chart in the post alone; a bare “Spotify” or “Apple Music” claim names two charts and keeps the wire’s authority, and a FORECAST (“projected to debut at No. 1 on the Billboard 200”) never takes the chart’s name because the release has not entered it – so the tag never guesses; the deep-chart floor reads the post’s chart the same way when the claim names none), else the wire’s authority – and the generic kicker only when all three are blank; chart left _KIND_ACCENT_KEYS). The verified source PILL then repeated the chart under the credit line (owner, same day: “sub is getting redundant”), so the renderer now skips the stamp whenever the tag already carries the source’s name as a whole word (market_chart.stamp_is_redundant, case-insensitive): “APPLE MUSIC US SONGS” draws no “✓ Apple”, “BILLBOARD 200 - NEW PEAK” no “✓ Billboard”, while “MUSIC · CERT” keeps its “✓ RIAA”. One rule in the renderer, so every surface’s card obeys it. The music desk’s chart cards (artist_watch.chart_story_card_fields, the weekly peak / chart-run milestones) are deliberately NOT changed: they already tag the chart label, and their caption carries the ARTIST (the title is the card’s headline on its own), so cutting it would drop the act from the card. The MARKET number card follows the same rule (owner steer 2026-09-08, “our market blocks should do the same: put the chart in the block and remove the duplicate sub”). A market whose own words name the CHART it settles on wears that chart as its accent block, and a LIVE rank hero then carries no caption: the reported card read “ENTERTAINMENT / #2 / Dai Dai / 20% · Runner-Up Daily Music Video Global on YouTube: Sep 8, 2026” beside a music card reading “APPLE MUSIC US SONGS / #16 / the cure - Olivia Rodrigo”; it now reads “YOUTUBE GLOBAL MUSIC VIDEOS / #2 / Dai Dai”, with the take under it carrying the odds. One home, market_cards.chart_card_fields (called once in build_market_card, so the drop, the alert and every other market surface get it), over a registry of the exchange’s own chart phrasings in market_chart_tag: the four YouTube daily music-video series, Billboard 200 / Hot 100, Spotify’s year lists (region + year, since the list is annual), Netflix’s weekly lists, a registered rank authority (TelevisionStats most popular movies), the Luminate count metrics (“Ow by Sylvan Esso: First Week Streams” tags FIRST WEEK STREAMS and titles the card “Ow by Sylvan Esso”), and the artist YouTube view-count family (“Ariana Grande: Highest daily YouTube view count (August 24 - August 30, 2026)” tags YOUTUBE DAILY VIEWS and titles the card “Ariana Grande” – the most-posted music market family of the 30 days to 2026-09-08, 30 alerts, and every one of them titled its card with the whole event title). Three deliberate keeps: a PROJECTED rank keeps its caption (“projected chart position” is what says the rank is a forecast, not a repeat of the tag); a COUNT hero keeps its odds qualifier (“22% to clear 2M” is not the chart); and the context STRIP (“#1 on YouTube today · 7.2M views”) stays, since it is a different fact from the market’s slot. A market whose words name no chart (a sports seed board, a draft pick) is untouched: the category ladder tags it and its caption stays, so nothing is cut where nothing moved into the block. Measured on the live board (8,000 open events, 2026-09-08): 146 tag, all Entertainment. Telemetry: market_card_hero.detail.chart. The same-day SWEEP over every music card (owner: “do a sweep for all music cards and entertainment music sub category cards”, then “keep the ranks on Spotify cards”) found five more repeats and fixed each at its source: (1) the music NEWSROOM’s streaming-milestone card (music_news._milestone_card_fields) read “MUSIC · MILESTONE / 30.1B / Harry Styles / 30,098,500,000 Spotify streams” with no rank anywhere – it now tags “music_news.claim_count_label reads the unit words right after the number in the claim (“EQUIVALENT ALBUM SALES” over “80M”), only when that number equals the hero. It sits after the metric tag and before the kind noun in _milestone_card_fields; a multi-word label of this kind is not merged with the hero’s unit word (“6M units” does not read “equivalent album sales units”). Deterministic on purpose: asking the classifier for the label was dry-run in five prompt variants on 53 real wire posts (n=3 each), and every variant moved other fields (the hero became “80M equivalent album sales”, non-stories became milestones), while its labels often named a ranked list over a total.milestone, figure “#69”, no source and no milestone_id, so the milestone lane had no metric and no catalog count to read, and the block fell to the bare kind noun. A rank hero is a position on a list, so the block now names the list, read off the claim (music_news.rank_list_tag -> _milestone_card_fields, after the catalog tag and before the metric tag): “BEST-SELLING ARTISTS”. The block carries no “all-time” (owner steer 2026-10-01, “BEST-SELLING ARTISTS”): “of all time”, “ever” and “in history” are read only so they do not end the list name. The number is the source’s own ranking (ChartMasters’ CSPC estimate here, not RIAA, which certifies US units and never ranks “best-selling”); the post text keeps its “is reported to”. A platform the claim names goes first (“Spotify most-streamed artists”). The tag applies only when the figure IS the claimed rank, the list names people or records, and the list is not a chart (a chart story has the chart lane’s own block). What may FOLLOW the list name is an allow-list, not a block-list: the end of the claim, sentence punctuation, an “on classify_music_news returns rank_list on the milestone and chart kinds (“Spotify most-streamed albums”). The field never names a list by itself. The regex reading above still binds the rank to the list, checks the owner and names the list; the field only VOUCHES for that name. When the words after the list fail the safe-end allow-list but open with a comparison clause (“moving past …”) and name no country or year, a field with the same words as the parsed name lets the block through. Every other refusal stands. (A first version accepted the field on its own bounds; two Codex rounds on #4212 found nine gaps in those bounds, all precision guards the regex already has.) Dry run on 104 real wire posts: the field agreed with the regex on 10 of 10 labels and recovered the 2 the allow-list refused (“moving past Drake’s Take Care”); the bounds refused both wrong values. The field also moved 4 ranked-list stories from chart to milestone, the kind whose card names the list. claim_platform now reads “YouTube Music” as its own service; it used to print “YouTube”.chart for many ranked-list stories (“Lana Del Rey rises to #7 among Spotify’s most-followed female artists”, posted 2026-10-08 under the block “SPOTIFY”). _chart_card_block now gives that card the rank_list_tag block when no live chart read settled the claim (meta["chart_settled"]: a position, a count or a projection). A settled read keeps its chart label. rank_list_tag refuses any list name that is a chart, so a real chart story keeps its block. _rank_keeps_number accepts the unsettled chart kind, so an outcome does not clear the “#7”. Zero added calls. Measured on 30 days: 12 chart-kind posts carried ranked-list wording, and only 1 had a list name the tag accepts (the rest name a year, “1984’s most-streamed songs”, which the tag refuses). Most ranked-list stories on the chart kind never reach the card: the scope gate (_chart_in_scope) cut 11 as chart_cut, because they name no chart the room reports. That gate is the owner’s chart cut and is unchanged; #4212 moves part of this traffic to milestone, which skips it.
A scope that covers every service (“across audio streaming services”, “across all platforms”) is dropped, so “#7 on the list of most-streamed artists across audio streaming services” reads MOST-STREAMED ARTISTS (owner, 2026-10-02: no label is worse than MILESTONE, so the bare fallback stays and more claims must reach a real name). A scope that narrows the list (“across Asia”, “in India”) is still refused.music_news.catalog_count_tag -> _milestone_card_fields): “SONGS PAST 1B SPOTIFY STREAMS”. The wording is the DESK’s own catalog_stats.catalog_tag, the block music_desk already draws on its catalog card off the same kworb count – the desk had this rule and the newsroom did not, so one home now serves both. The tag applies only when the card’s figure IS the claimed count, so a block never labels a hero it does not describe. The Spotify RANK STRIP also returns to these cards: it used to share one early return with the metric tag, so a story whose milestone_id carried no readable metric lost the strip too – and those are the cards that need it most, because their block says the least. The strip hangs on the SOURCE alone now; a missing metric says nothing about whether the act ranks on Spotify.last-modified), that premise is wrong for most of what we read: ten of the fourteen pages refresh INSIDE a day – US iTunes was 0 minutes old at the probe, the Spotify all-time songs ranking 7, YouTube trending 10, the Spotify artist ranking 12, Apple Music songs 18, Apple Music albums 28, the YouTube video page 64, US radio 196, Spotify listeners 261, the US YouTube weekly 357. Only the four /spotify/country/ charts are daily (all four about 1370 minutes old, so they refresh once a day near 18:30 UTC). That flat hold sat on exactly the pages the CROWN lanes read, so a title could take #1 and wait up to 3h for our next real read, even though the alert tick runs every 10 minutes – the cache, not the tick, set how late we posted. Every kworb page serves BOTH etag and last-modified, so _fetch now re-asks CONDITIONALLY once _REVALIDATE_AFTER_SECONDS (2 min) passes: an unchanged page answers 304 with no body and no re-parse, and a changed one arrives within one tick. The conditional headers ride along ONLY when a cached parse exists, so a 304 can never leave a caller with nothing, and a page serving no validator keeps the old flat hold rather than re-scraping a full body every window. Only a whole-CHART or RANKING page revalidates. A PER-ENTITY page (_PER_ENTITY_QUERIES: track_, artist_songs_, artist_albums_ – the same three shapes ops_monitor._OPAQUE_QUERY_PREFIXES collapses, for the same reason) keeps the flat hold: its number moves daily, no lane reports it as breaking, and there is one path per entity. That distinction is measured, not assumed – on the live Axiom data (24h to 2026-09-12) the per-entity reads are 337 of 540 chart_fetch events (track_* 168, artist_songs_* 154, artist_albums_* ~15) against 14 or fewer a day for each real chart page, so revalidating them would have put the biggest and most unbounded group of reads onto a two-minute timer. A 304 is applied only while the cache entry is still the one that call read (the GENERATION GUARD, Codex on #3193): bot.kworb is ONE client shared by the alert tick and the desk slots, so two callers can revalidate one path at once, and a 304 that lands after a concurrent fresh 200 would otherwise write its stale rows over the new ones. That rollback does not self-heal – a 304 carrying no ETag keeps the NEWER validator, so every later revalidation is answered 304 against a page we never parsed and the new #1 stays invisible until kworb changes the page again. The losing 304 returns the fresher rows and marks itself superseded on its chart_fetch event. A 304 is a SUCCESS (ok=true, reason=not_modified, http_status=304, cached=true): counting it as a failure would trip the breaker on a perfectly healthy page. ONE reader comes with it: refreshed_at(query), the page’s OWN refresh time (its last-modified, never our fetch time). It is the first timestamp in this repo that says when a CHART changed rather than when we looked; utils/chart_presence.py stores only our first sighting. A changed_on_last_read companion was written and CUT before merge – nothing called it. A lane that wants to fire on the refresh itself should add it back WITH its caller, so the signal and its consumer land together. The four crown lanes stamp both onto chart_crown as refreshed_at + refresh_age_s. That is the DETECTION half: the event fires while the lane BUILDS the signal, before the daily gate, the self-gate and the repeat check, so a crown counted there may never have posted (Codex on #3193). The DELIVERED lead time – the “how fast did users see it” number the crown_lead_time panel reads – rides music_alert_posted.refresh_age_s, which only a shipped post emits; _CROWN_LANE_PAGE is the ONE home mapping a crown lane to the kworb page it reads, so the two cannot name different pages. Keeping both splits a slow READ from a slow COMPOSE. the RADIO crown lane emits that event for the first time, since it composes its own blob and so never rode _chart_crown. Deliberately NOT done here: the card states no time. A chart’s refresh minute is when the PAGE published the change, not the minute the title reached #1, and printing the one as the other would be the kalshi_price fail direction – prefer absent over invented. The same revalidation is not yet wired into the other scrape clients (Billboard, box office, HITS, Pollstar); that is the follow-up, and the helper is shaped to take them.utils/itunes_charts.py, #3469, owner report 2026-09-18: “how do we miss Bass Persuades iTunes at number one?”). kworb mirrors iTunes SONGS only (its albums URLs 404, probed 2026-08-10), so Miley Cyrus’ album led the US iTunes Top Albums chart with no reader behind it, while the song of that name sat at #32 on the songs chart we did read. The reader is Apple’s own iTunes Store RSS (itunes.apple.com/us/rss/topalbums/.../explicit=true), a SALES ranking distinct from Apple Music’s streaming albums chart (ap_albums) – the two rank the same titles differently, which is why it says something the Apple Music row cannot. The feed prints ranks only, so the client derives the two columns every consumer keys on (pos_change, is_new) against the PREVIOUS UTC DAY’s final snapshot, held in one durable kv_cache row: day-over-day rather than read-over-read on purpose, because the alert tick (10 min) and the desk slot (hourly) share one client and a read-over-read diff would hand a takeover to whichever caller fetched first and show the other a hold. The first day after a deploy (or after the 14-day row expires) reads QUIET by design – pos_change=0, is_new=False on every row, reason=warming on the event – rather than every row as NEW (prefer absent over invented). Rows are ItunesAlbumRow, a subclass of kworb.AppleSongRow, so nothing downstream branches: the alert cog’s itunes_album_crown + itunes_album_debut lanes ride _chart_crown / _apple_debut_core (which grew pill / platform / provider parameters so the card and blob say iTunes, never Apple Music), the desk’s _WATCH_CHARTS row it_albums rides chart_stories + the platform top-10 / new-entries boards (the day ceiling went 12 -> 14 for the two added boards), and the newsroom’s CHART_SOURCES row itunes_albums settles a wire’s “iTunes” claim against songs OR albums the way the Apple pair already does. The desk and the newsroom resolve a chart method through bot.kworb first and bot.itunes_charts second (MusicDesk._chart_reader), so the one table spans two clients. No whole-chart PRESENCE board on it: the feed’s row count varied per request (56 of a requested 100 on 2026-09-18), so there is no printed size to hold a whole-chart claim to. The short reads were the CLEAN chart (owner report 2026-09-28, “how are we missing this”, Tinashe’s “Popstar (Bonus Edition)” #1 on iTunes). The feed URL had no explicit=true segment, so Apple removed every explicit album and moved the rows under it up. Tinashe’s #1, her standard “Popstar” at #8 and Taylor Swift’s “The Encore” at #2 were all absent, and every lane that reads this chart missed them. The reader now asks for explicit=true (100 rows). The durable snapshot moved to a new key (albums_us_explicit): a diff against the clean-chart row would read every explicit album as NEW, so the switch takes one quiet warm-up day for MOVES. The #1 is never quiet (owner, same day: “we can’t miss number one changes”): the reader keeps its own record of the #1 (roll_top: current #1, the title it replaced, the day it changed) and sets took_top on the #1 row, which needs no baseline. The new state is seeded with only the #1 of the old clean row, so Tinashe’s #1 posted on the day of the switch. The recognition-list limit on store and radio charts is gone (owner, same day: “top 10 should be top 10s, they’re not limited; only outside of the top 10 are things limited by the watch list”). it_songs, it_albums and radio_us joined the desk’s _TOP_ALL_CHARTS, _TOP_KNOWN_CHARTS is empty, and the alert cog’s _store_chart_rows keeps every #1 and every top-ten row for every act; below the top ten the iTunes jump lane keeps only WATCHED acts. This reverses the 2026-09-19 “wash artists” middle setting described above. It also reverses the 2026-09-21 unwatched_move cut INSIDE the top ten: artist_watch.chart_stories now tells every kind (new entry, climb, fall) for any act at or above OPEN_TOP_N (10) on an open chart, and only a row below the ten needs a watched act. Measured on the live charts that day with an empty watchlist, top-ten stories per sweep went from 15 to 45 (mostly falls and one-place climbs). Each still posts once per chart, song and position. The unwatched_move refusal now fires only when MUSIC_DESK_WATCH_TOP_MOVE_RUNG widens the move band past ten. Spotify ChartRows now carry kworb’s NEW marker (is_new), so a Spotify title that enters at #1 crowns too (Codex on #3724). The clean-chart seed cannot prove a #1 CHANGE (an explicit album may already have been #1), so a #1 it reveals carries top_unverified and the crown states only that the title IS #1 (framing standing). The same PR closed two #1 gaps on EVERY crown lane: _chart_crown (and the radio crown) now also fire on a title that ENTERS at #1 (is_new; it carries pos_change == 0, which the old gate read as a held #1), and a crown lane is no longer once a day – _daily_gate lets it post every distinct #1 in a day, never the same title twice, and a crown whose take the self-gate blocked is retried next tick (up to MUSIC_CROWN_MAX_TRIES, 3; a live dry run of the Tinashe crown passed 2 of 3 takes, so one try could lose a #1). Health: the itunes_chart_fetch event (its own breaker + probe), fail-open everywhere – a dead feed empties every lane silently, so the ops monitor’s integration rate is the only place it shows. Chart parity + the fanbase-push filter (owner ask 2026-09-19, “chart parity across the board” and “filters for wash artists”). The parity audit found three gaps and closed them: (1) the iTunes SONGS chart had desk coverage only, so it gained the same crown / debut / jump trio on the ten-minute tick the Apple songs chart has had since #1737, and Spotify US Daily gained a crown + jump pair beside Global’s; (2) the desk’s top-ten budget was ONE move card an hour while the nine charts carried 5-8 moved top-ten rows EACH (measured live that morning, about 50 stories), so _MAX_TOP10_STORIES_PER_SLOT (env MUSIC_DESK_MAX_TOP10_MOVES, default 40) now budgets EVERY story inside a chart’s top ten whatever its kind, and only deeper stories pace on the two-card event cap – the owner’s rule is “any change in top 10 should ship card updates”, and each still posts once per chart, song and position; (3) the iTunes songs and radio tops were WATCHLIST-ONLY on purpose (“those tops churn on fanbase pushes and slow format inventory”), and the new albums chart has the same property, so widening them to everyone would have been the wrong parity. They take the MIDDLE setting instead: _TOP_KNOWN_CHARTS widens their top-ten kinds to the RECOGNITION list (kworb’s ~1000 biggest streaming acts + the kept-genre Billboard names) through the ONE rule artist_watch.recognized_for_store_chart, which the desk’s card path, its standing pass and the alert cog’s iTunes crown / jump lanes all read; an EMPTY known list keeps a store chart watchlist-only (fail-closed – a kworb ranking outage must not turn iTunes into a fanbase-push wire), and a declined #1 emits chart_crown reason=unknown_act. Every card shape the new chart feeds was rendered off the live feed with the real model before the PR was handed off (scripts/dryrun_itunes_albums_cards.py --simulate-prior: the platform top-10 board, the alert crown and debut rank cards, and three top-ten move cards; all six takes cleared the 0.6 gate at 0.75-0.92). Three follow-ups closed the same day (owner: “fix them all; hourly is fine, it should be the next slot that they ship”). (1) #3470: the desk’s _sched_on_dedup now stamps a watch_chart card’s kc: key when the poster dedups it, so a story the poster has judged already-said is not derived, composed and scored again every slot (Morgan Wallen’s iTunes #1 was composed nine times in one day). (2) #3482, the NEXT-SLOT rule: _MAX_WATCH_BOARDS_PER_SLOT went 1 -> 4 (attempts 3 -> 6) so every act’s artist board composes in the sweep that found it instead of one act per slot; with the top-ten budget already covering every story inside the ten, nothing a slot derives now waits for a later slot except the compose budget for deeper-chart events. The cadence stays the desk’s hourly slot by the owner’s call; the ten-minute tick keeps the crown / debut / jump lanes. (3) #3483, the PRE-ORDER rule: an album that charted on pre-order read as established on release day and the release-age resolver dated it from Deezer’s pre-order listing, so the desk rejected “Bass Persuades” at #15 on Apple Music albums every slot. Now release_age takes the LATER of Deezer and Apple’s artist-verified row for an ALBUM (a pre-order listing, a same-named single and a pre-release track are always earlier than the album), a chart row that states its own date (the iTunes albums reader) is dated from the row, and both debut gates (_resolve_new_entry, _apple_debut_core) treat an established title released within MUSIC_DESK_PREORDER_GRACE_DAYS (1) as a debut rather than charted, framed plainly (the pre-order sighting’s rank is not an entry). Editions and credits (owner ask 2026-09-19, “fix 2 & 5”). A DELUXE / expanded / anniversary / “(The X Edition)” listing is its OWN entry on a store chart – the Miley Edition of “Bass Persuades” sat at #2 beside the standard album at #3 – so song_pool.song_key now KEEPS an edition marker (release_type.edition_marker) as a suffix on the identity, and the two listings key apart everywhere the chart lanes key on it (the presence memory, the per-chart song fold, the kc: dedup keys, the alert cog’s daily gate); a remix or live cut of a SONG still collapses into the song, and songs carry no edition markers, so the games’ keys are unchanged. Apple’s feed prints a band whose leader is also credited solo as both parties (“Neil Young & Neil Young & The Chrome Hearts”); the reader collapses an exact repeated party (itunes_charts._credit) before the row is built.MusicDesk._watch_chart_units drops a top_fall story from the card path, and _pick_chart_moves drops a Billboard fall / top_fall after _move_is_cardworthy. The derivations (artist_watch.chart_stories, billboard.movements) still produce falls, so the artist boards (watch_board) and the daily platform_top / weekly chart_board top-10 boards still show them, with the day-over-day marker on each row. The cut covers every chart (owner: “cut all downward movement in general”). Four charts have no top-10 board, so a fall there is not shown anywhere: Billboard Global Excl. US (weekly boards cover only _STANDINGS_CHARTS + _WEEKLY_EXTRA_CHARTS), and Spotify Global Weekly, US radio and US YouTube (outside _PLATFORM_BOARD_KEYS). They posted about 10 fall cards in the 7 days before the cut (Codex on #4679 flagged the gap; the owner’s call is to cut them too). Each cut emits music_desk_trim reason=fall. The alert cog’s debut-window fall (“is at #N, not its debut position”) no longer posts: the Apple lanes cut it on 2026-10-23 and the Spotify Global Daily lane on 2026-10-27 (apple_debut / spotify_debut reason=slipped). The Spotify lane was missed by the first cut. The newsroom twin (2026-10-27). The newsroom relays OTHER accounts’ chart posts, so it has no positions to compare: the Haiku classifier (classify_music_news) reads a chart_direction (up / down / level) for each chart story, and mn.direction_cut cuts a fall, settled or forecast, and a forecast hold, before verify and compose (music_news_filtered reason=fall / position_only via=direction). Before it, position_only_claim read only chart_event, so a move that was a fall (“#7(-3) Been By Now”) reached compose, and a forecast fall (“drop dead … expected to fall below the top 25”) shipped on 2026-10-26. The same day fixed the other half: when a live chart read rewrites a rising story’s claim (mn.settled_claim), the rewrite dropped the wire’s move, so “enters the Top 5” shipped as “sits at #4”. A second Haiku read (ClaudeClient.settled_chart_move) now checks the live position still fits the wire’s move, and a kept move rides back as a word (“climbs to #4 on …”), never as the wire’s number. The mention lane (2026-10-30). cogs/x_mentions.py answers an @tootsiesbar mention with the act’s chart cards, and its single-title card handed the compose the row’s move marker, so “Choosin’ Texas drops two spots to No. 26 on Spotify Global Daily” shipped on 2026-10-29. A single card about a title that fell now does not post (x_mentions kind=boards reason=fall); the act’s board still shows the fall in its rows. Not covered: the desk’s old-title watch kind (reentry, “is an OLDER title … on this chart at”) is position-only by design, so a catalog song that climbs #9 -> #4 inside the iTunes “new” window still reads “sits at #4” (Donnie Iris, 2026-10-27).x_gate.ITUNES_TOP_N is 5 (was 10), and it is the one number for all three gates: the X gate’s x_chart_depth, the desk’s watch lane (_watch_chart_units, it_songs / it_albums) and the alert cog’s iTunes lanes (_run_daily_lanes, read through _lane_x_facts, before the release-age cut; the title is marked out of tries for the day). A #1 crown and the genre #1s are unaffected; the iTunes top-10 and new-entry boards still post. Each cut emits music_desk_trim reason=itunes_depth. Measured on X over the 2 days before: 28 of 77 iTunes posts sat at #6-#10, about 17 of them falls the fall cut already removed.chart_peak.fallen_peak_candidates, so a peak posts only once it is HELD for _PEAK_SETTLE_MIN (“climbed to #5”); a peak the title falls from before that posts nothing (the desk’s top-3/5/10 rung card tells the climb as it happens). (2) The DEBUT lanes (_apple_debut_core, Apple + iTunes, songs + albums) skip a debut that fell below its entry rank inside its first day; the scan ends on apple_debut reason=slipped (detail.slipped), which the gate monitor counts as a rejection. Each was about 2 posts a week.cogs/music_alert.py _itunes_genre_crown_signal, utils/itunes_charts.py GENRE_CHARTS). Eight crown lanes, one per iTunes Store genre chart: Hip-Hop/Rap, R&B/Soul, Pop and K-Pop (genre 51, added 2026-10-27, owner ask), each as songs and albums. A lane posts the rank card when its chart gets a new #1, through the same _chart_crown gate as the overall crowns. It stays quiet when the genre #1 is also #1 on the overall iTunes chart of the same kind, because the overall crown already posts that title; the match rule is below. An empty overall read does not hold the genre crown back. A genre chart’s FIRST read posts its current #1 with the standing wording (“X is #1 on The RECORD card (owner steer 2026-09-10 on the Rihanna Songs of the Summer card, “make the block the outcome and get rid of sub”, then, shown three layouts, “c but the block should still name the chart”). A claim worded as a SUPERLATIVE (“most top 10 hits in Billboard Songs of the Summer chart history”) heroes the RECORD as its headline, the ACT on the byline, the CHART or body the record is set on in the block, and no caption – the take under the card carries the sentence, the same trade the rank card makes: BILLBOARD SONGS OF THE SUMMER / Most Songs of the Summer top 10s / Rihanna. The classifier names the record as outcome – the same field the market kind uses for the thing it prices, extended in the prompt line that defined it (“for a RECORD … the record itself, with the chart or body it is set on”), and _parse_music_news no longer scopes it to the market kind (a release, a miss and none still carry none). The layout keys on that field, never on the kind: the classifier’s kind for a record drifts between milestone, novelty and chart on the same wire (the Rihanna wire came back novelty before the #3135 prompt reword and milestone after). music_news.py:_record_card_copy builds the copy (title-cased headline, the artist split off the subject as the byline, data_source as the tag, else the generic kicker so nothing is invented for the block); _build_card applies it after the market copy and before the tour/live/announcement shapes, counts the headline as a hero for the no-card rule, and the milestone and chart branches stand down for it; _sched_compose clears card_figure for a record where the hero is settled, so the music_news_verified event reports no hero rather than a count the card never draws, and scripts/dryrun_music_news.py prints the record headline. The stamp follows the existing redundancy rule (stamp_is_redundant): a block that names the source draws no <check> pill. Rendering it found a caser bug: title_case_headline wrote “Top 10S”, because _WORD_RE starts a word at a letter and the “s” after “10” was a word to it; a token that follows a digit with no space is now left as written (“10s”, “3rd”). Dry-run on the real wire n=5: every sample returned the outcome “Most Songs of the Summer top 10s” with source “Billboard Songs of the Summer”; the count controls (a #135 debut, a first-week total) returned none. The market card’s parts are the classifier’s fields now (2026-09-18, #3417). classify_music_news returns market_odds, market_venue, market_deadline and market_award on the market kind. _market_card_copy reads them ahead of the claim regexes (market_claim_parts, market_award, market_deadline_phrase): the claim’s own odds phrase and exchange tail still win where they cut the clause, the fields fill what the words leave empty, and the deadline and the award are BOUNDED to text that appears in the claim (mn._stated_in), so the card never names a window or a prize the wire did not state. The market_award field asks for the PRIZE by name, which is what outcome could not promise; the win-verb regex is the fallback. The market card’s headline rung is logged (#2825). _market_card_copy names the headline from the prize, else the classifier’s outcome, else the ACT (the clause rides the caption). The last rung is correct but silent, so music_news_verified carries detail.card_headline (award |
outcome |
act_fallback |
none, from the pure _market_headline_rung) on a market story with no hero figure. |
feed_photo), and it took that image on TRUST: industry_feeds._first_image returns the enclosure, else the media:content url, else the first <img> in the body HTML — and a publisher that opens its body with a HOUSE AD hands us the ad. Billboard’s tour story led with i.imgur.com/2HpFicp.png, the 728x90 “ADD OUR PASS” leaderboard, and it shipped to @tootsiesbar as the art under a correct, well-scored take. Nothing downstream could have caught it, and that is the part worth keeping. download_image_bytes checks only the HTTP status and the body length. The delivery coherence gate (x_crosspost._media_gate) DID run, DID look at the image, and passed it with the note “Generic Billboard branded ad banner, not tied to any specific artist so it doesn’t contradict the post” — because that gate is a wrong-SUBJECT tripwire whose prompt lists “a branded graphic, a logo” as an explicit YES, and it lists them so our OWN chart and number cards clear it. An advertisement names no person and no work, so it contradicts nothing and passes by construction. The fix is at ATTACH time, not at delivery, and it is deterministic. catalog_art.ad_banner_reject reads the decoded size: display advertising is sold in fixed IAB units whose shapes no editorial photo has (the leaderboard 728x90 and its super 970x90 are past 8:1, the mobile banner 320x50 is 6.4:1, the skyscraper 160x600 is 1:3.75), and the same 200px short-side floor catches a feed THUMBNAIL — Billboard’s own lead images are 237x158 crops, too small to set as card art and too small for bound_bare_art, which bounds the long side without cropping. A rejected image falls through to the catalog/entity resolver exactly as a download MISS already does, so the story keeps art instead of going bare. The bands are measured, not guessed: over the four live industry feeds on 2026-09-16 the 8 Pollstar stories carrying an image ran 1080..2560 on the short side at ratios 0.80-1.78, so the floor clears the real photos by more than 5x and the ratio band by better than half, while the same pass flagged 6 of 6 Billboard images (the ad, plus five thumbnails). The guard FAILS OPEN on a decode error, the same direction is_blank_image takes, so a Pillow hiccup never discards a real photo. What geometry cannot catch is the 300x250 medium rectangle, which sits inside both bands, and that residual is left open ON PURPOSE rather than answered with a vision judge: this rung builds about 4 cards a week, and the newsroom’s own measurement is that the moment-framed photo gate (subject_image.gated_source_photo) rejects a REAL Billboard lead photo as readily as the ad (5/5 each on the Tinashe story), so routing the rung through it would cost good art to close a case we have not yet seen ship. desk_art carries dropped + reject_detail for the refusals, because a refused image still leaves ok=true via the resolver and the guard would otherwise degrade silently (see docs/OBSERVABILITY.md). The exposure is ONE rung wide, and that was swept, not assumed: cogs/music_news.py is the only consumer of utils/industry_feeds, and every other scraped-art rung already clears a vision gate — the open-web found rung through pick_subject_image + confirm_subject_image, the wire desks’ source_photo through gated_source_photo; the catalog, Deezer and TMDB rungs are deterministic lookups by id. The siblings that take media_urls[0] the same way (cinema_news_stories._best_image, pop_stories) read X wire posts, whose media is the post’s own, and they route through the gated rung. The delivery gate is the SECOND layer, and it was fixed too, not excused (owner steer 2026-09-16: “cut all scar tissue from whatever prompts we have in that pipeline that would cause this”). claude.crosspost_media_mismatch has a fourth verdict, unrelated, for an image that is not about the subject at all and says so itself. The cause was ONE clause: the prompt’s blanket YES for “a branded graphic, a logo, generic artwork”, written to keep our own chart and number cards. Our cards never needed it – they NAME the subject, and the YES now says so positively – while every third-party graphic rode through on it. The new verdict keys on what the image DECLARES ITSELF to be (an ad’s own copy, a publisher’s badge, a placeholder wordmark), never on a failure to recognize a face or a title, which is the abstention rule the #2238 false drops earned and it stays. Measured, arms interleaved, n=5, 40 real cases: our own 20 shipped @tootsiesbar images 0/100 false drops in both arms; Billboard’s real Tinashe photo 0/5 both; 7 real publisher images x 2 captions 45/70 dropped before, 70/70 after; 5 wrong-subject swaps 25/25 both. The 25 the old prompt missed were exactly the two images that name NO subject – the ad and Billboard’s 1250x1250 lazyload placeholder – while IQ’s magazine covers, which name a DIFFERENT thing, it already caught as NO. That placeholder is why both layers stay: it is square, so geometry cannot see it, and the gate is fail-open and non-stationary, so geometry’s determinism still earns its place in front of it. The clip gate (crosspost_clip_mismatch) is unchanged: it already names “an advert” as a NO, and its media is the wire’s own video, never a scraped page. scripts/dryrun_media_gate_unrelated.py re-runs the shipped arm over the same corpus. A RELATED-story thumbnail is not the item’s image either (2026-09-30, the Rauw Alejandro “Macacoa 2000” card). Billboard’s RSS body carries no image of its own. Its first <img> sits in the “Related” widget (<div class="injected-related-story">) and shows ANOTHER article’s lead photo. On that card it was Ricardo Montaner. The delivery gate dropped it as the wrong artist, so the post shipped as a bare number card. A sweep of the live Billboard feeds that day found 12 of 12 feed images came from that widget, so every Billboard feed_photo before this fix was another story’s picture. industry_feeds._body_image now skips any <img> inside a block whose class contains “related” (Billboard, Jetpack jp-relatedposts, YARPP). The span ends at the close tag that balances the open tag, and an unclosed block runs to the end of the body. A Billboard story now has no feed image, so it takes the catalog/entity resolver like any other miss. The article’s own lead photo exists only as the page’s og:image. So when a feed item has no image, the card build calls IndustryFeedClient.article_image, which reads the article page and returns its og:image (industry_feeds.og_image). The read happens at card time, so only a story that becomes a card costs a page fetch. The result is still the feed_photo rung: the geometry guard and the delivery coherence gate both check it. Dry run 2026-09-30: 8 of 8 live Billboard stories resolved their own lead photo (the Macacoa story got Rauw Alejandro on stage), and all 8 passed ad_banner_reject. A miss emits feed_article_image ok=false and falls to the catalog/entity resolver.x_crosspost.maybe_crosspost – the chokepoint; crosspost_with_quote is a thin wrapper over it, and every cog that crossposts (music, music_desk, music_alert, music_news, market_drop, market_alert, discourse, x_mentions, pop_desk, sports_desk, sports_boards, cinema_desk, cinema_news, betting_alert / betting_value / betting_board) reaches X through one of those two names; grep -rn "post_tweet(\|upload_media(\|upload_video(" cogs/ utils/ is the check, and it returns only these and (2). Inside it, four media branches: image / images -> _media_gate per photo (the unrelated + subject verdicts, on a found photo the lenient Haiku OCCASION check over the judge’s note (#3259, _occasion_differs; the note is read off a one-line reply too, _verdict_and_note), a second-vote confirm, enforce on by default and X_MEDIA_GATE_ENFORCE=1 in production, verified 2026-09-16); fallback_image -> ungated on purpose, it is our own face-free number card with no art; video_url / video_bytes -> _clip_gate on the clip’s own thumbnail, and the photo is dropped with a native clip; x_tweet_id quote / repost -> media only via the gated photo branch. Two structural holes closed in the audit: the photo gate ran only if text, so a take that stripped to nothing shipped its photo unjudged – maybe_crosspost now skips such a post and records crosspost reason=ungated_media; and nothing watched for a bypass, so the ops monitor carries media_ungated (docs/OBSERVABILITY.md), a per-surface tweets-vs-checks tripwire that fires on ANY future caller that uploads on its own. (2) cogs/x_game.py calls XPoster directly (a clip it renders from the song preview + the catalog row’s artwork_url_hi, both by id from the song pool, 7 rounds a month). It is ungated because nothing in it is scraped; it is the one exception, and a new lane must not copy it. The resolution side, per art rung: catalog / deezer / tmdb / sports_api / steam / apple_url are lookups by id; found (open web) clears pick_subject_image + confirm_subject_image; the wire desks’ source_photo clears gated_source_photo; feed_photo (the one rung that took a scraped <img> on trust) now clears ad_banner_reject and then the delivery gate. Measured, 30 days to 2026-09-16: every one of the 13 surfaces that tweeted a photo had photo checks >= media tweets (3,384 tweets, 3,448 checks); since the clip gate landed on 09-14, clip checks (8) >= video tweets (6). The one residual: _clip_gate needs the wire post’s thumbnail (photos[0]) and a clip with none ships ungated; no such tweet has occurred since the gate shipped, the clip is always the wire post’s own mp4 (never a scraped page), and closing it means extracting a frame from the mp4 – filed, not built. Discord delivery is out of this audit’s scope: the same cards ship to the rooms with no vision gate, and feed_photo was the only scraped rung on that path too.utils/x_artist_tags.py, applied inside x_crosspost.maybe_crosspost through its tag_artists argument. A tag names a real person in public, so it is a one-way door and the rule is prefer absent: a name becomes @handle only when BOTH steps agree. (1) Grok (grok_search, purpose x_artist_handle) names the artist’s own account or says NONE; two different handles in one answer count as NONE. A reviewed PINNED_HANDLES entry replaces step (1) for a watched artist whose real account fails the badge or name check: a stylized display name (Bad Bunny is @sanbenito “Benito Antonio”, Future is @1future “FUTURE/FREEBANDZ”), or no badge (Tate McRae, Peso Pluma, Daniel Caesar). A pin skips only the badge and name checks; the rest still runs live. Each pin carries evidence that does not come from Grok: the verified accounts that tag it in X search (2026-10-01; for example Atlantic Records tags @alexwaarren). A pin’s follower floor is 1,000, not 25,000: the pin already carries the evidence, so its floor only catches a wiped or taken-over account (Grupo Frontera’s own @frontera_grupo has 2.9k). Coverage of the 135-artist watchlist after the pins: 124 tag. The other 11, after X user search, likely-handle probes, the tag search and Grok’s strongest model: no account exists or it is deactivated (Frank Ocean, Lana Del Rey, Ariana Grande – @ArianaGrande reads “user not found” –, Dominic Fike, Olivia Dean, Zach Bryan, Neton Vega, sombr – @sombr is a 2-follower account named “Serge” that Warner Records tags by mistake –); the evidence is too weak (Tito Double P – @titodoublep has 11 followers and a stop-sign display name –, MC Ryan SP – no verified account tags @McRyanSp2 –); and “Disney”, which is not an artist. The prompt asks Grok for its answer, not for its certainty: the first wording (“NONE if you are not certain”) returned NONE for Sia and the Michael Jackson estate over the 135-artist watchlist (2026-10-01), and the profile checks are what decide certainty. A Grok reply of HANDLE: @NONE is read as NONE (@NONE is a real, protected account). (2) twitterapi.io reads that account’s profile and verify_profile checks it deterministically: the handle matches, the account is not protected or automated, it carries a verification badge, it has at least X_ARTIST_TAG_MIN_FOLLOWERS followers (default 25,000), the artist name EQUALS the display name or the handle after normalize_name (case, accents, spaces and punctuation removed, $ read as s), and the bio does not say “fan account” / “parody” / “not affiliated”. The name check is what refuses a blue-check fan account: measured 2026-09-30, @TSwiftNZ (“Taylor Swift News”, 547k followers) fails it, while @whamcbfw4 (“Lil Baby”) passes on the display name and @Drake (“Drizzy”) on the handle. Both steps are needed: live, Grok named @alexwarren (unverified, 903 followers) for Alex Warren and the profile checks refused it; and the name check refuses some REAL accounts (Bad Bunny’s @sanbenito shows “Benito Antonio”), which is a missed tag, the accepted cost. Verdicts are cached in kv_cache (namespace x_artist_handle): a handle for 30 days, a profile refusal for 7, a Grok NONE for 2 (Grok varies run to run: Lil Baby got NONE once and @whamcbfw4 twice), a missing profile for 1 (twitterapi.io returns None for a dead handle and a network miss alike). Every reviewed handle also carries its permanent X account ID (x_artist_handles.REVIEWED_ACCOUNT_IDS), and every tag requires the live profile to have that ID (reason=id_mismatch otherwise): an ID survives a rename but does not move with a released or sold handle, which is the identity check a pin otherwise lacks (Codex review on #3775). A cached handle saves only the Grok call: the live profile is re-read and re-checked before EVERY tag, so an account that turns protected, renamed or taken over stops being tagged at once (Codex review on #3775). tag_text KEEPS the name and adds (@handle) right after its FIRST whole-word, CASE-SENSITIVE mention: “Espresso by Sabrina Carpenter (@SabrinaAnnLynn) has surpassed 3.2B Spotify streams.” (several names are common words, so “the future of pop” is never tagged). A possessive keeps its shape: “Linkin Park’s (@linkinpark) …”. Why the name stays (owner pick 2026-10-02, after a Grok x_search read and a web read on X ranking): X search and topic matching read the plain words, so the first version, which REPLACED the name with the handle, stopped the post matching a search for the artist, in exchange for a notification a big artist rarely acts on. Keeping the name also means a post that opens with the artist opens with a word, never a bare @ (which X treats like a reply), so the short-lived .@handle form (#3785) is gone. It never appends a name the take does not say and never touches a URL. The crosspost event carries count = the tags that landed in the post, because x_artist_tag ok=true only says a handle resolved. The tag goes in AFTER the dedup key and the media gate, so both still read the plain name, and a tag that would overflow the length limit is dropped. At most 2 artists per post. Every lane on a music surface is scanned (owner ask 2026-10-02, “make sure we tag on all lanes”): on x_artist_tags.SCAN_SURFACES (music, music_news, music_desk, music_alert, x_mentions), maybe_crosspost also tags any reviewed artist the finished post NAMES (names_in_text over x_artist_handles.REVIEWED_NAMES, case-sensitive, also the title-case and accent-free spellings), whether or not its lane passed one. Measured that morning: 11 of 15 posts naming a reviewed artist shipped untagged because their lane passed none (“Drake has 12 songs…”, “Timeless by The Weeknd and Playboi Carti…”, a featured “Eminem”). market_drop / market_alert are NOT scanned (they post sports and politics, where “Drake” can be Drake Maye), and reviewed names that are everyday words or common first names (Future, Queen, Sia, Pitbull; from tier 2 also Miguel, Dave, Journey, Muse, Oasis – “Luis Miguel” must not tag @Miguel) are never scanned, only tagged when a lane passes them. More generally, a ONE-WORD reviewed name right beside another capitalized word is part of a longer name and is not scanned (“Camilo Sesto”, “Collin Raye”, “Drake Maye”; Codex review on #3798), except beside a credit connector (“Drake Featuring Yebba”). The scan also matches the capitalized form of a catalog-lowercase name (“Aespa”, “Benny Blanco”). Tier 2 (2026-10-02, owner ask “dive deeper down the watched list … triple check with grok”): the top-300 watch tier added 225 artists (349 reviewed in all). Each passed THREE checks: Grok named the account, a second, independent Grok call confirmed it is the artist’s own account, and either every profile check passed (159, into VERIFIED_HANDLES) or the account failed only the badge / exact-name / follower check and 2+ verified accounts with 50k+ followers that are not fan or news pages tag it (66, into PINNED_HANDLES). The second Grok call refused Tyga’s @Tyga (which passed every profile check) and Phil Collins’s @PhilCollinsFeed; 21 more were left out for too few verified taggers. Tier 3 (2026-10-02, owner ask “keep expanding down the watchlist”): the next 300 names (the watch list at size 700, minus every name reviewed or tried before) added 241 more (590 reviewed in all): 144 into VERIFIED_HANDLES, 97 into PINNED_HANDLES. Same three checks. Eight pins came from a second tagger search (Top and Latest posts) for names the first search left short: Frank Sinatra, Sleep Token, Cazzu, Keane, PNL, Skillet, Hungria, Werenoi. Two candidates were dropped by hand: UZI (Grok named @LILUZIVERT, which is Lil Uzi Vert, not the Turkish rapper UZI) and Dire Straits (@direstraitshq, an “HQ” handle with non-music taggers). The scan skips the new names that are also common words or first names: Train, Disclosure, Disturbed, Bronco, AURORA, Zoé, Hungria. Tier 4 (2026-10-02, coordinator issue #3803): the next 280 names (minus every name reviewed or tried before) added 230 more (820 reviewed in all): 125 into VERIFIED_HANDLES, 105 into PINNED_HANDLES. Same three checks. Eleven pins came from the second tagger search (Top and Latest posts): Odetari, RAF Camora, Ghost, Foster The People, Royal Philharmonic Orchestra, London Symphony Orchestra, Elley Duhé, George Ezra, Dermot Kennedy, Jeremy Zucker, TV Girl. One candidate was dropped by hand: MC PH (@_mcph4m), whose two taggers are not music accounts. Mainstreet stayed out with one tagger. MØ was dropped too: normalize_name("MØ") is the one-letter key “m”, which verify_profile always refuses (Codex review on #3805). The scan skips the new names that are also common words, first names or short tokens with another meaning: Topic, Ghost, Logic, Mustard, Passenger, Flume, BONES, KISS, SEVENTEEN, WILLOW, Ciara, Daya, Emilia, MARINA, Santana, Jul, Nas, YG, NAV, TLC. Tier 5 (2026-10-02, coordinator issue #3803): the last 281 names of the frozen list added 153 more (971 reviewed in all, after the drops below): 49 into VERIFIED_HANDLES, 104 into PINNED_HANDLES. Same three checks; the coordinator session ran them and posted the kept rows on #3803. Sixteen pins came from the second tagger search (Top and Latest posts). 118 names were left out (no account, too few taggers, or a check refused them). Four kept pins were dropped in review because only one tagger was left once news, info and non-music accounts were removed: ELENA ROSE (@celebsinparis), Hombres G (@TMBinfo), Lenin Ramírez (@SinLinea_Mx, a news outlet) and Max McNown (Codex review on #3806). The same sweep over the tier 4 pins dropped L7NNON: one of its two taggers, @RaridadeL7, is its official fan account. A pin’s taggers must not be news (owner-coordinator rule, Codex review on #3806): a tagger counts only if it is not a news outlet, a music magazine or publication, a morning or news show, or an individual critic or journalist (billboard, pitchfork, stereogum, Pop Crave, GMA, India Today, Rolling Stone, Genius; a critic such as @sudhirsrinivasn). Broadcasters and radio stations (MTV, BBC Radio 1, HOT 97), streaming services, labels, artists, award bodies, venues and brands still count. Applied to every tier 4 and tier 5 pin, with the unsure bios read live, it dropped Mazzy Star, Jeff Buckley, Eurythmics, Capital Cities, Sai Abhyankkar and LATIN MAFIA (@ailoviutl is an event-info account). The coordinator’s check of tiers 2 and 3 dropped Mitski (@mitskileaks: of @pitchfork and @SIRIUSXM, only one counts). The same review widened _spellings for every reviewed name: a multi-word name also matches with its small words in lowercase (“Walk the Moon”, “Banda el Recodo”), a leading “Mc” or “Dj” token also matches in capitals (“MC Livinho”), and every form is compared in NFC, so a catalog name stored with a combining accent still matches the one-character “é” a post uses. It also adds an en or em dash as an ASCII hyphen (“Salim-Sulaiman”) and the name without the symbols at its ends when 3+ characters remain (“NSYNC”); “$” and paired quotes or brackets stay, and a dotted abbreviation keeps its last dot (“T.I” would match inside “T.I.P.”). The scan skips the new names that are also common words, places, first names or other tokens: fun., Survivor, Creed, Blur, Bleachers, No Doubt, Chicago, Kansas, Ashe, Vivek, Belinda, Shaan, Arko, Haricharan, Fergie, Boza, LMFAO. The skip for “fun.” matters most: without it, the scan matches the word “fun” at the end of a sentence. A name in a RELEASE-TITLE position is never tagged, by the scan or by an explicit lane (_is_title_position, Codex review on #3792): inside double quotes (Drake’s “Madonna”) or right before “by” (“Madonna by Drake”). The 2-per-post cap counts TAGS, not names tried, so an unreviewed composite credit early in the list cannot use up the budget; _MAX_LOOKUPS (4) bounds the work. Explicitly Wired callers: music (the artist of the drop’s Apple Music link, via apple_track_meta, passed as tag_artists_lookup so the iTunes read runs only once the post is really going to X), music_news and music_desk (the artist half of the "Title - Artist" subject, artist_from_subject; a BARE subject gives no artist, because it may be a title – a song called “Future” must not tag the rapper), and music_alert’s market lane (reading.subject). The kworb lane of music_alert is NOT wired: its subject is "Artist Title" with no separator, so the artist cannot be split out safely. A collab credit (“A & B”) is resolved as one string and so does not tag; that is deliberate. Rollout (X_ARTIST_TAGS): off does nothing; log resolves and emits x_artist_tag without changing the post; reviewed is the default (owner steer 2026-10-02, “enable the tags for the ones we verified are real”): only an artist with an owner-approved handle is tagged – the 24 PINNED_HANDLES plus the 100 in utils/x_artist_handles.py:VERIFIED_HANDLES (watched artists that passed every automatic check on 2026-10-01). A reviewed artist skips Grok, but the full live profile checks still run, and a cached answer never overrides a reviewed handle. Any other name emits reason=not_reviewed and is not looked up, so no Grok is spent on it; that event is the list of who to review next. on also tags new artists that pass the checks with no review. Cost: one Grok call (~$0.03) plus one twitterapi.io read per NEW artist per cache window. A one-word or everyday-word name found by the post scan is checked by Haiku (#4199). scan_names runs the deterministic scan (names_in_text, now with the _SCAN_SKIP names too) and keeps a multi-word name as is. A one-word or skip-list name is kept only when the lane passed it as its own artist, or when claude.is_music_artist_mention (Haiku, purpose=x_artist_mention, through the shared ClaudeClient.classify helper, cached one day per post and name) says the post means the artist. An error drops the name (prefer absent) and emits x_artist_tag reason=check_failed; a NO emits reason=not_artist. So “Queen of the night” never tags Queen, and “Future’s Mask Off” now tags Future, which the old skip list never did. The X mentions lane runs the same check on a one-word watchlist hit the mention classifier did not name (x_mentions.confirm_scanned). The position rules and the fan-bio phrases ask a model too (CB-27, 2026-10-18). Two scan rules used to drop a name with no model call: a name right before “by” and a capitalized word (_before_by, a title position) and a one-word name beside another capitalized word (_inside_longer_name). In 30 days of real posts they dropped 6 names, and 4 were the artist (“Four Drake songs”, “BTS World Tour”, “f/Don Toliver by Drake”, “f/Ava Max by BIGXTHAPLUG”). Now held_names returns those names and scan_names asks the same is_music_artist_mention with a hint (purpose=x_artist_position) that says where the name stands and carries the old cases and _CREDIT_WORDS as examples. Only YES keeps the name; NO or an error drops it (reason by_not_artist / longer_not_artist / by_check_failed / longer_check_failed). A confirmed “by” name is recorded for that post text, so tag_text and has_taggable_mention tag it there. A quoted title is still never tagged. The bio check is bio_refused: Haiku x_bio_owner (OWN / FAN / UNSURE, cached 7 days per handle and bio, the 12 _FAN_BIO phrases as examples) runs after verify_profile passes, on the Grok path and on the cached re-check. FAN refuses (fan_bio); OWN passes; UNSURE, an error or a timeout use the phrase rule.utils/x_crosspost.maybe_crosspost and crosspost_with_quote return a CrosspostResult: posted (X accepted the post), reason (the same word the crosspost event records: surface_off, dedup, same_song, over_length, unprovisioned, ungated_media, post_rejected, error, plus guild_locked and empty for the two exits that emit nothing), and tweet_id. Callers may ignore it. Before #3644 the helpers returned None, so on the X-only lane (X_ONLY_CHANNEL_ID) a caller could not tell a landed tweet from a dropped one, and market_alert (#3638) had to skip its shared post history for every X-only alert. Now market_alert._ship_card returns x_only only when X accepted the post and x_dropped otherwise. x_dropped still stamps the alert’s own state, so a dropped post does not re-fire every tick (a retry of a surface the X picker has off would only be skipped again), but it writes no shared post history and no RT forecast call. The other X-only callers (the wire desks, market_drop, the betting lanes) record their dedup keys BEFORE the crosspost and still ignore the result: making them wait for it would turn every dropped tweet into a retry. Measured 2026-10-17: zero delivered=x_only events in the 14 days before this change, so no live lane runs X-only today._dedup_key is scoped by surface and keys on the content link, so two surfaces with two links for one song both posted: the desk’s 500M card for Drake’s “Janice STFU” at 14:02:57 and the music drop at 14:03:06 on 2026-10-20. The drop caused the desk card. Music._sched_deliver runs _maybe_break_milestone before its own crosspost, and that runs the desk’s _sched_deliver directly, so the #4419 milestone claim (taken in _gate_and_deliver) never saw it. maybe_crosspost now logs each music-surface post that lands on X (surface, text, and the song the caller passes) under song_posts in the x_crosspost_dedup cache for 30 minutes. A post from a DIFFERENT music surface skips X with reason=same_song when its own song is named in a logged text, or a logged song is named in its text. “Named” = the title (accent-folded, then title_key) and the lead artist’s artist_key are whole words of the text. A title whose key is under 3 characters or all digits (“22”, “1989”) names nothing: that drop gets no same-song check, so it can repeat on X but never loses a post. Known limit: a common-word title of 3+ characters still matches a post about the same lead artist (“One” by Ed Sheeran matches “Ed Sheeran’s ‘Perfect’ becomes one of five songs…”); the result is one skipped X post, and it needs a drop plus another post about that act within 30 minutes. Only the music drop passes song (its TRACK: line, cogs/music.py:drop_song), so only pairs that include a drop match; desk vs alert vs newsroom stays with the #4419 claims. The room copy is not changed. Fail-open: a cache error posts. Measured: of the 16 drops that reached X in the 30 days to 2026-10-23, this pair was the only one where another music X post named the drop’s song within 12 hours. Open follow-up: the desk’s break path takes no #4419 claim (cogs/music_desk.py)._XMarketRoomsSelect, settings KV x_market_rooms). It lists the rooms set up on the market-topics page. maybe_crosspost takes the source room as channel_id; for a MARKET_ROOM_SURFACES surface (market_drop, market_alert) it skips with reason=room_off when the room is not ticked (is_market_room_on_x). Unset = every room mirrors, so a guild that never opens the picker sees no change. The X-only sentinel lane is never gated, because it runs only when no room exists. The per-surface picker still decides first. Retired Kalshi tags: a room’s stored Category:Tag whose tag Kalshi no longer lists is on no page of the step-3 subcategory picker, so it could not be unticked. _DropSubcategorySelect’s callback now drops any stored tag that is not in the live taxonomy on save, so the saved set is exactly the ticked boxes. A category narrowed ONLY to retired tags is removed, not turned into a bare whole-category entry: with nothing ticked, the bare entry would post every market in the category. The room summary flags such a tag as (gone from kalshi) (_mark_retired_tag).utils/x_gate.py, owner steer 2026-10-13: “we’re only focused on X … Discord volume is fine”). X went from about 80 posts a day in mid-September to 110-250 a day in October. Discord keeps every music post; the gate decides only the X copy, and runs in music_desk._sched_deliver, music_alert._fire_kworb_lane and music_news._sched_deliver just before the crosspost. Each lane turns its unit into an XFacts (_desk_x_facts, _lane_x_facts, _news_x_facts). The desk and the alert loop build theirs from structured fields. The newsroom’s stories are free text, so _news_x_facts hands a milestone or settled chart story over as CLASSIFY, and the gate has Haiku read it (ClaudeClient.classify_x_post, owner steer 2026-10-14: “replace the error prone checks with haiku”): streams / chart / other, milestone or not, the lead artist, the title, and the unit. The unit is song or album only when the post’s own words say so (unit_from, kept only when those words are in the story; Haiku guessed an album title in quote marks as a song 5 of 5 times), else “” and the gate reads both. A title with no unit is dated both song-first and album-first, and the OLDER date wins (#4171): album-first alone dated Nirvana’s 1992 “Come As You Are” by a 2026 album of that name, so its 2.2B milestone passed the “this year” rule and went to X on 2026-10-15. #4004 had fixed this and #4008 removed it. The call runs only after the home-guild and X-picker checks, so a post that will not go to X costs nothing, and a failed read is OTHER (fail-open). It replaced the subject split, the daily-count, monthly-listener and chart-unit regexes and the @PopStats02 source rule, which drew a new Codex finding on each round of #4004; on 273 real newsroom posts from 2026-10-06..13 it made the same X call as the regexes on every chart story. The rules: (1) a STREAMS post (a stream count, a streaming milestone, monthly listeners, a catalog count, and the copied @PopStats02 / @chartmastersorg posts; a daily count that does not say song or album is matched on song and album charts) goes to X only when the release is 14 days old or less, or the title is in a top 10 NOW on the Hot 100, the Billboard 200, Spotify Global or US daily, or Apple Music US songs or albums; an artist-level post needs the artist in one of those top 10s. The watchlist alone is not enough, with two exceptions for MILESTONES only (owner steer 2026-10-14): a release that came out this calendar year, and an artist in the top 10 of the watchlist (Watchlist.position, read through MusicDesk.recognition_lists). Daily stream counts (@PopStats02) get neither. A release the catalogs cannot date and no chart holds stays off X. (2) A CHART position below the top 10 stays off X; outside iTunes, #11-#20 still goes when the artist is a top-10 watchlist act or the release came out this calendar year (owner steer 2026-10-14). (3) iTunes genre #1s (Pop, Hip-Hop/Rap, R&B/Soul) all go to X; the R&B cut was dropped 2026-10-14 (owner). (4) A live Kalshi FORECAST (a desk sales projection or tracking card, a music_alert line move, last call or new release) goes to X the first time X gets a forecast of the release in ANY metric, and again only when it is 20% or more ABOVE the last value that went to X for the same release, week and metric (FORECAST_RISE). A drop never goes, and a metric with no X value yet on a release X already has does not go (#4683). Units, pure sales and streams of one album share one release code (release_pair_key, e.g. DOP26OCT22), so the gate keeps the value per <release>:<metric_slug> and one mark per <release> in kv_cache x_forecast, shared by the desk and the alert loop. A SONG market (KXSONG*) is its own release (song:<code>): a title track shares the album’s suffix (KXSONGSTREAMS-BAS26SEP24 beside KXPUREALBUMS-BAS26SEP24) but counts one song (Codex on #4694). Until #4683 the rule was a 10% move either way per metric, so each metric posted its own “first” card and a drift of 18K, 22K, 25K, 20K, 24K posted five times: in the week to 2026-10-23, 36 forecasts went to X, and a replay of the new rule on the same posts sends 6 (14 days: 69 by the old rule, 20 by the new). A NEW PROJECTED RANK does not re-open X yet: the reading carries no rank, so the callers must pass one first. Discord keeps its daily card. Boards, settled results, HITS projections and releases are not gated. A skip is a crosspost event with reason x_streams_not_new / x_chart_depth / x_forecast_flat (the x_gate panel on the X board). The chart reads go first because they are cached; only a non-charting title pays the release-date lookup, and only on the home guild (crossposts_media). Owner: no daily cap, so an important post is never held to the next day. A replay of 2026-10-06..13 (1049 room posts) put the cut at about 300 posts with the milestone exceptions, R&B #1s back and the top-10 chart rule, about 28% (150 -> ~107 X posts a day); iTunes top-10 moves are the largest share left.feed_photo). On a tour announcement that image is the ADMAT the publisher filed with the story, and an admat already prints the tour name, the act and every date. The card then drew all three again: the purple TOUR · ANNOUNCEMENT tag, “Do That Again Tour” as the headline, “Malcolm Todd” as the byline, the ✓ Pollstar stamp, and a bottom scrim that covered the date list the admat exists to show. market_cards.build_bare_art_card is the third card rung beside build_number_card and build_board_card: it takes her take and the picture, and draws nothing on the picture. market_chart.bound_bare_art only re-encodes the image to PNG and bounds its long side, with a byte budget so a busy photo admat cannot cross the 5MB limit that makes X reject an upload — it never crops and never stamps, because shipping the image as its publisher made it is the point. The rung is CANDIDATE-then-CHECK. Three cheap conditions pick the candidate: feed_photo art (the only rung that can say the picture belongs to THIS announcement — the catalog cover and the Deezer portrait that dress most tour cards are not the story’s own image at all), a TOUR, and no figure (a card that heroes a number of ours keeps its markup; a tour GROSS is the touring kind and never reaches is_tour). None of the three says the picture IS an admat, and that is what the check is for (Codex review on #3291). industry_feeds._first_image takes the enclosure or the first <img>, and a tour article leads as readily with a press portrait or a live shot; stripping the card off one of those costs the headline, the byline and the source stamp and returns nothing. So claude_client.image_is_tour_admat reads the image on HAIKU and answers ONE question: does the image itself list shows — dates, cities or venues — FOR THE ACT THE ANNOUNCEMENT NAMES. The act match is the second half and it was added for the same reason as the first: a feed article can lead with an OLDER or a merely related poster, and a judge given only the image accepts any schedule at all, so the rung would delete the card carrying the real announcement and ship an unrelated set of dates — the fabrication direction this repo refuses by default. The date list is the condition, not “does this look like a poster”: it is the one thing an admat has that a photo does not, it is exactly what the card’s scrim was covering, and a model can read it off the image instead of judging a style. The judge fails CLOSED, the opposite direction from the delivery-time media gates: doubt keeps the CARD, which is the shipped, branded behaviour. Measured live on every tour and festival image in the Pollstar feed of 2026-09-14, three samples each, all unanimous, with each image under its own announcement: the reported Malcolm Todd admat YES 3/3; the Stagecoach lineup poster YES 3/3; the Big Ears lineup poster YES 3/3 (it prints “APRIL 1-4 2027 KNOXVILLE” over a dense lineup our card would have covered); the Tim McGraw, Ed Sheeran and Oasis live shots NO 3/3; the Dave Chappelle tour graphic NO 3/3 (it names the act and the tour but lists no shows, so the card buries nothing). Four of those live-photo and no-date cases would have shipped unbranded under the unchecked gate. The MISMATCH pair was measured too and is what the act match buys: the Malcolm Todd admat under a Stagecoach announcement NO 3/3 (“not Riley Green”), and the Stagecoach poster under the Malcolm Todd announcement NO 3/3. Note that the context also CORRECTED a false refusal — without the announcement the judge read Big Ears as “lists artists but not individual show dates” and said NO 3/3. The cost is that the post ships UNBRANDED: no wordmark, no tag, no source stamp. desk_art carries bare as a TRI-STATE so that share is countable (see docs/OBSERVABILITY.md): true shipped bare, false was a candidate and kept its card, absent was never a candidate. It also carries delivered, because the card is built before _sched_deliver branches on the stage — read delivered == 'room' so a STAGING audition, which only reaches #bot-logs, does not count as an unbranded post. It records what SHIPPED, never what was attempted — the judge can say no, and download_image_bytes checks only the HTTP status and the body length, so a non-image body reaches the builder and comes back None. The take still names the source (“per Pollstar”). A middle option was NOT taken and is the one-line change if the owner wants it: keep the corner wordmark and drop only the headline block.✓ Kalshi stamp under that – over a photo of Jean Smart. The post around the picture already prints the eyebrow and the title in its heading, and Toots’ take states the odds, so the card said all of it a second time. Two pure functions own the rule, both in utils/market_cards.py. figure_is_number asks TWO things: does the hero carry a digit, and do its letters stay within _FIGURE_MAX_LETTERS (8). ships_bare_art requires four: real art, a non-music lane, a non-empty figure, and a hero that is not a number. Both tests are MEASURED, not invented. The digit test replaced a pattern that scripts/dryrun_number_cards.py broke on the live builders inside one run – the Billboard climb record heroes “+34”, the Netflix card “80.6M hrs”, the forecast ladder “~17M” and the companion projection “~61.4K”, each read as a word, and each would have lost the drawn card it should have kept. The LETTER CAP then came off the live hero distribution (market_card_hero, 888 cards, 400 distinct over 30 days), which splits cleanly at 8: at or under it every hero is a number wearing a unit (“27.9M hrs”, “14,806 units”, “957,000 copies”, “100 entries”, “13 concerts”), above it every hero is a phrase the caption says better (“1x Platinum”, “53 certifications”, “22 weeks at No. 1”, “Death of the Pastor’s Wife: Season 1”). Re-measure the cap rather than nudge it. MUSIC IS EXEMPT WHOLE (is_music_lane, reading the wordmark lane the card prints on itself, so the test cannot disagree with the card). The music lanes hero a number nearly always, and their few word heroes are facts the artwork does not carry: the chart-exit “OUT” and the RIAA level “Gold”, which the owner reads as a number card in its own right. Dropping the markup there would lose the story; dropping “Emmy Winner” off a photo of the person it names only removes a repeat of what the caption already says. An earlier revision of this rule stripped them and also split the RIAA lane in half – “8x Platinum” kept a card while “Gold” lost one – which is the incoherence the exemption removes. Both market surfaces brand a music chart the same way. is_music_chart_market used to live in cogs/market_drop, where only the drop could reach it, so a Billboard or Spotify chart market branded tootsies music from the drop and tootsies predictions from the alert – one market, two wordmarks. The bare-art rule reads the wordmark, so the alert’s copy was not recognised as a music card and a word hero on it would have been cut. The test now sits in market_cards beside is_music_lane, and market_alert stamps the brand on its base figure dict, so every figure shape it builds carries it (owner steer 2026-09-15, “just keep music”). The rule is wired at both funnels – build_market_card’s number rung (where the Emmy card is built) and build_number_card (the desk / newsroom / cinema lanes) – and each passes the SAME lane it hands the renderer, so the exemption and the wordmark always agree. Unlike the tour-admat rung above there is NO vision judge and no feed_photo condition: the test is what the card would DRAW, not what the picture happens to contain, so it needs no model. The post keeps its heading, its take and its market button – build_bare_art_card takes url + button_label so a card that drops its markup does not also drop the reader’s way through to the market. A build that returns None (bytes that do not decode) falls through to the full card. Measured reach: 92 of 912 heroed cards a month (10%) ship their picture BARE, all on the market surfaces (alert 48, predictions 26, drop 18) and NONE on a music lane – the awards, film, festival and TV markets whose hero is a nominee’s name, a show’s name, “Emmy Winner”, or a settled YES/NO/Tie. Every FIGURELESS card is untouched: that is the announcement shape, and music_news decides it per story with the judge above. Telemetry: market_image gains the bare_art value on image_source, and market_card_hero the bare_art value on rung, so the mix is queryable with no new field against the Axiom budget. The verify judge names the rung now (2026-09-18, #3416). verify_music_claim returns threshold_passed (the claim’s figure is a rung the count went past) and count (the research’s count of the same thing past it). _sched_compose opens the swap when EITHER the judge or figure_is_passed_threshold says rung, and BOUNDS the count the same way for both: count_past_threshold must accept it (same unit noun, larger than the rung), reading the judge’s count first and its figure second. music_news_verified detail.via says judge_count or regex_count when a hero was swapped.ships_bare_art takes two more conditions. (1) The art is not a mark (_LOGO_ART_SOURCES: team_logo, wiki_logo, and the matchup backdrop sports_api / sports_api_solo). (2) The card is not a sports card (build_market_card reads _is_sports_card; the result lane passes sports= for an “A vs B” title). The week to 2026-10-23 had 115 #sports posts. 34 of them were a bare mark: 19 blurred two-crest backdrops with no words on them, and 15 crests 150px to 500px wide. About 17 more were a bare photo, and several were wrong: the losing side’s players, a football photo on a basketball result, stock photos with an alamy or getty watermark. A team photo does not say which game it is or who won, so the drawn card stays. The Emmy rule above is unchanged for a photo of the subject.utils/music_news.py, five pure guards; the first four are each the deterministic backstop under a line in the classify_music_news prompt, the fifth has no prompt line because the prompt cannot place a word in the pill). figure_is_rank_word drops a ranking word with no number behind it (“most”, Cardi B’s Diamond-singles card, 2026-08-30, #lame-most). collapse_rank_run heroes a list of positions as the subject’s best CURRENT position (“#1, #2, #3” -> “#1”; owner steer 2026-10-19, “i want the current position … always”). It was the block name “Top 3” from Drake’s Billboard 200 lockout, 2026-09-04, and briefly the range “#1-#3”. A card names the POSITION, never a “Top N” band (owner steer 2026-10-18, the Taylor Swift “Top 2” card: “stop doing Top N, and just always name the position like #N”). The verify judge wrote “Top 2” for her place on Billboard’s most-weeks-at-No.-1 list because its prompt carried the example “#1, #2 and #3 is ‘Top 3’”; that sentence was cut from the judge and from classify_music_news (measured in docs/PROMPT_OPTIMIZATION.md). A bare band that still reaches the hero (“top 10”: the classifier’s threshold rule for “entered the top 10”, when no live chart read settles it) goes to MusicNews._name_top_figure: Haiku (ClaudeClient.name_chart_positions, claude_api purpose music_news_rank_position) reads the post and the verify research for the position, and one claim-scoped Perplexity search (pplx_music_news_rank) runs when they name none. The answer must sit inside the band and be written as a position in the text it came from (mn.position_grounded); mn.position_hero makes “#4”, the best position when the subject holds several. With no position the figure goes empty and the card falls to headline mode. A market story prices a result that has not happened, so its band clears with no model call and the card heroes the outcome (_market_card_copy). _build_card clears any bare band that reaches it. Telemetry: music_news_recover detail.via=rank_position. A RANKING names its STAT (owner steer 2026-10-24, the JENNIE card: “second longest-running active streak among female artists on the Global Spotify chart” over a “2nd” hero, while the verify note said “JENNIE at 1,113 days”; owner: “Always say the actual stat. Never top N, first, second, third.”). MusicNews._ranked_stat runs after the qualifier check on every story but a market, a release, a chart miss, an Apple catalog release and a refuted claim. A claim with a ranking word (mn.claim_may_rank) goes to Haiku. Whether a number already in the claim is the subject’s stat is Haiku’s call, because a claim can state another act’s amount (“behind Taylor Swift at 1,812 days”; Codex review on #4755). Haiku (ClaudeClient.name_ranked_stat) reads the post, the verify research, the X context and the verify note. An ordinal that COUNTS the subject’s items (“his 44th song past 100 million”) and a chart position are not rankings. When no text states the stat, one Perplexity search runs, limited to the last week (with no limit the dry run read a song’s day count as JENNIE’s streak). The stat’s number must be written in the text it came from, with its unit word within three words after it and before the next number (mn.stat_grounded, so “Hot 100” never grounds “101 weeks”). A found stat is added to the claim (mn.claim_with_stat: “… (1,113 days)”), so the compose writes it and the digit gate grounds it, and it heroes the card, a record card included (meta["ranked_stat"] turns the record layout off in _build_card); a live chart, ladder or stream reading still outranks it. A ranking with no stat ships nothing (music_news_filtered reason=ranked_no_stat), and a take that leaves the stat out ships nothing (music_news_scored reason=ranked_stat_missing). No verdict keeps the claim as written (fail-open). Measured 2026-10-24 on 400 real lines from the 7 days before: 132 carry a ranking word, the shipped prompt flags 18, 16 of them real rankings with no number. Telemetry: music_news_recover detail.via=ranked_stat, dashboard newsroom_ranked_stat. lead_figure keeps the FIRST of two or more magnitudes joined by a slash or a semicolon (“282k / 496k” -> “282k”: BTS’ four sold-out SoFi Stadium shows, 2026-09-08, owner report “makes no sense and card number is wrong”). The wire stated a four-show count AND an eight-show total, the classifier wrote both into figure with a slash, and a slash between two numbers reads as a fraction on the card. The first side is the number the claim leads with and the take is told to quote, so the card and the prose agree; a side that is a word, a list of chart positions (left to collapse_rank_run), a range (“120-130k”), a comma inside a number (“282,000”) a composite measurement joined by “and” or a comma (“1 year and 6 months”) and a ratio of two small bare integers (“3/3 albums”; both from the Codex review on #3099) are never split. A RUN LENGTH inside a chart block heroes the LENGTH and the block goes to the PILL (split_figure_block, Alex Warren’s “Ordinary” Global 200 card, 2026-09-17, owner report “only one person, one number should show here … have the top 10 be in the pill”): the wire (@talkofthecharts) stated the run and the block it held, the classifier wrote figure “62 weeks top 10”, and the card heroed all of it under the plain “BILLBOARD GLOBAL 200 MILESTONE” kicker. The splitter reads a DURATION (a number in days, weeks, months or years, with up to two qualifier words: “100 consecutive weeks”) followed by a chart POSITION or BLOCK (“top 10”, “#1”, “No. 1”), with or without a preposition between them, and returns the duration as the hero and the block normalised (“top 10” / “#1”). The card then reads “BILLBOARD GLOBAL 200 TOP 10” over “62 weeks”: the block outranks the kind noun and the metric tag in the pill because it is the outcome the hero measures, and with no chart credited the block stands alone. Nothing is dropped and nothing is invented; the two halves the post stated each draw in the slot built for it. A bare duration, a bare block, a count of songs in a block (“5 songs top 10”) and a duration followed by any other words (“62 weeks on the chart”) stay whole with no block. _sched_compose splits the FINAL card_figure before the fit test and carries the block on meta["figure_block"]; _build_card re-runs the split as the renderer-side backstop and falls to the meta’s block when the figure arrives already split; scripts/dryrun_music_news.py mirrors both. All of them run in _sched_compose where card_figure is settled (so the music_news_verified event reports the hero the card draws), again in _build_card as the renderer-side backstop, and in scripts/dryrun_music_news.py. A THRESHOLD the wire states is a rung the count passed, not the count (figure_is_passed_threshold + count_past_threshold, Rod Wave’s Hot 100 card, 2026-09-09, owner report “Should be 107”): the wire read “16 songs from ‘Don’t Look Down’ chart on the Hot 100, surpasses 100 career entries”, the classifier quoted “100 entries” as the figure (its prompt says to quote the post’s number), the verify judge confirmed the story and returned “16 songs, 107 career entries”, the take quoted 107 off the research, and the card heroed 100. The media gate read the disagreement and kept the image by design (a copy verdict presumes the card is the correct half; here it was not). The rule is a fourth pair: the first helper reads the WIRE TEXT (the classifier’s claim plus the post) and says whether the figure’s number sits behind a passing verb or a floor phrase there (“surpasses”, “passes”, “crosses”, “exceeds”, “tops”, “over”, “more than”, “past”, “at least”) or carries a “+”; the bare adjective “top” is not a cue, so “top 10” stays a block. The second picks the count out of the judge’s figure: the segment that shares the wire figure’s unit word (“entries”) and is LARGER than the rung; a figure with no unit word (“100M”) takes a single-segment answer only; anything else returns “” and the rung stays, because the rung is at least true. It runs only on a VERIFIED verdict and only when the wire figure is a rung, so a number the post states as a count still wins, exactly as before. The swapped count drops its decimal at 10 or more of its unit (numfmt.whole_hero, FATHER’s Spotify card, 2026-10-10, owner report “Didn’t need decimals”): the judge counted “100.8M” past the wire’s 100M rung and the card drew “100.8M”. The hero now reads “100M”. The rule rounds DOWN, so the hero never states more than the count; under 10 of the unit (“1.5B”) the decimal stays. It runs on every non-market hero in _sched_compose, after the unit split; a market keeps its strike exact. The take’s evidence still carries the judge’s raw figure, so a take that quotes “100.8 million” stays grounded. Settled in _sched_compose and mirrored in scripts/dryrun_music_news.py; _build_card cannot backstop it (it has no research). The prompt line was REWORDED, not extended: “the number the story is ABOUT” became “ONE number … the headline figure the claim leads with … never two totals side by side” (docs/PROMPT_OPTIMIZATION.md). The CAPTION is one fact too (lead_clause, the same BTS card, owner report “why is the sub so long”): the caption is the classifier’s claim, the claim rule quoted the post’s exact figure, so a wire with two totals gave a two-clause claim that the renderer’s two-line cap ellipsized. The claim rule now says ONE fact (“a second total the post also states stays out of it”), and until the caption went (2026-09-10, below) _build_card passed lead_clause(claim, figure) as the card’s sub: on a STAT card (a figure heroes), the clause before the first semicolon when the text after it carries a second TOTAL (a k/M/B or million/billion/thousand scale, a currency sign, or a thousands comma), else the claim whole, so a date, a week count or a show count after the semicolon never cuts (“34-date APOP World Tour; opens Oct 12 in Paris” stays whole even under a “34 dates” hero), and a tail that QUALIFIES the hero (“10 albums; each sold 1 million copies”, opening with each/every/all/both/including/of which/averaging, or “with” followed by a number or a distributive word; “per” and a bare “with figure_is_phrase, Rihanna’s Songs of the Summer card, 2026-09-09): the wire (@chartdata) stated the record with the count in a second sentence (“Ten of her songs have made the season-end lists”), the classifier wrote figure “10 Songs of the Summer top 10s” (30 characters; its prompt said a count takes the noun for the thing it counts, and the model put the whole chart name into the noun), figure_is_rank_word passed it (“top” is followed by a digit), the digit test passed it, and the renderer shrank it to its floor, could not fit it, and ellipsized: the card shipped heroing “10 Songs of the Summer to…”. The verify judge had returned “10 top 10s” for the same claim; it never reached the card because the research fallback fires only on an EMPTY or ranking-word wire figure. The rule is the hero’s SHAPE: a figure longer than _FIGURE_MAX_CHARS (24) is a phrase, not a hero – the verify judge’s own prompt already bounds its answer at “about 25”, and this is the same bound under both models. It was first measured on the renderer (2026-09-10, the 60pt floor of the day: 23 characters set whole, 25 were cut); #3131 then moved the figure’s last-resort ellipsis down to 44pt, where about 32 characters fit, so the bound is no longer where the ellipsis lands but where the figure has shrunk to the small end of its range and stopped reading as a hero. _sched_compose runs the two list guards on the wire figure FIRST (so a long run of positions is judged by its best position), then treats a phrase exactly like a ranking word: the VERIFIED research’s count heroes when it carries a digit and fits, else the figure goes empty and the card falls to headline mode. _build_card drops a phrase as the renderer-side backstop, and scripts/dryrun_music_news.py mirrors both. The classifier’s prompt line was extended in the sentence that caused it (“the noun is the UNIT, a word or two … under about 24 characters … a chart’s name is not the unit, it is the source”), dry-run on the real wire n=5 each: before 5/5 wrote the phrase, after 5/5 wrote “10 top 10s”; a tighter wording that only named the chart-name rule (no character bound) still wrote the phrase 5/5, so the bound is the part that lands. The verify judge’s prompt already carried the same bound (“keep it under about 25”). Four refinements from the Codex review on #3135: the guard measures the figure as the RENDERER draws it (abbrev_grouped first, so “1,234,567 Spotify streams” is not a phrase); the researched figure gets the two list guards before the fit test (a judge’s “#1, … #7” heroes “#1”); a phrase that is also a passed RUNG keeps the rung’s unit-matched replacement (count_past_threshold), never whichever short number the judge also stated, and goes empty with no unit match; and the FINAL card_figure is fit-tested after every rung has settled it, so a phrase-length threshold replacement or live reading is cleared where the hero is settled and the music_news_verified event, the meta and the card agree. The 30-day market_card_hero sweep found one sibling on this surface, “35 wins, 99 nominations” (23 characters, 2026-09-04), which the renderer set whole at its floor; it is two counts joined by a comma, which lead_figure deliberately does not split, and it is left as is. A COUNT about an ACT draws the BOARD, not the count (_chart_count_card + chart_boards.artist_count_board, owner report 2026-09-13, #3224). A wire states “Lil Durk has 6 titles inside the top 100 on the US Apple Music albums chart”, the settler reads the live chart and rewrites the claim, and the card heroed the settled “6” over a Lil Durk photo with no titles under it. The count says nothing about WHICH titles are on the chart. resolve_artist_count already keeps the (position, title) pairs it counted, so _sched_compose stamps that reading on the unit and the card draws those rows: the take and the board come from ONE count and cannot disagree (this is why it needs no counts-agree gate, unlike the certification sweep on line 95). The value column is blank – an entry carries a position and a title and nothing else – EXCEPT on a Billboard 200 count, where each row carries its HITS units (owner report 2026-10-18, “wheres the stream”, on a Drake board of three albums with an empty column; the owner picked total units over streaming units or Spotify plays). _count_entry_units reads the printed week and the HITS building document for THAT week, and chart_boards.count_entry_units joins them with the top_ten_sales join and its week fence. The current building document is usually the NEXT week’s (on the day of the report the printed chart was dated Oct 17 and the building document Oct 24), so on a mismatch the cog reads the dated documents (HitsClient.projection_docs) and keeps the one for the printed week. A row HITS has no figure for stays blank, the pill reads “Billboard · HITS” when any figure draws, and any miss leaves the column blank as before. Lifetime Spotify totals now lead that column, on every chart (owner report 2026-10-29, “fix streaming”, owner pick the same day). HITS publishes its Top 50 only, so Drake’s 11-album Billboard 200 board drew a figure on 4 rows and blanks on every album below about #50; no units source covers those rows. MusicNews._count_entry_totals resolves each drawn row through utils.stream_totals.StreamTotals (the resolver the desk and X-mentions artist boards use), and artist_count_board(totals=...) draws them under a “total streams” header with the pill “Billboard · Spotify”. A board draws ONE measure: when any drawn row has a total, HITS units are not read and never share the column. HITS units remain the fallback only when no total resolves (an act kworb has no id for). The dry run on the reported board filled all 11 rows (Views 13B, Habibti 416.7M). A COLLABORATION counts as the act’s title (owner rule 2026-09-13, “we never do solo only”): the count matched the credit EXACTLY and dropped every joint one, so on the live charts that day it said 6 of Lil Durk’s 7 albums in the Apple albums top 100 and 18 of Jhene Aiko’s 19 songs – both cards short by the row a reader sees on the page. It now reads _artist_agrees, the credit rule resolve_chart_position in the same module already used, so one module holds one answer to “is this row theirs”; the subset rule still refuses an act who is not credited. The joint row prints NO credit beside its title, per the 2026-09-10 steer that governs every act board. A FEATURE counts too, and that half is bigger (owner rule the same day, “features and collabs always count”): the streaming charts name a feature in the TITLE and credit the row to the lead alone, so a guest spot was invisible to EVERY lane at once – Kendrick Lamar sat at #3 on “So Good (feat. Kendrick Lamar)” and neither the newsroom count nor his own act board carried it. A row’s identity is its credit AND its title, so artist_watch.row_credit is the ONE definition of the whole credit a row carries (credit_matches/_artist_agrees both take a credit string, and feeding both the whole credit fixes both without merging two matching rules that exist for different reasons). row_is_theirs is the listing rule; every counting/listing lane reads it – chart_boards.watch_artist_rows (so every act board), the newsroom’s count and position resolvers, artist_watch.standing_block, the X-mentions units and scripts/artist_sweep.py. It says who is ON the record, never whose record it is: attribution stays the row’s own credit line, so a single-entry card about a guest spot still names the lead act (the #1911 misattribution class). RIAA already counted features and is untouched. Two skips keep the number card: the card’s settled hero is no longer that count (other_figure, a market story heroes its own leg), or the count is one title (under_floor, MIN_COUNT_ROWS). Telemetry: the count_board event. A CATALOG streaming milestone gets the same treatment (_catalog_board_card + chart_boards.catalog_rung_board, #3225). “Drake has 30 songs past 1 billion Spotify streams” heroed a bare 30; #3131 made that card say WHAT it counts but never WHICH songs. #3225 first shipped the board as a SECOND image beside the number card (owner steer 2026-09-13, “can we make the board additional”). On 2026-10-04 the owner reversed that on the Billie Eilish “13 songs past 1.3 billion” tweet: “should’ve gone with right”. The tweet text already states the rung and the count, and the board states the count in its caption (“top 11 of 13 shown”), so the number card only repeated the “13”. The board’s block names the metric only (“SPOTIFY STREAMS”), never the rung (owner, same day: “the block shouldn’t say 1.3 billion”). The board now REPLACES the number card, the same shape as count_board; a skip (under_floor) or an error ships the number card. It builds through build_board_card with no art, so it gets the board bloom like every other board. StreamReading.entries carries the titles the resolver already selected, so it costs no read; the value column is each title’s lifetime total, and kworb’s feature “” is stripped for display by catalog_stats.clean_title. Scoped to the CATALOG shape – a track or career reading is one number with no list. Telemetry: the catalog_board event. A CAREER chart-ENTRY count is the third of these, and its board also REPLACES the number card (_live_entry_history + _entries_board_card + chart_boards.career_entries_board, owner report 2026-09-15, #3331). #3331 first shipped the board as a second image beside the number card. On 2026-10-20 the owner reposted “Gunna becomes the 25th artist to chart 100 Billboard Hot 100 hits” to X with the board alone (“didn’t need the card”): the take states the milestone, so the “25th artist” card only repeated it. So no newsroom post carries two images now. “LISA ties JENNIE with 7 Billboard Hot 100 entries” heroed a bare “7” with none of the seven titles on it: the newsroom had NO reading for this claim shape at all – _live_artist_count settles a top-N window off the live chart, and a career total is not on that page – so the take shipped on the wire’s word and the card had no list to draw. music_news.entry_count_claim scopes it to a claim naming ONE Billboard chart whose per-artist chart-history page we read (billboard.CHART_HISTORY_CODES, the two flagships) and stating a count; every other story pays nothing. The wrong-artist guard is the COUNT (resolve_entry_history): every other consumer of a chart-history page checks that the reported TITLE is among the entries, and a career count names no title, so the page must hold exactly as many entries as the claim states – a page that counts differently is either another act’s or behind the chart, and both draw nothing. The board ranks on the PEAK and carries the chart-DEBUT YEAR as its value column, both read off the page, and the BLOCK names the column (“BILLBOARD HOT 100 PEAKS”) because the card draws no descriptive caption – without it a bare “#68” reads as this week’s position on a card about a whole career. The reading it depends on had a silent bug of its own (same PR): billboard.artist_slug GUESSES the artist page from the credit, and the guess does not merely 404, it lands on ANOTHER ACT – LISA is filed as lisa-of-blackpink, so “lisa” returned a 1980s act’s three Hot 100 entries from 1982-84 and the DEBUT guard (line 87 of docs/OBSERVABILITY.md) had been reading that page as hers. BillboardRow.history_slug now carries the slug Billboard prints in the row’s own “Chart History” link and BillboardClient._history_slug reads it out of the cached week, so the guess is the fallback rather than the rule. The row’s cut lead (_lead_credit, cut at the first separator) matches only when the row’s slug is not the slug of a LONGER part of the credit (_slug_is_the_lead, #3451): on the live Billboard 200 of 2026-10-15 “Earth, Wind & Fire” links earth-wind-fire, so a solo act named Earth no longer takes the band’s page, while “Bob Seger & The Silver Bullet Band” links bob-seger and still answers to Bob Seger; a joint credit yields no slug (its bare artist links are not in credit order) and an act on no week we hold still falls back. The board draws the story’s own ART (owner steer 2026-09-15, “show with art”): the cover or photo _build_card already resolved, so the board costs no second art lookup, and _entries_board_card forwards that art’s art_source to the crosspost media gate. It builds through market_cards.build_board_card, the ONE definition of what a board looks like (owner report 2026-09-15, “that’s not how we normally do it”): that builder pins art_anchor="corner" and the board bloom, so the art bleeds from the bottom-right and closes the list off. Calling chart_cards.render_board_portrait directly took its default art_anchor="top" – the HEAD BAND, the picture where the type starts – and the one board then looked like no other board we post. A second caller of the low-level renderer is how a look drifts, so there is one caller of the look. The catalog board now builds through it too (2026-10-04). Telemetry: the entries_board event + billboard_artist_history detail.slug_source. The classifier reads the entry count first (2026-09-18, #3415). entry_count on the milestone / chart kinds, and the chart off chart_key (#3414), are read by entry_count_claim ahead of its regexes; a chart with no history page and a top-N window are still refused.track, so @tootsiesbar posted “Jay-Z’s “Blueprint” has surpassed 600M Spotify streams”; Céline Dion’s album “Falling Into You” at 1.6B shipped the same way in the same slot (the song has ~32M). The fix has five parts. (1) classify_music_news has a fourth stream_shape, album, keeps the word album in the claim, and is told a song that shares its album’s name is a track. The classifier DECIDES: a word-matching backstop that re-read track replies as albums was tried in review and dropped, because every review round found a new title collision in it. (2) mn.album_stream_story accepts the album shape on a ‘Title - Artist’ subject unless the post calls the title itself a song (_calls_title_a_song: “Lover, a track from the album Lover”, “the song Lover”); a vetoed shape, or an album shape on a bare-artist subject, DROPS the story as music_news_filtered reason=album_shape_conflict, because the classifier wrote “album” into the claim and the claim cannot be trusted either. (3) An album story skips every SONG lookup (stream_claim returns None, _songstats_verify spends nothing, no song rank strip), keys its dedup as milestone_story_key(..., album=True) (an |album suffix, so its same-named title track keeps its own key), and asks the catalog for the ALBUM row first for its art. (4) The compose reads the kind as album milestone; mn.mark_album puts “album” after the title when the take does not attach it to the title (dry run: half the takes dropped it), else the take is dropped as music_news_scored reason=album_unmarked. (5) The card block reads “SPOTIFY ALBUM STREAMS” (unit_label(album=True)). Follow-up not built: kworb’s albums page (utils/album_milestones.py) could settle an album claim first-party.utils/dedup.py, the post dedup gate every wire lane shares (ScheduledPoster._gate_and_deliver -> arbitrated_match; the judge-only lanes music_alert, market_alert and x_mentions pass their history through judge_evidence before topic_duplicate: empty when nothing is comparable, else the whole history with the comparable posts first). The signals: same_link and text_similarity always run; shared_run, content_overlap and same_chart_position are the SHAPE signals (FORMAT_SIGNALS), which the wording-only chart-card lanes switch off. A chart-position claim is title-aware (#3912). chart_claim reads a line as a claim when it states a rank or a debut, names a chart music_news.charts_named reads, names a title (in double quotes, or the house form “Title by Artist”), and is not a stream count. For such a line, the wording signals and the judge’s catch direction compare only the recent posts that name the same title on the same chart, in the same chart week when both state one (comparable_topics). A post that names the title but no chart stays comparable, because its chart is unknown, not different. A post is read for EVERY chart it names (charts_named per clause, since charts_named itself is first-match and the platform rows come first), so “No. 1 on iTunes and the Hot 100” counts as a Hot 100 post; a bare “per Billboard” clause adds no chart. A feature credit (ft., feat., featuring, or with in brackets) and an edition tag ((Deluxe), [Expanded Edition]) are not part of the title. same_link still compares against every post. When nothing is comparable, the judge is not asked to catch; when something is, those posts go to the judge first, after the matched post, and no post is removed from its evidence. same_chart_position drops a claim only when a comparable post states EVERY (chart, rank) pair the claim states (a subset, not any shared pair), so a past rank the regex does not know (“drops to No. 3 a week after reaching No. 1”) cannot make a fall read as a repeat. Both posts are read by ONE function (_post_ranks), so they are read alike. Past ranks are removed first (“up/down from No. X”, “from No. 9 to No. 8”, “No. 3 to No. 1”, “#3 → #1”, “peaked at No. 2”, “after No. X”, “after … at No. X”); “After the VMAs it hits No. 1” keeps its No. 1. Each remaining rank belongs to the chart named in its own clause, else the nearest chart before it (or the first after it). A rankless clause never borrows the rank before it (owner decision): “No. 1 on Spotify and makes the Hot 100” states no Hot 100 rank, and no verb list can tell it apart from “No. 1 on Spotify and the Hot 100”, so the second form gives the Hot 100 no rank either. Known limit: in an earlier post naming two songs, a rank is not tied to the title next to it (““Other” is No. 1 …, with “Song” at No. 4” also reads as ““Song” at No. 1”); that only makes the gate catch more. A post naming no chart keeps its ranks as chart-unknown, which match any chart. “atop” / “tops” state No. 1. A chart charts_named cannot read (Shazam) leaves its rank chart-unknown, so that line keeps the stricter gate. A line the reader cannot parse keeps the full gate. Why: on 2026-10-05 the Billboard story ““Patient Zero” … is at No. 1 on the Billboard Hot 100” was dropped as content_overlap against an x_mentions post about “Patient Zero” on Spotify’s US Daily chart, with a text_similarity hit on a different title’s Hot 100 post (Showgirl) behind it. Five versions died, and music_desk dropped the same No. 1 debut twice the next morning the same way. The 30-day replay: 856 chart-claim drops (179 stories) had no earlier post naming the title; all pass now. Of 2,368 drops that did have one, 2,225 still drop and 143 pass the wording signals (mostly a new measure of the same title, such as a forecast and then a chart rank). Known gap: “dated Oct. 11” and “week ending Oct. 4” can name the same Billboard chart week, and the week check reads them as different.event_poster.py + utils/event_trigger.py, the EventPoster spine + the off-slot BREAKING lane (#news-breaking-speed, the CADENCE half of #1742): the wire desks’ breaking lane was already fresh-first (utils.wire_lanes.order_for_breaking), but it only ran when a calendar slot fired, so measured break latency sat at a 37–43 min median per desk (p90 ~55 min, 7 days of prod) against the owner’s 5–15 min target. EventPoster (cogs/event_poster.py) is the TRIGGER-driven sibling of ScheduledPoster, and the two differ in exactly ONE step — an inversion of control: a SCHEDULED post decides BEFORE it looks at the material (the clock says go, then find the best thing to say), an EVENT post lets the material decide (look at what exists, a bar says whether anything is worth saying). That is why their state cannot merge either: a clock trigger must know how many slots it OWES, an event trigger must know when it LAST FIRED and how often today. Everything else is shared BY CONSTRUCTION — the standing gates (ScheduledPoster._surface_gate: kill switch + mood + experiment/provisioning), the room lookup (_surface_channels, incl. the X-only sentinel), and the whole dedup/deliver/history tail (_process_unit) — so a trigger-driven lane cannot drift into honouring fewer gates than a slot does. Identity is the one thing NOT duck-typed: pick_by_velocity takes a key callable rather than reading .dedup_key, because the three wire desks carry that attribute and the music newsroom’s LaneCandidate does not (its identity is the source post id) — reading identity off an attribute would have made the spine unusable on that desk. A single Poster with a pluggable trigger (SlotTrigger / VelocityTrigger) is the honest END state, deliberately NOT built yet: ScheduledPoster serves ~8 live surfaces, and two triggers do not earn a registry — a third would. It is a mixin over ScheduledPoster (pop_desk / sports_desk / cinema_news, and since #3824 music_news for its NUMBER AUTHORITIES only – with its own poll cadence, no velocity trigger, no gap and no quiet hours, set by the EVENT_* class settings; see the X WATCHER entry. Its culture wires stay off the fast path: those stories need a paid classify per candidate and the pop desk already carries the music CULTURE wires): between slots each desk polls the HEAD of its wire list (top BREAKING_SOURCE_ACCOUNTS=3 handles — the lists are priority-ordered and the top handles carry nearly every genuine break) on an ALIGNED 15-minute cadence (poll_bucket: wall-clock :00/:15/:30/:45, half the calendar’s 30-min slot grid, so the two paths INTERLEAVE and something can post every quarter hour; at :00/:30 both run in one tick and the slot goes FIRST, so its post is in history before the poll’s dedup reads it — bucketing by wall clock rather than by elapsed time also stops the cadence drifting off the grid and self-corrects after a late tick), and when a moment clears the pure trigger — parseable timestamp, age ≤ BREAKING_MAX_AGE_MINUTES=60, engagement VELOCITY (score/minute of age) ≥ BREAKING_MIN_VELOCITY=100 — it composes + ships IMMEDIATELY through the desk’s NORMAL breaking pipeline: the same _compose_one(lane="breaking") (news-age gate, grounding, self-gate) and the same _process_unit (the per-unit dedup/deliver walk, factored out of the slot loop for exactly this reuse), so the fast path changes WHEN a break ships, never WHAT ships. Velocity is the right TRIGGER here even though it was rejected as a lane RANKING (ranking mid-window stories needed ~2h of accrual; a yes/no “is this minutes-old post already moving” reads clean at 10 min — a real break on a top wire runs 200–500+/min, routine posts idle at ~30–60). Guardrails, all BEFORE any credits: the slot gates (kill switch / mood / experiment / provisioning), the ONE /tune “breaking posts” knob (breaking_gap_min: the minimum GAP between two breaking posts on a desk, default 30 min, 0 disables the lane — its only off switch). It replaced a daily CAP + cooldown pair, and the reasoning generalizes: a cap answers “how many today”, a number nobody can pick honestly because the honest answer depends on how much news broke; a GAP answers “how often at most”, the same question the /menu calendar already asks of every other posting surface, so a mod reads it without learning a new idea. Dropping the cap also removed the class’s one durability problem — a cap must survive a redeploy to mean anything (a reset count re-opens the whole day’s budget, and Railway redeploys on every push to main), while a reset gap costs at most ONE post shipped early, which the next record re-imposes. So the pacer stays in memory with no table, no migration, no cleanup. Sized against measured volume: pop posts ~15 breaking takes/day over a ~16h span (one per ~64 min), cinema ~13, sports ~6 — so 60 min reproduces today’s volume almost exactly and the 30-min default leaves headroom for a busy news day, with the velocity floor keeping a quiet day quiet. The gap is a CEILING, not a target, and within_calendar_span (fast posts only between the day’s first and last slot + 30 min grace — the calendar stays the owner’s quiet-hours contract). A compose DECLINE is remembered in-process so the next poll doesn’t re-pay for it; the durable per-moment dedup means a fast-shipped story is simply seen to the slot lanes. The WINDOW and the FLOOR do two different jobs on purpose: the window says how stale a break may be, the floor says whether it is a break at all. 60 min matches the slot lane’s own breaking window, so no story CLASS is lost when breaking moves off the slot — and at a 15-min cadence a story inside it gets FOUR looks, so a break that builds speed slowly can still qualify. The first cut was 20 min and was wrong: it gave a slow-building break ONE look, survivable only while the slot’s breaking lane still ran as a backstop. Cost: 3 handles × 3 desks every 15 min ≈ 576 twitterapi.io requests/day over the calendar span, a THIRD of the 5-min cadence — and that saving is what pays for widening the handle list, which is how the 60-min window keeps today’s breaking VOLUME (baseline ~4.4k/day, limiter capacity 14.4k/day); the fetch publishes via wire_sources.remember so discourse’s union rides it free. Telemetry rides existing events — wire_read category=breaking for the poll, the desk’s *_posted with lane=breaking + a folded fast=true + source_age_seconds for a ship — so the ops monitor’s Break latency table and breaking_lane_stale finding measure it unchanged (and double as its silent-death watchdog: a dead fast path drifts the p50 back to the ~40m slot baseline). Pure trigger/pacer/span in utils/event_trigger.py (tests/test_event_trigger.py); the wiring pinned by tests/test_event_poster.py.commentator.py, the live sports commentator loop: Toots calls live games (World Cup soccer + NBA) in a dedicated sports channel (configured on /menu page 1, beside music channels). Pure orchestration over the already-tested sportsdata backbone — bot.sports_hub.live_games() (all providers MERGED, deduped by matchup, #435 breadth: API-Sports covers football+basketball, SGO covers everything else it carries - NFL/MLB/NHL/UFC/tennis - so the loop calls every major sport SGO scores, not just soccer+NBA; the first provider per matchup wins so a game both carry keeps API-Sports’ richer-depth row; PLUS the free ESPN provider (#1113/#3569) for every team sport. The metered TheOddsApiScoreProvider (#725) is OUT of the chain since #3552: its sgo.degraded gate stayed open for the weeks SGO was quota-dead (#3229), and its 24/7 /scores reads spent the month’s credits by the 20th; ESPN covers the same live + finals reads for free, so the class stays in utils/sportsdata/providers.py and is wired nowhere) for “what’s live”, utils.sportsdata.cadence.decide_post for the per-game “post now? on what trigger?” decision (goals, quarter/period boundaries, halftimes, finals, a ~10-min heartbeat tighter in clutch time — the heartbeat + clutch intervals are mod-tunable per guild via /menu’s tune page, resolved by tunables.commentary_cadence into the per-guild CadenceConfig), and claude_client.commentate (SONNET on every phase — quality over cost, a cheap-model-on-heartbeat routing was tried and pulled; the LIVE DATA is always injected, never from a tool — see the narrow slow-trigger background tool below) for the voice, with a per-phase format: a pregame rundown / goal reaction / a calm interval “where it stands” read (NOT a goal-style reaction) / a final recap beat, length scaled to the moment. Box-score depth (#432): NBA has no per-play events, so bot.sports_hub.game_leaders(game) pulls the live box-score leaders (API-Sports /games/statistics/players, top scorers parsed to PlayerLine by _basketball_leaders, rendered by format_leaders into a WHO’S COOKING block) and feeds them to commentate (stats_blob) so she calls a standout out by name (“brunson’s got 24, wemby near a double-double”) instead of just the scoreline; grounded (names/numbers given only, never invented) and fail-open (a stats miss → scoreline as before). Soccer gets the same WHO’S COOKING depth (game_leaders is now per-sport, not basketball-only): _football_leaders parses API-Sports /fixtures/players (goals/assists/shots/key-passes/rating, scored by goal-involvement-then-rating to the top few) so she calls a soccer standout by their real line (“wirtz with 2 goals and an 8.4 rating”), beyond just the goal-event scorer name. The commentate prompt leads with WHO’S COOKING (standout player + numbers) AHEAD of any market/odds talk (players + action are the story, the line is secondary color), and per-trigger length is a ceiling not a target (one sharp line is fine; never pad to reach 2-3 sentences). Odds-aware (#476): a matched betting line — sportsbook moneyline (utils.the_odds_api) + Kalshi/Polymarket implied % (bot.markets), matched to the live game by team name (utils.sportsdata.format.match_event_odds/match_prediction_snapshot) and rendered by format_commentary_odds — is injected on every trigger EXCEPT the goal (#1140: a score change lags the market, so the odds/market block is withheld from the goal compose ENTIRELY via _ODDS_SUPPRESSED_TRIGGERS — nothing for her to invent/misread from, and the #507 no-line forbid rule then fires; _odds_blob is still computed so odds_health telemetry is unaffected, only the text handed to the model + self-gate is withheld. The prompt already said the line “never appears on a goal”, but prod showed her inventing a Polymarket flip / handicap line off the still-injected block ~half the time and the self-gate dropping the whole post — ~56% of goal reactions shipped nothing; withholding the block structurally enforces what the rule only asked, validated n=5 on the real card+goal feed), but it is secondary to the game, not the lead (#603 follow-up: the prior “speak on the line at the first opportunity” framing made commentary market-LED, reading as a call on the odds instead of the game — and the market lags a goal, so a goal post citing a not-yet-repriced % read as wrong). The reorder is priority, not suppression (owner steer: “game/player/team focused, but equip our bettors with fresh market info always”): the prompt leads HARD with the GAME on EVERY phase (a top “THE GAME IS THE STORY” rule naming pregame/in-game/final + the rule order puts players/shape/props/standings AHEAD of the line), then hands the bettors the fresh line as a secondary beat so they stay on current numbers — it FOLLOWS the game, NEVER leads, and NEVER appears on a goal (stale-relative-to-the-event). A pure-game post with no line is fine when the game’s the whole story. Still grounded: she quotes the numbers given and never invents/shades a line. Fail-open per source (a miss omits that source; the Bookie surface, #435, goes deeper into line-shopping + user bets + leaderboards). SGO-sourced games (NFL/MLB/NHL/UFC/tennis, #435) get their OWN moneyline surfaced via format_sgo_odds_line (the snapshot already carries it) in the $100 wins $X convention, home/away-paired to avoid the prior misread footgun, used as the sportsbook line when The Odds API has no match - so combat/etc. get “who’s favored” even with no other odds source. Beat-the-line player props (#390): SGO player props (bot.markets.sgo.get_player_props(league), INTERNATIONAL_SOCCER/NBA) are pulled scoped to THIS game’s SGO event (get_player_props(league, event_id=...), #SGO-eff — the caller already knows the game, so it bills one event’s props via the eventID filter instead of the whole league’s limit-event slate, a ~10x entity cut live-verified against SGO; the /ask lookup_player_props path is event-scoped the same way) and matched to the players actually in this game by name (format.match_player_props, a fuzzy person-name match against the box-score leaders + goal scorers — surname-first Brunson Jalen ties to a prop’s Jalen Brunson, a different game’s player is dropped, and a bare initial never false-matches) then rendered by format_player_props into a props_blob fed to commentate — the beat is a player’s ACTUAL box-score number vs the book’s o/u (“brunson’s at 34, smoked his 26.5”), grounded (quote the line exactly, never invent/shade one) and fail-open down the whole chain (no SGO key / unmapped sport / no in-game players / fetch miss / no match → no props, scoreline as before). The SGO prop snapshot carries structured player/market_type on meta so the matcher keys off clean fields, not the title. NOTE: SGO’s playerProps shape is best-effort/version-varying and not yet live-verified end to end (amateur-tier quota + uncertain payload), so watch the commentary_posted has_props rate to confirm props actually land in prod. Market-edge beat (#622 slice 3): the deeper-market siblings of the moneyline — a total / both-teams-to-score / 1st-or-2nd-half total / corners line, the “interesting” market the moneyline doesn’t say — are surfaced via the full SGO board (bot.markets.sgo.get_game_board(league) – the entity-heaviest SGO call, so it and the player props now read a cross-tick TTL cache (_SGO_SECONDARY_TTL_SECS, 4min, #sgo-burn) instead of re-pulling every 60s tick: a slow-beat secondary doesn’t need per-tick freshness the way the live moneyline does, which cut ~75% of these two beats’ SGO calls) matched to this game by team name (_board_for_game, accent-folded _team_match, either orientation), then the single standout is picked by the pure utils.sportsdata.board.pick_standout_market (the book’s most confident READABLE call that isn’t a foregone conclusion — highest top-selection implied % at or under a 0.90 near-lock ceiling; moneyline/spread/3-way + player props excluded since other beats cover them) and rendered as a compact one-liner (“over 0.5 1st-half goals at 75%”) into a market_edge_blob fed to commentate. SLOW triggers only (pregame/interval — never a goal/clutch reaction, where the market lags the event). It is an ALTERNATIVE source for her ONE market beat, not a second one (#603 game-leads discipline preserved): the prompt tells her to use the deeper line instead of the moneyline when it’s the sharper tag, still one clause, still behind the game, still skipped when the game’s the whole story; the no-line grounding guard (#507) now also clears when a market edge is present. Grounded (quote the % exactly, never invent) and fail-open down the chain (no SGO / unmapped sport / no board match / no standout → no edge). When SGO is degraded (#725) the edge falls back to the Odds API deep board (_odds_api_market_edge): a FREE get_events resolve + ONE metered get_event_board (ODDS_BOARD_MARKET_KEYS — totals/BTTS/corners/team-totals/halves) → parse_odds_api_board → the same format_market_edges, gated on sgo.degraded (zero spend when SGO is healthy) AND the_odds_api.has_enhancement_budget (credits above the 5,000 _ENHANCEMENT_RESERVE, so the /bet SGO-down backstop credits are preserved) AND an 8-min per-game cache, so it’s a slow-trigger-only, ~5-credit-per-game-per-window enhancement that never threatens the bookie budget. Dry-run confirmed she works it in as secondary color (“Spain’s been the better side all night… the ‘no both teams to score’ at 61% tracks, Germany’s barely gotten looks”) and ignores a stale/irrelevant edge rather than forcing it. Watch commentary_posted has_market_edge. Bookie tie-in (#623): on the bookend beats she works the room’s play-money bets in via _bets_blob → the Bookie cog (get_cog("Bookie")). PREGAME folds in who’s got open action on this game (Bookie.open_bets_blob → db.open_bookie_bets_for_game, names not pings, biggest stake first, capped at _BETS_READOUT_MAX); FINAL settles the game (Bookie.settle_and_roast_blob → the shared idempotent _settle_game, which ALSO fires the per-channel payout embed, so whoever reaches a finished game first — this or the 5-min settle loop — settles it and the other no-ops) and hands back the W/L for a light roast (format_bets_results). The roast prompt rule is don’t force the joke (only when there’s a real angle, else read it straight), text-only, names never invented. Bookie-experiment-gated + fail-open to “” (a bookie blip never breaks commentary); settlement itself still runs via the loop regardless. Pure formatters (format_bets_action/format_bets_results) are unit-tested. Watch commentary_posted has_bets. (A scheduled follow-up surfaces the same open_bookie_bets_for_game data as a user-facing “everyone’s bets on a game” command, open + closed.) Shape-of-the-game depth (#432): soccer had only score+goals+market% to call on (which is why prod check-ins read as one-liners), so bot.sports_hub.game_team_stats(game) pulls live team match stats (API-Sports /fixtures/statistics: possession / total shots / shots on target / corners / cards, parsed to a MatchStats by _football_team_stats, rendered by format_match_stats into a MATCH STATS block) and feeds them to commentate (team_stats_blob) so she calls the shape (“germany’s on 64% and 14 shots to 3, one-way traffic”) not just the score; grounded (home-away numbers given only, never invented), fail-open (a stats miss → scoreline as before), cached per game per tick. NBA gets the same shape depth (game_team_stats is now per-sport): _basketball_team_stats parses API-Sports /games/statistics/teams (FG% / 3PT% / rebounds / assists / turnovers, mapped to home/away by team id since the stats block carries team.id only, names from the snapshot) so an NBA check-in reads “knicks shooting 39% and getting crushed on the glass 47-38” not just the score; format_match_stats renders both sports’ labels from one _STAT_ORDER. Watch the commentary_posted has_team_stats rate to confirm the depth lands. Pregame depth (#432): on the pregame trigger only (where it belongs, so live reactions never pay for it), bot.sports_hub.game_pregame(game) pulls API-Sports formations + coaches (/fixtures/lineups), each side’s recent form (/fixtures?team=&last=, rendered “WWDLW” newest-first by _form_string), and the head-to-head record (/fixtures/headtohead), assembled into a PregameContext and rendered by format_pregame into a PREGAME CONTEXT block fed to commentate (pregame_blob) so the rundown sets the table with a real hook (a hot/cold run, a formation mismatch, the history); grounded (quote, never invent), fail-open per source, cached per game per tick, team-ids carried on the snapshot meta (home_id/away_id) so no name→id resolve is needed. Watch commentary_posted has_pregame. (Highlightly’s own data endpoints — lineups/H2H/standings/predictions — are a deferred follow-up pending live response-shape verification; the commentator’s pregame depth uses API-Sports, whose shapes are verified.) The commentate prompt was also reshaped for depth-not-length: one sharp sentence is the norm with a HARD two-sentence ceiling (never three), a “DEPTH OVER LENGTH” guard (every sentence carries a real stat/name/run from the feed), a “ONE ANGLE, not a stat dump” rule (the data is there so she PICKS the sharpest beat, never lists ‘three yellows AND outshot AND 55% AND a 7.3 rating’), and a “no wrap-up line” rule (no ‘that’s the story in a sentence’ meta) - the deepen-not-LENGTHEN tightening after the depth pass over-corrected into long stat-sheet posts in prod. The depth still lands, just as DIFFERENT angles across SEPARATE posts (rotated via the ‘story so far’ context - a player in one take, the shape in another, the line in another), never crammed into one and never all at once (the #502 stagger spaces them). Post-game highlights (#432 dim 3): the commentator drops a video clip for a finished game via utils.highlightly (the HighlightlyClient, key-gated on HIGHLIGHTLY_API_KEY — Highlightly covers BOTH soccer + NBA, the one source for our whole coverage, free Basic tier 100 req/day). Because clips land 0-48h after the whistle (the final recap fires the instant a game ends, too soon for a clip) AND a finished game leaves recent_finals() at UTC midnight, the cog persists a pending-clip window: when a game we called finishes, db.mark_game_for_highlight stamps its matchup + highlight_finished_ts onto sports_post_state, and a separate per-tick pass (_process_pending_highlights, run BEFORE the live-games early-return so a next-day clip still posts when nothing’s live) re-queries Highlightly for any game db.due_highlight_games returns — finished within _HIGHLIGHT_WINDOW_SECS (~48h), not yet posted, last checked over _HIGHLIGHT_RECHECK_SECS ago (60min, free-tier-budget-safe; the window/pace/dedup are all enforced in SQL). It posts the first VERIFIED clip (the API’s own verified title + url, which Discord unfurls — real API data, nothing fabricated) and marks the game done. Once per game, durable dedup + pacing + the 48h window via sports_post_state columns (highlight_posted_ts/highlight_checked_ts/highlight_finished_ts + the persisted matchup, durable so frequent redeploys don’t re-post or re-burn budget and a clip landing the day AFTER the game still gets caught). Gated by the same live_scores experiment (production → room, staging → #bot-logs audition) + fail-open throughout (no key / no clip yet / send failure → leaves it pending for the next window). Emits highlight_fetch (the API call) + highlight_posted (the disposition). Phase 2 (deferred, tracked on #432): an X-accounts cog pulling clips from curated accounts (@BleacherReport, @NBACentral) — blocked on a timeline source (fxtwitter does single tweets, not timelines). Odds grounding (#507): when NO odds block is matched/injected, the prompt forbids her mentioning a moneyline/spread/implied-%/Polymarket/Kalshi at all (the inverse of the odds rule) plus an always-on no-bet-sizing rule — prod soccer logs showed her inventing market %s from nothing when the match missed (the self-gate caught most, but it’s a real grounding hole). Right-market-info-at-the-right-time (the capability matrix applied to commentary): The Odds API h2h moneyline is pre-match only (utils.sportsdata.sources.provides_live_odds("the_odds_api") is False — it does NOT reprice in play), so on any in-play trigger (everything but pregame) format_commentary_odds(live_game=...) frames its line as the OPENING line (“where they opened”, not the current price) and points the model to the prediction markets for the LIVE read — otherwise she’d cite a stale pre-match number as live (the Bookie stale-odds bug, in the commentary voice; acute while SGO’s monthly entity cap is exhausted, since the Odds API is then the only sportsbook line). The SGO line (when present) IS live-capable, so it’s never relabeled; the prediction markets are always the live read. live_game flows from the trigger through _odds_blob. Cross-provider matching (#508): so the odds/props blocks actually LAND, format._team_match/_name_match accent-fold via NFKD (utils.sportsdata.format._fold — the Türkiye→trkiye prod miss was the matcher deleting diacritics) and match on accent-folded exact-or-containment ONLY (the fragile English-club suffix list + bare last-token fallback were dropped: containment covers the real cases and biasing toward a miss over a wrong match is safe now that a miss = no line, not an invented one). Game-state awareness (#508): on the SLOW triggers only (pregame/interval, via _context_tools), commentate is handed ONE narrow read-only tool — lookup_background (utils.reference.lookup_background, the Wikipedia-article-reader slice only, deliberately NOT the full lookup_reference so it’s not bundled with Genius/MusicBrainz/chart sources that are noise for calling a game) — for background/stakes/history. Never on goal/clutch (a reaction can’t wait on a round-trip), never for the live score/odds (those are injected, real-time, authoritative — a search there would only risk stale data); live-data tools stay off the commentator. Fail-open (a lookup miss → call it without). Each composed line is self-gated (commentate_score, a Haiku classifier like discourse_score) and dropped under the 0.6 ship floor (fail-open on a scorer error so a Haiku blip can’t silence the surface). The self-gate is handed the live FEED the line was built from (score + events + team/player stats + the line) so it verifies the line’s numbers are real (in the feed) rather than false-failing injected depth - player match ratings, possession %, key passes - as ‘invented’ (#432: judging blind was dropping ~half the good posts in prod) — parity with discourse/music’s quality gate. The commentate prompt inherits the shared _VOICE_REMINDER block AND the cross-surface _LESSON_* grounding constants (substance / not-curator / one-verdict / plain-not-critic — the single source of truth also composed by _POST_GROUNDING, so a prompt-rule edit propagates to every room surface instead of drifting; tests/test_prompt_lessons.py fails if any surface stops referencing them) so commentary can’t drift from the other room surfaces; _ROOM_DIRECTED/_TOOL_DISCIPLINE are deliberately not applied (commentary calls a game, no debate-starting; the only tool is the narrow slow-trigger lookup_background, no live-data/web tools). One @tasks.loop(60s) tick does one global live_games() fan-out PLUS an upcoming_games(~15min window) fan-out PLUS a recent_finals() fan-out (#512 post-game recaps: a completed game is filtered out of live_games() (g.live only), so the final trigger was structurally unreachable — the #441 bug but for final, and zero finals ever posted in prod; recent_finals surfaces just-finished games (reusing the same day-slate fetches, filtered to completed; SGO has no finished feed so it’s API-Sports-sourced) so the once-per-game final recap fires. Gated in the cog so only a game we were ACTUALLY calling recaps: a completed game with no prior sports_post_state (last_post_ts is None) is skipped, never a cold final for a game we ignored. At final the recap material is already in hand — final score + the full event timeline + final box-score leaders + the story-so-far — so no new persistence is needed, #430 stays deferred) (#441 pre-game detection: the loop used to see only live games, so the pregame rundown trigger was unreachable; now a fixture tipping/kicking off within _PREGAME_WINDOW_SECS is merged in, deduped by game_key, so it gets its rundown before the game — the window reads start_ts added to each snapshot’s meta, padded by a few-minute _PREGAME_GRACE_SECS clock-skew tolerance on both ends so a schedule slip doesn’t drop a game) then loops over guilds, so an idle bot (no live/upcoming games / no configured guild) spends nothing — it returns before the loop when _resolve_targets is empty. (Kickoff at 0-0 doesn’t mis-fire the goal trigger: cadence._score_changed normalizes None→0, so an upcoming game’s null scores becoming 0-0 reads as no change.) The dedup memory (cadence.PostState) is persisted per (guild, game_key) in sports_post_state (restart-proof: last_post_ts is wall-clock epoch, not monotonic, so a redeploy mid-match never double-posts a goal or loses the heartbeat clock). Cross-game spacing (#502): when several concurrent games clear a trigger on the same tick they’d post back-to-back in one channel (a wall); so a guild posts at most _MAX_ROUTINE_POSTS_PER_TICK (1) routine reads per tick and the rest DEFER (their PostState is left untouched, so the cadence re-fires them next tick — no wall, nothing lost). MILESTONE triggers (goal/final, a reaction loses value if held) are never deferred but DO count toward the tick budget, so routine reads space themselves around a goal. Emits commentary_deferred. Per-post game header (#502): a sent message is prefixed with a format_game_header line (matchup + live score + clock, e.g. **Spain 2-1 Korea** · 67') so multi-game commentary is self-labeling, but ONLY when the game or score changed since the guild’s last post (deduped via in-memory _last_header), so a single game reads as a clean stream and the header re-appears on a new game or a goal instead of stacking an identical bold line on every consecutive same-game post. Delivery-only: post_preview stays the take so the eval pass grades her words, not the scoreboard. This (identification) is the complement to staggering (volume). Gating, in order: master kill switch (is_bot_enabled) → mood OFF (it’s a proactive surface, a muted room stays muted) → the per-guild live_scores experiment (production posts to the sports channel, staging auditions the line in #bot-logs with the room kept quiet, off skips the guild — no fetch, no spend). Per-guild watched-sports filter: _resolve_targets resolves each guild’s db.get_watched_sports (the /menu page-3 picker over utils.sportsdata.sports.WATCHABLE_SPORTS, empty/unset = ALL) onto the target tuple, and the game loop skips a game whose meta['sport'] the guild isn’t watching (is_watched, fail-open: an untagged game is always covered). This is the lever that lets a World-Cup-only room mute the MLB/NFL breadth that the all-providers merge surfaces — the fix for a sports channel flooded with games nobody asked for. (Every SGO game now carries a meta['sport'] tag, _SGO_SPORT_LABEL extended to all renderable sports — SGO FOOTBALL→americanfootball, never soccer’s football.) Fail-open throughout (provider miss / compose 429 / send failure never crashes the loop; the state is still recorded so a trigger doesn’t re-fire). Emits commentary_posted (with post_preview on room deliveries, graded by the live-log eval pass) + commentary_scored (the self-gate). The behavioral net is scripts/eval_commentate.py (golden + per-trigger + live-log), parity with the other room surfaces.betting_board.py, the “who’s favored” line posts for the sports a guild takes BETS on (#1229/#1234) — the market-read complement to the two existing sports surfaces: the live commentator CALLS a game in-game, the Bookie TAKES the bets, and this reads the LINE around a game (who’s favored + the implied %) so a bettor sees it and is nudged to /bet. DUAL-PATH (owner steer — the ability to “post today’s bets in the morning” run by the calendar, AND a pre-kickoff nudge): (1) a CALENDAR-scheduled morning “today’s bets” SLATE roundup — a ScheduledPoster subclass (mirroring market_drop) posts a compact board of the day’s bettable games + who’s favored, calendar-managed via /menu (bet_board is now a first-class calendar surface in utils.schedule_calendar.SURFACES + cogs.calendar_view), falling back to a morning-leaning CHILL_TIMES/YAPS_TIMES pool when unconfigured; _todays_slate (today’s UPCOMING games, soonest-kickoff first, capped _SLATE_MAX, then SPREAD across the sports on the day by _spread_by_sport) + _slate_blob feed compose_market_drop; self-gated by drop_score, text-deduped over bet_board_history, slot-paced via bet_board_slots. (2) a per-game PREGAME line, EVENT-DRIVEN off the game’s START time — a @tasks.loop(TICK_MINUTES) (board_tick) posts a game’s line once when it enters the pregame window (_PREGAME_WINDOW_SECS, ~1h, env-overridable), capped at _MAX_POSTS_PER_TICK per guild per tick, per-game deduped via bet_board_matchups (a game’s line posts once, not re-posted while it sits in the window / across a redeploy). The slate board is UPCOMING-ONLY (owner steer 2026-09-13, “games already passed”). _todays_slate judges a game by its KICKOFF TIME, not by the feed’s live flag: a game whose start has passed is dropped even when the feed still calls it upcoming, and a game with no known kickoff is dropped too, because nothing shows it has not started (prefer absent over invented). A slot with no game left today posts nothing — _sched_compose returns None and the board goes quiet. Two cards paid for this rule. (a) The 18:00 ET board on 2026-09-13 led with “Green Bay Packers 86% over Minnesota Vikings” while Green Bay was 19-10 up at half; the card does not mark a row LIVE, so the in-play number read as a price under the headline “Today’s Odds”, and four of its six rows were games already under way. (b) Earlier the same day two MLS games kicked off at 02:30 UTC, ended about two hours later, were still repriced as live 13 hours on (535 reprices each), and the board hero’d “Draw 91%” as the best odds on the slate — the settled outcome of a finished game, offered as a line to bet. That one’s CAUSE was the slate freeze (see the two clocks, below), which is fixed; a 6h ceiling on live rows (_MAX_LIVE_SECS) was the first floor under it, and dropping live rows outright replaces it, removing the class instead of capping how stale a live row may be. The live lane keeps its own surfaces: the commentator calls the game and betting_value/betting_alert read the line moving. The board gives every SPORT on the day a row (_spread_by_sport). The cap used to be filled by soonest kickoff alone, so the sport that starts earliest took every slot. Measured on the live slate of 2026-09-13, an NFL Sunday: 30 priced games were on, 13 of them NFL, and the board showed six — all soccer. The European card kicks off between 8:00am and 12:30pm ET and the NFL card at 1:00pm ET, so the six soonest kickoffs were soccer every week and the whole NFL slate fell off the bottom (owner report: “NFL day, I don’t see odds”). The cap is now filled one sport at a time in rotation, in each sport’s first-appearance order, so the earliest sport still leads the board, a sport that runs out of games simply yields its turn, and a single-sport day is unchanged. It is a SPORT rotation, not a league one: six leagues each holding one row reads as scatter, while three NFL rows beside three soccer rows reads as a board. Decided games are skipped (#betting-boards-game-calls, owner steer: cut the game calls): a game whose favorite is ≥ DECIDED_PCT (97, env BET_DECIDED_PCT) is all but won — a game CALL, not a line read — so is_effectively_decided drops it from the morning slate, the pregame window, AND (since the slate is filtered first) the marquee card, so the roundup never anchors to a ~99% game (the Real Betis screenshot). The betting alert’s live “winner lock” call, which announced this same decided state, was removed in the same change. Deliberately DISTINCT from market_drop: the drop reads cross-market FUTURES and CUTS single games (Bookie/commentator turf); this is the exact opposite — single-GAME lines ONLY, main line only, no props/exact-score/corners (owner steer). The Bookie’s slate cache keeps TWO clocks, and they must stay apart (Bookie._bettable_cache[0] vs _odds_fresh_at). _bettable_cache[0] times the GAME LIST and is what _cached_bettable expires against (_BETTABLE_TTL_SECS, 180s). _odds_fresh_at times the ODDS and is what the /bet board’s “line updated Ns ago” reads. They were one number, and the warm loop’s cheap live-odds re-fold stamped it. The re-fold reprices the games already held and never re-reads which games are on, so the stamp made a frozen list report as freshly fetched — and the loop fed itself: a game only stops being live when a full refresh re-reads it, the stamp stopped the full refresh, so ONE game stuck on live held the list until the next deploy. Measured in production on 2026-09-13: the last full refresh was 10.5 hours old, two MLS games that ended the night before still read live, and the morning board, the pregame line, /bet and BET PLACEMENT (documented to block for a fresh line) were all reading that list. The re-fold now stamps _odds_fresh_at only. bet_slate_refresh.detail.refresh_age_s reports the age of the list each refresh replaces and the ops monitor gates on it (slate_stale) — but only from NOW ON: it could not detect this freeze retroactively, because the stamp it reads is the stamp the freeze corrupted, and a correlation rule built to cover that gap fired on 13 of 22 pre-freeze windows when measured and was cut (Codex review on #3228; a sound detector is #3230). Separating the two clocks is what makes the age honest. Reuse, not duplication (owner steer: reuse the Bookie odds code): both paths ride the Bookie’s own slate (Bookie._bettable_games → _bettable_for_guild, shared via _slate_games) and line math (_sides/_matchup/_game_when, implied % = round(100/decimal), all from cogs.bookie), and compose_market_drop’s voice (angle="leaderboard", a game-line context_blob). Filtered by the guild’s BETTABLE sports and THEN by its COVERAGE sports (bettable_slate → _watched_only, #mlb-menu-visibility); posted to the betting-drops channel (db.resolve_betting_channels — the dedicated betting channel set on /menu’s live-sports page (page 3) if a guild picked one, ELSE the live-commentary sports channel, so commentary and betting drops route independently and an unconfigured guild is unchanged; #betting-links-channel-routing shares this resolver across board + both alerts); gated on its own bet_board experiment (production → the room, staging → a #bot-logs audition, off → skip) + master switch + mood. The bet_board experiment is split out of bookie (which gates bet-taking/settlement/alerts): the two used to be welded together, so silencing the board meant staging all of bookie (which also reroutes bet-taking + the settlement payout pings). Now a guild can take bets WITHOUT the board drops (bet_board off/staging, bookie production), or run the board without opening betting. The morning slate stays calendar-scheduled on top of this gate (the calendar owns cadence, the experiment owns on/off — mirrors market_drop); the pregame line is event-timed so the experiment is its sole on/off. The COVERAGE narrowing (#mlb-menu-visibility, owner ask 2026-09-14). /menu’s live-sports page holds TWO sports pickers and they answer different questions: 🎰 sports you can bet on (bettable_sports) says what the Bookie takes bets on, and 📺 sports she covers (watched_sports) says what Toots covers at all. Betting used to read the betting picker ALONE on every path, so a guild that bet MLB without covering MLB got an all-MLB odds board; the morning board of 2026-09-14 came back five rows MLB out of six. The POST surfaces now require BOTH pickers to agree. /bet is unchanged and still reads the betting picker alone, so a room keeps taking MLB bets while its board stays quiet about MLB. The filter lives in betting_board.bettable_slate — the one entry point the board AND the line-move alert already shared — so both post surfaces take it from a single place, and it reuses sportsdata.sports.is_watched, the one watched-sports matcher, so a game’s LEAGUE refines its key exactly as it does for the live commentator (a room covering the Premier League keeps an EPL line and loses an MLS one; soccer from a source that speaks a different league vocabulary stays ambiguous and is kept). Two fail directions, both deliberate: an UNSET or empty coverage picker covers NO sports, so the post slate is empty (#2765, owner decision 2026-10-15: “if a room has no sports list set. It means no sports at all”; read through utils/sportsdata/sports.py:covered_sports, the one definition every sports surface uses), and a coverage READ ERROR keeps the full bettable slate, because a DB blip must not silence a surface. The narrowing emits bet_slate_narrowed when it actually cuts something (see docs/OBSERVABILITY.md): without it a board quiet because of a menu tick looks exactly like a dark feed. The coverage picker is read FIRST (#4218): a guild that covers no sports returns [] without reading the Bookie slate, because that read starts a slate refresh (about 30 API-Sports calls). The skip emits bet_slate_narrowed with reason=no_coverage, kept=0, dropped=0. A coverage read error still fetches and keeps the full slate. Cards — two shapes, one per lane (#board-structure, owner steer 2026-08-27 “this should be a board”): the PREGAME post is about ONE game, so it renders a single-game market CARD (utils.market_cards.build_market_card — a clickable Kalshi/Polymarket market link as the title + a %s chart, the take as the description, so the MARKET is the headline and /bet rides below). The SLATE roundup names the WHOLE slate, so it renders a BOARD card instead (_board_rows → utils.market_cards.build_board_card, the shared board portrait the music + sports boards ride, branded “tootsies bets”): one FAVORITE-FIRST row per game — the favored team is the subject, “over {underdog}” is the subline, the implied % is the number — ranked strongest-favorite first, the marquee (_marquee_game) the highlighted top row. The HERO LINE NAMES THE CARD (_SLATE_HERO, “Today’s Odds”), not the leading club (owner steer 2026-09-11, and the same call sports_boards.upcoming_board took on 2026-08-11 with “Tonight’s Favorites”): the card is a whole slate, so a single club in the big slot reads as though the card were about that club – and the caption that used to explain it (“favored at 64% - 6 games on the slate”) is no longer drawn (chart_cards.card_caption). The favorite still leads the take, carries the highlight, and names the caption. This RESOLVES the old KNOWN LIMITATION: the slate used to card only its marquee, so a multi-game take sat beside a one-game picture; the board now shows every game the take names. The cost the owner accepted is the clickable market button — a board carries no market CTA (build_board_card returns url=None), the trade chosen over a one-game link. Favorite-first (not the neutral “away @ home” fixture) matches how the take reads a line (“Barcelona over Athletic Club”) and fits one team name per column, so a long club name never truncates (the reason the sports upcoming board reaches for nicknames it has and the Bookie snapshot does not). Each row also names its own LEAGUE, in a tag pill BESIDE the team (#2982, owner steer 2026-09-05: “this should have the league in pill”, then “cant it fit on one row or be next to the teams”). A slate MIXES competitions — the live slate that day was five Premier League games and one La Liga one — so the card’s one eyebrow can only say “TODAY’S BOARD”, and a reader could not tell which league a row was. utils.sportsdata.sports.league_label reads the game’s own meta in three steps: the provider’s meta['league_name'] (API-Sports + ESPN carry the real competition name, including ones SOCCER_COMPETITIONS does not cover), else competition_for_league(meta['league']) (an SGO code like LA_LIGA or an API-Sports id like 39), else the code itself when it is already the name people use (NBA, NCAAF). It returns “” for anything else — an Odds-API machine key, SGO’s multi-competition INTERNATIONAL_SOCCER bucket — so that row shows NO pill instead of a guessed league (the repo’s prefer-absent-over-invented fail direction). The pill is CardRow.tag, drawn by chart_cards.render_board_portrait on the row’s own line, just after the label. It costs NO height: _tag_width reserves its room out of the row’s span, so _fit_row_size sizes the title around it and the pill can never be drawn over a team name (the cost is that a long club name is cut sooner — “Borussia Mönchengladbach” beside a pill is). A first version gave the pill a second line per row, which made every row 30px taller and cut a board from 10 rows to 7; the owner asked for one row instead. An untagged music or sports board renders byte-identically either way. A ONE-LEAGUE board hoists the league to the EYEBROW instead (_hoist_shared_league, owner steer 2026-09-05 “hoist it”, on an all-MLB board that read “MLB” six times). When every row shares one league the pill repeats and tells the reader nothing after the first row, so the league becomes the card’s eyebrow (“MLB” in place of “TODAY’S BOARD”) and the row pills come off. A MIXED slate keeps its per-row pills, because no single eyebrow can name four competitions. A ONE-ROW board is left alone too: a pill that appears once does not repeat, and that board is the PREGAME fallback, whose eyebrow names the post (“GAME LINE”). The hoist runs in _board_card, the one place both lanes build their card, so neither lane can miss it. The SLATE lane passes “BEST ODDS” as its default label (_SLATE_LABEL), which the hoist REPLACES with the league when every game is in one (owner steer 2026-09-11, on a block that read “6 GAMES”, then “mixed leagues need a block too”): an all-MLB slate wears “MLB”, a mixed slate wears “BEST ODDS” and keeps its per-row pills, and no slate card is left with an empty block. The block itself stays, on this card and on every other board card (owner: “keep the block”). The PREGAME lane keeps its own label, “GAME LINE”. The board’s pure row builder (_board_rows) + the favored-side reader (_favored, label + implied % + underdog off _sides) are unit-tested. CROSS-LANE matchup dedup (#betting-boards-2u0yqe, the duplicate-card fix): the two lanes deduped on different keys — the slate on POST TEXT (bet_board_history), the pregame on the MATCHUP (bet_board_claims) — so the same game carded twice, ~1h apart, one Kalshi card and one Polymarket card (the provider differs because the pregame lane’s coherent_card_snap re-resolves Kalshi-first per post). The slate marquee card CLAIMED its matchup in the shared bet_board_claims table so the PREGAME line lane would not card the same game a second time (_pregame_games skips a claimed matchup, keyed by the shared _norm_matchup, for _RECENT_MATCHUP_HOURS = 12h). The board no longer claims (#2644 Codex review): the old marquee card carried a market LINK, so suppressing the duplicate protected against two LINKED cards of one game; the board card has NO link, so claiming it would suppress the pregame lane — the ONLY remaining path with a clickable Kalshi/Polymarket link — and the marquee game would get no bettable link at all. The board (a morning overview) and the pregame card (a single-game linked nudge) are different posts, not the duplicate the claim guarded, so the board ships without claiming and the pregame lane stays free to post its linked card. The pregame lane still claims its OWN posts (a game’s line posts once in the window), so its dedup is intact; db.refresh_bet_board_claim is now unused. The slate ALWAYS renders the board, even when its headline game was carded recently. A morning roundup names its whole slate, so it re-mentions its headline game every slot and cannot drop one; suppressing only the picture would leave the game NAMED in the take with no card beside it (the 3:00am board that read as broken — Real Madrid carded at 07:00, then the same game named text-only at 10:00). So the picture always follows the headline; the slate no longer reads the avoid-set at compose time. The only cost is the rare reverse order (an early-morning kickoff cards in the pregame lane, then the morning slate boards the same game ~1h later) — two DISTINCT posts (a pre-kickoff nudge vs a day roundup), each of which legitimately wants art, which is better than a headline with none. The PREGAME card fetches the game’s prediction-market snapshot via the shared fetch_prediction_snapshots (Kalshi preferred — #prefer-kalshi, US-web-bettable — Polymarket the fallback, and kept for a soccer game whose Kalshi line lacks the draw, usable_prediction_line guard); a game with NO matched market (common for a preseason / lower-league fixture no exchange lists) first ATTEMPTS a two-way card off the book’s OWN line (_book_line_card → _book_line_snap → build_market_card, attach_market=False): a clean two-sided moneyline (exactly two priced team sides, no draw) becomes the SAME rich cover a matched game gets — both crests + the two implied %s on the legend — so a sportsbook-only game is no longer stuck with the plain single-row board (owner steer: attempt a two-way card, the board remains the absolute fallback). The implied % per side is the RAW 1/decimal, the SAME read the take is composed from (_line_blob) and the board shows (_favored), so the take, the board, and this card all quote ONE number for a game — _CONTEXT tells the take to quote the data’s numbers exactly, so a re-normalized card would contradict it. The pregame take and the board fallback credit the line’s REAL source (#2653) via _line_source_label: a Kalshi/Polymarket fold (meta["odds_source"]) names that venue, a book line names “the sportsbook”, and a fold stamped prediction (no known venue) declines the post. The morning SLATE credits every venue on it via _slate_source_label (#4173): “the sportsbook”, “the sportsbook and Kalshi”, and so on, in the compose source, the board’s source pill and the bet_board_posted market_source. A slate fold with no known venue credits “a prediction market” instead of declining the whole slate (emit_decline reason=unknown_odds_source). The morning slate still credits “the sportsbook” for the whole board. A prediction-SOURCED line (a Kalshi/Polymarket price folded into game.odds when the game had no book price, meta["odds_source"]) is declined here — the card credits “the sportsbook”, so it must not render folded prediction prices under a false source — and the banner rung of build_market_card is treated as a miss so the board still runs. The single-game BOARD card (_line_board_card → _board_rows/build_board_card, the favored side as its one row, chart-label “GAME LINE”) is the ABSOLUTE fallback below it — reached by a 3-way line, a game with no clean two-sided price, or a card-build error — so the pregame line always ships a picture — numbers + wordmark, no looked-up face — instead of bare text (#art-board, owner steer 2026-08-27 “no art no board”, the Bills/Steelers preseason post that had neither a market card nor a board). The board carries no market link (build_board_card returns url=None) and stamps art_source="sports_api" so the delivery-time media gate keeps its PNG, the same verified marker the slate board uses (a board has no face to mismatch). Only a game with no readable favorite at all falls through to plain text (_line_board_card → None). The single-game fallback board shows as bet_board_posted carded=true with market_source=null (a market-backed pregame card carries the source), so the two are told apart in telemetry. Incident — the caption/chart mismatch (#caption-chart): a season-long OUTRIGHT (“Ligue 1: 2027 Champion”) names every club, so a game’s home + away both appeared in its outcome labels and match_prediction_snapshot’s step-3 raw-text fallback shipped the league FIELD (PSG 86%, Marseille 4%, Lens 3%) as the card chart beside a caption reading the game’s own 1X2 line (Rennes 67% / draw 20% / PSG 17%). Fixed in match_prediction_snapshot with _is_outright_field: a snapshot that prices 3+ outcomes with any runner mapping to neither side nor a draw is a field, never a single game’s line, so it is dropped before matching. bet_board_posted carries carded + market_source. The pure helpers (_line_blob = the who’s-favored blob incl. the soccer draw leg labeled “Draw”; _todays_slate/_slate_blob; _pregame_games = upcoming-in-window, soonest-first, skipping a db.recent_bet_board_matchups fixture; _norm_matchup) are unit-tested; both self-gated by drop_score (0.6 floor, fail-CLOSED). Live-dry-run-verified rendering on real games (Rangers 68%, Angels 38%). X mirror — the two lanes are SEPARATE rows (owner steer 2026-08-28): the per-game PREGAME card crossposts under its OWN X surface, bet_game (X_SURFACE_PREGAME), split out of the slate board’s bet_board row. One toggle used to govern both, so silencing the per-game cards on X also silenced the morning board — the owner could not express “the board yes, a card per game no”. bet_game is opt-in (x_crosspost.OPT_IN_SURFACES), so a guild that never opened the picker tweets the board and NOT a card per game, and a guild that wants both ticks the row. The reason is TIMELINE COST, not what the card claims: a per-game card ships a full portrait image, and the measured week ran 111 per-game lines against 18 slate boards, so a busy night filled the feed with near-identical cards. Discord is unaffected on either setting — the room gets the pregame line and its linked card as before. The pregame lane’s X-only path (x_only_active + X_ONLY_CHANNEL_ID, for a guild with no betting channel) now reads the bet_game row, so a guild that tweets only the board never runs the lane for X. Note what the default costs on X: with bet_game off, the betting posts carry no clickable market link, because a board card has none. Emits bet_board_posted / bet_board_scored / bet_board_dedup. (The calendar grid is 30-min per #1238.) ONE POST, ONE NUMBER (#2676, owner decision 2026-10-17). When the pregame line ships on a matched Kalshi/Polymarket card, _post_line resolves the card snapshot (coherent_card_snap) BEFORE the compose and builds the take’s blob from the percents that card draws (market_cards.card_outcome_pcts runs the card’s own _chart_outcomes with no history fetch, so the rows and the display_pct rounding match; card_side_pcts maps each row to home/away/draw, with ONE unnamed row assigned by elimination for a long exchange club name; card_line_blob renders it). The take credits the card’s venue. The book’s 1/decimal carries the book’s margin and drifted 1-5 points off the exchange: 144 of 156 matched-market posts in the 31 days before the fix stated a percent their own card did not draw. A card that cannot carry the take (a matchup-titled leg naming neither team) is not shipped: the take quotes the book and the book-line cards (_book_line_card, _line_board_card) draw those same numbers, the rule _book_line_card already followed. A market card that fails to build ships the take as plain text, never beside a book-line card. The post stamps detail.implied_pcts.betting_alert.py, the betting LINE-MOVE alert (#1234) — the event-driven sibling of the betting board (betting_board.py posts on a schedule/pregame-offset; this fires when a bettable game’s moneyline MOVES), and it shares the board’s slate entry point (betting_board.bettable_slate), so it also takes the COVERAGE narrowing: an alert names only a sport BOTH /menu sports pickers agree on (#mlb-menu-visibility), the game-line analog of market_alert (the prediction-market movement alert). TWO triggers, both PRE-GAME: a FLIP (the underdog overtakes the favorite — the favored side changed; the rarest, most legible “whoa”, always fires) and a SWING (same favorite, its implied % moved ≥ SWING_THRESHOLD_PP ~15pp from the baseline — pulling away or its edge collapsing toward a flip), both watching UPCOMING (pre-game) games ONLY (#game-lines-live-alerting, owner steer): a LIVE line moves with the play-by-play, so an in-play swing/flip is the game happening, not betting NEWS — it read as noise (a live 27pt Rays swing crossposted to X), so the loop skips game.live (and drops any pending confirmation) and only alerts on the pre-game line, where a move IS news (sharp money / injury / a scratch before first pitch); in-play edges stay owned by the value alert (betting_value.py, book vs live markets) + the commentator. A flip lands only when the new favorite leads by FLIP_MIN_MARGIN_PP (5pp, owner steer) so a pick’em jitter isn’t a “flip”. There is NO live-game trigger (#betting-boards-game-calls, owner steer: cut the game calls). A WINNER LOCK call fired here until then — a side crossing into a near-lock (≥97%) DURING a live game fired ONCE as “team X has it all but sewn up at 99%”. That call only ever said a team had already won, which is a game CALL, not betting news, so it was removed (with _is_foregone_blowout/_maybe_winner_lock/_fire_winner_lock/_lock_blob/_lock_number_figure and the LOCK_* constants). An effectively-decided game is now suppressed at the source for BOTH betting surfaces: betting_board.is_effectively_decided (favorite ≥ DECIDED_PCT, 97 — env BET_DECIDED_PCT) drops such a game from the board’s slate + pregame + marquee, and this alert has no call to make about a live decided game (a pre-game line move in-play is skipped as before). The durable bet_lock_alerts table + its db accessors are left in place (dropping a table needs a migration; the empty table is harmless), and the bet_lock card eyebrow in market_cards is now unreached. _fire shares the _ship compose→gate→card→deliver→record→emit path. Rare + high-bar: a persistence gate — a flip/swing must hold for PERSIST_PERIODS (2, owner steer “2-3 periods”) consecutive polling ticks before it counts (an in-memory _pending debounce, the noise guard bet_alert needs vs market_alert’s much slower cadence; not durable — a redeploy just re-confirms, never false-fires) — plus a per-game cooldown and a per-guild rolling daily cap (BOTH per-guild TUNABLES on the /menu tune page, tunables.bet_alert_daily_cap/bet_alert_cooldown_minutes, grouped as the “betting alerts” card — parity with market_alert_daily_cap + the chime-in cooldown; SEPARATE from market_alert’s cap so a busy market day can’t starve game alerts), and one alert per guild per tick. The baseline is DURABLE per (guild, game_key) (bet_line_alerts, one upsert row carrying ref_favorite/ref_pct + the cooldown/cap clock last_alert_at + the last shipped line — mirrors market_alert_state): the line as of the last alert (or first sighting, seeded WITHOUT alerting), so cumulative small moves accrue into one alert and a redeploy never loses it or double-fires. Reuse, not duplication: rides the SAME betting_board.bettable_slate + line math (_favorite/_line_blob/_matchup), Bookie’s provider-independent match_key for the baseline (utils.sportsdata.models, the SAME key bets/settlement use — NOT the provider-specific commentator game_key, which would fragment one game’s baseline across providers on the merged slate; order-independent + team-folded, so “consistent with bookie”), and compose_market_drop’s voice (angle="race_moved"). Single-GAME main line only; gated on the bookie experiment (production → the betting-drops channel — db.resolve_betting_channels: the dedicated betting channel set on /menu’s live-sports page (page 3) if a guild picked one, else the sports channel (#betting-links-channel-routing) — staging → a #bot-logs audition, off → skip) + master switch + mood. Links the market (owner steer: link the market, drop the misleading play-money /bet nudge): like the board + value alert, a resolved game ships a rich market CARD (build_market_card via the shared betting_board.market_snap_for_game — Kalshi preferred, #prefer-kalshi; Polymarket the fallback) whose clickable title is the Kalshi/Polymarket market the reader can go bet on directly, with the move as the description; a game with no matched market degrades to plain text (fail-open). The compose steer no longer tells her to push /bet — the linked card is the CTA. The card charts the side the take is about (#show-the-fav-line, owner steer “show the Giants line”): a 2-way game’s Polymarket moneyline bundles as ONE leg tracking one designated team, so when the take is about the OTHER side the chart showed the loser collapsing to ~0% while the words narrated the winner at ~99% — so build_market_card(favored_label=...) (threaded from _favorite/the lock side) charts the FAVORED side of that leg (market_cards._favor_outcome inverts a loser-tracking leg + relabels), landing the line + legend on the side the take reads. Pure helpers (_favorite w/ margin, _classify_move flip-beats-swing, _move_blob) are unit-tested; self-gated by drop_score (0.6 floor, fail-CLOSED). Emits bet_alert_posted (now carrying carded + market_source) / bet_alert_scored. (An earlier PR also wires db.prune_bet_board_state into the prune loop — it shipped in #1235 defined-but-uncalled, so the bet-board-family tables were growing unbounded — and extends it to prune bet_line_alerts.) The take quotes the CARD (#2676). _fire resolves the card snapshot before the compose. The card ships only when it names both teams AND draws the book move’s favorite on top (_card_favorite_line); _move_blob(card=...) then states the card’s percents, while the book’s cur_pct still sets the swing direction and the durable baseline. A card that heroes the other side cannot carry the alert, so it ships as plain text off the book.betting_value.py, the betting VALUE alert (#1234 follow-on) — the third betting surface beside the board (who’s favored) and the move alert (the line moved): this fires when the SPORTSBOOK line DISAGREES with the PREDICTION MARKETS on a live bettable game, a profit edge (“Kalshi has Spain at 74%, the book’s still at 60% — value on Spain”). The premise is the #968 odds-health insight, now SURFACED instead of just logged: there’s no oracle for the “true” line, but cross-source AGREEMENT is the signal — when the book and the live prediction markets diverge hard on the same game, the book is the outlier (slow to reprice an in-play swing), and the side the markets rate HIGHER than the book prices is the value side (you’re getting book odds implying less than the market’s probability). Cheap by construction: compares the book line ALREADY in the bettable slate (SGO/enriched, free — no metered fetch) against the two FREE prediction markets (Polymarket + Kalshi via the shared fetch_prediction_snapshots, the SAME entry point the Bookie reprice + commentator color use). LIVE games only (an in-play book lagging the live markets is the sharpest edge; pre-game value is a tracked follow-up). Trustworthy, not trigger-happy: fires only when the game carries its OWN book line (_has_book_line — not one already prediction-folded, else book==market), when the divergence clears VALUE_THRESHOLD_PP (15pp, the odds-health “sources disagree” bar), and — when BOTH markets match — when THEY agree with each other within _MARKET_CONSENSUS_TOL_PP (a lone mispriced market isn’t a consensus to chase); both lines must be _coherent (sum ~100±vig, so a garbled parse can’t manufacture an edge). Rare + high-bar: a per-game cooldown + a per-guild rolling daily cap (REUSES the shared “betting alerts” tunables bet_alert_daily_cap/bet_alert_cooldown_minutes, counted against its OWN bet_value_alerts table so value + move alerts pace independently) + an edge baseline (#2162: the cooldown is pacing, not a content gate — during a long live game it lapsed while the edge stood still, and the surface re-stated the SAME book-vs-market numbers ~50–115 min apart; the edge signature team|book%|market% is stored on each fire (bet_value_alerts.last_edge) and an unchanged signature never re-fires, observable as market_filtered reason=same_answer kind=bet_value) + one alert per guild per tick, self-gated by drop_score. Reuse: the odds_health extractors (game_sides_pct/prediction_sides_pct), match_prediction_snapshot, match_key, compose_market_drop (angle="race_moved"). Pure helpers (_coherent, _has_book_line, _value_edge biggest-gap-first, _value_blob) are unit-tested; live-dry-run-verified that efficient pre-game lines AGREE (book vs Polymarket within ~2pp on real World Cup games → no false edge). Gated on the bookie experiment (production → the betting-drops channel — db.resolve_betting_channels: the dedicated betting channel if set (/menu live-sports page), else the sports channel (#betting-links-channel-routing) — staging → #bot-logs, off → skip) + master switch + mood. Links the SPORTSBOOK where the value bet lives (owner steer: “if the odds are lagging on the odds api, link the sportsbook instead of poly/kalshi; if available use it, else just link the prediction market”): the value is at the LAGGING BOOK (it’s slow to reprice; the prediction market has already caught up to the true number, so clicking through to Poly/Kalshi lands you where there’s NO edge left). So the card’s clickable title links the sportsbook bet-slip for the value side when The Odds API gives a usable one — _sportsbook_link maps the game’s sport → Odds-API key, reads the SAME cached get_odds slate the Bookie uses (no new metered call in the common case), matches the game (match_event_odds) + the value side (sportsbook_link in utils.sportsdata.format, team/draw name-fold), and returns the best-priced book with a CLEAN link (the_odds_api._best_link_per_outcome over the includeLinks=true response — the per-outcome bet-slip link, else the bookmaker event link; _clean_link drops the {state}-templated US-regulated books (BetMGM/BetRivers) we can’t fill). The card links the book (title → the bet-slip, e.g. FanDuel addToBetslip/DraftKings), footer bet it on {book} · odds via {source}, and the CHART still shows the market %s (the evidence for the value). Fallback: no clean book link (or Odds API unprovisioned) → the card links the prediction market as before (build_market_card(link_url=None)). includeLinks is a paid-plan feature that adds NO credit cost (billing is markets×regions), so it rides the slate’s existing call for free. Live-dry-run-verified: MLB + World Cup games resolve real FanDuel/DraftKings/BetOnline/Bovada bet-slip links (a game whose books are all {state}/link-less correctly falls back to the market). Fail-open to plain text if the card can’t build. Emits bet_value_posted / bet_value_scored (posted carries carded + market_source + book = the linked sportsbook, None = fell back to the market link). Kalshi now actually contributes a prediction line (#1234 follow-up): a live-telemetry audit found Kalshi in 0 of 82 production odds_health snapshots (Polymarket 63/82) despite ~64k fetches (99% ok, NOT a cache — 0 cache hits) — the value alert’s cross-market consensus guard was effectively running on Polymarket alone. Root cause was CONSTRUCTION: Kalshi builds one MarketSnapshot per Yes/No contract with an EMPTY outcomes dict (the outcome label lives in meta['yes_label'] = yes_sub_title), so every Kalshi snap failed prediction_line_odds’s empty-outcomes guard, while Polymarket aggregates an event’s legs into a populated outcomes map at construction. Fixed by markets.fold_kalshi_winner_snapshots — applied ONCE at the PUBLIC MarketsManager.kalshi_snapshots entry point, so EVERY surface that reads Kalshi benefits uniformly (the ask kalshi/props tools, the value alert, the Bookie live reprice, and the commentator’s odds_health color) — which groups a Kalshi event’s sibling per-contract snapshots by event_ticker and, for an event with ≥2 DISTINCT yes-labeled legs (a genuine one-winner-per-participant market; a single Yes/No market’s NO side is ‘not-X’, NOT the opponent — Kalshi’s no_sub_title == yes_sub_title — so it’s never folded), emits ONE aggregated snapshot with outcomes = {yes_label: yes_prob} mirroring Polymarket’s shape, PREPENDED to the untouched per-market originals (so ask’s granular per-contract lines + player-prop extraction still work, and its game reads now ALSO get the clean aggregated line). Correctness rides the existing prediction_line_odds guards — a futures/multi-team market (World Cup host, 32-team winner) folds to many outcomes but bails there (>2 valid legs with no draw, or only one side mapping to the game), so it can’t manufacture a bogus game line. Kalshi soccer label normalization (#1234 follow-up): Kalshi’s per-game soccer markets prefix every leg with the period qualifier (Reg Time: France / Reg Time: Spain / Reg Time: Tie), and the raw prefix broke the DRAW detection (the leading token is reg, not tie) — so prediction_line_odds saw 3 legs with no mapped draw and BAILED, rejecting EVERY Kalshi soccer game line (verified on the live World Cup slate: 0/3 games yielded a line before, 3/3 after). _clean_kalshi_label strips the X: qualifier when the fold builds outcomes (Reg Time: Tie→Tie→draw, Reg Time: France→France); a label with no qualifier (MLB Toronto, a futures Greece or Turkey) is unchanged. A YouTube leg keeps its tag whole (#4380). Choosin' Texas [YouTube video 2: nUsrYVxrDwI] held a ‘: ‘ inside the id tag, so the old split returned nUsrYVxrDwI]. _clean_kalshi_label and _kalshi_player_from_label now return a label with a video-id suffix (kworb.label_video_id) unsplit. The fold names each leg with kworb.display_legs: a unique title reads bare (Choosin' Texas, as on every other surface), and legs of one event that share a title keep the raw label so outcomes does not lose one. This is the parsing half of making Kalshi contribute a game line ‘fair and square’ — SEARCHING already works (the live FTS kalshi_event_search index carries 65 game series incl. MLB/NFL/NBA-summer/soccer/UFC), and the source PREFERENCE order is now KALSHI-first (#prefer-kalshi; it was Polymarket-first when this was written); the remaining Kalshi empties are genuine coverage gaps (no NBA regular season, non-WC soccer leagues), not a bug. Cost-neutral (Kalshi was already fetched + discarded; the fix only parses data we already pull). The fix is observable in the SAME odds_health telemetry (watch kalshi appearing in home_pcts/sources); pure fold_kalshi_winner_snapshots is unit-tested. One percent per side, the CARD’s (#2676). _value_blob states the book side as its American odds (_book_odds, off the same line game_sides_pct reads) and the market side as the percent the card draws; it no longer states a point gap, because the gap sits between two numbers the post does not show. The edge picker still compares the book to the renormalized fair line. No card percent for the value side, or no book price -> phase=declined reason=card_mismatch. Measured on 8 live games with a derived 17-point edge: 0 of 8 takes stated a percent off the card (old wording: 6 of 8); the self-gate passed 5-7 of 8 across runs (old: 8 of 8), the drops reading an odds-vs-percent value call as unverified. The value angle, the value rubric and the number backstop (#4391). Re-measured on main 2026-10-24 (the production compose model gpt-5.6-terra, the Haiku gate, 8 games, 2 runs): 3 of 8 games passed both runs and 2 of 8 kept the book’s odds. Two causes. (1) The compose ran on the race_moved angle (“the favored side + its %, then STOP”), so most takes posted a bare market % and dropped the value call. It now runs on its own value angle. (2) The DEFAULT drop_score rubric’s premise (“what the prediction markets reveal”) made Haiku turn the odds into a percent and call the gap invented, so it failed clean takes. drop_score(value=True) changes only that premise. That premise tells the judge not to do the odds math, so a figure the blob never gave is checked in code first: ungrounded_numbers on numeric_grounding(blob), as betting_alert does (bet_value_scored reason=ungrounded_number). After: 8 of 8 games pass both runs and keep the odds, on gpt-5.6-terra and on Sonnet; 0 of 8 state an off-card %. Gate-only on the same 8 real takes, n=5: 40 of 40 pass (main: 14 of 24 at n=3). Goldens under the new gate, n=3: wrong odds, an invented %, the wrong side and siren voice all fail; a point gap passes the judge and the backstop drops it. Known limit: a take that states the odds as a percent passes the judge (0.62), and the backstop misses it when the same digits occur elsewhere in the blob (the date).health.py, the self-healing health watch loop (#443): closes the loop from Toots’ OWN telemetry to her self-repair tools. The ops-monitor already flags a silently-degraded integration twice a day for a human; this cog gives her the same eyes in near-real-time and lets her ACT without waiting for a complaint (the day iTunes 403/429’d her ~1,600x and collapsed the /guess clip pools to a few songs, she watched her backbone crater and said nothing). One @tasks.loop(30min) tick reuses the ops-monitor’s own Railway pull (scripts.ops_monitor.fetch_events, run off-thread; gated on RAILWAY_API_TOKEN/RAILWAY_SERVICE_ID, fail-open to a no-op) then runs the pure utils.health.assess over the EVENT window to find a watched backbone integration that has cratered (fail-rate over a floor with enough real attempts, reusing the ops-monitor OkRate counter, hoisted into utils.health so there’s one definition the report + watcher share, the utils/stats.py move). For each crater she (1) autonomously files a fix-order with the real numbers (cogs.order.Order.file_health_order, the bot-attributed sibling of file_autonomous_order: same preflight + kitchen/pipeline/in-flight + AUTO_ORDER_DAILY_CAP gates, attributed to guild.me, filed in the first guild whose gates clear), deduped durably per integration via health_state (24h window, process-wide since a throttle is process-wide, survives the redeploy the filed PR triggers), and (2) alerts the MASTER in #bot-logs with an @master ping (the bot master (utils.permissions.MASTER_USER_IDS)) that a surface has gone dark — #968 owner steer: to #bot-logs ONLY, never a content room, and now for EVERY crater incl. the ops-only backend watches that used to file a fix-order silently (the gap the drained twitterapi.io wallet exposed — detection worked, the fix-order filed, but nothing reached the owner). Deduped per (guild, integration) durably, backstop-gated (a covered outage isn’t dark), and deliberately NOT mood-gated (an ops alert isn’t proactive content, so a muted room can’t hide “your feed is down”; the master kill switch still silences a fully-off bot). Posts with a user-only allowed_mentions so the ping notifies but a stray @everyone/@role can’t. The watched set (WATCHES) is apple_music (keyed on apple_music_lookup’s clean throttle signal, not catalog_lookup’s noisier empties) plus every market_fetch source — api_sports (the commentator’s scores/events/leaders/team-stats/standings backbone), sgo + the_odds_api (the betting lines), and polymarket + kalshi (the prediction markets) — each pinned to its source via the optional Watch.source sub-filter so the sources don’t cross-count, all clean because a successful empty slate is ok=True (only a real fetch failure is ok=False). So a crater of any market/odds/score feed self-files + warns, not just the sports backbone. Broadened to the whole app backbone (#968 “watch everything”): beyond apple_music + the market feeds, the registry now also watches embeddings (OpenAI — semantic recall + Kalshi discovery), elevenlabs_tts + elevenlabs_stt (voice in/out), perplexity (live-web grounding, kinds = all pplx_*), twitterio (#1255 — the content curator’s X source, keyed on curator_fetch; a datacenter-IP crater or a drained credit balance quietly stops the curator finding fresh posts + crawling the follow/repost discovery graph, ops-only notice=""), and odds_divergence (the cross-source line-disagreement signal from #968 — it has no ok field, so its Watch.ok_when predicate treats a ≥15pp odds_health.max_divergence as a “failure”, keeping it inside the same assess machinery). Most of these are ops-only (empty notice → files a fix-order but posts no room warning, since a backend crater isn’t a visible “I’m degraded” moment); only voice (tts) keeps a room notice. Each has a CLEAN ok signal (a real failure, not an empty result). Fail-soft surfaces with noisy signals (gif/image/reference/links/video) are deliberately left to the twice-daily ops-monitor’s integration-health (OBSERVE, no fix-order), not acted on here — the two-tier split: the 30-min loop ACTS on the load-bearing set, the ops-monitor OBSERVES everything. The registry is the extension point for more. Backstop-aware notice suppression (#748): a Watch can name a backstop integration (a bot-client attribute) that carries its user-facing impact when it craters; when that backstop is provisioned + healthy (breaker closed) the in-room notice is held (the fix-order still files for ops) and only fires when the backstop is ALSO down. SGO rides this with backstop="the_odds_api": once the Odds API SGO-down backstop shipped, an SGO crater no longer drops betting lines, so the “lines might be missing” warning would over-promise — she stays quiet unless the_odds_api.degraded too (“everything flaky and down”). The other watches declare no backstop, so they warn on any crater. Fail-open throughout. Emits health (action=filed/dedup/alert/usage_alert, with delivered=bot_logs on a posted owner alert, or delivered=suppressed/reason=backstop_healthy on a held one). The same tick also runs the metered-API usage poll (_emit_usage, #727), and now drives an owner-facing metered-wallet EARLY WARNING off the same reports (_maybe_usage_alert → utils.usage.usage_alerts): a prepaid wallet under its floor (twitterio < 100k / scrapecreators < 500 credits — the credit drain that took the curate feed dark) or a capped source ≥85% of its limit pings the owner in #bot-logs before it drains to a 402/429 wall (deduped per guild on a durable key: usage:<source> with a 12h window for a prepaid wallet, since a balance drains slowly; usage:<source>:<period>:<level> for a capped source, where level is low (≥85%) or full (at the cap or 0 left). The key carries the level so an 85% post never hides the later full post, and the period so a day post never hides a month post. A month key repeats once every 7 days, other periods every 12h. #4186: a full SGO month cap posted every 12h before this. A calendar-month window was tried and rejected, because ElevenLabs resets on its billing date and a stale reading on the 1st would use up the month’s only post): it reads each metered source’s quota via utils.usage.collect_usage (SGO’s quota-exempt /account/usage, API-Sports’ free /status, the Odds API’s free header refresh, Highlightly’s passively-captured headers, ElevenLabs’ free /v1/user/subscription monthly character cap, GitHub’s free /rate_limit REST-core budget, and Giphy’s daily + OpenAI’s per-minute (informational) rate headers captured passively off their search/embed calls, #753; plus twitterapi.io’s prepaid CREDIT BALANCE via the quota-exempt /oapi/my/info, #1255 — a prepaid wallet with no fixed cap, so it flags on a balance FLOOR, not a pct) and emits one quota event per (source, metric), running unconditionally (bot-wide, not per-guild, so it fires even with no configured guild / no Railway creds). This is what turns a quota filling toward its cap into a flagged ops-monitor quota_low finding (high) before the 429 wall — the early-warning that was missing the day SGO’s monthly entity cap (100k, rookie tier) silently exhausted and took /bet dark while API-Sports sat at 90% of its daily cap.voice.py, voice in → voice out: a Discord voice note directed at Toots is transcribed (ElevenLabs Scribe), answered through the exact /ask pipeline (Ask.produce_answer, allow_voice=True so the model can tag a <sing> delivery), and replied to with a native voice note (no file-card attachment). “Directed at her” is a reply to one of her own messages: a Discord voice message has no text body, so it can’t carry an @mention — reply is the only deterministic spoken summon (a voice note that isn’t a reply to her is ambient room audio, heard as transcribed context by the chime-in path, not answered here). Runs whenever the ElevenLabs key is provisioned; whether she answers out loud vs in text is provisioning (the ElevenLabs key / bot.tts): when provisioned she speaks, else she stays in text. A spoken question always gets an answer (text at minimum). Shares the per-user ask rate bucket and the /ask abuse classifier. Failure degrades to a plain text reply (never a file): transcription miss → in-voice deflection, synth/send miss → the composed answer as text. cogs.ask skips voice notes so this cog owns the path. The voice-out delivery decision lives in the public deliver method and is shared with the typed-mention path (cogs.ask routes its mention answers through it with user_sent_voice=False): a voice note forces voice-in→voice-out, a typed mention voices only when the model nominated it, so the delivery format follows the answer, not the input channel.starboard.py, fixes + leaderboards for the server’s MEE6 #wall-of-fame starboard (two independent boards, one keyed on 😭 and one on 😂, each promoting at 10 of that emoji). MEE6’s promotion is rate-limited to one message/minute, so a message that crosses the bar in a busy stretch silently never makes the wall. /star period:[1h|1d|1w] (mod-only, optional threshold defaulting to 10) sweeps the configured discourse channels for messages that cleared a board but aren’t on the wall and lists them as jump links (posted in-channel, NOT ephemeral so anyone can help react; paged; each line tagged with the author mention, pings suppressed) for a HUMAN to react to, because MEE6 ignores reactions added by bots (confirmed in prod #541 via Railway logs: a /star bump added 65 reactions across 64 messages and MEE6 promoted none — starboards gate their reaction handler on if user.bot: return), so only a person’s reaction re-triggers MEE6. (An earlier mode:bump that re-reacted as the bot was removed for exactly this reason — it never promoted anything.) The scan runs in the background (acks ephemerally, posts the list when done) so a big week’s sweep can’t hit the 15-min interaction-token timeout. The report filters out already-promoted messages by harvesting original-message ids from the jump links in the wall’s MEE6 posts (parse_jump_message_ids), resolving the wall channel via the stored channel: pin (set on /star) or a name auto-detect. /star also carries the optional channel: wall-pin (the new home for it — it used to live on /leaderboard bangers, which folded into the /leaderboard stats hub): the pin persists to the per-guild settings KV (wall_channel_id) so /star + the card’s wall-bangers flex resolve the wall by id thereafter, else a name auto-detect (looks_like_wall_channel matches any wall/hall + fame channel ignoring emoji/separators so spelling variants all hit — wall-of-fame, the server’s actual wall-off-fame, etc.; so zero-config works out of the box and a one-time pin makes it rename-proof). Emits starboard_report. /leaderboard stats (everyone) is the single engagement HUB that absorbed the former separate /leaderboard bangers / reactions / card commands (this PR — they were redundant with the in-card board picker, owner steer): one navigable _LeaderboardView driven by FOUR in-card dropdowns so every stat board is one surface: the board toggle (🏆 bangers / 🔥 reactions / 😭 funniest / 🃏 card OVR / 🫶 besties), the when: PERIOD window (all-time / this month / this week, #1017), the show: scope (here-now / everyone), and the how-many: row count (top 10/25/50/full). The command is argument-free — everything is a dropdown (the period + count moved off slash args, owner steer). The hub is never gated so the always-public bangers + reactions + funniest boards stay reachable even with cards off; it opens on the card OVR board when the viewer can see it (the headline ranking), at all-time / top 10, else bangers. The funniest board (#1017) ranks all-time laughs pulled per member (laugh_reactions, 😭/😂) straight from the seed — the comedy-VOLUME twin of bangers (wall-clearing posts) and the leaderboard mirror of the awards’ Funniest (_build_funniest_seed, public like bangers/reactions). WINDOWED stats (#1017): all-time reads the member_engagement seed directly; weekly/monthly subtract a daily SNAPSHOT of the seed from N days ago (db.engagement_snapshots + the pure utils.engagement_window: window_display/window_state diff the cumulative counters + the reply/mention graph) — so the SAME board builders re-rank over the window (_period_display/_period_state swap the data source; the card OVR over a window is a ‘form’ rating via ovr_board(windowed_seed=); besties over a window is the ‘best new duo’). One daily snapshot feeds BOTH weekly + monthly by subtraction — no extra scan jobs, instant reads. Forward-looking (windows fill in as snapshots accrue; a window with no snapshot yet renders EMPTY, never all-time mislabeled). The bangers board ranks all-time BANGERS per member — posts that cleared the wall bar (10+ 😂 OR 😭) — read straight from the member_engagement stats seed, no scan (#957: the bangers board moved off the wall onto the seed; this is the seed’s would-be-wall count, a SUPERSET of MEE6’s actual wall promotions — the card’s separate “wall bangers” flex still reads the wall for that). The reactions board ranks total reactions pulled per member, also straight from the seed (the /stats full-history scan, kept fresh by the daily catch-up writer) — no live scan (#957: the old LIVE windowed 6mo/1w/1d scans were dropped; weekly/monthly are now the period: snapshot-delta windows (#1017), derived by subtraction, not a live scan). Boards show the full board with no row cap (paged past Discord’s 4096-char description limit), delivered as ONE navigable message — the interactive _LeaderboardView (#902): ◀/▶ page flips that are in-memory over the prebuilt pages (instant, no re-fetch), the board picker dropdown (data-driven off self.boards), and a show: scope dropdown (here-now / everyone) that filters whichever board you’re on by guild membership (the universal departed filter, default here-now). Only a board/scope change re-fetches (defer + _build_board dispatcher, which reuses the board builders + resolves the right data source); a public shared board, so anyone in the room can navigate it. The card OVR board (#570) ranks active members by their /card OVR (rows show the OVR number) — built off the PlayerCard cog’s ovr_board (the SAME member_engagement seed + snapshot the card reads, so a member’s board OVR == their card), rendered through the same shared _board_pages as bangers/reactions for a consistent board (_rank_mark + mention_or_name, real-member-only via the PR #985 roster fold). It’s gated on the playercard experiment (PRODUCTION=everyone, STAGING=mods-only, OFF=hidden — exactly as visible as the cards it ranks) and joins the toggle picker only in PRODUCTION (_available_boards), so the toggle never exposes the card/besties surfaces to a non-mod during the staging preview — a mod previewing in staging still gets the card board surfaced by run_stats. (The games + bets boards stay their own /leaderboard subcommands: different data shapes — game points, play-money — not folded into the stats toggle. /leaderboard games is argument-free too (mirroring this hub): its options — when: PERIOD (this season / all-time) + how-many: row count (top 10/25/50/full) — live on in-card Components V2 dropdowns (a _GamesLeaderboardView in cogs/games.py mirroring this hub + /card), so period/count re-slice in place; there’s one game today so it opens straight on Guess the Song (a board picker returns if a second game lands). The board build is the shared Games._build_games_board — paged under the V2 text budget so a “full board” pages rather than truncating.) Boards run anywhere and are all-time and uncapped (accuracy over a ceiling — explicitly NOT capped, per owner steer on #541), tallied by USER ID (fold_entries keys on uid:<id>, the id coming from the embed-author avatar URL for the wall / the message author for reactions) so a person’s posts merge across a display-name change — the prod bug where @zapper showed up as two rows (4 + 2 moments) because he’d renamed. An embed-fixer webhook repost carries the user’s name but the webhook’s id, so it’s keyed by normalized name (clean_author_name strips the (Embed Fixer) marker + #0000 discriminator) and folded onto the real user by resolve, which matches a name-keyed entry against the set of names each id has posted under (so a fixer repost under an old name still lands on the right person). resolve is seeded with the real (non-bot) MEMBER ROSTER (_member_roster, guild members’ display name + username → id) so a webhook/embed-fixer repost folds onto a real member by name EVEN when that member has no id-keyed wall post of their own — and, critically, an entry that matches NO real member is DROPPED (rank_tally now excludes uid=None slots). That’s the fix for the wall board filling with tweet-source reposts (Bleacher Report (@BleacherReport), Fabrizio Romano, …): a fixer/webhook repost of a tweet shows the TWITTER account as the embed author (a Twitter avatar, no Discord id, a name matching no member), so it now stays off the members-only board instead of topping it. The same roster-fold + drop applies to the reactions board (consistency). Every stat board now reads the member_engagement seed — NONE scans the wall. The wall incremental durable cache (db.cache_get/cache_put, namespace walloffame_board_v3, keyed {guild}:{channel}: a cursor + settled tally per channel, re-scanning only new wall posts, immutable → settle on sight) survives for exactly two consumers that genuinely need the ACTUAL MEE6 wall (the seed can’t capture promotions): /star (the un-promoted sweep) and the card’s “wall bangers” flex (lifetime_totals). The bangers + reactions boards read bangers / reactions_received per member from the seed (instant, kept fresh by the daily /stats catch-up writer). The former cold-cache background board-build path (_serve(background=...) + _deferred_board) was removed once every board moved to the seed — boards are always built inline now. The discord-free core (cleared_emojis / message_reaction_total / parse_jump_message_ids / classify_message / parse_avatar_user_id / clean_author_name / fold_entries / resolve / rank_tally / merge_tallies / tally_authors / tally_reaction_totals) lives in utils/starboard.py, unit-tested. The besties board (#570 follow-up) ranks the server’s closest PAIRS — the Ochiai coefficient (provably == cosine similarity; validated against the tie-strength literature, beating PMI/NPMI which inflate rare pairs) of each pair’s two-way reply+mention graph, read from the engagement seed’s state blobs (the full uid-keyed reply_targets/mention_targets Counters — no scan). It’s activity-normalized (the geomean of each member’s share of interaction aimed at the other), so it surfaces genuine duos, not the loudest hubs — “are you each OTHER’s people”, not “are you both loud”; reciprocity is baked in (a one-sided pair’s geomean collapses). The show: filter is UNIVERSAL across the whole /leaderboard family (#570 follow-up — this is also the “departed filter for leaderboards”): a _ScopeSelect dropdown on the _LeaderboardView toggles here-now (members still in the guild) vs everyone (incl. departed — the all-time view) and re-filters WHATEVER board you’re on (bangers / reactions / card / besties), defaulting here-now, persisting as you switch boards. The membership filter lives ONCE in _board_pages (drops uids not in guild.members, applied BEFORE the row cap so ranks re-number contiguously); besties applies the same Scope via closest_pairs. Besties itself is a board IN the shared toggle picker (_BOARD_OPTIONS + _available_boards, joins the cycle in PRODUCTION like card — no separate command, reached by switching boards in the picker), rendered with pair rows (A ↔ B · 20%); playercard-gated exactly like /card (same seed). A fixed interaction floor (40) is the small-sample guard (Bayesian shrinkage is moot at all-time scale — verified — but is the lever a future weekly window would need). The pure closeness core is utils.social_graph.closest_pairs (unit-tested); the cog reads the seed + resolves names. Emits starboard_report + walloffame_leaderboard + reaction_leaderboard + funniest_leaderboard + card_leaderboard + besties_leaderboard./undo command, and its admin.py cog, both removed) now lives in the /order view as a confirm-gated “↩ undo deploy” button, backed by the interaction-free order.py:_undo_deploy core. It’s deploy-scoped (rolls back the live Railway build, not a specific order), so it sits with the kitchen toggle, not the per-order retry/cancel, and only shows when RAILWAY_API_TOKEN + RAILWAY_SERVICE_ID are set. /order’s view is now the single mod-ops console: it absorbed /close, /open (kitchen toggle), and /undo.settings.py, /menu interactive wizard — the single mod-config surface. Config pages (Discord caps a view at 5 action rows / ~5 select menus, so the channel pickers are split across pages to stay ≤4 selects each), paged via nav buttons. The cinema alerts channel picker (_CinemaSelect/db.get_cinema_channels) lives in the channels hub, like every other picker. It used to sit on the betting & markets page (owner steer: the movie/TV NUMBERS are the markets-adjacent factual layer), and it stayed on the market-TOPICS editor page after #2137 moved the pickers into the hub. That left the same select in two places, so the copy on the topics editor is deleted (a #2137 follow-up). The topics editor now renders only its three room → category → tag steps. The channels HUB (_PAGE_CHANNELS, #2137 follow-up — owner ask “move them to one page with buttons”): every posting surface’s channel picker is reachable from ONE directory page — a button per surface (the _CHANNEL_HUB registry: key, label, state key, select class, single?, blurb) plus a “wired now” chips summary and the market-topics button; each button opens the shared one-select edit page (_PAGE_CHANNEL_EDIT, keyed by MenuView.channel_edit_key), so the hub carries 0 selects and a NEW surface’s picker is one registry row, never a new page. It REPLACED the four scattered picker sub-pages (market channels / pop desk / sports desk / cinema desk); reached by the betting & markets page’s 📺 set up channels ▸ CARD button — deliberately NOT an extra nav-row button (owner steer “keep the more ▸ convention”). The market-topics button rides the hub grid too (owner steer “this one too”); the topics editor’s ◂ back returns to whichever page opened it (MenuView.topics_back). The grouped pages below remain a second way into the same settings (same select classes, same storage/autosave). The channel pickers are also grouped onto contiguous pages so the wiring sits together: page 1 = people + vibe (the non-channel selects) = mod roles, mood, girls roles, ignore (silenced members) + the master kill switch on the nav row; page 2 = channels part 1 = discourse channels, chime-in channels (where Toots jumps in), memory channels (what the long-term memory writer reads), music channels; page 3 = channels part 2 + sports coverage = sports channel (the live-commentary channel) with the watched-sports picker beside it (_WatchedSportsSelect, a string multi-select over utils.sportsdata.sports.WATCHABLE_SPORTS — which sports the live commentator calls; empty = all (the default, stored null), so a guild only narrows when it wants to, e.g. mute the MLB/NFL breadth and keep the World Cup, persisted to the watched_sports settings KV and read by cogs.commentator._resolve_targets to filter each tick’s games per guild) plus the betting-sports picker beside it (_BettableSportsSelect over the settleable subset utils.sportsdata.sports.BETTABLE_SPORT_KEYS = soccer/NBA/MLB/NHL/NFL/UFC-MMA — which sports the play-money Bookie takes bets on; UNLIKE watched, unset = the {soccer, NBA} default, NOT all, so the new sports MLB/NHL/NFL/MMA ship OFF until a mod turns them on (#mlb-betting), persisted to the bettable_sports KV, read by cogs.bookie._bettable_for_guild which serves a game only if its sport is in the guild’s bettable_sports set (unset = the default; an emptied picker is stored as [] = bet on NO sports, owner steer 2026-10-11) — betting is INDEPENDENT of watched-sports (owner steer: the two menus are fully separate, so a guild can take MLB bets without commentating MLB and vice versa); the metered Odds-API odds fetch is likewise scoped to the cross-guild BETTABLE union (_bettable_sports_union), not watched. Page 3 also carries the live-commentary PACE knob (_CommentaryPaceButton → _PaceView): the heartbeat/clutch check-in intervals (the two SETTING_COMMENTARY_*_INTERVAL_MIN tunables, commentary_cadence) were MOVED here off the tune page so they sit beside the sports config they scope (the working-hours precedent — a knob lives with its config, hidden=True on tune). It’s a BUTTON opening a dedicated pace SUB-VIEW (_PaceView, swapped in place with a [◂ back] like the models/calendar/tune tail), NOT inline selects, because page 3 already has 3 select menus and Discord caps a message at 5 (the split that made this page keeps each ≤4) — so the two dropdowns get their own view with full headroom rather than pushing page 3 to 5/5. The sub-view renders the two intervals as preset DROPDOWNS (_PaceSelect, reusing the tune editor’s band-filtered preset_values/_option_label ladder, so a pick is always in range), each saved to the settings KV on pick; on [◂ back] the picked values sync into MenuView.selected so the page-3 button label (⏱️ 20m / 10m ▸) updates. The category-routed prediction-market surfaces (market_drop + market_alert) filter their SPORTS content by WATCHED sports ONLY (markets.resolve_market_sports), NOT the bet menu (owner steer — reverts the #1231 watched ∪ bettable union): the BET menu (bettable_sports) is BOOKIE-only and must not gate market drops — a bettable sport still powers /ask scores/odds, the commentator, and the Bookie’s prediction-market pulls (those fetch snapshots per game, never through this filter), but it only appears in a market DROP when its market is actually selected for the room. So the per-room Kalshi category/subcategory picker (_DropSubcategorySelect) is the SOLE control over a drop’s sports when watched is unset (the common case → resolve_market_sports returns None → no sport filter); a set watched list is a coarse per-guild narrowing on top. The subcategory picker now PAGINATES its tags (_subcat_all_pairs + _subcat_page_count, ◀/▶ _SubcatPageButton) since Discord caps a select at 25 options and a room’s covered categories can carry more (Entertainment’s ~24 live Kalshi tags + Sports’ ~20) — so NOTHING is hidden (the bug where a room covering Entertainment + Sports showed only Sports · Soccer and hid Tennis + every other sport). The paged multi-select MERGES on save (_page_values) so a selection made on one page survives editing another; subcat_page resets on room/category change. DEFAULT_BETTABLE_SPORTS (the {soccer, NBA} default) is hoisted to utils.sportsdata.sports as the shared source of truth (bookie + the market filter both read it). The WHOLE lifecycle works because the Bookie sources authoritative API-Sports STATE for the new sports on demand across all phases — hub.live_games/upcoming_games/recent_finals all take an api_sports_extra set (the enabled-new-sports union), so a MLB/NHL/NFL/MMA game shows correct pregame/live/final transitions even while SGO (the ODDS source) is throttled (the ODDS still fold from SGO/The Odds API/Polymarket+Kalshi; only the game STATE + settlement come from API-Sports). The slate scans are anchored on EASTERN TIME and cover TODAY + TOMORROW for all sports (#sgo-espn-backup): the day anchor is _eastern_today (not date.today()/UTC), so a whole US sports day stays on one date and UTC midnight (8pm ET) never splits a night’s card mid-slate. ApiSportsProvider.upcoming_games scans today AND tomorrow for the CORE sports too (soccer/basketball), not just the extra sports. Core USED to stay today-only because SGO’s get_upcoming_scoreboard supplied their multi-day slate; when SGO 429s that slate is empty and a US-evening game (tonight’s WNBA — a 7pm ET tip) never reached /bet (“WNBA isn’t showing”). Once surfaced, a game is priced FREE-FIRST (#3552): the ESPN pregame line (the provider folds it onto its own pre rows at conversion; _supplement_with_espn_odds donates it onto an API-Sports/SGO row of the same matchup from the provider’s cache, fold_line_from_snapshot), then the prediction markets (_supplement_with_live_predictions: the live reprice + the unpriced fill, bounded to _MAX_PREDICTION_PRICING games per refresh and dealt ROUND-ROBIN ACROSS SPORTS by models.round_robin_by_sport so one sport’s slate cannot starve another’s – a plain soonest-first sort pushed a 00:15 NFL game past the cap behind fifteen 23:00 NHL games and left it out of /bet, #3576), and the metered Odds API h2h (_supplement_with_odds_api) ONLY for a game still unpriced after both and not already asked about this hour (_odds_api_misses); a second, stale-live-only prediction pass then reprices any live game the Odds API introduced. The old order ran the Odds API first and fetched every bettable sport whenever sgo.degraded — the trigger that spent the month’s credits. On a tie in priced sides the slate keeps the sportsbook-fed row over the ESPN-stamped one (_slate_rank). The SETTLEMENT + LIVE scans (recent_finals/live_games, API-Sports and the ESPN backstop) union BOTH the Eastern and UTC anchors (_anchor_days): API-Sports files a fixture by its UTC date, so in the ET/UTC offset hours (00:00-04:00 UTC, before Eastern midnight) an Eastern-only scan would omit the current UTC bucket – a late US final would miss settlement until Eastern midnight and leave a stale SGO live row bettable (Codex #2586). Unioning both anchors is a strict superset of the old UTC scan, so settlement never regresses. Settlement is a per-sport REGISTRY, interchangeable the way the odds feeds are (#tennis-registry / #1113 enabler): utils.sportsdata.sources.SourceCapability.settles is a per-SPORT set (not a bare bool) declaring which sports each feed can authoritatively finalize — API-Sports its six hosts (soccer/NBA/MLB/NHL/NFL/UFC), The Odds API /scores the TEAM sports only (verified live: MLB 32/32 completed, but tennis AND mma come back 0-completed, so both are HONESTLY excluded — the matrix’s “encode the real competence boundary” rule; MMA’s real settler is API-Sports /fights). The Bookie’s bettable-capability set (_SETTLEABLE_CAPABLE) now DERIVES from settleable_sports() (the union of every feed’s settles) instead of a hardcode, and the settle loop is a _SettleLeg resolver (Bookie._settlement_legs) that walks the feeds in settlement_sources(sport) PRIORITY order (API-Sports the primary dedicated feed; The Odds API /scores an ESPN-DOWN-only backstop since #3552 (Bookie._espn_settling: the ESPN provider wired and the ESPN client’s breaker closed means the metered leg does not run) — it had been ALWAYS-ON (#725/#nfl-preseason) because API-Sports’ baseball/NFL hosts are on a free plan that reads only 2022–2024 seasons, so a 2026 MLB/NFL final never arrives there while the breaker stays CLOSED on the healthy soccer/NBA hosts; the free ESPN leg now meets that need for every team sport; ESPN is the THIRD layer for those same two sports (#2466) — the Odds-API leg is METERED, so a quota lapse or key failure would leave MLB and NFL with no settler at all, while ESPN is free, keyless, and carries the full final scoreline for both (verified live 2026-08-31: MLB 14/14 and NFL 14/14 completed games returned both scores). Its settles set grew from {tennis} to {tennis, baseball, americanfootball}, and bot.py derives the ESPN provider’s always-on fetch set from that same CAPABILITIES["espn"].settles rather than listing it twice: a sport ESPN is registered to SETTLE must be FETCHED whatever the breaker says, or the leg reads an empty feed and the bet voids at the 96h stale-void. ESPN’s per-game DEPTH now includes the play feed (#3404): espn_summary_events parses a football / hockey / baseball summary’s scoringPlays (no type.type, no scoringPlay flag — the soccer keyEvents fields the old mapper required, which is why every NFL touchdown was dropped) into GameEvents whose clock is the period + clock (“Q1 3:42”, “P2 11:48”, “inning 7 0:00”) and whose detail is the play text plus the score after it; espn_summary_recent_plays reads the last plays off drives.current.plays (football; falls back to the last finished drive between drives) or a top-level plays list. EspnScoreProvider.game_recent_plays + SportsDataHub.game_recent_plays carry it (a provider without the method reads as []). The team scoreboard row also keeps ESPN’s status label (TeamGame.status_detail, “11:48 - 2nd Quarter”) and the snapshot carries it as period once the game is live or final, so an ESPN-sourced live game shows its clock on every scoreboard render instead of only “in progress”. The should_fetch gate could not be left on api_sports.degraded for the same reason the Odds-API leg could not — that flag reads ONE shared breaker which the healthy soccer/basketball hosts hold CLOSED, so it can never open for a plan-limited host. MMA and the remaining team sports (soccer/NBA/WNBA/NHL) KEEP the degraded gate, because API-Sports really does settle those and a free no-SLA host should not be polled for games it is not the backstop for) — each finalizing only the sports it covers through the shared idempotent _settle_game, so an overlap can’t double-pay. The refactor is behavior-preserving (all prior settle/backstop tests unchanged) and makes settlement extensible the way odds already are: adding a new sport is a matrix/registry entry + a canonical_* name fold (a dedicated tennis provider with settles={"tennis"}, or a new API-Sports host like rugby/handball/volleyball — the hosts exist, verified 2026-07-03), NEVER a settle-loop edit; a sport no feed settles (settlement_sources()==()) is derived-out and never offered. utils.api_sports adds v1.baseball/v1.hockey/v1.american-football + the shape-flexible _team_game_to_snapshot, plus v1.mma’s /fights + _fight_to_snapshot (an INDIVIDUAL-event shape: fighters.first/second with an explicit winner boolean and no scores/league, so the winner is encoded as a 1-0 ‘score’ and the shared settlement path resolves it unchanged — a no-winner draw/no-contest reads 0-0 → a 2-way PUSH; season-less, so it plugs into _EXTRA_SPORT_FETCH via _no_season). utils.sportsdata.names.canonical_us_team (co-located with the soccer FIFA-code fold) folds cross-provider team-name spellings so a bet reconciles across the pricing + settling feeds — including a per-sport NICKNAME fold: SGO prices the US team sports with the bare nickname (Cubs, White Sox, 49ers) while API-Sports settles with the full name (Chicago Cubs, …), so canonical_team(name, sport) maps both onto one canonical (the nickname map is built per-sport + drops ambiguous keys so the Cardinals MLB/NFL collision can’t cross-resolve, and the bare Sox is dropped in favor of Red Sox/White Sox); NBA was added to the US roster (it was missing entirely). Without this, SGO-priced MLB/NHL/NFL/NBA bets reconciled 0% against the API-Sports final and all stranded — invisible during #1101 because SGO was throttled, caught live 2026-07-03 the moment it reset (SGO 0/30 → 30/30 on MLB after the fold). Soccer CLUBS get their own fold, canonical_soccer_club (#1469 Stage 2): a club is spelled differently by each feed (API-Sports settles “Newcastle”/”Bayern München”/”Inter”; SGO + The Odds API price “Newcastle United”/”Bayern Munich”/”Inter Milan”), and norm alone can’t bridge a truncation/compound-drop/transliteration/org-affix — so an unbridged club bet stranded + voided at 96h (Gap B on epic #1469, the national+US analog for clubs). Same machinery as the US roster: a curated _SOCCER_CLUBS table of (canonical, aliases) across the six covered leagues (EPL/La Liga/Serie A/Bundesliga/Ligue 1/MLS), built into a _us_key-normalized key→canonical map with the AMBIGUITY GUARD (a key two different clubs would claim is DROPPED → a safe fall-through to norm → a void, never a mis-pay). Canonical = the API-Sports settlement spelling EXCEPT where it nests as a substring of another club (“Inter” → “Inter Milan”, so the hub’s containment matcher can’t merge it with “Inter Miami”). Verified live 2026-07 against the API-Sports + The Odds API rosters (every measured same-club pair converges, every distinct-club pair — Man Utd/Man City, the three Real’s, Inter Milan/Inter Miami, AC Milan/Inter Milan — stays split, zero ambiguous drops); ships dormant-but-ready (off-season until ~Aug/Sep), an incomplete roster is SAFE. MMA fighters get their OWN fold, canonical_fighter — SGO abbreviates the first name (K. Usman) while API-Sports spells it out (Kamaru Usman), so both reduce to initial+surname (k usman), applied by match_key_parts (game dedup + bet↔final keying) AND bookie_bet_outcome(person_names=True) (winner↔side-label matching) when the sport is mma; without it a SGO-priced UFC bet reconciled 0/16 against the API-Sports final and stranded (live-caught 2026-07-03 the moment SGO’s throttle reset — the fold lifted it to 12/12 of the fights on both slates, the rest just not yet in the API-Sports window). A 2-way tie PUSHES rather than loses (bookie_bet_outcome(three_way=...); MLB/NHL never tie, a rare NFL tie refunds, an MMA draw/NC pushes; MMA is NOT in _THREE_WAY_SPORTS). MMA is UFC-scoped by a fold-only guard (_ODDS_API_FOLD_ONLY_SPORTS = {"mma"}): The Odds API’s mma_mixed_martial_arts key prices EVERY MMA promotion (UFC/Bellator/KSW/regional) but API-Sports /fights settles UFC only, so the Odds-API supplement FOLDS its line onto UFC fights already surfaced (by SGO’s UFC-scoped league or API-Sports) but never ADDS a non-UFC fight — which could never pay out a win (it would strand + void at 96h); live-verified 2026-07-03 (36 Odds-API MMA fights, ~12 UFC on the API-Sports slate). Tennis stays unbettable — API-Sports has NO tennis host (v1.tennis doesn’t resolve, verified 2026-07-03), so no finals feed to pay out a win; deferred), then feed channels + logs channel. chime-in + memory are split out of the discourse set so each can watch a different set of rooms, each independent (empty = that surface goes dark; a one-time DB seed copies the discourse set into both on first deploy so nothing regresses, then they’re edited separately); discourse posts/icebreakers + starboard sweeps keep the shared discourse set. The config pages carry a consistent nav row — ◂ back/⏻ power · more ▸ (steps to the next page) · experiments ▸ (the one-tap jump to the experiments hub, on every config page; on the prediction page, whose next page IS experiments, it replaces more ▸). The final hop experiments→models is its own models ▸ on the experiments page (the friendlier pages come first; the raw-numbers tune editor is now LAST), and the models page’s back is ◂ back (it steps back to the experiments page). Arrow-styled to match the nav row rather than standing out as emoji shortcuts. page 4 = the experiment rollout selects (one tri-state select per registered experiment, paginated via ◂/▸ nav buttons sharing the nav row once the registry grows past _EXPERIMENTS_PER_PAGE (4) selects, so a 5th+ experiment is reachable instead of dropped); the experiments page (page 4) leads via its [models ▸] nav button into the chain tail — models → calendar → tune — each a separate view swapped in place with a [◂ back] to its predecessor. There is no standalone /tune command — the tune editor is only reachable through /menu. Every select auto-saves and re-renders the embed. This cog absorbed the former standalone /logs, /girls, /ignore, and /experiments commands (those cogs were deleted). The experiments page leads to the Models page (cogs.models) via a [models ▸] button — one opus/sonnet select per answer/generation surface (ask/recap/discourse/memory) — which in turn reaches the calendar (cogs.calendar_view) via a [calendar ▸] button: a scheduled-post day-planner (chill/yaps pages over a 30-minute working-hours grid — each hour holds a :00 and a :30 slot — that the discourse/music/market-drop/bet-board/curator schedulers read in place of their fixed CHILL_TIMES/YAPS_TIMES slots when a mood is managed — auto-spread the surfaces evenly + tweak per slot, no overlaps, stored in the post_schedule settings KV; falls back to the full hardcoded slot pools when unset, so an unconfigured guild is unchanged — the calendar is now the ONLY per-guild control over scheduled-post frequency (the per-mood posts-per-day caps were removed). The calendar also carries a 🔥 FIREHOSE picker (_FirehoseButton → the _FirehoseView sub-view → THREE guild-wide, DISJOINT settings sets, one per CADENCE: scheduler_firehose_surfaces = every 30 min, scheduler_firehose_hourly_surfaces = every hour, scheduler_firehose_two_hourly_surfaces = every other hour): a PER-SURFACE opt-in where the picked surfaces post at EVERY working-hours slot OF THEIR CADENCE (ignoring their grid assignment), resolved in the single chokepoint every surface reads — schedule_calendar.calendar_hours returns firehose_slot_times(working_hours, cadence) for a surface in a set, else the stored calendar. The sub-view renders one multi-select per cadence and a pick drops the surface from the other two, so a surface has exactly one cadence; the every-other-hour pattern is anchored to the WINDOW START (the first working hour always posts), so moving the window start by one hour shifts the whole pattern by one hour. Per-surface (not all-or-nothing) so a guild firehoses the cheap token-only surfaces (curator/discourse) and leaves the SGO-capped metered ones (market_drop/bet_board) on the normal calendar — the affordable shape per the #firehose cost analysis (whole-bot LLM spend is ~$4/day; the wall is SGO’s monthly entity cap, already exhausted at baseline). The grid stays fully editable (it still drives the non-firehosed surfaces), off by default, never overrides a muted mood, and each firehosed surface is still thinned by its own channel config + dedup + the 0.6 self-gate + rate limits; emits scheduler_firehose). Trial-feature rollout stages (off / staging / production) are backed by the registry in the top-level experiments.py module + the per-guild experiment_states table; each registered experiment gets one tri-state select that auto-saves. GRADUATION (2026-09-01, #2796). A permanent surface leaves the registry: its Experiment is deleted, its key is added to experiments.GRADUATED_MASTER_ONLY or experiments.GRADUATED_ALWAYS_ON, and its gate swaps db.resolve_experiment_state for utils.permissions.graduated_stage (master-only) or a plain ExperimentState.PRODUCTION (always-on). A graduated surface then has NO per-guild stage, NO /menu row, and can never read STAGING – provisioning plus the kill switches are its only gates, and its stored experiment_states rows are ignored. 46 surfaces graduated the same day, in TWO passes, cutting the registry from 50 to 4 and the experiments page from 13 sub-pages to 1. A THIRD pass on 2026-09-07 took 4 more (owner steer “graduate these to on for master guild”): music_chart_tenure, music_board_presence, music_riaa_certs, music_riaa_boards – the #2800 music lanes the master guild had promoted since. Read off the live experiment_states table that day, all four were production in the master guild and off in the other guild, so master-only graduation changed no room’s behaviour; the four cogs/music_desk.py + cogs/music_news.py gates now call graduated_stage and the two RIAA gates collapsed into one read. The FIRST pass (34) took every surface at production in the master guild for three weeks or more. The SECOND (12) took the rest that were at production in the master guild at all, on the owner’s steer “everything in production in master should probably be removed too, and master only for all that posts to x”: market_outcome, market_reconcile, music_catalog_stats, music_lifetime_boards, music_news_artistpull, music_news_firstparty, music_news_newrelease, music_news_nochart, music_radio_boards, music_releases, music_stream_ranking, pollstar_boards. The non-master guild had every MASTER_ONLY key off and every ALWAYS_ON key production, so no room’s behaviour changed – with ONE exception: music_catalog_stats had no stored row there and fell back to its STAGING default, so master-only ends that #bot-logs audition (no room-facing post changes). The X clause was already satisfied structurally: crossposting is locked to _ONLY_GUILD (the master guild), so no other guild tweets whatever its stage says, and none of the four ALWAYS_ON keys makes a crosspost call. (bet_alert/bet_value DO tweet and ride the bookie key, but the lock keeps their X lane master-only regardless, so bookie stays always-on and the non-master guild keeps taking bets.) MASTER_ONLY (live in the master guild, off elsewhere): bet_board, cinema_boards, cinema_desk, cinema_news, market_alert, market_drop, market_outcome, market_reconcile, milestone_qualifiers, music_alert, music_artist_boards, music_board_presence, music_called_shot, music_catalog_stats, music_chart_projections, music_chart_records, music_chart_tenure, music_desk, music_genre_boards, music_lifetime_boards, music_lookup_boards, music_market_board, music_news, music_news_artistpull, music_news_firstparty, music_news_newrelease, music_news_nochart, music_platform_boards, music_radio, music_radio_boards, music_releases, music_riaa_boards, music_riaa_certs, music_standings, music_stream_ranking, music_weekly_charts, pollstar_boards, pop_desk, rt_scores, sports_desk, sports_odds_boards, sports_stats_boards, trending, x_crosspost, x_mentions, x_reply_draft. ALWAYS_ON (live in every provisioned guild): awards, bookie, curator, playercard. WHAT IS LEFT is the real trial set – a surface the master guild has NOT promoted (after the third pass, 7): live_scores, artist_curator, x_game, music_daily_hits, music_artist_markets, ktt2_chatter, versuz. A gate whose key is a runtime VARIABLE dispatches through utils.permissions.resolve_stage; after the second pass only cogs/curator.py still needs it (curator graduated always-on while artist_curator is still a trial), and the two music-desk board composers call graduated_stage directly. The contract is covered by tests/test_graduated_surfaces.py, and tests/conftest.py forces is_master_guild True suite-wide so the rest of the suite exercises the live path. Experiment catalogue (entries for the graduated keys are kept below for history – they describe what the surface does, NOT a stage a mod can still set): live_scores (default staging — gates the live sports commentator loop (cogs.commentator) posting to the dedicated sports channel; production posts to the room, staging auditions each line in #bot-logs, off skips the loop entirely; needs API_SPORTS_KEY/SGO provisioned), market_drop (default staging — the scheduled prediction-market drop), market_alert (default staging — the event-driven movement alerts, split out from market_drop so a guild can run the calm scheduled drops without the breaking-news swing/closing-soon alerts, or vice versa; gates cogs.market_alert), bookie (default staging — the play-money betting game; gates /bet + settlement, distinct from the board), bet_board (default staging — the “who’s favored” line posts (cogs.betting_board): the morning slate + the pregame nudge to the sports channel; production posts to the room, staging auditions each line in #bot-logs, off skips the guild — split out of bookie so a guild can take bets without the board drops, or vice versa), awards (default staging — gates the weekly/monthly engagement-awards loop (cogs.awards, #1017): production posts the weekly Player of the Week + the monthly MVP / Funniest / Besties gallery to the awards channel, staging auditions each post in #bot-logs, off skips the loop), and playercard (default off — gates /card, the engagement rookie card: off = hidden, staging = mods-only preview, production = everyone; ships dark on purpose so a mod runs /stats + validates the seed before the card is exposed; /stats itself is always available, only /card reads the gate, #570), and x_crosspost (default off — gates crossposting Toots’ drops (music / market_drop / discourse / curator) to the @tootsiesbar X timeline (utils.x_crosspost): production actually tweets each drop that ships to a room, staging auditions the tweet TEXT in #bot-logs (no post to X), off no-ops. Ships dark like playercard because it’s the ONE surface that posts OUTSIDE Discord to a public external timeline — so it’s opt-in per guild, which also scopes it to the account’s home server (only a production guild tweets). Production also needs the 4 X_* OAuth env vars (utils.x_poster.XPoster provisioning). The Discord/X destinations are SEPARABLE per surface — the CHANNEL picker is the Discord switch (#2137, owner ask 2026-08-08: “turn off what posted to X from Discord and not have both”, then “no new picker — the existing channel pickers should just stop posting to discord if empty but not stop x”): channels configured → the room posts and X mirrors as picked; channels EMPTY → the room stays silent but the surface KEEPS composing for X, riding the X_ONLY_CHANNEL_ID sentinel channel (0) through the same slots/self-gates/dedup/history (the ScheduledPoster base substitutes the sentinel when _sched_channels is empty; the event-driven alert surfaces let an empty channel list through their gate the same way), with the room send skipped at delivery — the surface’s *_posted event then carries delivered=x_only + channel_count=0. utils.x_crosspost.x_only_active decides ONCE whether the no-channel lane runs: only when the X post would actually ship (a crosspostable surface + home guild + x_crosspost production + provisioned — crossposts_media is the reused gate — + enabled on the X picker), so a guild that never touched X keeps the old behavior exactly (empty picker = fully off, no compose spend), and turning X off in ANY way returns the surface to fully-off rather than posting nowhere. Per-surface notes (EVERY crosspost surface has the lane): the market surfaces’ category config is per-ROOM, so their sentinel lane falls back to the "Global" cross-category pull (market_drop._sched_compose_units / market_alert’s room loop); discourse (its own scheduler, not a ScheduledPoster) runs the lane with a ROOM-LESS compose — _compose/_try_trending accept channel=None, degrade the room inputs (no local-context read, no channel topic, the neutral the-timeline channel name for the search/routing/compose prompts) and lean on the feed channels + Perplexity + Grok + markets, and the ICEBREAKER fallback is skipped on this lane (a room opener has no room to open — prefer absent); the bet surfaces’ channel source is resolve_betting_channels (the dedicated betting channel, else the sports channel), so “empty” means NEITHER is set. (An X-only post can still be lost to the crosspost’s own fail-open skips — cross-guild dedup, over-length — the deliberate prefer-absent direction; the crosspost event records those.)), and x_reply_draft (default staging — gates the X reply-draft queue (cogs.x_reply_draft): off stops drafting entirely and is the surface’s real off switch (it also stops the banger lane’s paid X search), staging queues each draft in #bot-logs, production sends it to the reply-drafts channel picked on the /menu “on X” page (x_drafts_channel_id, falling back to #bot-logs when unset or unreachable). The staging DEFAULT preserves what the surface already did — it ran to #bot-logs for its whole life, at ~120 drafts a day, so an off default would have silently stopped it on deploy. Shipped 2026-08-06 with the header’s master-user @-mention REMOVED, on the owner’s ask: ~120 drafts a day meant ~120 notifications a day for a queue read in batches), all reading their stage via db.resolve_experiment_state. icebreaker, voice, video, gif, and image graduated to permanent surfaces and were removed from the registry (and the menu): each is controlled by provisioning + kill switches alone — ElevenLabs for voice, Giphy for gif, OpenAI for image, the VIDEO_TRANSCRIPTION kill switch (+ yt-dlp/ffmpeg) for video; icebreaker always ships a floor-clearing opener — with no per-guild staging/off. The retired global ICEBREAKER_STAGING/VOICE_ENABLED env flags were the earlier form of the same trial→permanent path. Page 1 also carries the master kill switch (the “complete off button”): a power button on the nav row backed by the per-guild servers.bot_enabled flag (default on, db.is_bot_enabled/set_bot_enabled). Flipping it off takes Toots fully dark in the guild — every entry point checks is_bot_enabled: the mention (cogs.ask) and voice-note (cogs.voice) listeners, all three schedulers (discourse/music/memory) and both chime-in paths gate inline, and the whole slash surface is gated globally by bot._GatedTree.interaction_check (a custom app_commands.CommandTree whose interaction_check declines every command with an ephemeral note when off — the one exception is /menu itself, the way back on; fails open on a DB blip so a hiccup can’t brick every command). This is a strictly bigger hammer than mood=off (which only mutes the proactive posts, leaving mentions/voice/commands live); the kill switch silences those too. Emits bot_power.- tune.py, the per-guild editor view for every user-facing cap/cooldown/cadence knob — the rate caps, the chime-in cadence per mood (daily cap / cooldown — the POST cadence; reactions have no knob, they gate on the model score), the /order cooldown + in-flight cap, the market/betting alert caps, and the image reply knob (per-guild daily cap). (Two TUNABLES groups are hidden=True — still resolvable, but edited ELSEWHERE on /menu beside the config they scope, not on this raw-numbers page: the working-hours window lives on the calendar page, and the live-commentary heartbeat/clutch intervals live on the live-sports page — see _CommentaryPaceButton in cogs.settings.) (Reactions + gif replies are paced by the model’s own confidence, and the scheduled surfaces by the /menu calendar, so none of those carry a tune knob anymore.) Not a cog/command — it’s a view library imported by cogs.settings; reached via the tune ▸ skip from /menu’s calendar page (build_tune_view), as the LAST page of the chain (it’s terminal — no onward button; tune was moved to the end since it’s raw numbers). It’s a paged flat list of rows (tapping a row opens a modal to edit that value, int or float). Related knobs collapse into one row whose modal has a field per knob (a knob’s group/mood/field drive this): the daily caps row edits per-user + per-server (2 fields), the chime-in row edits cap + cooldown per mood (a 4-field modal), the /order row edits cooldown + in-flight cap (2 fields, mixed units), and the working-hours start / end pair is a 2-field modal. A merged row’s button shows its values grouped by unit (5 / 20 posts/day · 40 / 20 minutes, or 15 minutes · 3 orders), and each modal field is labeled by field when the bare mood would be ambiguous (cap (chill) vs cooldown (chill), or per-user vs per-server). Submits are parsed all-or-nothing, so a multi-field row never half-writes. A modal tops out at 5 inputs; the biggest row is 4. These used to be hardcoded module constants; now each is a per-guild override in the settings KV table, read live on every check (no restart), with the code-level value as the fallback default. The single source of truth for the set is utils/tunables.py:TUNABLES; this view renders it via ROWS = _build_rows(TUNABLES) (grouping by group), and page_count/page_rows paginate the rows. Paging (ROWS_PER_PAGE rows + a ◀/▶ nav row) lets the list grow past a modal’s 5-input cap; Discord’s 5-action-row ceiling is per message, so each page gets a fresh budget. The chime-in / reaction score thresholds are deliberately not tunable — they gate on a model-judged 0-1 confidence, so the cutoff is the model’s call (fixed DEFAULT_*_THRESHOLD constants in utils/tunables.py, read directly), kept out of the menu; the reaction threshold (DEFAULT_REACT_THRESHOLD) is now the SOLE reaction gate (no cap, no cooldown). The model knobs moved to their own Models page (cogs.models, reached from the experiments page): the ask-model select (#832) used to ride tune page 0’s top row, but when it generalized into a per-surface registry (ask/recap/discourse/memory, tunables.MODEL_SURFACES) it moved to a dedicated Models view reached via a [models ▸] nav button on the tune editor. Tune’s paging is now uniform (ROWS_PER_PAGE knob rows per page, no reserved row).
A button, select or modal that starts work checks the master kill switch itself (#4079). bot._GatedTree.interaction_check gates slash commands only, and a component press is not an app command, so the tree’s check does not run for it. The shared check is utils.gates.bot_switched_on (fail-open on a DB error, the same as the tree), and the decline text is utils.gates.SWITCHED_OFF. The games recap’s Play again (cogs/games.py:_start_replay) calls it before it claims the channel. A callback that takes (self, interaction, ...) carries the decorator @utils.gates.requires_bot_on, which reads the switch through interaction.client.db and declines with SWITCHED_OFF (#4336). The decorated callbacks are: the /order kitchen view’s new-order modal, Retry and undo-deploy confirm; the /card regenerate button; and the versuz sub-views (end confirm, round pick, round modal, poll side, merge earlier, post archive). The wheel’s _button_gate and versuz’s card press and setup form call bot_switched_on directly; their old local copies are gone. Callbacks that only navigate, change a setting, or stop work are not gated: page buttons, the kitchen open/close toggle, order Cancel, versuz match delete, and the games Skip / Hint / End. /menu’s own views are exempt: a mod must reach them to switch Toots back on. bot._GatedTree.interaction_check keeps its own copy of the check until the owner approves a change to bot.py.memory.py, long-term memory: an hourly writer (200 msgs/1h, same fetch as /recap) + a daily rollup that distills a guild’s memory-channel activity into attributed memory notes (the memory channels are set on /menu page 3, db.get_memory_channels, split out of the shared discourse set so memory can watch a different set of rooms than discourse posts/chime-ins; empty = the writer goes dark). Nothing is deleted by decay: the daily rollup synthesizes each day’s hourly notes into one daily note but the hourlies are KEPT (flagged rolled_up, not deleted) for detail drill-down, and daily is the durable searchable grain, retained across the whole history. There is no weekly tier — at this data scale storage isn’t the constraint, retrieval is, so recall searches the day-grained notes (and can drill into the hour-grained ones) instead of compressing the past into a coarse weekly summary. Every note is concept-tagged AND embedded at write time (_tag_and_store runs tag_memory_note — a Haiku pass storing keywords like drama, beef, callout in memory_notes.keywords — AND _embed_note — an OpenAI text-embedding-3-small vector in memory_notes.embedding — concurrently), so recall has two paths: keyword/FTS (the tags carry the concept vocabulary the fenced prose deliberately lacks) and semantic vector (cosine similarity matches the concept natively, so “messiest thing you remember” lands near a drama note with no tag/expand dance needed). Embedding is fail-open: no OPENAI_API_KEY → NULL embedding → that note just falls back to the keyword path. Stored as a JSON float array in a TEXT column with in-process cosine ranking, not pgvector (at this data scale retrieval, not storage, is the constraint, so cosine over the bounded candidate set is fast and adds no Postgres-extension dependency — a CREATE EXTENSION that isn’t installed would break the every-startup schema init). Read back into /ask/mention context as a tier mix (recent daily arc + freshest hourly) so Toots does callbacks and knows her regulars. In the same hourly pass she also keeps a parallel self_* pyramid of her OWN takes (_maybe_write_self_hourly distills just the messages she authored — subject="self", gated on her own lower SELF_ACTIVITY_THRESHOLD, tiled on the self pyramid’s own clock so a dead-but-she’s-talking room can’t double-count — rolled up self_hourly→self_daily by the same _maybe_rollup with family="self"). It rides the same fenced write/rollup (people are still fenced; only the framing changes to “your own opinions/calls/bits”) and is read back via memory_tier_mix labeled [you've said before] so she stays consistent with herself, distinct from the human-attributed memory. Plus /forget (self-service erasure, no parameter, you can only forget yourself; deletes across all tiers incl. self — and the consented user_facts a user taught Toots about themselves, via db.delete_user_facts) and /remember period:[week|month|2months|6months] (mod-only one-time backfill that seeds the room memory from channel history as per-day daily notes across the whole range; idempotent per day; runs as a background task so a long day-grain backfill isn’t bound by Discord’s 15-min interaction window — it acks immediately and drops the result in-channel when done; the same run also reindexes — _reindex_keywords concept-tags any notes that lack keywords AND _reindex_embeddings embeds any that lack a vector, each emitting memory_reindex. The reindex is scoped to the selected period’s window and walks it newest-first one page at a time until the window drains, so the period actually bounds the reindex — a 6months run reindexes 6 months, a week run just the week; rerun the longest period to cover the whole history. Termination is by forward progress (a per-run seen set of attempted note ids stops the walk the moment a page brings nothing new), not a guessed count ceiling. When an idempotent re-run writes no new daily notes but the reindex DID sharpen the index, the reply says so (_reindex_note) instead of reading as a no-op). The write prompt is fenced (observed public behavior only, never inferred private traits, no transcripts) to keep attributed “who did what” memory inside the constitution. The fence’s code check (#4528, owner merge): _tag_and_store, the single write path, runs output_checks.scrub_memory_note before every store. It deletes URLs, raw <@id> mentions and Discord ids, and drops the @ of a bare @name (the display name is allowed, the mention is not). The note is stored only when memory_note_violations then finds nothing; otherwise nothing is stored (memory_write skipped=fence). Until #4528 no runtime path called the check, and 30 of 582 room notes in 30 days held a bare @token.polls.py, native Discord polls Toots creates + manages on a regular’s behalf (#888). The on-demand surface is the @Toots mention: “make a poll for who’s recoupling” routes the model to the create_poll tool (and the list_polls/read_poll/update_poll/end_poll siblings), which delegate to this cog. She supplies the question + options — sourcing them first if asked (e.g. look up the Casa Amor cast, use the names) — and the pure core (utils/polls.py) clamps to Discord’s limits (2-10 options ≤55 chars, question ≤300, duration 1-768h default 24) so a bad request degrades to a friendly note, never a 400. Authorization is the ASK-side gate on top of Discord’s own permissions: create/list/read are open to anyone (poll-create rides the per-user ask bucket), end/update are mod-gated (is_mod). Discord polls are immutable once posted, so update_poll CLOSES the target poll and posts a fresh copy with the edits merged over the old values — votes reset, said plainly. Reads are GUILD-WIDE (#888 owner steer): Discord has no “list every poll” API (a poll is a message), so list_polls/read_poll run a bounded multi-channel scan (_scan_guild_polls: every readable text channel, the request’s channel first, recent history each, channel-/concurrency-capped) and label each poll with its #channel; an id from the list resolves anywhere in the guild (mutations too, via _resolve_poll_message’s guild fallback). The model is also steered to read existing polls before creating (the buffer + list_polls) so it doesn’t post a duplicate, mirroring file_fix→check_fixes. Fail-open throughout; emits poll (action create |
list | read | update | end + ok + reason). Pure validate/normalize/format in utils/polls.py (unit-tested); discord I/O here. Reading a poll’s voters is a bounded fan-out (#1950): answer.voters() is a paginated REST call PER OPTION, so a 10-option poll walked serially stacked ten round-trips onto an /ask the user was waiting on; _answer_voters now runs them through fanout.gather_deadline (5s, concurrency 5). An option whose lookup misses renders without names — the vote COUNTS beside it are read off the poll object and are never affected. |
versuz.py, the Versuz host (#2874, owner ask 2026-09-03): Toots hosts a two-player song battle. /versuz typed bare posts a Set up button (owner steer 2026-09-03: “any input buttons, not commands”; the command took NO arguments until 2026-09-11, when the owner asked for a one-line door – /versuz @a @b now skips the form and opens the match in the voice channel the host is sitting in, with no name and no first-to, versuz action=start reason=quick against reason=form for every other path) (the AUTO-START offer posts the same form prefilled: when two different people summon the music bot in a voice chat with no match on it inside 5 minutes (a typed command or a slash /play in that chat, or a command typed in any other channel once the bot’s Started-playing line lands in the chat, because the second player queued from a text channel on 2026-09-03 and no offer came; or a command typed in any other channel by someone SITTING in a voice channel with no match on it, which counts for that voice channel at once (_offer_from_elsewhere; 2026-09-03 23:40 UTC: Jockie stayed bound to the text chat after a night and both players played from it, so no line ever landed in a voice chat; two recent players and nobody in a voice channel is an offer ok=false reason=no_voice miss; a play by a participant of a running match in the guild is never read for the offer, verified night 2026-09-04: a live player’s play logged a no_voice miss), Toots posts “want me to host a versuz?” with a Set up button there (or, when she cannot post in that voice chat, in the last channel a versuz ran in, db.versuz_last_channel; owner ask 2026-09-03), both players and the voice channel filled in and a picker for the match’s text channel prefilled with that last channel; one offer per voice channel per 30 minutes, gated on the same experiment; versuz action=offer) whose form has user pickers for the two players FIRST, then an optional name, a voice-channel picker (blank = the one the host is in) and an optional first-to (owner ask 2026-09-11: “move the name down underneath the players and make it optional”, so the form is two taps); a blank name box titles the match gaza vs cd and every printed title reads MatchState.title, never theme directly – the card’s H1 and hero, the poll title, the archive picker’s row and the X caption (which drops its leading clause instead of opening on a comma). The versuz_matches.theme column stores what the host typed, so a blank box stores an empty string and the readers fall back on the names; it opens one match per text channel (gate: the versuz experiment, default OFF), and the bot needs View Channel + Read Message History on the voice channel. Songs register themselves: the cog listens to that voice channel’s text chat, the players type M!play <link> to the music bot (Jockie) and Jockie answers “Started playing Track by Artist”; the pure core (utils/versuz.py) credits the line to the player whose play command came within versuz_attribute_seconds (a Started-playing line ALSO counts from another channel when it comes from the bot whose lines the voice chat has shown, or from the first bot to post one IN THE MATCH CHANNEL while the voice chat has shown none (a line in some other voice chat is another room’s music bot), and the server has one match (Jockie answers in the channel it was summoned from once it has idled out of the voice channel, 2026-09-03 21:56 UTC: the match sat on “nothing started”); a play command counts from ANY text channel in the server, not only the voice chat: Jockie answers in the voice chat wherever it was summoned from, first-night miss 2026-09-03; outside the voice chat only the STRICT shape counts, a prefix, a slash or a mention, so “play nice” in #general cannot reset the window) and applies the slot rules read off the real 2026-09-02 night (one song per player in either order; the same player again before the poll = a do-over that replaces the slot (within versuz_redo_seconds, or whenever the player SKIPPED their own pick, read off Jockie’s “skipped by <@id>” line, since a self-skip is a withdrawal whatever the clock says; past that window, with the other slot still empty, the same player’s next queued track is an EXTRA: the pick stays, the card says so, a skip or a typed A: song swaps it, since Jockie’s queue ran 12 deep on the first hosted night); once the poll exists the next song opens the next round; anyone else’s song is ignored and noted on the card). When both slots are in and the second song ended (Jockie’s skipped / no-more-tracks, or ANY next track starting, whoever’s it is: with a deep queue A, B, A is the common order and the third track is held for the next round), Toots posts the poll herself (title <the match's printed name> · <scoreA> · <scoreB>, options <track> ft <feature> - <artist> · <player> (owner ask 2026-09-05: the answer names the artist, not only the title; poll_option drops the ARTIST first, then ellipsizes the title around the feature, then drops the feature too, so an option never reads worse than it did before – measured over the real 2026-09-05 night, 15 of 16 options fit with the artist and the 16th falls back to the older shape. The PLAYER TAG is never dropped or trimmed (#2990): it is the only part that tells the two answers apart, and two songs whose titles shared a long prefix used to truncate to the SAME string, which made build_poll_spec refuse a poll with one distinct answer so the round never opened at all (poll_open ok=false reason=invalid); a titleless song bills the artist instead of a bare dash), the 1 h Discord minimum). The poll posts UNDER the card (owner ask 2026-09-09: “poll should post after embed”): _open_poll moves the card down first, sends the poll, then edits the card in place to show it, so the room reads card, then poll, and the poll is the newest message; before this the card moved under the poll. The poll can end by itself: versuz_poll_close_seconds (a /tune knob, default 0 = off, up to 3600; owner ask 2026-09-09: “an auto poll close option”) runs End poll that long after the poll opened (_arm_close / _close_clock, poll_close reason=auto); a deploy re-arms it on resume from the poll’s opened_at, and End poll, a void and the finish cancel it. The poll also opens on a clock, versuz_poll_seconds into the second song (a /menu tunable, default 60 since 2026-09-04 (was 120), 0 = wait for the song to end; owner steer 2026-09-03 after tapping Open poll now 79 s and 48 s in on rounds 4 and 5: “2 minutes into the second song you can auto open the polls”; _arm_clock / _round_clock, poll_open reason=clock): a skip by the OTHER player before that opens it at once, a repick restarts the clock for the new song, a typed slot never starts it, an earlier poll still open defers it and End poll re-arms it (it fires at once when its time has passed), and a deploy re-arms it on resume. A self-skip is the normal end of a turn (live: TSU skipped at 115 s, Papi’s Home at 89 s) and opens the poll like any skip, unless the skip came within WITHDRAW_SECONDS (60) of the start and the player queued another track of their own while the song played: then they have withdrawn it (Song.withdrawn, set by song_ended when the skipper owns the song, no poll is up, and queued holds their later track; a queue-ahead of the next round’s pick followed by a turn-end skip has the same shape late in the song, so the time bound keeps it a turn end, and if the other player plays next the withdrawal is undone and that song opens the next round; live round 8, 2026-09-03: cd played a repeat, queued the right song, skipped the repeat, and the poll opened on the wrong pair and was voided seconds later by the replacement): the round is not ready, the clock stops, the card says “cd pulled their pick · waiting on cd’s next song”, and the queued track replaces the slot and gets its own clock; Open poll now still works as the override. The card says who is up first (owner steer 2026-09-03: “keep track who’s next, versuz does alternating go first by default”): the players alternate, so MatchState.up_first is the other side from whoever started the last round with a song. Before any song lands it is the slot Toots DREW when the match opened (MatchState.opener, a random.choice in start_match, in the payload; owner ask 2026-09-11: “it just randomly spits out who goes first” instead of the two of them picking a number), and the opening lines say it (“i flipped it: cd goes first”) while opener_stands holds – a match seeded from songs already played has a real first player, so she says nothing about a draw. The draw is the opening order only: the first real song to start takes over and the alternating rule carries on from there. The line is shown on the card’s small status line (card_status: “cd is up first”, and “gaza is up first” under a closed round; the “tap Re-vote round to run it back” phrase is gone, owner steer 2026-09-03: the button is on the card); it follows who actually went first, so a player starting out of turn does not break the rotation. The tally is the result, the song, then the score (owner ask 2026-09-13, “reduce the tally too”): # ROUND 18 TO DRAFTDAY2 · 9-7, then ## <the winning song>, then -# <the running score>. What came off is what it said twice – the winner used to be named in the H1 AND again inside the running score, and the votes read “9 to 7” on a second header. The SCORE stays (owner ask 2026-09-13, “don’t cut the score”): one pass dropped it, on the grounds that a close always moves the card down and the card’s H2 carries it, and that was a step too far – the standing is part of the result, and reading it should not need the next message. A tie is # ROUND N IS A TIE · 6-6 over the no-point line and the score. A versuz is a MATCH, not an evening (owner correction 2026-09-13): it runs at any hour, and a tournament runs several back to back – the 2026-09-12 run was nine matches in about an hour. So the finish says # <NAME> TAKES THE VERSUZ (finish_head), the drawn card’s caption line and the X caption say “takes the versuz” (versuz_card, recap_caption and the versuz-recap skill’s shape), the champion line reads “` (Codex review of #3122). The tones run as their own tasks (`_spawn_cue`, held in `_cue_tasks`, cancelled on unload; the warning too, so a slow connect never holds the close clock past its due time): the poll flow saves the state and arms the auto-close first, then starts the tone, so neither the card refresh nor the End poll tally waits on a voice connect. Only the End poll close carries `final_ok`, so it still plays when the match ends before it starts (the End poll that decides a first-to); an open or warning tone still waiting when the match ends stays silent, and both expire (`still`) once their poll closed (the warning also once its due time went by), checked before the tone starts, again after the wait for the guild lock, and once more right after the connect, just before play; ffmpeg is only spawned past that last check, and a `play` the player refuses (the connection dropped between connect and play) cleans its ffmpeg up. The leave at the finish is its own task (`_leave_voice_later`) that keeps the voice channel reserved in `_by_voice` (so another match's cue stays `busy` while the last tone plays) and waits, bounded, for the match's tones in flight and the one still sounding before it disconnects; a tone that arrives while another is sounding (End poll's close, then the queued round's open) waits for it too, and cuts it only past 2 s. Unload drops every voice connection the bot holds, not only the live matches' (a match that just ended has only its leave task, which unload cancels). The warning is also skipped when its lead is already spent at arm time (a resume inside the warning window). The ops monitor reads the ok rate as the `versuz_cue` integration-health line (`no_voice`, `stage` and `busy` excluded: configuration, not degradation). **Anonymous mode** (owner ask 2026-09-12) opens with `/versuz @a @b anonymous:true` and flips any time after that on the control card's **Anonymous** button. It is off unless the host asks. It is not a setup-form field (the modal is at Discord's five-row cap) and not a guild setting, so one night runs blind and the next one open. While it is on, a round IN PLAY never says which player picked which song: the poll answers and the card's slot lines read `pick 1` / `pick 2` (`MatchState.slot_label`) instead of the names, and the tag keeps doing the job the name did -- it is what tells the two answers apart, so two songs sharing a long title prefix cannot truncate to one string and kill the round (#2990). The two run in TITLE order rather than play order (`MatchState.anon_order`): the room watches both songs play, so "whoever went first is on top" would name them, which overrides the 2026-09-03 steer "keep track of who went first per round with the order here" for a blind round only (`play_order` is still the play order, and the host's ephemeral Fix a round page reads it and names both players). An open poll's order stands (`Round.poll_order`), so `pick 1` never moves under a vote in progress. The card holds BOTH songs back until the round is set (`hidden_slots_note`: "one pick is in · both songs show once the round is set"), because one song on a public card belongs to the only player who has played; for the same reason the round line says "waiting on the other pick" and "a pick got pulled" instead of naming anyone. **Who plays next is not printed either** (owner ask 2026-09-12, "i just want to make sure that isn't spoiled"): the drawn opener would otherwise appear in four places -- the opening line, the empty round line, the closed round line, and the two empty slots the closed card prints in UP-FIRST order -- and all four now say nothing. Toots still DRAWS it and sends it to the two players by DM instead (`opener_dm`, `Versuz._dm_opener`), each told only about THEMSELVES ("you're up first" / "cd goes first, you're second") so a forwarded screenshot says no more than that player already knows; the room reads "i flipped the order and sent it to the two of you". The DM is FAIL-OPEN -- a player with DMs closed settles the order with the other player, and the match runs either way. This is defence in depth rather than a fix for a live leak: the title order plus the held songs already stop a card-and-poll voter joining "cd went first" to a pick, but that protection is a CHAIN, and hiding the draw means restoring play order later cannot silently turn the opening line into a spoiler. Every other public mid-round line drops the names too, and this is the part a fix of the poll alone would have missed: the prefill remark ("one pick is in round 1, waiting on the other"), the cleared-slot note ("a pick is off" -- the cleared song was never shown), the extra-track note (the extra track is not a pick so it is named, the KEPT one is not), the repeat note ("a pick already ran in round N", and its public room line is held entirely, since it would print the pick), and the no-track note. The opening announcement says the night is blind. The tally REVEALS the map when the round closes (`reveal_line`: "pick 1 was gaza · pick 2 was cd"), and the scored round, the board, the finish, the drawn card and the X caption name the players exactly as before. The button is disabled while a poll is up: Discord cannot rewrite the answers it already sent, so a flip mid-vote would half-reveal the round. It rides the resume payload AND a `versuz_matches.anonymous` column, so a deploy and Reopen versuz both bring a blind night back blind. Its limit, stated plainly: it hides the pick from whoever votes off the card and the poll, NOT from the people sitting in the voice channel, who see the songs play and the play commands; blinding those needs privately submitted picks, which this does not do. Emits `versuz action=anon` (reason on|off). **Deleting a night** (owner ask 2026-09-12, "so it doesn't count against stats") is the **Delete a night** button on the `/versuz` reply, next to Archive: a picker of the guild's WRAPPED matches (`db.versuz_voidable`, `status = 'ended'` only -- a live one is ended first), then a confirm naming the night, the score and the rounds, then `db.versuz_void` in ONE transaction -- the row to `status = 'hidden'` / `end_reason = 'voided'`, its `game_scores` rows deleted so `/leaderboard versuz` stops counting it, and the crown its finish gave off the winner's streak (`best_streak` stays, the rule `versuz_reopen` and `versuz_merge` already follow). It is **MOD ONLY** and uses its own `_is_mod`, never `_allowed`: `_allowed` also lets the two PLAYERS act on their own match, and a player must not be able to erase a night they lost. The night is UNSCORED and out of sight, not erased -- the `versuz_rounds` rows stay, so a night voided by mistake can be rebuilt from them. `hidden` needed no new plumbing: the schema already documented it as "taken off the archive by hand" and all three doors back into a match (the archive picker, Reopen versuz, Merge earlier match) already filter on `ended`/`live`. No code set it before this; the owner had set it by hand on three matches, all of which carry no `game_scores` rows (read 2026-09-12), so the button is that manual operation. Emits `versuz action=void` (count = standings rows removed). Emits `versuz`.
**The control card carries its own kill switch (#3313).** `bot._GatedTree.interaction_check` gates the slash surface only, and a card press is not an app command, so a guild that switched Toots off still had every control card live: polls, recap posts, paid voice cues. `on_card_button` (both the bound view and the `on_interaction` listener reach it) and `start_match` (the auto-start offer's form is a modal) call `Versuz._enabled`, which reads `is_bot_enabled` and fails OPEN on a DB error, the tree's direction. A declined press answers ephemerally and emits `versuz action=switched_off`.arcade.py + utils/arcade/ + web/arcade/, the Tootsies Arcade (#3687, owner ask 2026-09-25): members fight each other live in Ring Kings, a 1v1 boxing game, with their own profile pictures as full-body fighters. /fight (renamed from /arcade on 2026-09-25, owner decision; the internal names stay arcade: this cog, utils/arcade, the experiment key, the /arcade/ URL paths and surface="arcade", so the URL mapping and the Railway and Discord setup keep working) has NO options (owner steer): one member runs it and goes straight into the game, and Toots posts a lobby card (room code, players) with ONE Join game button (owner steer: no choice between browser and Discord). Join game opens the game inside Discord (the Activity, when DISCORD_CLIENT_SECRET is set). The ephemeral browser link is the LAST resort (owner steer 2026-09-25: “/fight should do Discord mostly”). A member gets it only when the Activity is not set up, when the launch call raises (any exception), or when the same member presses Join game again within 2 minutes of a launch and has NEVER connected to the room (Room.seen; the Activity opened but its sign-in failed; arcade_room action=link reason=relaunch). A successful join clears the launch record (ArcadeHooks.on_join), and a second launch inside the 2 minutes does not restart them. The link message names no code or room: “Can’t open the game inside Discord? Open it in your browser instead.” with one “Open in browser” button. /fight in a channel that already has an open room runs the same Join game path for the caller. /fight checks that Toots can post the card in the channel (Send Messages, or Send Messages in Threads, plus Embed Links, and the thread is not archived) BEFORE it launches; when it cannot, the card goes out as the interaction response and the caller gets the link. A card post that still fails emits arcade_room action=open ok=false reason=lobby_post. End game (owner steer 2026-09-25). The host has End game on the lobby and results screens ({"t":"end"}); everyone else has Leave, which closes only their socket. End game voids a running match (game_match reason=void_ended), sends every socket ended with the session tally, and closes the room (4410). The room keeps a session tally for its life (Room.matches / wins; vs AI counts too, with the AI as one fighter named “AI”; the leaderboard rule does not change). The lobby card shows it between matches (“Matches: 4 · Ana 3 – 1 Bo”). Any close turns the card into “Ring Kings · Game over” with the tally, “Run /fight to start a new game.” and no buttons. A room with no open socket closes after 2 minutes (EMPTY_CLOSE_S); a join link minted but not opened yet (Room.pending_links) still holds the room for its 30-minute life. When the host leaves, the next player becomes host and the room gets a note (“rk3d.js and Tripo makes the props they place: 33 props in web/arcade/props/rk/, each a full-detail GLB plus a <name>_lo.glb (a burn barrel, a dumpster, a parked sedan, a water tower, a subway car, ring posts, a DJ booth, speaker stacks and so on), each listed in props/rk/manifest.json with its triangles, bytes, height, file hash (sha), prompt and Tripo task ids. scripts/tripo_props.py makes them: Tripo v3 text-to-model with the P series model P1-20260311 (auto_size, detailed PBR textures), then models/convert to a GLB with the pivot at the center of the bottom. A repack step here cuts the textures (base color and normal map to the prop’s size, metal/roughness to half), turns the model -90 degrees about +Y (Tripo exports the front on +X; the contract is +Z; a few generations come out long on the other axis, so the venue code sets each prop’s turn from a render), quantizes the geometry (KHR_mesh_quantization: int16 positions, int8 normals, uint16 UVs; it cut the car wreck from 1.1 MB to 760 KB, and three r149’s GLTFLoader reads it), and checks the budget (--check; tests/test_arcade_props.py runs it on the committed set). The budget: a normal prop 5k triangles and 600 KB, a hero prop (a venue’s centerpiece: the sedan, the car wreck, the dumpster, the newsstand, the water tower, the subway car, the ring posts) 15k triangles and 2 MB, the whole rk set 10 MB (8.7 MB on 2026-10-14). Every prop also has a lo file: a second convert of the same generation with about a third of the faces and only the base color texture at 512 px. Cost: 50 credits a generation (detailed PBR textures), 10 a convert. Network drops lost 4 of 33 props in one run, so the client retries them, and each task holds its cost against --budget under a lock. The server serves them at GET /arcade/props/<set>/<file> (utils/arcade/web.py:serve_prop; the set and file names are checked by pattern; the manifest revalidates, a GLB caches a day because its URL carries its hash). web/arcade/props.js (window.PROPS.place) loads a set’s manifest once a page load, parses each file once with THREE.GLTFLoader (#4011) and draws all copies of a prop with one InstancedMesh per mesh. It draws front faces only unless a prop is open (double): Tripo marks its materials double-sided, which also draws the inside of a closed prop. The level of detail: on a phone (R.touch, the path that also turns shadows off) every copy draws the lo file (loOnly), and a prop without one keeps its procedural stand-in, so a full-detail file is never fetched there; on a desktop only a copy more than 12 m from the camera’s usual spot draws the lo file. The phone rule exists because the full-detail props broke the frame-time rule: at 6x CPU throttle #4025 cost THE BLOCK +21% and CLUB TOOTSIES +19% on the phone path, and the hotfix #4034 brought both back to the pre-#4025 frame time. Each venue builds its procedural props first; a stored prop replaces its procedural stand-in (the barrel drums, the water tower, the AC units, the station columns, the painted train, the speakers, the ring posts) when its file arrives, and a file that arrives after the round ended is dropped. A failed file (404, bad file, 15 s timeout) keeps the stand-in and sends one client_error per file per page (props: rk/<name>: <why>), so error triage sees it; a dead manifest is one report for the set (props: rk/manifest), because app.js sends at most 3 a page. A failed file is not cached, so the next round tries again. Without a GLTFLoader on the page, nothing loads and nothing is reported. Placement lesson: the camera is close (z 4.7 then; z 3.55 at 1.32 m since the Def Jam framing pass; a 40 degree view) and the crowd and fence hide everything past the pit’s back edge, so props stand on the open floor between the fighters (always on z = 0) and the back crowd, and at the side edges. The same pass lifted CLUB TOOTSIES’ ring apron 1 cm: its top and the floor were both at y = 0 and z-fought. Deathmatch’s maps (#4023) use the same route, loader and manifest format with the sets dm and dm-<city>.tests/test_arcade_playtest.py runs the real client scripts in Node and fails when its fix is reverted). (1) Punches did not touch. The server’s fixed corners (x 100 and 156) put the 3D fighters 1.12 m apart, the 3D arm reaches 0.6 m, and the punch aimed along the body’s +x, which is turned 0.42 rad toward the camera. Measured at full reach (the server’s strike), glove center to head center: 0.42 to 0.71 m, procedural and model bodies alike (contact is about 0.3 m). Now the corners stand GAP = 0.82 of that apart (0.92 m; 0.87 and 0.97 m since the Def Jam framing pass), a punch lunges the body 0.15 m, and as the 2D glove goes from the guard to full reach the 3D target moves to the foe’s GUARD face (reachFor, F.face0: where the face is when the foe is not dodging). So a hit ends on the face (0.19 to 0.31 m), and a clean slip back still misses on screen (0.56 to 0.62 m). The model rig takes the same absolute target (K.hit) instead of a share of the 2D reach. The server’s hit logic did not change. RK3D.probe() returns each fighter’s gloves, head and chest in world meters for the tests and the Playwright harness. (2) The models. In a real round with prod GLBs (two members, two artists), the models are scaled, grounded, facing, posed, textured, and a failed model falls back to the procedural body. Their only visible fault was (1). (3) Reconnect after a deploy. A deploy closes every socket with 1012. On 2026-10-15 the Activity reconnected, got stale_client and the reload message, and the reloaded page never reached the server again (no token call in Axiom). Every Activity sign-in step now has a 15 s limit (within), so a Discord handshake that never answers shows the “Could not open the game inside Discord” screen with Try again, not an endless “Connecting to Discord…”. A reload the page cannot do (session store blocked, or a second one inside a minute) shows “A new version of the game is ready.” with a Reload button, and so does a reload that has not unloaded the page after 5 s. After a 1012 the overlay says “The arcade is updating”, a web room that did not survive says nothing was recorded, a fight screen with no live match says “The arcade restarted for an update. Nothing was recorded. Press Ready for a rematch.” (also after the reload, through a session-store note), and the reconnect overlay shows a Reload button after about 30 s of failed tries. A deploy voided live matches at the time; the deploy drain below now keeps them alive. (4) “You always play as yourself” (Deathmatch). After a death the camera went into the KILLER’s eyes for the whole respawn wait, with the killer’s sleeve on the gun. With unlimited lives that happened on every death, so the owner kept seeing a bot’s or an artist’s first-person view. Now spectating() (a spectator, or a player out of lives) decides the camera, and a player who waits to respawn keeps the view at their own spot. watching() still decides what a player may do. The identity data was checked and is correct: side_of matches only a human fighter, and _face_pool excludes every player, spectator and open socket. During the respawn wait the HUD shows your own kills and “Taken out by X / Back in N”; only a spectator or a player out of lives sees a “Watching X” line, and only they can switch it (nextSpec). (5) Bot faces prefer members with a model (coordinator call, a two-way door: the owner’s Deathmatch had 5 member-face bots and 0 models). _faces_without_opt_outs puts the members in db.fighter_model_ready_ids() first, each group still in random order; the per-member opt-out read still decides, and a failed list read keeps the plain random order (emit_error source=fighter_model).frame()): from (x, 2.05, 4.7) looking at (x, 1.15, -0.8) to CAM = (x, 1.32, 3.55) looking at (x, 1.02, 0): 3.6 m from the fighters instead of 4.85 m, 5 degrees down instead of 9.5, the sway 0.12 m instead of 0.4 m. A fighter is 0.54 of the frame height before and 0.72 after (the procedural body, 1.87 m; a 1.8 m model is about 0.69), the head top at 17% from the frame top (the HUD covers 0-15%), at 1280x720. On the phone portrait view (390x686 since #4158, with its wider vertical lens) a fighter goes from 0.24 to 0.315 of the view height. prop()’s near/far eye point reads CAM. (2) Lead side: the lead hand and the front foot are on the side away from the camera for BOTH corners (corner A orthodox, corner B southpaw), so the camera sees each chest and the near hand is the rear hand at the chin. Before, corner A’s lead glove was the near one, at eye height. A model chooses its lead per frame (rig.neg / rig.pos), because it can load before the first frame turns the root. (3) Stance: the feet 0.62 m long and 0.3 m wide (was 0.44 x 0.25), the back foot turned out 0.6 rad, the hips at HIP = 0.87 m (was 0.94). A model’s hip drop (rig.drop) comes from its own leg length: the hip joint comes down until it is 0.92 x the leg from the ankle, about 45 degrees of knee bend on any model (was 5-6 degrees), at most 0.15 m, so a very short leg cannot sink the hips to the ankles. The rig’s feet use the stance’s z, not the A-pose foot width. (4) Upper body: a 0.17 rad forward lean, the shoulders 0.1 m ahead of the hips, the chest bladed 0.22 rad lead shoulder forward (BLADE; on a model Spine2 turns), and the neck turns 0.18 rad back so the eyes stay on the foe. (5) Facing and spacing: the root opens 0.3 rad toward the camera (was 0.42), the guard is 6 px nearer the body and 7 cm lower, the rear glove 8 cm lower again at the chin (on a model the glove’s depth is the procedural guard’s own, not an offset from the rig’s often narrower shoulders: the test rig’s gloves ended 7 mm apart in depth, now 8.5 cm), GAP 0.82 -> 0.87, and a slip back moves 1.3 times its 2D sway in 3D. Without the last two, the clean-slip check fails (0.439 m < 0.45). tests/test_arcade_playtest.py, model case: punches 0.17-0.225 m from the face (ceiling 0.31), a clean slip 0.502 m (floor 0.45), the idle lead gloves 0.64 m apart. (6) CLUB TOOTSIES: no ropes on the camera side (the top rope at 1.35 m crossed the chests at the new camera height); the posts stay. (7) Knockdown: the camera centers on the standing fighter and the fallen BODY (body(): halfway from the feet to the head, which lies about 1.8 m behind them) and moves back (R.wide) by at least 0.7 m, or as far as the view’s horizontal lens needs to hold the standing foe and the fallen head with 0.45 m to spare (about 1 m on a 390x702 phone view; a first cut centered 0.75 m behind the feet and put the fallen head off a phone’s edge). With reduced motion the camera cuts to that framing and does not glide. The KO zoom aims at the body (camera y 0.78, z 2.75, looking at y 0.42). (8) Round banner (app.js render): from y 57-83 to y 112-138 of the 160-high banner band, over the shins, not the chests; the resume “FIGHT!” moves with it. On a tall phone view (#4158 centers that band) it covers the hips. In the 2D view it also covers the tops of the two front-row corner silhouettes for the 1-second intro. (9) Hit effects: no body flash on a stored model (a white emissive over the whole texture turned the fighter flat white); a 0.2 warm white (#ffe8c8) on the procedural body; sparks 13 (24 on a power punch, 5 on a block) up to 0.48 m (were 8/14 up to 0.23 m), a pool of 40. Not shipped: the audit’s light change (B1), a darker sky fill, a tighter key spot and one colored back rim spot per fighter. Phone frame time (390x844 touch, 6x CPU throttle, SwiftShader, 3 ABBA rounds): THE ROOF +10.4% (A/A noise 5.8%), THE BLOCK +6.3%, CLUB TOOTSIES +3.5%. THE ROOF breaks the 10% rule. This pass itself against main: THE ROOF -3.2%, THE BLOCK +5.3% (A/A 6.4%), UNDER THE BRIDGE -2.0%, all within the noise. Left for later PRs: A7 and B3-B9 of the audit (hit-stop, head snap, camera kick and the rest).E1_JabLeft (jab), E4_CrossRight (cross), E19_DodgeLeft (slip back), E21_DuckLeft (duck in). The dodge and duck takes are southpaw and are stored mirrored.dcee652e-... and 5b7c62f8-..., 60 credits, run by the coordinating session): hit_to_head (both hits: a jab’s 12-frame stun plays the snap back, a power punch’s 24 frames play more of the stagger) and victory_celebration (the win). The GLBs hold the artist’s mesh and stay out of the repo; only joint positions are kept. Not used: box_01/box_03 (lunging left punches with a long step: the feet must stay planted, and there is no right-hand cross), box_02 (a crouching spin), fall (on the planted stance it pitched forward into the foe’s feet and ended curled on the knees).13_18 (the guard with its bounce, a loop), 76_04 (the cover block), 90_18 (RugPullFall: the knockdown, and the KO from its first frame), 140_08 (get up from the back), 79_71 (sad: the loss).scripts/arcade_rk_mocap.py (run once; it downloads about 12 MB) writes web/arcade/rkmocap.js (13 clips; the file is about 112 KB). The retarget reads joint POSITIONS, not the source’s bone rotations: the ACCAD BVH’s zero-rotation pose lays the spine along +x, so a rest-relative rotation (the loco.js method) does not carry over. Per frame, in the fighter’s own frame: the hips’ offset from standing (leg lengths), the hips’, chest’s and head’s rotations from upright, per arm the elbow and wrist from the shoulder (arm lengths) and the hand’s direction, per leg the knee and ankle from the hip joint (leg lengths) and the foot’s direction. Every clip is orthodox; a southpaw plays it mirrored (RKMOCAP.mirror). The credit is in the clip data (CLIP.credit) and in the file header.RKMOCAP.at). The sim decides when a move starts and how long it lasts; each clip is laid over the sim’s own states. A punch holds the guard through the first part of a long wind-up, then extends at its captured speed (faster when the wind-up is shorter), so contact lands exactly as the wind-up ends; the 4-frame strike holds contact; the recovery plays out the clip. A slip or a duck reaches its peak by 40% of the 20-frame state and is back in guard at the end of slipRec. A block rises over at most 40% of the state and holds. A hit snaps to its peak (the head furthest back) over the first 30% of the stun (a jab’s 12 frames play the light hit, a power punch’s 24 the heavy one). The knockdown plays at 1.5x speed through fall and down, then holds; a third knockdown in a round (a TKO) plays the KO cut. The get-up plays over rise and the 50-frame resume phase. In the over phase the winner plays the win and a standing loser the loss (app.js passes winnerSide(MATCH.over)). A new move blends in from the last pose over 0.1 s. When the sim state changed on that frame, the blend starts at the sim time already spent in the new state (a pose the server is part-way through shows at once); a move that starts without a state change (the guard’s block flash, the win, the end of the get-up) fades from zero, and so does the same state started again (a second hit during a stun, a punch thrown again straight from its recovery). A fighter first seen already down (a model that loads during the count, a reconnect) lies at once; the fall is not replayed. The get-up’s first frame is moved to the knockdown’s last (join_rise), and the gap eases out over the get-up. The slip keeps the guard’s arms: the source actor’s lead hand swings out in a dodge, and it read as a punch.rk3d.js driveMocap). The torso bones take the clip’s rotations relative to their rest; the arms and legs reach the clip’s joints with two-bone solves on the rig’s own lengths. While standing, the feet stay planted on the Def Jam stance (K.ft, K.bk, the back foot turned out) and only the knees follow the clip; the falls and the get-up move the legs. The guard’s own hip height moves to the rig’s stance drop (G.drop, at most 0.15 m), so a duck still dips below it; a fall eases that shift out. The lead side is the framing’s (K.zl): the fighter on the left of the screen plays the clips as captured (orthodox), the one on the right mirrored (southpaw); with the view flipped, corner A is on the right. For the guard, the punches, the dodges and the hits, each glove takes the procedural guard’s depth (lead out on the far side, rear at the chin), as in driveRig. At contact the punching hand is pulled onto the foe’s guard face (reachFor) by the clip’s reach weight, and the hips carry the shoulder forward when the face is past the arm’s reach (the bones are then rebuilt with a new frame stamp: before, the arm solved from the old shoulder, and corner A’s cross stopped 0.44 m from the face). So test_ring_kings_punches_land_on_the_face holds for a rigged fighter too (0.15-0.19 m glove to face on a landed punch, 0.58 m on a clean slip, the gloves 0.10 and 0.17 m apart in depth). A mocap fighter does not tip over as one piece (the procedural fall).x per corner, so a fighter’s place does not move with the snapshots. Fixes: the build low-passes every clip (a zero-phase 2nd-order Butterworth, 8 Hz, 6 Hz for the slow clips; smooth), keeps 30% of the guard’s side sway, 60% of its bounce, 40% of its turns and 50% of its arm motion (calm_guard), and closes the loop’s seam in pose and in speed; RKMOCAP.sample draws a cubic (Catmull-Rom) curve through the stored frames; driveMocap lowers the hips (and only then moves them toward the foot) until each leg reaches its planted foot at 97% of its length, eases between the stance and a clip’s feet over 0.35 s through a smoothstep with the moving foot lifted on an arc (a step back into the stance), keeps a limb’s last clear bend direction when it is nearly straight (moPole), and eases the punch’s reach to the face through a smoothstep. The guard’s gloves go 80% of the way to the framing’s guard at the chin (the captured boxer held them at his chest; it read as a loose guard), easing over 0.12 s. After: the idle guard’s acceleration fell from 42 to 4 m/s² (head and hands), the side sway to 16 mm, no planted foot moves, the largest ankle move at the end of a get-up fell from 358 to 96 mm a frame (the 96 is the fall itself, where the captured feet slide), and no knee or elbow flips except one elbow flip in the block (as the gloves move from the chin guard into the cover). The phone frame-time rule (6x CPU throttle, four interleaved rounds): 21.9 ms a frame against 22.5 ms for the old 2D-driven pose and 23.5 ms for the mocap before these fixes (the rounds vary by about 10% on their own). A real Tripo retarget GLB faces +x at rest, which FMODEL.place’s feetFacing reads as -z (it chooses only between a rig’s default facing and its opposite); the served fighters face +z and are not affected, so it is noted, not changed. Codex review on #4166: (a) the feet changed from flat to the shin’s rest turn in one frame when a fall or a get-up passed its midpoint (69 degrees in a frame); the flat-foot weight now eases with moFree (13 degrees, the captured fall itself); (b) on a short-legged rig the reach clamp pulled the hips back toward the back foot after a punch had moved them to the face, and the cross stopped 0.53 m from the face; at contact the clamp only lowers the hips, and the reach to the face is checked again after that (0.21 m). The parent session’s review on #4166 measured the same reach loss on a short-armed rig (0.34 m of upper arm and forearm: the cross ended 0.34 m from the face; now 0.20 m). It also found fixes with no test. Each test below fails when its fix is reverted: test_ring_kings_punches_land_on_a_short_limbed_rig (short legs and short arms); test_ring_kings_planted_feet_stay_put_and_the_get_up_steps_back (foot turn per frame; the step back starts at rest and ends by 0.5 s, against an exponential ease; no knee or elbow bend flips, against the clip’s raw joint as the pole); test_ring_kings_guard_is_tight_and_the_punch_reach_eases (GUARD_HOLD, and the smoothstep on the reach to the face); test_every_clip_is_low_passed (the share of each clip’s motion power above 11 Hz: it fails on the unfiltered data) and test_lowpass_keeps_motion_drops_noise_and_does_not_shift. moPole now blends at a rate set by the frame time, takes a direction opposite the kept one at once, and starts from +x when the first frame has no clear direction; a standing ankle before any fall uses the clip’s ankle. Codex review of dd2a3b3: on a short-legged rig the punch’s move to the face takes the hips up to 0.5 m forward, further than the leg reaches from the back foot, and the solve lifted that foot 5.5 cm and dragged it. A planted foot beyond 99.5% of the whole leg now slides on the floor after the hips (a boxer’s back foot following a lunge); the measured Tripo rigs (legs about 1 m, arms about 0.68 m at 1.8 m) and the test mannequin never reach that branch.RingKings (a snapshot every 3 steps, with its events, as the hub sends them) and replayed through app.js’s own playback and RK3D.frame at 60 fps, with each fighter’s body as capsules (bodyCaps: head, torso, two capsules per arm and per leg, the gloves). Before, on the mannequin: 76% of the fight frames had two body parts overlapping by more than 2 cm (deepest 21 cm), and in 52 frames a glove’s center was inside the other’s head; on a real Tripo rig 82% and 260 frames. The causes: (1) both lead hands are on the far side (the Def Jam framing) and the captured elbows pointed at the foe, so the two lead arms met between the fighters in the guard; (2) a captured punch reaches further than the foe stands, so the glove went into the face, the chest and the guard, and on its way back dropped forward through the chest; (3) the captured fall slid the fallen fighter’s feet 0.7 m forward, under the standing one. The fixes, in driveMocap and after both fighters are posed: with the gloves at the chin the elbows point down and out, corner A’s lead on the inside line and corner B’s on the outside (LEAD_OUT: the lead hands fence; the procedural body too); no glove target goes past the foe’s face (a smooth minimum, smin); a punch aims at the face’s surface (reachFor: less the glove’s and the head’s radius), a little high and to its own side (PUNCH_UP, PUNCH_SIDE); a contact pass (contact, up to three rounds) pushes each glove and forearm out of the other fighter’s capsules and solves the arm again (moArm): straight out of the head and the torso (the glove stops on the face), mostly up and around the guard, shared between the punch (40%) and the guard arm (60%, as if knocked aside), and the push starts before the touch (soft) and follows a critically damped spring that may trail it by at most 1.5 cm (contactEase), because a push found fresh each frame shook the gloves; a fallen fighter’s body moves back so its feet stay behind the stance; and a separation pass moves the hips back while the heads, torsos or thighs overlap (and, against the procedural body, its forearms and gloves, which no contact pass moves). In the replays it triggers only in mixed matches; test_ring_kings_the_hips_move_back_when_the_bodies_overlap forces it. After, over four replayed matches: 0 frames over 2 cm on the mannequin (deepest 0.5 cm), 10 on the real rig (deepest 3.4 cm), no glove inside a head. A mixed match (one rig against the procedural body; the parent review found the rig gave a never-moving procedural arm only half of the push) went to 0.6% (deepest 8.5 cm: a procedural jab’s forearm on the rig’s chest, and a thigh in a duck): the rig now takes all of that push and of the separation, and a procedural punch stops at the face too. Two procedural bodies (no contact pass) went from 47% to 12%, all in punches. The cost: the punches’ glove acceleration rose (RMS over all fight frames 156 to 222 m/s^2 on the mannequin, 166 to 246 on the real rig), almost all of it on the defender’s gloves, which a punch now moves; the guard is unchanged (73 to 77). The impact (Part B): the sim already holds still for 5 frames on a landed power punch and 3 on a jab (hitstop), but the view kept moving through it (up to 220 mm in a frame) and the camera shook at random. The hit event now carries the tick of the hit and the hold (k, stop); app.js passes what is left of it when the hit reaches the view, and rk3d.js holds every fighter, the crowd and the camera’s sway for it (3 frames on a cross, 1 on a jab: the event reaches the view up to 2 frames into the hold), but only when the punching glove touches the head (within 0.34 m): the hit can reach the view before the glove does. The hit fighter’s head snaps back about 0.12 rad and eases back (snapA, peaking 40 ms in), the camera kicks about 6 cm toward the fighter who was hit and springs back by 0.2 s (kick, also on a knockdown; it replaced the random shake), a cross winds up with a 3 cm dip of the hips and a coil of the chest away from the punch, and every clip blend follows a smoothstep (the return to the guard after a punch eases through it; an extra settle dip of 1.2 cm was dropped: the guard’s own sway is larger, so it could not be told apart or tested) (rkmocap.js; a straight line changed the joints’ speed at its first frame). Reduced motion: no hold, kick, snap, dip or coil. The cost in frame time: RK3D.frame 0.26 to 0.37 ms in Node (the contact pass reads the capsules from current matrices into a per-fighter pool, with no allocation a frame, and its rounds stop when one moves nothing); the phone path in headless Chromium at a 6x CPU throttle (the real page, two rigged fighters, a replayed match, RK3D.frame timed in the page, the rounds alternating). Measured after the review fixes, #4185 cost +8.3% (3 rounds, median 17.41 to 18.86 ms), and +9.8% and +14.9% in two later 4-round runs: the rounds vary by about 10% on their own. All of it is JavaScript (the render does not change): 4.7 to 6.4 ms a frame, mostly the contact pass solving the arms again (about 1.8 rounds and 4 extra arm solves a frame, because the soft zones make it push in most frames). The follow-up keeps each fighter’s capsules until its pose changes, rejects far pairs by bounding spheres first (segment tests 190 to 50 a frame), and drops the allocations and closures made each frame: 6.15 ms of JavaScript and 16.94 ms a frame against 15.39 before #4185 (+10.1%, at the rule’s edge; #4185 itself +14.9% in the same run). Tried and dropped, because each broke an overlap gate: moving a pushed arm without solving it again (the real rig 10 to 119 frames over 2 cm), two rounds instead of three (33 frames), and a 6 cm soft zone around the guard instead of 10 cm (the mannequin 1 frame, the real rig 22). Known and not fixed here: coming out of a hit, a lead elbow can swing from behind the body to under the glove in two or three frames (main does the same). Tests: test_ring_kings_fighters_do_not_pass_through_each_other (a 35 s replay under 2 cm), test_ring_kings_a_rig_against_the_procedural_body_does_not_pass_through_it, test_ring_kings_the_hips_move_back_when_the_bodies_overlap, test_ring_kings_the_procedural_lead_arms_fence_in_the_guard, test_ring_kings_a_landed_punch_holds_the_view_kicks_the_camera_and_snaps_the_head (with no hold when the glove has not reached the head), test_ring_kings_a_cross_winds_up_with_a_dip (and the coil), test_a_change_of_move_starts_at_rest.main many times a night, and every deploy voided the running matches (void_shutdown). The old process is the Discord gateway client AND the arcade socket server, so a plain overlap of two deployments would answer every Discord message twice. The Railway facts, from Railway’s docs: the OLD deployment gets one SIGTERM when the NEW deployment is Active (its /health returned 200, which needs the gateway up), then SIGKILL after RAILWAY_DEPLOYMENT_DRAINING_SECONDS (default 0; config-as-code deploy.drainingSeconds). WebSockets have no duration or idle limit on Railway. The docs do not say that an open socket to the old container stays up during the draining window; the first drain in production measures it (arcade_drain). On SIGTERM, bot.py calls utils/deploy_drain.py:drain_for_deploy before bot.close(). When the budget is above 0 and a match runs, it: (1) closes ONLY the gateway (TootsiesBot.close_gateway: ws.close(1000), and is_closed() reads True so discord.py’s connect() returns and does not reconnect; the REST client stays open); (2) Arcade.begin_drain stops the sweeper, the fighter backfill and the model runs, and ArcadeHub.begin_drain refuses new rooms and new matches (_start returns draining), closes every room it does not keep (_drain_keeps: the room is in fight, which starts at room.start() before the match task exists, OR its match task still runs, because room.finish() puts the room back in the lobby before the result write ends; live_matches counts the same rooms). The hub drains BEFORE the cog’s own cleanup, which makes DB calls of up to 2 s each with 1012 (its clients reconnect to the new process, as before), and sends each running match a note (“Toots is updating. This match finishes first.”); (3) unloads every extension except cogs.arcade, so no scheduled surface posts twice (AFTER the hub drain: an unload can take seconds, and a host could start a match in that time; Codex review); (4) each match that ends records its result through the normal _finish / on_match_end path (the leaderboard write is DB, the result card and live card are REST, so neither needs the gateway), then after DRAIN_HOLD_S (8 s, so players see the result) the room closes with 1001, not 1012, because the client reads 1012 as “nothing was recorded”; the client shows “The arcade updated. Reconnecting…”; End game during the drain counts the match as void (reason=void_ended). (5) wait_drained returns when the last room closes or the budget passes (one deadline from the drain start, so the unload time comes out of the budget and the 30 s teardown reserve stays whole); at the cap, a room whose match already ended (it shows the result) closes with 1001, and only rooms still in fight count as left, and bot.close() voids what is left (void_shutdown). runner in bot.py waits on a drained event after bot.start() returns, so the DB pool, the REST client and the health/arcade server stay up until bot.close() ends. The budget is min(240, RAILWAY_DEPLOYMENT_DRAINING_SECONDS - 30) s: 240 s covers a full Deathmatch round (180 s plus intro and hold) and three Ring Kings rounds (the longest match in 30 days: 173 s); 30 s is kept for the normal close. Without the variable the budget is 0 and a deploy behaves exactly as before, so the Railway variable is the switch (proposed value 270). A reconnect during the drain reaches the NEW process, which does not have the room: a web token gets 4404 “This game is gone”, an Activity gets a new lobby, and the old match voids or forfeits after the 20 s grace. Measured 2026-10-15: arcade_auth reason=no_room came 4 times in 30 days, so no cross-process routing was built. Rooms live only in memory and have no DB rows, so the new process cannot touch a drained room; a reused room code does not admit an old token, because the user id must be in that room’s profiles. A gap that already existed: between the new process going Active and the old one getting SIGTERM, both are on the gateway for a moment. Alternatives not taken: the arcade as its own Railway service (bot deploys stop touching matches, but arcade deploys still void them, and the cards, gate and leaderboard need a second Discord client), and a match snapshot to the DB resumed by the new process (both sims take live inputs at 60 steps/s, so it needs a full state serializer for both and a socket move). Tests: tests/test_deploy_drain.py (the budget, the controller order with fakes, and discord.py’s real connect() loop returning after close_gateway), tests/test_arcade_hub.py “the deploy drain” (lobbies close, the match goes on and is recorded, 1001 after the result, the cap, a void during the drain). Events: arcade_drain (docs/OBSERVABILITY.md), panel arcade_drain.cog_unload and the test fixture arcade_tasks_cancelled both call Arcade._cancel_live_tasks. It sets _stopped FIRST, then cancels and awaits _live_tasks until none is left. The order matters: a cancelled _publish runs _pump in its finally, and with a newer edit queued that _pump armed a new _tick timer. cog_unload already set the flag first; the fixture did not, so the timer outlived the test and a later test on the same xdist worker (test_axiom) saw it. The fixture also cancels _model_tasks now. tests/test_arcade_live_wire.py::test_cancelling_a_blocked_edit_leaves_no_timer_behind is the repro. Production behavior does not change: the drain and the unload close the same rooms and keep the same running match.ring_kings.POWER_*). It starts empty and fills 10 a second in the fight phase, +15 for each quick punch that lands, +25 for each correct dodge, +10 for each punch the fighter blocks. A power punch needs a full meter, uses all of it, cannot be blocked, and keeps its telegraphed wind-up. A power punch pressed with the meter not full is refused at once, and the sim emits a nopower event for that side. The AI obeys the same meter. A pressed action waits 18 frames (ring_kings.BUFFER_F, was 10) for the fighter to be free. Snapshots carry pw (power) in place of sta (stamina). The port therefore no longer matches the prototype in either mode; the 80-match parity check applied only before this change. The client draws a gold POWER bar under each HP bar (“POWER READY” when full); the phone Power punch button fills with the meter and lights up gold when full; a refused tap flashes the bar and shows “Power not ready” for 0.6 s. Each tap clicks, vibrates 15 ms where the browser allows it, and starts the fighter’s wind-up at once on the client (the next snapshot corrects it). The client plays procedural WebAudio music (a calm lobby loop and a faster fight loop that crossfade, a crowd swell on a knockdown; the music ducks under sound effects). It starts only after the first tap or key press and follows the Sound on/off button. Deathmatch (PR 2 of #3687, coordinator rulings R20-R28). The second game is an online FPS in the same rooms and the same /fight flow. The host picks the game in the lobby (game action, Room.game); a room holds 2 fighter slots for Ring Kings and 8 for Deathmatch (room.CAPACITY), plus any number of spectators. A switch to fewer slots moves the latest joiners to the spectators. The host also picks a city (city: one of the prototype’s 14 keys, or random, resolved at start) and Bots (bots, default on). With Bots on, bots fill every empty slot up to 8, so the host alone can start; with Bots off a round needs 2 players; with 2 or more players every player must be Ready. utils/arcade/deathmatch.py is the pure sim, a port of the fps.html prototype: the four hand-made 24x24 maps (each city = one map + its look, which the client owns), the prototype’s tryMove with radius 0.24, move speed 3.1 tiles/s, a 25-step fire cooldown (the prototype’s 0.42 s, rounded down so a client that waits 0.42 s is never refused), one hit kills, no respawn. The last fighter standing wins; at the 2:00 timer the most kills among the fighters who did not leave wins, and a tie is a draw (a fighter who left never wins on time, and _winner_id never crowns a winner whose row has left). The intro (2.6 s) and the “over” hold (1.6 s) are the prototype’s; nothing moves or shoots during the hold, so the result cannot change after the deciding moment (the prototype’s bots kept acting). Netcode: the server steps at 60/s and sends a snapshot at 20 Hz. Movement and yaw come from the client: the server holds the latest mv and applies it every step, and yaw may turn at most 14 rad/s: the first input in a step gets the turn for the steps since the last input, and the inputs in one step share it. The server refuses an input whose yaw is over 4 pi (YAW_ABS_MAX; the client sends [0, 2 pi)), and ang_diff is closed form, so a huge yaw cannot stop the event loop. When a seated fighter attaches during a fight (a return after a drop, or a new socket that replaces an open one), the hub calls clear_input, which also resets the fighter’s sequence number, because the client counts its inputs from 1 again after each start. A human shot is a hitscan on the SERVER: the target is the nearest living fighter in front whose lateral offset from the aim line is under 0.228 tiles (the prototype’s center-column aim window, 0.24 x 0.95 sprite widths) and who is nearer than the first wall on the aim line (the renderer’s DDA). The server rewinds the OTHER fighters to the tick the shooter’s client drew (k in the input), at most 12 steps (200 ms). A kill resolves once: fighters act in slot order, and a fighter killed earlier in a step does not shoot. Bots run on the server: the prototype’s BFS paths, view cone (1.15 rad), line of sight (an exact grid walk, so a bot never shoots through a wall; a test checks every bot shot), reaction time and hit chance, all at one “normal” skill (OVR 82, raised from 72 on 2026-09-26 when members won 5 of 7 rounds), since a bot has no card. Bot faces are different members, by the Ring Kings AI rule (never anyone in the room), and Toots’ face fills the rest. Per socket: start and each snapshot carry the socket’s own you (slot) and ack (last applied input); inputs above 30 a second (burst 5) are dropped. Drops: a fighter whose socket stays closed past the 10 s grace time is OUT (a kill event with no killer) and the round goes on; the round is void only when every human fighter is gone. When no human fighter is alive, the hub runs 4 times the steps per snapshot, so the spectators do not wait for the bots. Leaderboard (game key deathmatch, R24): a round writes only when 2 or more humans played it to the end (over.pvp). Each such member gets a win 3 + 1 a kill, at most 10 a round; a member who left gets no row. A human winner is crowned champion; a bot winner crowns nobody. All rows + the champion go in one db.record_game_result transaction. The result card is one Toots line + the top 3 of the standings. game_match carries game_key=deathmatch, reason last|time|draw, mode pvp|bots, and folds city, map, fighters, winner_ai, kills and pvp into detail (no new top-level field; nothing per shot). The full protocol for the client: lobby + game/city/bots/max; start + game, city, map, layout (24 strings), spawns (each fighter’s current [x, y, yaw], so a reconnecting client does not jump back to its spawn point), fighters[].slot, round_s, intro_s, you; input {t:"in", s, k, mv:[fwd, strafe], yaw, fire}; snapshot {k, ph, tm, ack, p:[[slot, x, y, yaw, alive, kills]], ev} with events shot / kill (by null = left) / win; over + game, win_slot, city, reason, standings[{id, name, ai, slot, kills, alive, place, out_by (the killer's slot), left}], pvp. Ring Kings messages do not change (they carry no game field). Deathmatch on the platform features (integration with #3691 and the live card). End game, Leave and the session tally work the same in a Deathmatch room; the tally counts round wins per fighter (Room.record_result takes the winner’s slot), and all bots share the one “AI” entry. The lobby card’s title is the game the host picked (“Ring Kings” or “Deathmatch”), and any close reads “Deathmatch · Game over” for a Deathmatch room. The live card has a Deathmatch layout (live_card.DmCardState, live_feed.DmLiveFeed): a small tile in the city’s sky colors (DM_CITY_SKY, the client’s sky pairs; a test keeps the two lists equal), the city and the round clock in the header, up to 8 fighters in two rows (face, name, kills, ALIVE / OUT / LEFT / WINNER), and the last kill (“Ana took out Bo”) in the moment band. A kill and the round end (win) are urgent frames, like a knockdown.wheel.py + utils/wheel.py + utils/wheel_card.py, the random wheel (#3300, owner ask 2026-09-14): a wheel the room builds, spins, saves and comes back to. Two doors build one and they meet at the same card. /wheel make items: takes a typed list (split on commas, new lines or pipes, so nobody has to learn a format). /wheel theme theme: asks the model for the slices (claude_client.generate_wheel_items, the guild’s wheel Models-page knob, default Sonnet), and the card’s Regenerate button redraws every slice on the same theme with the previous draw passed as exclude — measured 0 overlap across five themes on the live dry run, which is what makes “pull until you like the wheel” work. The posted card carries Spin, Regenerate (themed wheels only) and Save, and anyone in the channel may tap them, like the /guess game controls. Save names the wheel on the GUILD’s shelf (/wheel load, /wheel list, /wheel delete); a wheel is a thing the room spins, so the shelf is shared and created_by is provenance, not ownership (its saver or a mod may clear one).
“Shelf” is the INTERNAL name only. The code calls the guild’s saved-wheel list the shelf (_shelf, saved_wheel_owner, the saved column); no text a user reads ever says it. The UI says saved and the button says unsave, because a control has to state what it does, and a metaphor in a button label does not (owner steer 2026-09-15: “wording is confusing, too clever”). Keep the two apart when editing: rename the internals if you like, but never put the metaphor back in front of a user.
ONE command, and the page is the surface (owner steer 2026-09-14: “/wheel on its own should open up the builder … no need for the shortcut commands, everyone should go through the builder”). /wheel opens the builder page; /wheel name: opens it on a saved wheel. There is no command GROUP – make, theme, load, list and delete are all gone, and the page does all five: add parts, take parts off, hand it to the model, redraw, pick a saved wheel off the shelf, name it, take it off the shelf, post it. One thing to learn instead of six. The count parts: 8 of 24 leads the page, with the ceiling beside it rather than discovered by hitting it. The user-facing word is parts everywhere – the card caption, the page, every reply – rather than the code’s slices/items.
The page holds no state of its own. /wheel writes a DRAFT row – an unsaved wheel, allowed to hold fewer than the two parts a spin needs – and every control carries that row’s id, so the page survives a redeploy mid-build and an abandoned draft is an unsaved wheel prune_wheels clears.
Opening a saved wheel does NOT copy it; the fork is LAZY. The page works on the saved row, so posting it straight back to the room posts the wheel itself: same row, so its spins keep counting and the shelf entry is the thing being spun. That is what “come back to a wheel” means. The copy happens the moment something actually CHANGES, because a saved wheel may already have cards posted in the room and rewriting its parts would leave those cards showing one set and spinning another. The fork takes the name across when the person may take it, so the shelf follows the version they just improved. An earlier revision copied on OPEN instead, which was safe and lost the continuity the surface exists for.
Off the shelf UN-SAVES, it does not delete. The row stays, so the page stays alive and any card already posted for that wheel keeps spinning; it ages out through prune_wheels. Clearing a shelf entry should not kill a card somebody else has open.
A redraw is a NEW wheel, not a rewrite. Every card for a wheel – the one it was posted on AND one per spin since – carries buttons pointing at the same row, so rewriting that row left every earlier card showing one set of options and spinning another (#3301 review). Regenerate writes a new row with its own card; the old cards keep spinning exactly what they show until they age out. The shelf entry follows the redraw when the person may take the name.
One ownership rule, in two places. Taking a shelf name that is in use, and clearing a shelf entry, both need the person who SAVED it or a mod. saved_by and not created_by: anyone may tap Save on a posted card, so the builder and the saver are often different people. Before that column, saving over a name was the way round the delete rule, and /wheel delete’s own words (“whoever saved it, or a mod”) were false in both directions. A takeover UN-SAVES the wheel that held the name rather than deleting it, so its card keeps spinning.
The buttons carry their own kill switch. bot._GatedTree.interaction_check gates the slash surface only, and a component press is not an app command, so a guild that switched Toots off still had every wheel card in its history live and making paid model calls. Each handler checks is_bot_enabled itself.
No session state, on purpose. Every posted wheel is a wheels row from the moment it is posted, and each button’s custom_id carries that row’s id (discord.ui.DynamicItem), so a press is resolved by READING THE ROW. The bot redeploys on every merge to main, so a wheel whose buttons died on a deploy would have been the surface’s most common failure; a card posted last week still spins. One table holds both halves — a posted wheel and a saved wheel are the same object at two ages — with a PARTIAL unique index (WHERE saved) so one name per guild is on the shelf while the unsaved rows, all named '', never collide. Saving over a name in use UN-SAVES the wheel that held it rather than deleting it, because that wheel’s card may still be on screen; the un-saved row ages out through prune_wheels (30 days, saved wheels never).
The draw and the picture are separate. utils.wheel.spin is a uniform random pick with an injectable generator; the card ROTATES the wheel so the drawn wedge sits under the pointer, lights it and dims the rest. The picture never picks anything, and tests/test_wheel samples the pixel under the pointer to prove the two agree. The spin is deliberately NOT a deal without replacement and does not avoid the last winner: a wheel that cannot repeat is not a wheel.
Two fail-open paths, both loud. A render that raises posts the slice list instead of the card (a wheel_card error), so a spin still tells the room what it landed on. A themed draw that comes back with fewer than two usable slices posts NOTHING and says the theme came up short — prefer absent over invented — and shows up as the ops monitor’s wheel_generate health line. The model’s WORKING is filtered out of the draw by utils.wheel.is_narration plus the shared has_self_correction: the live dry run returned Mask Off - No wait, that's Future and Actually let me redo cleanly: as options and the first one won the spin. A song-pool line is verified against iTunes downstream; a wheel slice is verified against nothing, so the parse is the only place to catch it. EVERY marker carries task vocabulary (redo, rewrite, try again, correction), never a bare pronoun or apology – the first version matched sorry, oops, let me and i'll anywhere in a label and deleted Sorry Not Sorry, Oops!... I Did It Again, Let Me Love You and I'll Be Missing You, four real songs on exactly the music themes this surface is for. A filter that silently drops good options is worse than the leak it was written for, because nothing on the card shows that it fired.
Cost. A spin is free (no model call), so only the draws are gated: /wheel theme and Regenerate ride the per-user daily bucket @Toots mentions use, keyed wheel. The slot is charged BEFORE the call, because the call is what costs money – charging only for a draw that produced a wheel let one person spend unbounded model calls on themes that came back unusable. A spin’s only cost is the card render, which runs on a thread and carries a 1.5 s per-wheel floor so a held-down button is not a load. Emits wheel.
A spin answers at once, and the render is cheap. Production measured the animated spin render at p50 2.6 s / p90 3.8 s, and the press showed NOTHING for that long: a button defer() is deferred_message_update, so the card sat unchanged until the GIF landed (owner feedback 2026-09-17: “no feedback at all”). Two changes, same PR. (1) FEEDBACK: the first response to a card’s Spin press is an edit_message that swaps the winner line for ⏳ spinning... and greys every button (_ack_spin, the buttons taken from the message itself so a themed card keeps Regenerate); the result edit replaces both. It is the same single Discord call a defer costs, so it adds no latency; it falls back to a bare defer if the edit is refused, because the press must be acknowledged inside three seconds. The page’s spin it posts to the room as a NEW message, so it uses defer(thinking=True) and Discord’s own placeholder stands where the card lands. (2) LATENCY: render_spin draws the plate and the disc once at full size, shrinks each ONCE to the GIF’s width, and rotates the small disc per frame; the frames are quantized against ONE shared palette before the save (the GIF writer’s per-frame adaptive search was ~750 ms of the old ~1.8 s), motion frames undithered and only the held last frame dithered; disposal=1 so each frame stores only the wheel’s box. Frames 20 -> 14 over 2 turns instead of 3, so the animation plays in ~1.2 s instead of ~2.1 s (owner: “make the spin cycle shorter”). Dev box: 1.8 s / 1.1 MB -> ~0.4 s / ~0.4 MB on ten parts; 2.2 s -> 0.5 s on twenty-four. The ops-monitor ceiling went back from 6 s to 4 s.message_search.py, Discord message-history search Toots runs on demand. The @Toots mention routes “find where someone posted that link”, “what did discord_info.py, read-only Discord server/member lookups Toots runs on demand — the structure complement to message search (search reads message HISTORY; this reads the server’s STRUCTURE, facts she’d otherwise guess at). The @Toots mention routes “when did X join”, “how many people are here”, “what roles are there / who’s a mod”, “what channels/emojis exist”, “who reacted to that”, “what’s pinned in #rules” to the single discord_lookup tool, which dispatches by aspect: member (account age + JOIN date + roles + booster, target=a person), server (member count people-vs-bots, age, boost tier, channel/role/emoji counts, owner), roles (every role + member count, or target=a role → who has it), channels (the list by category + topics), emojis (custom), reactions (WHO reacted per emoji, target=a message link/id), pins (a channel’s pins). One tool by aspect rather than seven, to keep the wiring proportional. Everything is READ-ONLY (no writes, no moderation) and scoped to what the bot can already see — member roles/join dates are the same facts in Discord’s own UI. Resolves names/handles/#channel/role refs against the cache; member/server/roles/channels/emojis are cache reads (no API), reactions/pins make a bounded fetch. member/server/pins also surface IMAGE URLs (the member’s avatar, the server icon+banner, a pinned message’s image) so she can chain to look_at_image and actually SEE them (“what’s their pfp”, “what’s our icon look like”). Fail-open; emits discord_info (aspect + ok). Wired into client.ask, so it reaches every model uniformly. Pure row shapes + formatters + duration math in utils/discord_info.py (unit-tested); discord I/O here. _reactors is bounded now (#1950): it walked reaction.users() serially per emoji with NO wall-clock bound — the unbounded twin of the feeds.resolve_reactors fan-out — and a message can carry ~20 distinct reactions. It runs through fanout.gather_deadline (5s, concurrency 6); an emoji whose lookup misses still renders its ROW with the aggregate count, just without names.scheduled_events.py, Discord scheduled events Toots creates + manages (#888), the sibling surface to polls. The mention routes “schedule a watch party Friday 8pm” to the create_event tool (+ list_events/read_event/update_event/end_event), delegating here. EXTERNAL events (a free-text location), since a chat request rarely names a voice/stage channel; the model passes an ISO 8601 start_time it computes from the current date (the [ctx] line it’s given is ET, so the pure core reads a naive timestamp as ET, not UTC — what keeps “Friday 8pm” from landing 4-5h off; an explicit offset/Z is respected), and the pure core (utils/scheduled_events.py) validates (name ≤100, future start, end defaulted to +1h, location ≤100 defaulted to TBD) so a bad request is a friendly note. The model is steered to read list_events before creating so it doesn’t duplicate one already on the calendar. Authorization: list/read open to anyone; create/update/end are mod-gated AND require the bot’s Manage Events permission (checked + reported clearly in the cog). Events resolve by id or a case-insensitive name-substring match (_resolve_event, ambiguity lists candidates); end_event is status-aware (a scheduled event is cancelled, an active one ended, an over one reported as such). Unlike a poll, an event edits in place (no reset). Fail-open; emits scheduled_event. Pure core in utils/scheduled_events.py (unit-tested); discord I/O here.playercard.py, the engagement “rookie card” (#570, slash command /card): a public /card [member] builds one member’s Discord-Wrapped-meets-NBA-2K card — a 0-99 OVR, a rarity tier (Bronze/Silver/Gold/Diamond/Legend), three named ratings, a deep “tape” of raw stats, earned badges, and a stat-derived archetype — every rating percentile-ranked against the server’s ACTIVE members (Spotify-Wrapped “you out-rate 80% of the room”), with a “next tier” chase line. Rendered as a PAGINATED Discord Components V2 LayoutView (#570): a hero page (name+OVR emblem + avatar Thumbnail; the LABELED CLOUT/HUMOR/SOCIAL ratings strip; the top headline flexes; then the tier/archetype line BELOW the stats per owner steer; badges; chase) plus ◀ ▶ nav buttons that flip in-memory (mirroring /leaderboard) through FOUR detail pages — 💥 reactions · 🫂 social · 🎧 content · 📊 activity & career — that surface EVERY counted stat, grouped one-per-line so each is self-explaining (the de-jumble fix: 🔥/😭/🤝 are reserved for the ratings and never reused as a detail-line emoji). A 📋 button publishes the explainer — itself paginated (scoring page + one page per stat group) that DEFINES every single stat with a one-line blurb (Discord has no hover tooltips, so the explainer page is the only place an explanation can live; #570 owner steer “fill out the explanation page with every single stat”). The blurbs live in the pure pc.STAT_GLOSSARY (+ a badges page from pc.badge_glossary() explaining how each badge is earned), and tests/test_playercard.py:test_glossary_covers_every_detail_stat asserts the glossary defines EXACTLY the stats the detail pages render (same emoji+label) so a stat can never ship without a definition. Labels are written to be self-clear (e.g. “total emoji used”, “total messages”) so the glossary is a backup, not a requirement. A text fallback covers a V2 send failure. Accuracy + de-fluff pass (owner steer): a banger is now a post that hit the wall bar (10+ 😂 OR 😭 on a single emoji, utils.engagement.BANGER_*), not a combined-laugh heuristic; badges are tightened to a genuine top-10% bar (_top q=0.90) + each carries a how shown in the explainer (no more arbitrary top-quartile tags), and the games/bookie badges (Sniper/High Roller) are dropped; the canned archetype line + the arbitrary chase are cut from page 1 (the memory hype-up replaces that “who is this” role, follow-up); 🎮 /guess wins + the bookie record are dropped from the card in favor of 🏆 server rank (Card.rank_by_reactions, “#N by reactions pulled”) — those game surfaces are too thin right now, lean into the Discord metrics, add them back when real; and a left-the-server member resolves to a real name via db.names_for_user (the leaderboard’s identity map), never a bare “someone”. Server rank rides the HERO (first page, owner steer) — a _render_hero standing line off card.rank_by_reactions, surfaced up front (it also renders on the activity/career detail tab). /guess wins was pulled BACK off the hero (owner steer: too thin for the first page) and now lives on the activity/career detail tab — “where the rank was” — so it’s still on the card, just not the headline (tape["game_wins"] → the career group’s 🎮 /guess wins line, glossary-defined). Nav arrows render as emoji — the ◀️/▶️ page buttons (card + rubric views) carry the FE0F variation selector, since bare ◀/▶ (U+25C0/25B6) render BLANK as Discord button emoji on some clients (the malformed-nav fix). Memory hype-up (#570): the card HEADER (next to the avatar, the “who is this” role the cut archetype used to half-fill) carries a short Toots-voice scouting line generated from her long-term MEMORY of the member. To make it genuinely TRUE it pulls everything she knows about the person across all of memory: cogs.playercard._generate_blurb FTS-searches her DAILY memory of them by every alias they’ve gone by (rename-proof over names_for_user), fetched CHRONOLOGICALLY (db.search_daily_notes, daily-tier only) so it reaches the guild’s FIRST day — then condenses each note to just the sentences that NAME them (the pure, unit-tested pc.about_member_text: word-boundary, NFC, case-insensitive sentence extraction), dedups recurring lines, and SPREADS the kept notes EVENLY across their whole arc (pc.spread_evenly, anchored on the first) to fit the budget. A daily note is about the WHOLE room, so a person is usually a line or two buried in ~13k chars; extracting only the about-them sentences collapses that to pure signal, and the even spread lets a fixed char budget (_BLURB_MAX_CHARS) reflect the member’s ENTIRE history — from their first days to now — instead of only the densest recent stretch (denser for a heavy regular, broader for a light one). Daily-only on purpose (owner steer): the hour-grained tier skews to today’s un-rolled notes, and the daily rollup already synthesizes the hourlies into the daily note. The deduped, evenly-spread block goes to claude.player_blurb, told to build the line from the sharpest, most specific receipts in the notes, to prefer the NAMED + grounded over a vague/loud label (the “Lizz not a streamer” lesson — when the notes are mixed/loaded on a detail, take the grounded version, not the loudest), and to skip a generic role-label opener; fenced exactly like the memory notes it’s built from (observed public behavior only, no private inference/PII, others named are just context); returns “” / EMPTY on too-thin memory. The consented per-user user_facts store is deliberately NOT read here — those are private-to-that-user and must never surface on a public card. A blurb per PAGE (#570 owner steer “one for each page”): beyond the hero memory hype-up, each detail page (reactions/social/content/activity) carries its own short Toots-voice stat ROAST of the actual numbers ON that page (claude.player_stat_roast over pc.page_stat_block(page) — “1312 GIFs and only 22 spotify links, unhinged”; “0.3 hit rate and 7,058 left on read, you’re spraying the whole clip to land 10 bangers”). Each stat carries its glossary DEFINITION inline (pc._STAT_DEFINITION, the same STAT_GLOSSARY the 📋 explainer renders, drift-tested to cover every detail stat) so the model knows EXACTLY what every number means and never misreads one (owner steer); the roast prompt is told the parentheses are context, not fodder. The hero + all page roasts are generated concurrently (_generate_blurbs) as one bundle ({"hero": ..., "<page.key>": ...}), each leg independently fail-open. The card blurbs run on OPUS (_BLURB_MODEL, owner steer: cost no object, a gift to members) — a blinded dry run found Opus pulls noticeably deeper, more specific receipts from the full memory than Sonnet; low-volume + durably cached, so the cost is bounded, and one knob flips it back to Sonnet. Opus EMPTY-parse fix: Opus sometimes THINKS OUT LOUD (“EMPTY\n\nWait, there’s plenty here.\n\nstats.py, the mod-only /stats engagement SEED builder + updater (#570) — the /remember analogue for the player-card stats. A scan over the configured MEMORY channels (the curated “real conversation” set the long-term-memory writer reads, db.get_memory_channels; owner steer) and their threads (active + archived public, rolled up to the parent channel for the per-channel count but scanned as their OWN flow sequence so a thread isn’t interleaved with the main timeline), walked CHRONOLOGICALLY (history(after=, oldest_first=True, limit=None) — no per-channel cap, Discord’s history is already bounded), tallies per-user engagement via the SHARED utils.engagement ScanState and writes the durable member_engagement seed /card reads — so the card’s numbers are ALL-TIME + instant (no per-call scan). Oldest-first is what lets the flow stats (reply latency, closer/reviver/burst) and reply attribution resolve in order (a COMPLETE in-scan msg→author map, no dependence on Discord embedding the replied-to author). TWO MODES so the seed STAYS FRESH without re-walking all of history every time (#570 owner steer — “catch up the latest, don’t rebuild the past; users expect stats to stay updated”): catch-up (the DEFAULT) scans only the messages since a per-guild snowflake WATERMARK (the id of the newest message a prior scan covered, stored in the settings KV) and FOLDS them into the stored totals via the engine’s additive merge (merge_into_state → EngagementAcc.merge), upserting just the active members (db.upsert_engagement) — cheap, the call an hourly/daily writer will make; rebuild (rebuild:True, or implicitly the first run / a legacy seed with no merge-state) re-walks ALL history and rebuilds the seed — the canonical recompute that makes the few boundary-approximate stats exact again + backfills a newly-added stat key across history. The rebuild is RESUMABLE (the shared utils.resumable_scan spine, #570): the owner’s server is ~300-400k messages (one channel, #habibis, is 90k+), so a whole-server scan that holds everything in memory and writes ONCE at the end runs far longer than the gaps between redeploys (the bot auto-deploys on every merge to main) — a run can be killed before it writes a single row, and the prior all-or-nothing rebuild did exactly that (1.5h+ in, seed still empty, then a deploy killed it). So the rebuild folds the memory channels ONE AT A TIME into the seed via the same additive merge (_scan_one → _merge_channel_rows, bounded memory: one channel’s scan in flight, not a whole-server map) and CHECKPOINTS each channel done ATOMICALLY (db.upsert_engagement_and_setting writes the folded rows + the resume checkpoint in ONE transaction, so an interruption between the fold and the checkpoint can’t re-merge a channel and double-count). A fresh rebuild clears the seed first so the per-channel fold rebuilds from empty (guarded: if read access to EVERY memory channel is gone it KEEPS the existing seed rather than wipe it — _any_readable, reason=no_access); a resume keeps the partial seed and skips the done channels. Recovery is automatic: cog_load kicks _resume_on_boot, which picks up any guild whose rebuild checkpoint is unfinished (the deploy that interrupted it is exactly what cancelled it) and continues from the next unfinished channel — so a redeploy/restart/OOM mid-scan never restarts from zero, and the seed accretes channel by channel across however many deploys it takes (a snagged channel is left not-done so a resume retries just it). A catch-up is provably == a rebuild applied incrementally on the additive stats (tests/test_engagement.py); a handful of boundary stats (cross-window reply resolution, conversation flow, reply latency, left-on-read) go slightly approximate between rebuilds, and first_of_day is explicitly guarded against the per-run double-count (the catch-up ScanState(opened_through_ordinal=) suppresses re-crediting the watermark day’s opener — else it inflates every run). Re-running /stats while one is in flight STOPS the prior run and starts over (a per-guild in-flight asyncio.Task map → cancel-restart; never two concurrent scans, which would double the rate-limit pressure). Each member’s row is {"display": blob, "state": merge-state} JSONB (so adding a stat never needs a migration; db.get_engagement unwraps the display, db.get_engagement_states the merge-state; a legacy bare-display row has no state → forces a rebuild), and embed-fixer webhook reposts fold onto the real user BY USERNAME (the repost’s avatar is the webhook’s, not the user’s). Runs in the background (acks immediately, drops a server rundown when done), mirroring /remember’s backfill; mod-gated (a rebuild is heavy). Every author with at least one message is seeded (no min-messages floor — just skip genuine bots). Both writes are guarded so a transient blip (a readable channel erroring mid-walk, an empty scan while a seed exists, or a fresh rebuild having lost read access to every memory channel) leaves the existing stats untouched. Observability (a big rebuild outruns Discord’s ~15min interaction-token window, so the ephemeral followup silently dies — the SCAN must be visible in the LOGS): _build logs the START + the OUTCOME (the rundown text, always — so a long/snagged run is in Railway even when the Discord reply is suppressed), _scan logs per-channel msg + thread counts, _walk logs a heartbeat every 10k msgs (a huge channel shows progress mid-walk) and logs a snag LOUDLY (which source errored, at WARNING — not the old silent DEBUG); _deliver falls back from the (expired) ephemeral followup to a plain channel message so the mod still sees the result; and the snag/empty/no-access NON-write paths now emit stats_seed with ok=False + reason (snag/empty/no_access), so a rebuild that wrote nothing is a structured event, never silent. Emits stats_seed (mode=rebuild |
catchup | backfill, trigger=manual |
scheduled, ok, reason). Scheduled daily catch-up writer (#957, the automation half of the living seed): a @tasks.loop(time=09:00 UTC) (daily_catch_up, started in cog_load) folds the settled new activity into every configured guild’s seed once a day, mirroring cogs/memory.py’s writer — so the leaderboards/cards read current numbers with no on-demand scan. It folds with a 24h SETTLE-LAG (before_cutoff = a now-24h snowflake threaded through _catch_up→_scan→_walk’s history(before=)): reactions on a post keep landing for ~a day and a catch-up freezes a message’s reactions when it scans it, so a message is folded only once it’s >24h old (an hourly fold would freeze ~0 reactions; the watermark advances only to the settled boundary, leaving the fresh tail for the next day). It’s catch-up ONLY (never an unattended rebuild; a guild with no seed waits for a mod’s first /stats), shares the _builds in-flight map (a scheduled run and a manual /stats never scan a guild concurrently; a manual run cancel-restarts a scheduled one), is master-kill-switch gated + fail-open per guild. The MANUAL /stats catch-up keeps no lag (scans to now on demand); a rebuild re-reads everything current. Daily engagement SNAPSHOT (#1017, the windowed-stats layer): right after the scheduled fold (_snapshot_after_fold in _run_scheduled, idempotent per UTC day) it freezes the now-fresh seed into engagement_snapshots via db.snapshot_engagement — a ~1-line server-side INSERT ... SELECT FROM member_engagement copy, NOT a re-scan — so the /leaderboard stats period: weekly/monthly boards compute their window by SUBTRACTION (current seed − snapshot from N days ago, via utils.engagement_window). A cog_load bootstrap (_bootstrap_snapshots) takes the FIRST snapshot for any seeded guild with none yet (so windows aren’t empty until the next daily run); the daily loop also prunes snapshots past _SNAPSHOT_RETENTION_DAYS (40, monthly + buffer). One daily snapshot serves BOTH weekly + monthly — no extra scan jobs. Emits engagement_snapshot. /stats period:[week|month] backfill (#1017, the “seed the weekly NOW” path): the forward-looking daily snapshots mean a window is only accurate once N days of them accrue (weekly in ~7d, monthly in ~30d). The period: arg seeds it immediately: it scans the last 7/30 days, then RECONSTRUCTS the snapshot as of the window’s start as (current seed − the window's scanned activity) and stores it stamped at today−N (db.backfill_engagement_snapshot, a past-dated insert into engagement_snapshots), so the board’s current − snapshot reproduces exactly the scanned window. The reconstruction is the WINDOWING APPLIED IN REVERSE — engagement_window.reconstruct_snapshot is literally window_display/window_state over (current, scanned_delta), so the same diff the board applies forward yields a snapshot whose forward diff is the delta (round-trip proven in tests + validated against the live 180-member seed: 3,735 counter pairs + 1,194 graph edges, 0 mismatches). It writes ONLY snapshots — the all-time member_engagement seed is never touched (owner constraint) — and REFUSES while a scan is in flight (rather than cancel-restart like a rebuild/catch-up) since it reads the current seed to subtract from. Run it per window (period:week seeds today−7, period:month today−30); the rundown reminds you to run the other. Emits stats_seed (mode=backfill) + engagement_snapshot (trigger=backfill). |
awards.py, the weekly + monthly engagement AWARDS (#1017): a Player of the Week (Sundays, a single award but the SAME full fanfare as the monthly — owner steer “Player of the week should get the same fanfare as the others, the only difference is it’s just that single award”) and a FULL monthly gallery led by the {Month} MVP (e.g. “June MVP”, the 1st), posted in Toots’ voice to the dedicated awards channel (a single-channel /menu picker on the “where she posts” page, awards_channel_id in the settings KV). Each award is the TOP of a WINDOWED board — the live seed minus the snapshot from 7/30 days ago (window_display/window_state), ranked by the same windowed counters / closest-pairs the leaderboards use (no live scan, no new table; rides #1021’s windowing + #1033’s backfill). The pure winner-selection (registry + dedup) is utils.awards; this cog is the I/O (schedule, windowed reads, name resolution, the claude.awards_post voice call, the champion-card image edits, the send, durable state). Every award is engagement/activity based (owner steer): weekly = Player of the Week (most engagement = reactions_received+replies_received), a single crowning that gets the SAME celebratory host-intro fanfare as the monthly (awards_post(single=True) — owner steer; the only difference is it’s one award, not a stripped-down “light” post); monthly = a gallery led by the {Month} MVP (the SAME engagement metric, titled "{month} MVP" off the recapped month — “June MVP” — via AwardSpec.title’s {month} template) + Funniest (laughs) + Besties (the duo pair). (Weekly + monthly headline are one award/metric, just titled per cadence.) There is NO OVR / “Player” award (owner steers “no player of the month just week, that’s mvp” → then “use same metric for mvp weekly”): OVR is stable (“ovr doesn’t vary too much”), so it makes a dull award — it stays the /card rating + the card leaderboard, NOT an award. The philosophy: awards reward what you DID this period (engagement / comedy / the closest duo, all of which move week to week + are further varied by the per-category rolling dedup), while the card shows your overall standing (OVR). bangers was REMOVED as an award entirely (owner steer “we aren’t doing banger awards anymore”; it overlapped MVP/Funniest) — the AwardSpec, its bangers metric/_FIELD_LABEL/_detail branch, the _CARD_BODIES card prompt, and its tests are all gone; the separate bangers LEADERBOARD board (a different surface) is unaffected. The player/OVR + breakout AwardSpecs were removed entirely (owner steers “no player of the month, it’s player of the week and month mvp”; breakout was never live), and with them the now-dead window_ovr + alltime_ovr inputs — the awards no longer compute or read an OVR board at all (so the cog no longer depends on the PlayerCard cog). One @tasks.loop(time=noon ET) daily tick (_POST_AT, a DST-aware ZoneInfo("America/New_York") time so it stays 12PM Eastern year-round — owner steer; noon ET is 16-17:00 UTC the same calendar day, so the UTC-based _due_period/_token date logic is unaffected) decides the cadence — the 1st of the month posts the monthly gallery and supersedes that day’s weekly, every other Sunday posts the weekly. The post is a delightful gold-chromed multi-embed GALLERY (owner steer “polish + chrome, delightful, no em dashes” + “draw images for them all individually”): one gold embed per award stacked into a gallery — the FIRST carries the TOOTSIES AWARDS banner + Toots’ host intro (claude.awards_post, Sonnet, a short hosting hook, NOT an award list — the gallery shows the awards) + the headline winner + stat; each subsequent embed is a clean labeled trophy card (emoji title + winner + stat). A CHAMPION CARD per board champion (owner steer “remix an image of their pfp winning, each type in the prompt, a besties image for the pair”): every winner’s Discord avatar is remixed into a trophy card themed to that board via image_gen.edit (_CARD_PROMPTS per key = themed body + the shared _CARD_TEXT_RULE — crown / reactions-raining / comedy / duo). Maximalist style (owner steer “the maximalist emojis and gloss and fireworks is what i want”): the cards are deliberately glossy, badge-heavy FUT/2K-style trophy cards — the rich vibe descriptions in _CARD_BODIES are what the image model renders as the baked title + flair (fireworks, medallions, glaze badges). A “de-corny”/minimal-nameplate/textless detour was tried and reverted — minimal was the wrong direction. {award} is baked as the card title, so the on-card title is always the CURRENT award name. _CARD_TEXT_RULE (owner steer chain: “don’t spell out exactly the metrics” → meme-GLAZE badges (GOAT/ICON/LEGEND) → “remove the meme glaze” → “why does it say june mvp CHAMPION not june mvp”): the ONLY text on the card is the award title itself, spelled EXACTLY, rendered big + glossy as the centerpiece — NO word-badges at all (no metric/stat labels, no glaze/hype words, no slogans, no speech bubbles), and no extra word appended to the title. The “champion” leak was the MVP body wording (a {award} champion card put “champion” right after the title, so the model baked “JUNE MVP CHAMPION”); the bodies now say a {award} card and the rule POSITIVELY pins the exact string (a forbidden-word list is deliberately avoided — naming a word can summon it in an image model). The maximalist hit comes from the visuals (fireworks, floating emoji, crown, trophy, laurels, gold gloss), not text — so the bodies are pure visual direction with no vibe phrases that bake as badges. Besties crowns each person. The MVP card featuring the winner’s SIGNATURE reaction emoji (their most-pulled unicode emoji from the seed’s reactions_by_emoji, threaded in via _card_hints; owner steer “mvp is reactions+replies not most-loved, use their most reacted emoji as input”), and the besties card compositing BOTH pfps side by side (_compose_pair) into one duo portrait; generated concurrently (asyncio.gather), cached per (guild, period-token, award, winners) so a PRODUCTION re-post reuses them (a STAGING audition passes use_cache=False — it neither reads nor writes the card cache, so a preview never warms it for a real post nor is served a staged card; part of staging being fully stateless); fail-open per card (no image client / no avatar / edit miss → that embed falls back to a plain card / the hero a thumbnail). The winners are @-tagged once on the content line (the only place a ping lives, since an embed mention never notifies — allowed-mentions users-only; a crowning, distinct from the no-ping leaderboards). Two dedup layers spread the spotlight (owner concern “will uhlant just win every other week” + “win one per month / one every 4 months”): cross-category (a single person is never crowned twice in ONE post — keys order is priority, so the headline (Player weekly / MVP monthly) takes the top and later awards fall to the next distinct one; the pure taken set in utils.awards) + rolling anti-repeat, PER CATEGORY (the cog feeds recent_winners: the last _WEEK_MEMORY/_MONTH_MEMORY=3 winners OF EACH AWARD are skipped, so a person can’t repeat the SAME award for ~4 periods but CAN win a DIFFERENT one — owner steer “for monthly don’t be so strict on any-award dedup, maybe you can’t win THAT award”, relaxing an earlier global “any monthly award locks you out of all” rule; stored as a trimmed history list in the settings KV). The anti-repeat means the crown is often NOT the top of the board, so the WORDING has to follow the rotation (audited 2026-08-30 over the five weekly posts from Aug 2 to Aug 30: the board leader won 2 of 5, and on Aug 23 the crown went to 4th place at 516 points against the leader’s 1,065). AwardSpec.measure(period, rank) picks the superlative blurb only for rank 1 and the blurb_off_top wording otherwise, so the voice + hype prompts never read “pulled the most engagement of anyone” about a runner-up. The live n=5 dry run measured the claim the model actually wrote on the Aug-23 shape: 4/5 posts asserted a dominance that was false (“Player of the Week, no debate”, “and it wasn’t close”) before, 0/5 after. Idempotent per ISO-week / per-month so a restart never double-posts. Gated by the awards experiment (default staging, mirroring live_scores/market_drop): production posts the gallery to the awards channel, staging auditions the SAME gallery in #bot-logs (room stays quiet, via bot_logs.staging_header + bot_logs.post(embeds=, files=), no pings) so a mod QAs winners + voice + cards on real data, off skips the loop entirely. Plus the master kill switch + mood != off; production also needs the awards channel set on /menu. A mod /awards period:[week|month] posts on demand (preview/manual; respects the stage — staging→#bot-logs, production→awards channel, off→blocked; bypasses the idempotency skip; only a PRODUCTION post records state — a STAGING audition is a pure preview that records NOTHING (no idempotency token, no anti-repeat history), so a mod can re-audition the same period freely without consuming its token (which would block the real scheduled post) or biasing the dedup, owner steer “i can’t audition if it’ll dedup”). Per-award memory HYPE-UPS (owner steer): each award embed carries a one-to-two sentence Toots-voice appreciation of the winner(s) woven from her THIS-PERIOD memory of them + their receipts — claude.awards_hype (purpose awards_hype), generated CONCURRENTLY per award (_generate_hype → _one_hype over asyncio.gather). _one_hype gathers three grounding sources: _winner_memory (the about-them sentences from the period’s DAILY notes — rename-proof over every alias via match_forms + db.search_daily_notes, scoped to span_end >= cutoff, deduped + spread_evenly across the window, reusing the /card hype-up’s about_member_text/spread_evenly core), _winner_receipts (each winner’s biggest messages from this award window — the actual words behind the numbers; up to _HYPE_HIGHLIGHTS=4, most-reacted first, owner steer “it should still cite the week’s highlights from them”). Several, not one, and filtered for WORDS (_window_highlights / _quotable): the reel ranks by reaction count, and a week’s single most-reacted message is routinely contentless — this week’s Player of the Week topped their own reel with a row of crying-laughing emoji, so the hype-up quoted the emoji while their actual lines sat just below it. _quotable drops an entry with no real word left once emoji, URLs and pings come out; the bar is ONE word, not a character count, because the punchy one-liner is this room’s whole register (“6’11 bum” has six word characters). It reads the window by the same subtraction every other award input uses: the seed keeps a 200-deep highlight reel per member (top_reacted_posts), so the reel NOW minus the reel at the window start (_window_highlight) is what they posted this period. It used to hand over top_post_text — the member’s ALL-TIME best message — so the hype-up celebrated an old moment as this week’s; a live n=5 dry run on the real guild quoted that all-time post in 5/5 hype-ups before the fix and 0/5 after, and the windowed receipt also rescued a winner whose hype had been coming back EMPTY for want of grounding. A winner with no new reel entry gets NO receipt line rather than an out-of-window one, and for a DUO _duo_crosstalk (a bounded live scan of the memory channels for messages where the two mention/reply to each OTHER — their back-and-forth, the context memory may not capture). Fenced exactly like the memory notes it’s built from (observed public behavior only, no private inference/PII), fail-open per award (a miss → that embed just shows no hype line). Runs on OPUS by default (the awards are a gift — give them the best voice; a blinded dry run found Opus pulls noticeably sharper, more specific receipts than Sonnet), but the model is a per-guild /menu Models-page knob (awards surface, MODEL_SURFACES, opus/sonnet/gpt/gpt-cheap) via _awards_model so a mod can A/B the GPT-frontier voice. Fail-open throughout (no seed/snapshot → a “run /stats period:” note; a quiet window → no post; a voice/send miss → a soft status). Emits awards_posted (delivered=room |
staged, cards=# themed images rendered, hypes=# hype-ups that generated — a silent-degradation guard: hypes=0 with winners means the memory/model came back empty across the board). Pure core unit-tested in tests/test_awards.py, the schedule/format/embed/compose + hype-receipts helpers in tests/test_awards_cog.py. |
curator.py, the content curator (#curator): Toots keeps a themed room fed with fresh on-theme links, like a tasteful member. A curator channel (its OWN curator_channels set on /menu’s content-curation sub-page — reached by the 🖼️ set up curation button on the behind-the-scenes page, split off so that page stays under the ~5-select cap — separate from feed/discourse) is self-sourcing: she reads the accounts already recurring in that channel’s own history (utils.curator.rank_seed_accounts over the X/TikTok links there, excluding her own posts — the self-feedback guard), pulls their FRESH posts from the source (X via utils.twitterio.XProvider + TikTok + Instagram via utils.scrapecreators.TikTokProvider/InstagramProvider, provider-agnostic behind utils.source_providers.SourceProvider/SourcePost; #1294 the compose loop FANS OUT over every provisioned source — _platform_candidates derives each platform’s own seeds/adjacency, then interleave_candidates merges them into a FAIR judge slate (round-robin per platform, so a big-count TikTok batch can’t crowd X out of the judged top slots); a channel with no seeds for a platform just doesn’t fetch it. NO engagement PRE-FILTER (#curator, owner steer — “give the model more to determine based off everything vs prefilter on engagement, that isn’t working”): an earlier self-calibrating p25 batch floor sorted purely by engagement, so a channel’s single top-pulling account filled the WHOLE vision slate and the judge could only ever re-pick it — which (as a learned seed) re-promoted it into a self-reinforcing pin (prod: 13/14 of a car channel’s posts were one account). Now the only pre-judge gates are STRUCTURAL (dedup + original-with-media + a 24h source-recency gate, utils.curate.is_fresh / MAX_SOURCE_AGE — owner steer “all curation posts must be at most a day old from source”; fetch_recent is newest-first but unbounded in TIME, so without it a slow account’s weeks-old freshest post could still reach the judge; fail-open on an undated stamp — so every mapper MUST stamp extra["created_at"] or the gate no-ops for that platform. The TikTok + IG mappers originally did NOT (only the X mappers did), so the gate read nothing and failed open for every TikTok/IG post — the curator shipped month-old clips (a Post Malone TikTok, #curator). Fixed by stamping tt_video_to_post from create_time + ig_post_to_post from taken_at_timestamp (both epoch seconds → ISO via _epoch_to_iso)), and the per-platform slate is DIVERSIFIED across accounts (utils.curator.diversify_by_account, a round-robin over each author’s engagement-ranked posts, the account-level analog of interleave_candidates’ platform fairness) so the judge weighs VARIED accounts and decides on FIT — a fresh, on-theme, low-engagement post now reaches it instead of being cut for not out-pulling the biggest account (live-verified: a real car-channel pool went from an auto_porn-dominated 4/6 slate to a balanced 2/2/2 across three accounts). TikTok’s/Instagram’s “repost” endorsement analog is the collab (SourcePost.endorsed_handles, the co-creator handle free from collab_info / IG coauthor_producers), folded into the same 3×-weighted adjacency signal as an X repost; IG exposes no follow graph OR repost feed, so IG adjacency leans entirely on the coauthor collab (InstagramProvider.fetch_followings returns []), and its post ids are the shortcode so a channel-URL dedup matches parse_external_id), keeps every original-with-media post (dedup only — no engagement pre-filter, see diversify_by_account below) — INCLUDING a seed’s REPOSTS (#curator, owner steer “reposts are high quality”): is_postable no longer drops reposts, and both X providers (utils.twitterio.tweet_to_post + the ScrapeCreators backup sc_tweet_to_post) now RESOLVE a retweet to the ORIGINAL post it endorses (the original’s media/link/author/id/engagement drive posting + dedup + diversify, is_repost/reposted_handle kept for adjacency; the backup’s nested result carries no url so the permalink is constructed from author+id) — and a QUOTE tweet the same way when the seed added no media of its own (the visual content is the quoted original; a quote WITH the seed’s own media keeps the seed’s post) — an explicit endorsement is high-signal on-theme supply the curator used to throw away, so an aggregator seed that mostly RETWEETS (the #her hot-photo case) now contributes candidates instead of nothing (a media-less text RT is still dropped on an image channel; the fit-judge still gates every pick; curator_posted.via_repost marks a repost-sourced pick) — and hands an account-diversified slate to a fit-judge (a Haiku call comparing each candidate to the channel’s OWN recent posts, so the channel’s precedent sets the bar, no imposed SFW rule — owner steer, #her isn’t SFW), dedups against a durable seen-set (curator_seen, never repost a source id, restart-proof), and posts the winner’s source LINK rewritten to its embed-fixer mirror so Discord unfurls the media (X→fixupx, IG→kkinstagram, TikTok passes through — it unfurls natively; to_fixup_link via the shared _FIXUP_HOSTS; she never uploads raw media — exactly how #her/#art already post). Rides the shared ScheduledPoster spine (master kill switch + mood-OFF + calendar + per-channel slot pacing + text dedup + 429 retry) and the per-guild curator experiment (STAGING auditions each pick in #bot-logs so a mod QAs it’s on-theme; PRODUCTION posts to the room; OFF skips the guild). Calendar-managed via /menu (#curator follow-up): curator is a first-class calendar surface (utils.schedule_calendar.SURFACES + cogs.calendar_view), so its cadence is set on the /menu calendar like discourse/music/market_drop/bet_board; it falls back to its CHILL_TIMES/YAPS_TIMES pool (chill 2pm/8pm, yaps 12/4/7/10pm ET) only when a mood is NOT calendar-managed. (Note: a guild that already had a managed calendar before this must assign curator slots on /menu, else the managed mood posts it zero times — the standard new-surface behavior, same as bet_board.) Provisioning-gated on TWITTERIO_KEY (unset → bot.xprovider is None and the loop no-ops); fully fail-open. Pure seed-ranking + candidate filtering live in utils/curator.py (unit-tested tests/test_curator.py); the twitterapi.io client is guarded like SportsGameOddsClient (rate limiter + circuit breaker + retry). X-source FAILOVER (#curator resilience): when SCRAPECREATORS_API_KEY is ALSO set, the X source is wrapped in a utils.scrapecreators.FailoverProvider — the twitterapi.io XProvider primary + a BackupXProvider over ScrapeCreators (/v1/twitter/user-tweets), routed to the backup only while the primary’s breaker is open (XProvider.degraded), the SGO→Odds-API backstop pattern. So a twitterapi.io crater (datacenter-IP block / credit drain / outage) no longer takes X curation dark. Full SourcePost coverage incl. reposts (retweeted_status_result → reposted_handle), so repost-based adjacency survives an outage; ScrapeCreators has no X follow-graph endpoint so fetch_followings returns [] on the backup (adjacency degrades to the repost path). The façade is a drop-in bot.xprovider (cog unchanged); bot.xprovider_primary keeps the twitterio usage poll pointed at the primary. It forwards by DEFAULT (__getattr__), not by hand-listed method (#facade-forwards-all) — and that distinction was a live bug, twice. The façade originally forwarded an ENUMERATED set, so every twitterio-only capability nobody remembered to add raised AttributeError at the call site. fetch_trends hit exactly that and was fixed as an INSTANCE (its docstring records the silent []), but the CLASS was left open and five more were already broken behind it: fetch_tweet_replies, fetch_user_info, fetch_user_tweets_raw, get_usage, check_health, degraded. The proof surfaced only when the ops monitor learned to read forwarded ERROR logs (#1955): wire_identity.resolve_thread_identity calls bot.xprovider.fetch_tweet_replies(...) and had been raising on every wire moment about an unnamed person — fail-open, so that feature had simply never worked in production. Everyone ELSE had independently worked AROUND the façade by reaching for bot.xprovider_primary (ask, x_reply_draft, x_audience, health): four workarounds for one missing delegation is the tell that the façade, not the callers, was wrong. __getattr__ routes to the ACTIVE leg when it has the attribute, else the PRIMARY — that second half matters, since these are twitterio-only calls the ScrapeCreators backup doesn’t implement, so a degraded primary would otherwise swap in a leg that can’t answer and reintroduce the same error. Explicitly-defined methods still win (normal lookup precedes __getattr__), so fetch_trends’ always-primary rule is untouched. The general lesson: a façade that enumerates what it forwards is a list someone must maintain forever, and its failure mode is silent — a fail-open caller turns the AttributeError into a feature that just never runs. curator_fetch is stamped provider=twitterio|scrapecreators so integration-health attributes each; ScrapeCreators is a prepaid wallet (usage poll off /v1/account/credit-balance → scrapecreators:credits quota + the scrapecreators:budget balance-floor ops finding + a /debug/integrations probe). ScrapeCreators is the multi-platform seam (X/TikTok/YouTube/Instagram + transcripts, one SCRAPECREATORS_API_KEY) the broader curator + /ask cross-platform work builds on. Emits curator_fetch (the twitterapi.io read) + curator_posted. Phase 1.5 — image vs TEXT mode (#curator): the fit-judge is chosen PER CHANNEL from the channel’s own media density (utils.curator.prefers_media, ≥CURATOR_MEDIA_MODE_RATIO=0.6 of recent posts carry an image → IMAGE mode): an image channel (#her/#art) requires media + the vision judge (claude.pick_curator, candidate images vs the channel’s example images — plus each candidate’s CAPTION (#1294 follow-up): the vision judge weighs the image AND its words together, so an on-vibe-looking image whose caption is an ad / off-theme / engagement-bait is rejected on the text a picture-only judge would miss, live-verified that swapping only the caption flips the pick); a link/text channel (#man: cars/menswear/cool finds) drops the media requirement + uses the text judge (claude.pick_curator_text, candidate captions vs the channel’s recent post text + topic, no vision) so bare text/link posts curate too. Self-calibrating like the p50 engagement floor (no per-channel UI; the ratio is env-tunable, empty channel → text mode, the safe superset that still judges fit). Below the ratio, a Haiku room check decides (#3429): utils.curator.judged_image_mode asks claude.classify (purpose curator_image_room, labels IMAGE / LINK, cached in memory per channel + name + topic for a day) over the channel name, the parent’s name for a thread, the topic, and six short sample posts (each URL cut to its host, each line clipped to 80 characters). It replaced the _IMAGE_TOPIC_TERMS substring list as the decider, because that list missed picture rooms in other words and set link rooms that mention photos to IMAGE (measured 2026-10-16 on 16 labelled low-density rooms: list 6 right, Haiku 15, 0 wrong IMAGE; 3 runs, all identical). A room at or over the ratio makes no call. No verdict (error, unreadable answer, no client) falls back to the old density + topic-list rule exactly. Each check emits curator_mode_check. On the six live curated rooms (2026-10-16) every room reads IMAGE by density (0.88 to 1.00), so the check made no call there and no mode changed. The seed-harvest window is 30 days / up to 400 messages (_HISTORY_DAYS/_HISTORY_LIMIT, widened from 21/200): a low-volume curated channel’s 200 messages span well past 3 weeks, so the day window was the binding limit — a wider window keeps a seed account live longer, so a mod posts less often to keep her fresh (owner steer). Phase 2 — adjacent-account discovery (#1232): the taste self-WIDENS beyond the accounts already in the channel. Each slot fetches a 3:1 CURRENT:NEW mix across reserved tiers (utils.curator.pick_fetch_pool): 2 proven in-channel seeds (recency) + 1 learned seed + 1 fresh discovery. Discovery crawls each top in-channel seed for who it FOLLOWS (XProvider.fetch_followings → /twitter/user/followings, (handle, follower_count)) AND who it REPOSTS (the reposted author rides along on the seed’s recent timeline, tweet_to_post → SourcePost.reposted_handle, so no extra endpoint). rank_adjacent_accounts blends both by cross-seed count, weighting a REPOST far above a FOLLOW (#1251, owner steer: score = REPOST_WEIGHT*repost_cross + FOLLOW_WEIGHT*follow_cross, default 3:1 — a repost is explicit content endorsement, a follow is passive/broad), follower count as the final tie-break. A repost also surfaces an account NO seed follows (widening discovery beyond the follow graph). The ranked adjacency is durably cached per channel (DurableCache namespace curator_adjacency, ~3-day TTL) so the metered crawl runs ~once per few days, not per slot. Growth (the “grow the garden” mechanic, owner steer): because seed derivation ignores Toots’ OWN posts (no self-amplification loop), a discovered account can’t become a seed via her re-reading her posts — so an adjacent account that produces a SHIPPED pick is promoted to a durable learned seed (curator_learned_seeds, _maybe_promote), which fills the “learned” tier on future slots, so discovery COMPOUNDS. The vision fit-judge still gates every post, so an off-theme discovery is just skipped. curator_fetch gains phase=followings; curator_posted gains source = in_channel |
learned |
adjacent (watch the adjacent/learned rates to confirm discovery is working + growing). Full observability (#curator ops): the fail-open surface used to return None at 7 points with no signal, so a curator going quietly DARK (fetching fine but posting nothing because the judge rejects everything / the channels stopped being fed) was invisible. curator_evaluated now emits WHY on every no-post slot (no_seeds/no_history/no_posts/no_candidates/none_fit) + the mode; with curator_posted (which carries source + mode) it forms the ship FUNNEL. The ops-monitor renders a Curator health line (post rate + skip-reason breakdown + source/mode split) every run and flags curator_dark when she’s shipping ~nothing over enough attempts — HIGH when none_fit-dominated (judge/seeds off-theme, users see silence), MEDIUM when supply-dominated (no_seeds → post links). The twitterapi.io integration crater stays self-healed by the health cog (#1257); a content-SUPPLY dark is a human nudge (post links), so it’s an ops-monitor finding, not an autonomous fix-order. The text fit-judge is guarded by scripts/eval_curator_pick.py (registered in run_evals; picks the on-theme candidate over off-theme noise, golden = an off-theme post a healthy judge must SKIP; the vision judge shares the pick_gif path eval_gif_pick already covers). The top-artist ROSTER left this cog on 2026-09-07: it was an overlay here (the artist_curator experiment seeded one roster account per slot into every curator channel) and is its own surface now, artist_curator.py below. This cog seeds from channel history alone. The fit-judge must SEE the images it judges (#backpage, 2026-08-30). The vision API rejects an image it cannot use, and that 400 fails the WHOLE call – every other image in the request with it. Two real causes hit production: an X ?name=orig original wider than 8000 pixels, and a vxtwitter.com/rendercombined.jpg composite the API cannot download. Both arrived as EXAMPLE images. recent_image_refs takes the examples from the channel’s own history and does NOT filter them by host, while the CANDIDATE frames ARE host-filtered – an asymmetry nobody had noticed. Examples rank by reaction count, so one top-reacted bad photo stayed the #1 example and failed EVERY slot for days. _call then applied its normal recovery: drop every image and retry on the text alone. That recovery is wrong for this judge. The judge answered about pictures it could not see – 158 of 400 curator picks over two days came back from an image-stripped retry, picking a post on its caption alone – and when it narrated instead of answering, the slot died as pick_error (~2 lost slots per hour). The fix has two parts. (1) _image_block_source runs every vision URL through utils.vision_image.bounded_vision_url, which pins an X image to the large size variant (2048 pixels on the long side, well above the ~1568 the models downscale to), so an oversized original cannot reach the API. That helper is the ONE place every vision block is built, so every surface gets the bound. (2) pick_curator passes images_required=True, which stops _call from answering with the images stripped, and recovers in the CALLER instead: it re-judges the CANDIDATES alone. The examples are optional context and the candidates are the task, so the 1..n candidate numbering survives and the pick still maps to the right post. A rejected CANDIDATE image returns None, because dropping it would renumber the slate and ship the wrong post. Verified live against the real #backpage images (2026-08-30): the pre-fix call replied “I don’t see any images attached”, the post-fix call returned a real pick. The same incident exposed an observability gap and closed it: _sched_compose used to report a judge ERROR and a judge DECLINE with the SAME none_fit reason, so the funnel read “the judge rejects everything” while the judge was in fact failing on an unusable image. A None pick now emits pick_error (what the curate feed already did, and how the outage was found); none_fit means the judge looked and declined. The ops-monitor names pick_error in its curator_dark guidance. The flag rides the GPT path too (Codex review on PR #2731): _localize_openai_vision DOWNLOADS each image to send it inline and DROPS one it cannot fetch, which renumbers a 1..n slate – the model’s “pick 3” would then index the caller’s candidate 4 and the room would get a post the judge never saw. With images_required that raises OpenAIChatUnavailable with a provider category, so the router falls FORWARD to Claude with the images untouched. That direction is deliberate (Codex review): the download that failed was OURS, not OpenAI’s, and Claude fetches an image URL server-side, so the same still-aligned slate can often be judged there; an image Claude cannot use either comes back as its own 400, which pick_curator already recovers from. A request category would have lost the slot to a transient 429 or timeout on our own downloader. Reachable by CURATOR_PICK_MODEL=gpt and by the Claude-to-OpenAI failover, so it is latent rather than theoretical. The rejection is now ISOLATED, not guessed at (#2738, 2026-08-31). Shipping images_required in #2731 fixed the blind judging and immediately broke a second surface: the autonomous curator posted NOTHING for 2.5 hours (baseline 1-4/hour) because a rejected CANDIDATE image had no recovery, and unlike the curate feed this cog has no drain loop to walk with. The measured cause was 24 robots.txt plus 10 undownloadable images, arriving in PAIRS one second apart – the first attempt and the retry-without-examples both failing, which proves the offender was a candidate. The fix ISOLATES the offender two ways, because the API only sometimes says which image it refused – a live dry run caught that, and the unit tests had not: the oversized-original 400 carries messages.0.content.3.image.source.url, but the two that ACTUALLY happen (Unable to download the file and the robots.txt refusal) name no block at all – 72 of 72 over six hours of production named nothing. So _rejected_image_index parses the block WHEN it is there (one extra call, exact), and when it is not, pick_curator ELIMINATES: exclude one candidate per pass until the call goes through, at most one call per candidate and only on a slate that has a bad image. Each pass excludes a DIFFERENT candidate rather than accumulating exclusions, so every healthy candidate is still in the slate at the moment the call succeeds – which is what the drain’s positional peel could not do. _is_image_error keeps a malformed request from spending a call per candidate, and matches per-image failure SHAPES rather than the bare word “image” – a request-level 400 (“this model does not support image input”) says “image” too, and every reduced slate would get the same answer, so the bare word would burn a call per candidate before giving up (Codex review). _call’s own image-drop retry reads the SAME _is_image_error (2026-09-20). It used to carry its own three-phrase copy (Unable to download, format is invalid, unsupported), so a per-file 400 the copy did not know reached the room as a raw prompt error: The file exceeds the maximum allowed size., an EMBED image the API fetched and refused for its byte size (the attachment guard checks att.size; a link-preview or gif embed carries no size), twice in four minutes on an @Toots mention. The phrase is now in _IMAGE_FAILURE_PHRASES, and the branch matches through _is_image_error so the two lists cannot drift again. A robots.txt 400 still takes the next branch, which also drops a web_search tool the same 400 may be about. Known limit: the unnamed path excludes ONE candidate per pass, so a slate with TWO unnamed offenders has a bad image in every trial and still loses the slot – no worse than before this change, and the dominant trigger (Instagram’s robots.txt-blocked CDN) is removed by #2744, which downloads those images instead of asking the API to fetch them. Either way the surviving slate is judged on a clean 1..k numbering and the pick is mapped back through the surviving indexes. Every prompt block – the frame-count map, the captions, the transcripts – is a FUNCTION of the surviving slate, so a dropped candidate never leaves a gap the judge would read as a missing image. It loops, so several bad images isolate one per pass; it terminates because each pass removes one candidate. This lives in the shared judge, so it covers BOTH cogs and retires the drain’s positional peel as anything but a fallback. A second, smaller fix went with it: _call’s robots.txt branch no longer strips TEXT urls and retries for an images_required call carrying images, because the blocked url is an attached IMAGE and stripping text cannot make it fetchable – that retry burned a second failing call every time, 24 of them in 2.5 hours. The CAUSE is now removed, not just isolated (#2744, 2026-08-31). The post-ship verify of the isolation measured it working and delivering nothing: over 70 minutes live, pick_error held at ~11 per 2h against 12 before, and every one of the 6 failing slots burned its full 8-call budget and rescued nothing. Eight calls is one pass with the examples, one without, then one per candidate on a 6-candidate slate, so NO single exclusion produced a clean trial – the two-offender limit above, in production, on every slot. The offenders were Instagram: the roster fetch logged curator_fetch platform=instagram one second before each burst of 400s. Meta’s robots.txt disallows the vision API’s fetcher, and Instagram serves media off TWO host families picked per request (scontent-<pop>.cdninstagram.com and instagram.<pop>.fna.fbcdn.net), so a gate covering one still leaks the other. Both are now in _VISION_UNFETCHABLE_HOSTS, which is the SAME mechanism TikTok already used: download the image and pass base64. Proven against the live roster before shipping – the raw URL 400s from both families, our own egress downloads all of them, and the SAME images sent as base64 are described correctly. This also recovers real posts, not only Instagram reach: before #2731 those slots still posted, judged on stripped images, so the autonomous curator’s drop from ~2.2 to ~0.9 posts/hour is the size of the blind-judging that #2731 stopped. The curate feed needs no change – it is X-only by design, and it logged zero pick_error in the same window. Every image is downloaded now, not only the allowlisted hosts (#4287, 2026-10-16). The allowlist fixed only the hosts already seen. The channel EXAMPLE images come from embed-fixer hosts (kkinstagram.com, offload.tnktok.com, gif.fxtwitter.com) and mzstatic.com, and the API’s fetcher hit robots.txt on them or timed out: 69 + 6 of 169 curator_pick calls failed in 7 days, and #2738’s isolation spent up to 8 calls per slot on it. localize_vision (in this cog, shared by prepare_vision, the artist curator and the curate feed) downloads EVERY http(s) frame and example through utils.vision_image.fetch_all_as_data_uris, with an 8s total deadline per image, the X large size variant (bounded_vision_url), and bound_vision_image scaling to a 1568px long edge (or re-encoding past 750KB) so ten inline images stay small. The download is hardened (#4303): the body is read in chunks and refused past 5MB (a Content-Length over 5MB is refused before any read); an image whose header declares more than 50M pixels is DROPPED, never passed on (before, bound_vision_image caught the decode error and sent the original bytes, which failed the whole call); only http(s) URLs are fetched, an IP-literal host must be public, and the session’s _PublicResolver refuses a host name when ANY resolved address is private, loopback, link-local (169.254.x), reserved or multicast; redirects are followed by hand (at most 5) and each hop is checked again; the Pillow work runs in asyncio.to_thread. A refused image is one more dropped in curator_frame_fetch, with no new field. The address check lives in the resolver and not in a separate lookup per URL: a separate getaddrinfo per URL cost a p95 of 2.4s against 0.6s on 97 real images, because aiohttp’s DNS cache did not serve it. A miss drops only that image; a candidate with no frame left is dropped, and the slot goes on with the rest. The curate feed does the same, and a candidate whose frame did not download goes to the walk-local unjudged set (no verdict was reached). Dry run on 14 slates of the live curator channel’s real images: the old path judged 2 (98 of 100 API calls refused), the new path judged 13 in 13 calls. Known limit: a kkinstagram.com embed image URL redirects to an HTML page, so it cannot be downloaded either; Discord’s embed proxy_url copy of the same image does download. Using it changes the shared extract_media, so it is a follow-up. Per-account share cap (#3054). diversify_by_account spreads one judge slate across accounts, but it did not stop the busiest in-channel seed from winning slot after slot (archaeologyart: 75 of 401 room posts in 14 days). Each slot now counts the room’s own last 24 h of her autonomous picks per source account (own_post_accounts, read from the history the slot already loads; the curate feed’s posts neither count nor get capped). An account at or over curator_account_share_pct percent of them (a Tunable, default 15, 0 = off; never under 2 posts) leaves the fetch pool and the slate (capped_accounts). An Instagram post link (/p/<shortcode>/) names no account, so delivery writes the source handle to the curator_post_handle kv namespace (3-day TTL) and the count reads it back for a link with no handle. A slot skips with curator_evaluated reason=account_cap only when every platform with seeds had nothing but capped accounts; a mixed miss keeps its own reason (no_posts / no_candidates). |
artist_curator.py, the artist curator (2026-09-07, owner steer “split them”): the top artists’ OWN fresh Instagram + TikTok posts, in a room of their own. Why it is its own surface and not the overlay it started as. The overlay seeded ONE roster account per slot into EVERY content-curator channel and routed the pick on its own artist_curator stage. Measured 2026-09-07 over 14 days on the main server: 39 roster picks, all delivered staged (the overlay was still STAGING), and the three curator rooms were a car room, an art room and a beauty room – so a Weeknd Instagram was judged against car photos and lost to the room’s own seeds (none_fit in 651 of ~1,400 evaluations that week). Even PRODUCTION would not have put an artist post in a room. So the split: its OWN /menu channel picker (artist_curator_channel_ids, a settings key, on the channels hub), its OWN calendar row (artist_curator in schedule_calendar.SURFACES, default 0 shares like the curator), its OWN slot counters (artist_curator_slots), the artist_curator experiment as its gate (default PRODUCTION now: the handles are the curated OFFICIAL utils/artist_socials map, audited for two weeks in #bot-logs as the overlay, and the surface posts to Discord only – STAGING still auditions, OFF skips the guild with no watchlist read and no fetch). The loop, per slot: watchlist_source.build (the kworb top-100 the music desk ranks) -> artist_socials.roster_handles per platform -> _ACCOUNTS_PER_PLATFORM (3) handles drawn at random per platform (a random draw walks the whole list across slots; catch-up pagination against the SHARED curator_seen set makes a re-drawn account cost one page, and the shared set means a post the content curator already shipped never repeats here or the reverse) -> filter_candidates(require_media=True) + the 24h is_fresh gate + diversify_by_account -> interleave_candidates across the two platforms -> prepare_vision (the module-level function factored out of curator.py, the ONE definition of the TikTok/Meta CDN base64 download) -> claude.pick_curator against the room’s OWN recent images + its topic (a room with no topic gets _DEFAULT_TOPIC, “the top artists’ own instagram + tiktok posts”) -> a CAPTION LINE above the source link on its embed-fixer mirror. The caption (utils/artist_caption.py, 2026-09-15, owner steer “can we add a timestamp and a caption to these? Like, Beyonce with new photo”): one line, Sabrina Carpenter: "One year on pretty girl avenue". The artist NAME comes from artist_socials.name_for_handle (the same curated map the roster seeds from, read backwards, so the line can only name an artist the map already vouches for; an unmapped handle is written @handle, which is the real case for a COLLAB post owned by the other artist). Then the POST’S OWN WORDS, quoted: the second owner steer the same day was “you don’t need to count the number of posts, maybe more relevant is the caption or what it’s about”, so a count (“4 new photos”) gave way to the caption itself. QUOTED, never paraphrased, so nothing on the line reads as Toots describing someone else’s post; FIRST LINE only, cut to 40 characters on a word boundary, with links and hashtags removed and Discord’s markdown characters escaped. Measured on the live roster (62 posts): a caption’s first line carries the thought and everything under it is the promo block, the tour links and the tags; 7 posts carried no caption at all and several more carried only decoration (“🐝”, “♡ ∞”), which is why the MEDIA phrase from SourcePost.extra["media_kind"] remains as the fallback (“a new photo” / “a new video” / “new photos”, never a count; an unstated kind reads “a new post”, never an assumed photo). The post’s AGE goes on the STAGING AUDITION only (caption_for(..., with_time=True), owner steer 2026-09-15 “the timestamp is only for staging, I think”), as Discord’s own relative tag, which a mod sees in their own timezone as “3 hours ago”. It is a DIAGNOSTIC, not decoration: its job is to show whether the 24h freshness gate is holding, which is how the seven-month-old Beyonce post was caught. The room’s line leaves it off, because a post that reaches the room is inside 24h by construction – and if that stops being true, the bug is the gate, not the missing label. Each part is omitted rather than guessed, and an undated post carries no timestamp even on the audition. Both send paths pass discord.AllowedMentions.none() — the body now carries text nobody here wrote, and an @everyone in an artist’s Instagram caption must not reach the room as a real ping. The caption rides Composed.meta, NOT Composed.line. line is what the spine dedups on, and utils/dedup.py strips URLs before it measures text similarity, so a formulaic caption would be almost all the gate could see: measured, “Beyonce with a new photo” against “Drake with a new photo” scores over the 0.6 similarity floor, so every pick after the first would drop as a duplicate of a DIFFERENT artist’s post. _sched_deliver joins caption + link at send time (both the STAGING audition and the room), so the dedup key stays the link exactly as before. X is never roster-seeded (owner steer, the roster targets IG + TikTok) and there is no X crosspost (a bare repost would drown out her own tweets, same as the content curator). Telemetry: the SAME curator_posted / curator_evaluated kinds as the content curator, always source=roster, stamped surface=artist_curator via @scoped_surface on the tick (the content curator carries no surface stamp), so the curator dashboard + ops-monitor funnel read both and Axiom splits them on surface; no_seeds here means the watchlist was down or no artist on it has a mapped handle. The freshness gate was failing OPEN on Instagram until 2026-09-15. The v1 /v1/instagram/user/posts endpoint returned a null taken_at_timestamp AND a null created_at on every post of every handle probed live (beyonce, theweeknd, sza), and utils.curate.is_fresh reads an absent stamp as fresh, so the 24h window stopped filtering Instagram entirely — the surface shipped a SEVEN-MONTH-OLD Beyonce post (DUrhXmwEUF_, taken 2026-02-13) as a fresh pick, which is what the owner’s screenshot caught. The read moved to /v2/instagram/user/posts (same one credit, a populated taken_at, and media_type on top). Expect the room to go quieter: measured after the fix, none of beyonce / sabrinacarpenter / theweeknd had posted inside 24h, so the IG supply the room was drawing on was mostly stale posts the gate should always have dropped. The undated count on every curator_fetch phase=user read plus the ops-monitor source_undated finding are the tripwire for a repeat on either platform. Known thinness (follow-up, not this PR): the live top-100 has ~137 names and the handle map covers 26 on Instagram + 14 on TikTok, so the room draws from a fifth of the roster; a mid-tier act (Tinashe, #552 on kworb) is out of scope until the roster widens. Unit-tested in tests/test_artist_curator.py; the pure map in tests/test_artist_socials.py.curate.py, the curate-feed surface (#curate): the OWNER-DIRECTED, calendar-driven twin of the autonomous curator.py loop. A mod sets one or more X handles on /menu (the content-curation sub-page’s ✎ curate feed button — reached via the 🖼️ set up curation button on the behind-the-scenes page → a modal, stored per-guild in the curate_accounts settings KV; the modal validates on submit by FETCHING each handle — MenuView._validate_curate_handles calls bot.xprovider.fetch_recent, so a typo/empty handle is rejected with a “couldn’t pull @x” note and never saved, and an all-typo edit never wipes the existing list, a deliberately blank field still clears; unprovisioned X → accept as-is); this scheduled surface then rides the curator’s /menu calendar cadence (ScheduledPoster subclass, SURFACE="curate"; _resolve_schedule overrides the calendar-lookup key to curator, so there is NO separate calendar row — a mod configures the calendar ONCE for the curator and both surfaces follow those slots; owner steer). On each due slot, per curator channel, it pulls the configured accounts’ recent posts + reposts (bot.xprovider.fetch_recent, reposts already resolved by utils.twitterio to the endorsed original’s media/link, memoized per tick so the per-channel composes share one pull), filters to the shared 24h recency window (utils.curate.is_fresh / MAX_SOURCE_AGE, env CURATION_MAX_AGE_HOURS, tightened from the old 3-day window — owner steer “at most a day old from source”; the SAME window the curator now uses, via the timeline ACTION time stamped onto SourcePost.extra["created_at"] by the X mappers — a repost’s boost time, not the original’s) + drops replies + drops anything already curated, then BEST-HOME arbitration routes each fresh post to its SINGLE best-fit room across ALL the curator channels FIRST (claude.route_curator, a Haiku best-home classifier computed ONCE per tick + shared, memoized in _route_cache), so a room only ever considers the posts that belong to IT — an art photo #art would claim can’t be grabbed by #her (which, judged in isolation, takes the least-bad option in its slate: the #curate misroute fix). Among the posts routed to a channel it DRAINS every eligible one, best-first (owner steer — “curate all eligible recents vs slow drip”), not just the single best: the due slot is only the TRIGGER; Curate._maybe_post_to_channel overrides the ScheduledPoster one-post-per-slot handler to LOOP the curator’s OWN proven, eval’d fit-judge (claude.pick_curator for an image room, pick_curator_text for a link/text room, image-vs-text mode derived from the channel’s own media density like the curator) + deliver — each compose picks the best UNSEEN eligible post + marks it seen, so the loop slides through the pool best-first (O(N), since each compose judges only the freshest _VISION_CANDIDATES) until the judge declines or nothing eligible remains. Bounded by _MAX_DRAIN_PER_SLOT (20, env CURATE_MAX_DRAIN_PER_SLOT) per slot — set to cover a full fetched page (~20/account) so a single account’s page drains in ONE slot (“at least post the first page”, owner steer; below the page size the tail was left for the next slot, where a fast account had already pushed it off page 1 → missed). The best-home route cap (_ROUTE_MAX, 40, env CURATE_ROUTE_MAX) is likewise >= the page so every fetched post gets a home decision (below it, the oldest fetched posts were silently unroutable → never eligible). Only the fit-judge filters now, not these caps. A multi-account / firehose overflow still BATCHES across slots — the durable curator_seen dedup makes carrying the remainder to the next slot safe (nothing re-posts), and discord.py’s own 429 backoff means a batch never drops a post to rate limits (a small _POST_GAP_SECONDS courtesy gap keeps it gentle). The per-channel history read (_channel_context) is memoized per tick (_ctx_cache) so the drain’s repeated composes share ONE read. A post routed to no room (fits none) is dropped from every room; a slot with nothing eligible/fitting skips with curator_evaluated reason=no_home. It posts each drained post’s embed-fixed link (cogs.curator.to_fixup_link) prefixed with a 🔁 repost marker (an older owner steer dropped the raw via @<account> handle — the CONFIGURED source account still rides SourcePost.extra + the curate_run event for telemetry, just not the post text; pure _attributed_line, unit-tested), so a curate post is visibly distinct from the autonomous curator’s bare links, and marks it seen. A SHORT Toots caption sits on that marker line, above the link, for ARTIST curation ONLY (owner steer: “just needed it for artist curation”; the original steer was “caption them … something simple like ‘karol g posts via tiktok’”): a caption is composed ONLY when the post is a REPOST of a DISTINCT artist (the repost’s reposted_handle, and it differs from the reposting account) — the account’s OWN post, and a self-repost, drop to the bare 🔁 link. When it applies, _compose_caption → claude.caption_curation composes a cheap Haiku line grounded ONLY in that artist + the platform the link RESOLVES to (_platform_label, so “via tiktok” is read from the host, never guessed) + the post’s own text, and it NEVER claims what the unseen media shows. The caption rides a deterministic fail-CLOSED gate in the cog — short (≤ _CAPTION_MAX, env CURATE_CAPTION_MAX, default 90), single line, no link — so any miss (not an artist repost, a compose error, an EMPTY reply, or a caption that fails the gate) drops to the bare 🔁 link exactly as before, and has_caption rides curate_run. The caption stays on the MARKER line (marker still FIRST → is_curate_post seed detection unchanged) and the link drops to line 2 (still unfurls). This is a DISCORD-only change; a captioned crosspost to the @tootsiesbar X timeline is a deliberate follow-up (the curator/curate lanes still do NOT crosspost — see cogs/curator.py’s no-crosspost note — pending an owner decision on that one-way door). Known bound: the fetch is one page (~20/account, no pagination — the twitterapi.io per-page cap), so a firehose account posting 20+ between sparse slots can have middle tweets scroll off page 1 before a drain catches them; closing that needs pagination (metered by tweet volume) or denser slots. Dedup is SHARED with the curator (curator_seen, platform x), so a tweet lands in exactly one room and never double-posts (across both surfaces). Gated by the CURATOR’s experiment (owner steer — one toggle for both: STAGING auditions each pick in #bot-logs so a mod QAs the art/man routing; PRODUCTION posts to the room; OFF skips), its own de-facto on/off being simply whether an account is configured (no accounts → _sched_extra_gate skips it regardless of the curator stage). It keeps its own curate_slots pacing counters but SHARES the curator’s calendar cadence (no separate calendar row) AND the curator’s experiment (no separate experiment). Master-switch + mood gated like every proactive surface. Provisioning-gated on the X source (bot.xprovider) + a configured account. X-only: TikTok/IG reposts + bookmarks (and X bookmarks) are NOT a supported source — ScrapeCreators has no reposts/favorites endpoint and bookmarks are private. Pure timestamp/window/handle helpers (normalize_handle/parse_handles/parse_tweet_time/within_window) live in utils/curate.py (unit-tested tests/test_curate.py); the cog is the scheduler + provider + Haiku I/O. Fail-open throughout. Fully observable (#curate ops): emits curate_run (a post landed) + curator_evaluated source=curate_feed (a no-post slot, with the reason: no_home/none_fit/pick_error/no_candidates/no_posts) + curate_routing (the per-tick best-home DROP funnel: candidates/routed/dropped) + an error on every failure path so nothing fails silently (source=curate_route a route-classifier API failure, curate_drain a compose blip mid-drain, curate_deliver a room send failure, curate_staging a #bot-logs audition failure); the ops-monitor renders a Curate-feed health line and flags curate_dark when it ships ~nothing (its own funnel, kept separate from the curator’s so neither cross-contaminates). pick_error was the #backpage symptom (2026-08). One unfetchable EXAMPLE image failed the whole fit-judge call, so this feed dropped from ~44 posts a day to 1 in that room. The cause and the fix live on pick_curator – see the curator.py entry above. The drain did not drain (2026-08-30). The fit-judge is shown only _VISION_CANDIDATES (6) posts at a time, and the loop STOPPED the moment the judge declined a slate. A decline means “none of THESE six fit”, not “the pool is empty”, so the slot ended with the rest of the eligible pool never judged. Measured against the live account: @mejasonmejason produces ~8 eligible posts an hour, 40 of the 40 freshest were fresh, media-carrying and uncurated, the best-home router kept 39 of them for #backpage, and a full walk of that pool had the REAL fit-judge accept 38 of 39. The room got 1 that day. So the judge was never the gate – the loop was. _compose_once now returns its skip REASON instead of emitting it, and the drain treats none_fit (and a pick_error, so one unusable image cannot block the room) as “walk on”: the declined slate is recorded and EXCLUDED, so the next compose shows the judge the next slate, until the pool is exhausted, the post cap (_MAX_DRAIN_PER_SLOT) is reached, or the walk budget (_MAX_DECLINED_SLATES, env CURATE_MAX_DECLINED_SLATES) runs out. _MAX_DRAIN_PICK_ERRORS ends the slot when the judge is failing systemically rather than on one slate. A post the judge DECLINED, and one whose whole slate carries no fetchable frame (no_candidates, a deterministic property of the post), is remembered per room for _DECLINED_TTL (the source-freshness window) in the in-memory _declined memo, so two slots an hour do not re-judge the same rejects all day. A pick_error is handled differently again, in two ways, both from the Codex review on PR #2731. It is NOT remembered: the judge never RULED on it, and the cause is usually transient, so persisting it would bench a slate of fresh posts for the whole eligibility window on one model blip – it rides a walk-local unjudged set that dies with the slot, and a later healthy slot judges those posts. And it sets aside ONE candidate, not the slate: the error names no candidate, so the drain PEELS – exclude the head, rebuild the slate, judge again. Benching the slate coupled five healthy posts to one persistently unusable image, which left them unjudged on every slot while the bad post stayed fresh. A slate of ONE in image mode that errors is the exception – it IDENTIFIES the offender, since that post was the only image in the call – so it is recorded as a real verdict and the posts peeled on the way are RELEASED for this slot to judge. TEXT mode is excluded: there is no image in that call, so a None from pick_curator_text is a parse or API failure that identifies nothing, and persisting it would bench a healthy post on a blip. That is what keeps a room dark-free when the offender sorts LAST in a small pool: peeling alone ate the healthy post ahead of it and the slot posted nothing. The bet there is that an erroring single image is far more often an unusable image than a parse blip, and being wrong costs one post held for the freshness window against a dark room. _MAX_DRAIN_PICK_ERRORS counts CONSECUTIVE errors and resets on any answer the judge gives, so it covers a full peel and still stops a model that is down; before the reset, two unrelated blips a whole drain apart ended the slot. The memo is deliberately NOT written to curator_seen, which is the shared “already POSTED” set for both curation surfaces and must not gain a second meaning. The slot emits ONE curator_evaluated, not one per slate, so a walk cannot inflate the skip count the curate_dark rate divides by. Its reason is the last SUBSTANTIVE one, not the terminal one: the compose that finds the pool empty reports no_home, so without that the walk’s own exhaustion would overwrite the judge’s verdict and a fully-REJECTED pool would read as a ROUTING problem, pointing curate_dark at the wrong component. detail.dropped carries how many posts the judge passed over on the way. Expect one catch-up burst per room on deploy (up to _MAX_DRAIN_PER_SLOT, paced by _POST_GAP_SECONDS), then a steady ~4 per slot at this account’s rate.clipboard.py, the cross-server link clipboard (mods only): /copy channel: period: name: scoops every URL people posted in a channel over a window into a named, per-user buffer, and /paste buffer: channel: posts those links into a channel in (possibly) a different server — with an out:curate option that instead sorts the buffer’s links across the guild’s curator channels by best fit (/paste run through curation). The point is that Discord’s slash-command channel picker only lists the current guild’s channels, so a single command can’t read a channel in server A and post into server B; the buffer (keyed by user_id only, so it follows the mod across servers) decouples the two halves. Links only for now (Messages/Files/audio notes are a tracked follow-up — Discord attachments, including voice messages + uploaded audio, sit on a CDN with signed ~24h-expiring URLs, so a durable copy means re-downloading + re-uploading the bytes, not storing a link). The pure URL extraction + first-seen-order dedupe lives in utils/clipboard.py — add_urls(seen, out, text) is the incremental accumulator (the building block /copy streams history through so it never holds every message’s text in memory) and collect_urls(texts) wraps it for the batch case (both unit-tested). /copy walks channel.history(oldest_first=True) so the buffer keeps chronological order (it does NOT skip bot/webhook authors — an embed-fixer reposts a link via a webhook after deleting the original, so filtering bots would drop exactly the embed-fixed links), extracting URLs as it streams (only the link SET is held, not all message text) up to a _MAX_SCAN_MESSAGES safety ceiling — if a scan hits it the reply WARNS that older links may be missing rather than dropping them silently (no link loss). Re-copying into an existing buffer MERGES (the accumulator is seeded with the buffer’s existing links): a union, deduped, order-preserving — every prior link is kept (no loss) and a re-copy of the same window adds nothing (idempotent, a true no-op that skips the write). Stores via db.upsert_link_buffer (which replaces the row with the cog-computed merged list). /copy also accepts a THREAD (a thread copies just that thread’s messages, a channel just the channel’s own — Discord keeps their history separate), and the walk is deploy-resilient: it checkpoints to the buffer every _FLUSH_EVERY messages, so a redeploy that SIGTERMs a long scan doesn’t lose progress and the idempotent merge lets a re-run continue (the ceiling warning is oldest-first-accurate — a truncated walk keeps the OLDEST of the window, narrow + re-copy for newer). /paste posts one link per message (so each unfurls), paced (_PASTE_GAP_SECS), as the bot (channel.send, Toots’ own identity — no webhook, so pasted/curated links read like her own posts, matching the curator). /paste out:curate (_start_distribute → _run_distribute) is the curation destination: instead of dumping every link into one channel it enriches each buffer link (utils.link_enrich; social platforms, bare-URL caption fallback via the pure utils.clipboard.route_caption), routes it to its single best-fit curator channel with the curate feed’s OWN best-home judge (claude.route_curator, reused — a per-link Haiku vision call), and posts it there fixup-rewritten (cogs.curator.to_fixup_link) so X/IG unfurl. It AUTO-DRAINS: one invocation keeps going in the background until the buffer is empty, CONSUMING each link as it’s filed (rewriting the buffer to the unprocessed remainder + the un-routable, per batch of _DISTRIBUTE_BATCH) — so a re-run, OR a redeploy that kills the task mid-drain, never re-posts an already-placed link and always advances (the fix for a first-N slice that re-processed the same head; the buffer’s own consume IS the resume). A link that fits no room STAYS in the buffer; route calls run a few at a time (_ROUTE_CONCURRENCY) and a per-invocation safety ceiling (_DISTRIBUTE_BUDGET) stops a giant buffer with a “run it again” note. Targets are the guild’s db.get_curator_channels (each room’s routing vibe = its recent post texts + topic via _room_vibe). Both out paths share the _deliver_links one-per-message delivery (posted as the bot, no webhook). All run as background tasks (_spawn, with the _deliver_result followup→channel fallback) so a long scan / big paste / big sort isn’t bound by Discord’s 15-min interaction window. Mod-gated (_mod_gate). Emits links_copied / links_pasted / links_distributed.x_game.py, the X (Twitter) guess-the-song game (#1497): Toots posts an urban-90s+ song clip to the @tootsiesbar timeline on a scheduled slot, followers reply with a guess, a twitterapi.io filter-rule webhook delivers the replies (no polling — utils/healthcheck.py:/x/webhook → handle_webhook_tweets), the caption is specific — name the song TITLE (the win is the title; the artist isn’t required, just credited in the reveal). The first correct guess ENDS the round with a single board reply (a MUSIC-DROP-toned reveal of the track + a light nod to whoever called it + the running leaderboard — no fanfare); a NO-WINNER round skips the board/leaderboard entirely and just quote-reveals the answer (no “nobody got it” complaint). The winning board is posted UNDER the clip so the whole round is one thread (the round ends at first-correct, a race, not a fixed window), then a music-drop QUOTE of the clip with the answer + a one-line Toots-voice blurb (claude.song_blurb, Haiku) — GROUNDED trivia: the cog gathers real facts (_song_facts: a live Perplexity fact + the Genius reference — producer/writer/samples, citable) and the model writes the line using ONLY those facts (a real detail dropped casually, never invented; falls back to a pure vibe line when no facts are found, which carries no fabrication risk) — + the revealed album art attached when the model nominates it (song_blurb can end its line with an [art] tag, its call like the /ask <image>/<gif> nomination; parsed + stripped by parse_blurb_art so the tag never leaks; a quoted link doesn’t unfurl its player, so the cover is the visual when attached) + its Apple Music link (resolve_apple_music_url) so people can go listen. Two writes a round (board + drop), ~8 rounds/day under X’s ~17/day cap. A standalone @tasks.loop (NOT a ScheduledPoster — it’s a stateful round on an external timeline, not one Discord post per slot) reusing calendar_hours for WHEN to open. Round lifecycle: OPEN (a due x_game calendar slot + no round open → pick+verify a song, render the clip via utils.audio.make_video_clip, post it, register the to:tootsiesbar reply rule via utils.x_filter_rules.create_active_rule, persist) → COLLECT (webhook → judge each reply against the open round via the SHARED /guess matcher (cogs.games.grade_guess on HARD – exact title modulo case/punct/spacing, no fuzzy typo win; #1497 unification, not a local copy), processed OLDEST-first by X snowflake id (reply_order_key) so the genuinely first correct reply wins regardless of batch order, first correct claims the win atomically via db.x_game_claim_winner so a race yields one winner; the twitterapi.io rule polls ~60s, X_FILTER_RULE_INTERVAL_SECONDS — a game runs for hours so a tight poll just burns read credits) → CLOSE (_finish_round: the single board reply + a music-drop quote of the clip with the answer’s Apple Music link, delete the rule, mark closed — fired on the first correct guess, OR on the no-winner TIMEOUT with a “nobody got it” board. The timeout window is DYNAMIC (_round_window + the pure next_slot_gap_seconds): half the time to the guild’s next calendar slot, clamped to [X_GAME_MIN_ROUND_SECONDS 30m, X_GAME_MAX_ROUND_SECONDS 1h], with X_GAME_ROUND_SECONDS (1h) the flat fallback when there’s no calendar — so a dead slot closes with room to spare before the next round opens, capped at 1h, all env-tunable). ACCOUNT-global: one round open at a time across all guilds (the account is one @tootsiesbar); a guild’s experiment + calendar just decide when to open. Song pool is FULLY UNIFIED with the /guess game (#1497), no divergent copies: the candidate set BLENDS the model pool (claude.generate_song_pool(genre=_genre_for_model("urban"), era="1990s+") — the game’s urban hip-hop/rap/R&B bundle + 90s-onward era, proposing ‘Artist - Title’ lines) with REAL grounding off the SAME source /guess grounds off — the SHARED song_pool.current_genre_hits over the urban bundle’s lanes (hip-hop + R&B), Deezer-led per-genre chart + iTunes RSS hedge — so the set isn’t 100% model memory (which skews to the most OBVIOUS hit) and X + /guess ground IDENTICALLY off one function (not the lighter music.hot_chart facade X used before Stage 4). Each candidate is verified against the catalog via music.search_catalog (iTunes-first, Deezer fallback #371) for a real preview + artwork + a guessable, artist-matching track (_is_guessable_title + _artist_matches), gated by a RESOLVED-year era floor (_year_int coercion, >= _MIN_YEAR; a yearless Deezer row is dropped, as in /guess, #2728), and deduped by the SHARED song_pool.song_key against the SAME cross-round reuse block (game_recent_songs, 7-day window via db.recent_songs_within_days/record_recent_song) — so a song doesn’t repeat across X rounds AND the Discord game, and generate_song_pool gets the block as exclude= too (model-side avoidance). X rejects bare audio, so the clip is a still-cover-art h264/yuv420p/aac mp4 (tweet_video, uploaded via XPoster.upload_video’s chunked flow). Gated: master kill switch + mood != off + the x_game experiment (default OFF — it posts to a public EXTERNAL timeline, like x_crosspost; PRODUCTION runs the game on X, STAGING auditions the clip + board in #bot-logs with NO post to X, OFF skips). Provisioning: the 4 X_* write vars (bot.xposter) + TWITTERIO_KEY (bot.xrules, the reply webhook). Answer JUDGING is the SHARED /guess matcher (cogs.games.grade_guess, HARD), NOT a local copy (#1497 unification — the old utils.x_game.judge_guess was retired). The pure core (utils/x_game.py: reply_targets_round, reply_order_key, format_board_tweet/format_open_tweet/format_answer_drop, parse_blurb_art, apply_win, next_slot_gap_seconds) + the shared utils/song_pool.py (song_key + current_genre_hits) are unit-tested; leaderboard + rounds in x_game_rounds/x_game_scores (account-global). Fail-open throughout. Emits x_game_round + x_game_answer.x_audience.py, the @tootsiesbar audience telemetry (epic #1793 item 13 + #x-churn): two loops on one cog. (1) The nightly sweep (08:00 UTC + an idempotent boot run) emits x_audience – the follower curve plus the replies-vs-originals engagement split the growth work steers by – and joins each owner-posted reply back to its draft as x_reply_edit. (2) The hourly follower-churn sweep (#x-churn) answers the question the nightly one cannot: who left. x_audience records only the follower TOTAL, so it nets follows against unfollows – a day with 5 follows and 3 unfollows reads +2 and the 3 departures are invisible, which is exactly the signal you need when an automated surface starts costing followers. The churn sweep walks /twitter/user/followers (XProvider.fetch_followers, cursor-paginated, 200 rows a page), diffs it against the x_followers snapshot table, and emits one x_follow_change per departure with the account, its tenure, and a confirmation. Three design points carry the surface. Identity is the X user id, never the handle – a follower who renames keeps the id, so a rename cannot read as one departure plus one arrival. A departure is confirmed before it is called an unfollow (one user/info read, bounded per sweep): an account that deactivated or was suspended leaves the list identically, and only a live profile is feedback about what we post. A truncated follower list is never diffed (utils.x_followers.snapshot_trust) – the walk reports whether it finished, and a short list would name every follower past the cut as an unfollow, which is worse than no data; a refused sweep writes nothing and emits ok=False with the reason. HOURLY is a deliberate choice, not a default: the cadence sets the attribution precision, because a departure lands between two sweeps and an hourly window holds the 1-3 posts that could have caused it where a daily window holds ~20. Losses post to the master guild’s #bot-logs; the durable history is the Axiom event stream, which is the same dataset as tweet_posted, so a departure joins to the posts in its window. Pure diff + trust gate + report formatting in utils/x_followers.py (unit-tested); bot-wide, provisioning-gated on the X source, fully fail-open. Correlation, not proof: X publishes no per-viewer data to any API (twitterapi.io has no likers endpoint – verified 404, 2026-09-14), so the window narrows the candidates and the reply/mention/retweet record is the only per-account evidence that someone actually read a given post. Automatically ADJUSTING to the signal is deliberately not built yet (owner steer: “we can figure out the automatically adjust part later”).Utils (in utils/):
rate_limits.py, per-user daily limits (@Toots mentions, /recap) and server-wide daily limits (/discourse, /order) + cooldowns. Defaults are module constants (DEFAULT_PER_USER_DAILY, DEFAULT_PER_SERVER_DAILY, DEFAULT_ORDER_COOLDOWN_MINUTES); per-guild overrides live in the settings KV table and are edited via /menu’s tune page (resolvers _user_cap / _server_cap / order_cooldown_window read them live).tunables.py, the central registry + live resolvers for every mod-tunable behavior knob beyond the rate trio: the working-hours window (start/end ET), the /order in-flight cap, chime-in POST cadence per mood (daily cap / cooldown), and the live sports commentary heartbeat/clutch intervals (commentary_cadence, stored in minutes, resolved to the seconds the cadence engine uses). TUNABLES is the single source of truth (the tune editor renders the non-hidden knobs); each cog reads its value via a resolver (working_hours, chimein_tuning, max_in_flight_orders, commentary_cadence) that falls back to the code-level default. Two groups are hidden=True — kept in the registry (so the resolver + default still work) but edited elsewhere on /menu beside the config they scope, NOT on the tune page: working-hours on the calendar, the commentary heartbeat/clutch intervals on the live-sports page (_CommentaryPaceButton). (The quiet-room icebreaker’s silence window is not a separate knob: it reuses the mood’s chime-in cooldown.) Engineering internals (buffer sizes, tick intervals, retry backoffs, memory gaps) are deliberately excluded — not mod-facing, and so are the chime-in / reaction score thresholds: they gate on a model-judged 0-1 confidence, so the cutoff is the model’s call (fixed DEFAULT_*_THRESHOLD constants, read directly, never from a setting).permissions.py, is_mod() checks against mod_roles table. Also home of the two “master” constants: MASTER_USER_IDS (the master USER — a permanent, role-independent mod-access floor + who ops alerts @-ping) and MASTER_GUILD_ID (the master GUILD — the owner’s home “control room”, env-set via MASTER_GUILD_ID, None = unset). The master guild is (1) the default X-crosspost home (utils.x_crosspost._ONLY_GUILD falls back to it when X_CROSSPOST_GUILD_ID isn’t set to override) and (2) the sole destination for owner-facing ops alerts — the health cog’s crater/usage warnings + the new guild_join/guild_left pages route to ITS #bot-logs alone (cogs.health._alert_targets) instead of fanning out to every server; with no master set, both fall back to the pre-master behaviour (alerts to every enabled guild, no crosspost lock)sportsdata/competitions.py, the soccer-competition registry (#club-soccer) — the single source of truth for which soccer competitions the bot covers for state + settlement (SOCCER_COMPETITIONS: World Cup, UCL/Europa/Conference, EPL/La Liga/Serie A/Bundesliga/Ligue 1, MLS — each with its API-Sports league id + a two_legged flag). API-Sports /fixtures?live=all / ?date= return EVERY league worldwide in ONE call, so ApiSportsProvider now fetches ALL football once and FILTERS to COVERED_SOCCER_LEAGUE_IDS (_covered_football, fail-open on an untagged fixture), instead of the old hardcode that fetched the World Cup league only (which is why non-WC soccer couldn’t get state or finals-settlement from the primary feed). Adding a competition is one SoccerCompetition row — no per-league fan-out, no provider edit. (SGO already auto-discovers its own club-league codes; club-NAME cross-provider folding landed in names.py — canonical_soccer_club (#1469 Stage 2) — so a club bet reconciles across providers.) It also carries each competition’s SGO leagueID beside its API-Sports id (#2268-followup). A competition is a DIFFERENT string per provider – API-Sports says 39, SGO says EPL – and competition_for_league accepts either, because a caller does not know which provider a game came from and matching one vocabulary would silently mis-classify every game from the other. This is what lets the watched-sports filter be PER COMPETITION: soccer used to be two toggles (World Cup vs a single club_soccer), which was cosmetic while the live commentator was the only consumer, and stopped being cosmetic when the sports boards started pacing per sport – six leagues behind one key also shared one daily allowance, so the Premier League competed with MLS for cards while MLB had four of its own. sports.py:WATCH_OPTIONS is now generated from this registry, so a competition added here appears in the /menu picker for free, and normalize_watched expands the legacy stored club_soccer into every club key so no guild’s saved choice changes meaning. College is split out of the pro leagues the same way (2026-10-02). SGO files NCAAF under its FOOTBALL sport with the NFL, and NCAAB under BASKETBALL with the NBA/WNBA, so the raw americanfootball tag made a college game an “NFL” game (a WKU vs NMSU call posted to an NFL room). watch_key now maps leagueID NCAAF to ncaaf and NCAAB to ncaab, each its own picker toggle. The NFL keeps its stored key americanfootball, and legacy basketball still expands to NBA + WNBA only, so a room that already watches the pro league stops getting college games and needs no migration.
names.canonical_soccer_club tries an exact key first, then a word-order and plural tolerant token set over the same club table (_SOCCER_CLUB_TOKENSET, ambiguity-guarded like the country _TOKENSET_TO_CODE): “Red Bull New York” folds to “new york red bulls”. names.espn_team_name uses ESPN’s own shortDisplayName when the full name misses every registry (“LA Clippers” -> “Los Angeles Clippers”), and only when the short name’s words are all in the full name. An unknown team keeps its name, so a miss leaves the bet open (fail closed). Replay of 225 real bet groups against 2,766 ESPN finals (June to October 2026): 93 -> 94 matched, 0 wrong. No hand alias was added.sportsdata/hub.py + sportsdata/providers.py, API-Sports polling only for a sport with a consumer (#4218). OWNER RULE (2026-10-16): “if no one bets it should cost zero, or if nothing is active it should cost zero.” With no open bet, no /bet session (_BET_INTEREST_WINDOW_SECS) and no guild that covers a sport, the hub and the Bookie make ZERO background fetches from ANY source. A one-hour simulation on the live guild settings measured 0. An open bet costs only its own sport’s settlement reads, and /bet stays on demand. Keep it that way: a new background read needs a named consumer. The API-Sports provider fetches its two CORE sports (soccer, NBA) per tick only when _wants(sports, label) says a caller wants them. Before #4218 the Bookie called the hub with sports=None, which means “both”, so the soccer and NBA day-slates were pulled about every 5 minutes for no reader. Measured on 2026-10-15: about 2,500 soccer calls against a Free 100/day soccer plan, and about 3,100 basketball calls. The fix is one hub parameter, api_sports_core (hub._sports_for). It narrows the API-Sports provider ONLY, and SGO and ESPN keep the caller’s own sports. Who passes it:
Bookie._fetch_bettable) passes the bettable union (_bettable_sports_union). A DB error resolves to the {soccer, NBA} default, so the gate never fetches less than before.None and fetches the full baseline (fail open).api_sports_football_max_age=SETTLE_FOOTBALL_MAX_AGE_SECS (3h, #4220). The settle read then accepts a cached soccer day-slate up to 3h old; every other reader keeps the 20s _EXTRA_SLATE_TTL. A cached FAILED fetch (None) is reused for at most 15 minutes (_FAILED_SLATE_MAX_AGE_SECS) whatever the caller accepts, so a transient error does not hide a final for 3h; a one-hour outage raises the day to 18-29 calls, and a full day of failures to 208. The cache entry is shared, so a fresh slate from /bet or the commentator serves the settle read too. Measured: one open soccer bet cost about 624 soccer calls a day at the 20s TTL, and costs 18-19 at 3h (a one-day simulation of 5-minute ticks over the real _anchor_days). The cost is settle delay: a final in a league ESPN does not carry (World Cup, Champions / Europa / Conference League) waits up to 3h for the next soccer read when no other reader refreshes the slate. A final in one of ESPN’s six soccer leagues still settles on the next tick off the ESPN leg, IF the team names fold to the same match_key. Option 1 of #4220 (drop soccer from this leg, let ESPN settle it) was rejected on data: 81 of the 82 soccer games ever bet were World Cup games, which ESPN’s TEAM_LEAGUES does not carry.@Toots context pre-fetch and the commentator pass no gate. /ask therefore fetches a sport on demand even when nobody polls it, so “not polled” can never read as “no games”. The commentator already gates on the watched_sports union (unset or empty = no sports, #2765).
Three more boot and post costs were cut in the same change. betting_board.bettable_slate reads the coverage picker BEFORE the Bookie slate, so a guild that covers no sports never starts a slate refresh (the post surfaces made 260 refreshes a day for slates they then emptied). The Bookie’s boot no longer makes a live slate refresh: it only loads the durable seed, and the first real consumer fetches (62 boots on 2026-10-15 against one /bet open). The seed is stamped one TTL old (_BETTABLE_TTL_SECS), with _odds_fresh_at unset, so an open shows it and refreshes in the background while a placement blocks for a fresh list. A list older than _MAX_STALE_SECS is dropped and fetched cold, so the first open after an idle stretch never shows hours-old games. The API-Sports settle leg reads nothing when the open bets are all in sports it cannot settle (tennis); only an unreadable open-bet sport falls back to its full baseline. The knockout marker is 23h after a probe that reached the API and 4h after one where every season lookup failed. The knockout-round refresh (_refresh_knockout_round_once) is no longer a 24h boot loop: _fetch_bettable starts it (_ensure_knockout_refresh, single-flight) only when its slate holds a roundless World Cup bucket game, which is the only reader of the cached round. It also skips a run inside a 23h marker (_WC_REFRESH_MARKER_KEY), and ApiSportsClient.get_current_season caches each league in DurableCache("api_sports_season"): 24h for a season, 4h for a miss or failure. Before that, 62 boots on 2026-10-15 re-read all eight leagues each time (about 600 calls, all failing “account suspended”). The warm loop’s cheap live-odds re-fold (Polymarket/Kalshi) runs only during a /bet session. Its idle 60s cadence repriced a slate nobody viewed, and with no refresh, one stale live row kept it running.bookie_locked_games is keyed by the date-less match_key ({sport}:{sorted teams}), so one row is shared by every game between the same two teams. settle_bookie_game writes the row for EVERY final the settle loop sees, with or without bets. Until the fix, the row never expired: a preseason final hid the regular-season game between the same teams from /bet for good. On 2026-10-21 that removed Heat-Timberwolves, Nets-Hornets, Mavericks-Rockets and Warriors-Lakers (all preseason opponents that month) from the slate, and Bulls-Bucks on 10-23. The rule now stores kickoffs:
bookie_locked_games.locked_start). It covers only a game whose kickoff (meta['start_ts']) is at most 3h after that (BOOKIE_INSTANCE_TOLERANCE_SECS, for provider skew): _lock_covers for the slate filter, is_bookie_game_locked(key, start_ts) for the placement gate. A game with no kickoff reads as locked. A row with no stored kickoff (written before the column existed) covers games that started at or before locked_at.recent_finals) leaves it alone. The lock uses the kickoff, not the time the final was first seen, because the settle loop does not run while no bets are open: Monday’s final can first be seen during Tuesday’s game, and a lock dated by that sighting would hide Tuesday’s game (Codex on #4668). A final settles only bets placed at or before the row’s locked_at. A bet on the same game placed after locked_at got past the placement lock check while a settlement was committing; the next tick voids and refunds it, because the result was already known (Codex on #4668).bookie_bets.game_start), and a final settles only the bets whose stored kickoff is within 3h of its own (BOOKIE_INSTANCE_TOLERANCE_SECS). So yesterday’s MLB final does not settle a bet on tonight’s game, even when the settle loop first sees that final today (it does not run while no bets are open). A doubleheader nightcap (about 5h later) stays apart too. A suspended game resumed the next day still settles its late live bets (Codex on #4668). A bet with no stored kickoff (placed before the column existed) is settled only when placed within 12h of the final’s kickoff (BOOKIE_SETTLE_WINDOW_SECS).bet_slate_refresh carries dropped (folded): the games the lock removed from the slate. A settled game counts there for a few hours only. The SQL is tested against real Postgres in tests/test_bookie_lock_pg.py (the CI fts-postgres job).game_key starts with the sport tag only, so the NBA and the WNBA both read as basketball. bookie_bets.league (TEXT, NULL-able) holds a key from utils/bet_leagues.py: nba, wnba, ncaab, nfl, ncaaf, mlb, nhl, soccer, mma, tennis, golf, motorsport, other. That module is the one home of the vocabulary, the classifier and the board windows. Other parts of #4848 (the weekly winner posts #4850, the tournament #4852) read it from there.
league_for_game(meta, home, away) reads meta['league'] first. It knows the API-Sports ids (12 NBA, 13 WNBA, NFL 1, MLB 1, NHL 57), the SGO codes, the Odds API sport keys and the ESPN keys. With no known id, both team names must be in ONE roster from utils/sportsdata/names.py. Otherwise the result is None. One NBA team against one WNBA team, or a college team with no league id, stays NULL. It is never guessed.league= to db.place_bookie_bet. Bookie.cog_load backfills old rows from the stored sport prefix and matchup (league_for_stored). db.set_bet_leagues writes only NULL rows, so a re-run changes nothing. A NULL bet still counts on the all-sports board./leaderboard bets sport:<league> period:<week|month|all> (cogs/games.py delegates to Bookie.show_leaderboard). The default (every league, all time) is the original db.bookie_leaderboard board with balances. Any filter reads db.bookie_window_standings, which counts a bet by settled_at in [start, end). A week runs Tuesday 00:00 to Tuesday 00:00 in America/New_York (owner choice, so the Monday night games close the week); period=week is the current week so far. The filtered board does not show a balance, because a balance is not a per-league or per-week number.bookie_window_standings(..., by_league=True) returns one row per (league, user), with NULL-league rows last. bet_leagues.top_by_league groups those rows into the top N per league in vocabulary order and drops NULL and unknown leagues, so a league with no settled bets has no row.tests/test_bet_leagues.py. Real Postgres (the fts-postgres CI job): the #4849 section of tests/test_bookie_lock_pg.py.bet_awards.py, the weekly and monthly per-league bet winner posts (#4850, epic #4848). Every hour, on the hour ET, the scheduler checks utils.bet_awards.due_periods: a Tuesday posts the Tuesday-to-Tuesday ET week that just ended, and the 1st posts the calendar month that just ended (a Tuesday the 1st posts both). A period is due from noon ET to the end of its ET day, so a restart or deploy across noon still posts on the next tick, and an old period never posts a day late.
db.bookie_window_standings(by_league=True) over [start, end) by settled_at, grouped by bet_leagues.top_by_league into the top 3 per league (utils.bet_awards.build_board). A league with no settled bets has no row. A NULL-league bet never crowns anyone.db.resolve_betting_channels channel the bot can send in. Mentions render but do not ping (AllowedMentions.none()). There is no model text, so no self-gate.bot.image.edit, with a sharp-themed visual body plus the awards cog’s _CARD_TEXT_RULE (the only text on the card is the award title) and the same _CARD_EDIT_ATTEMPTS retries. A #1 who lost money keeps the row and gets no card. A card miss leaves the embed without an image. Cards cache per (guild, window, league, user).BET_AWARDS_ENABLED=0 stops the loop.<period>:<window start date> atomically BEFORE it renders or sends (db.claim_bet_awards: an INSERT ... ON CONFLICT DO NOTHING on post_dedup_history under the bet_awards partial unique index, the _claim_fact shape). Two instances alive at noon (a deploy overlap) therefore post once. When nothing reaches the room (no channel, or every send failed), the claim is released, so a later hourly tick on the same ET day retries. Claims are kept 60 days (POST_HISTORY_RETENTION_HOURS)./leaderboard bets sport: period: (#4849). A member who left and is not in the user cache shows as “someone”, never an id.bet_awards_posted per guild post, bet_awards_tick hourly heartbeat, the Bet winner posts by period + outcome panel, and the bet winners scheduler stalled monitor (no tick in 3h). Tests: tests/test_bet_awards.py.x_followers.py, the pure follower-churn math behind the hourly sweep: the /twitter/user/followers page parser, the snapshot_trust gate that refuses to diff a truncated list, the id-keyed gained/lost diff, tenure, the low-signal (follow-back bot) heuristic, and the #bot-logs report format. No I/O.gates.py, require_configured() guard for pre-/menu statefeeds.py, channel history fetching for context. recent_messages(..., stt=) transcribes room voice notes in place (via voice_ingest) so they read as ordinary text everywhere downstream. resolve_reactors (WHO reacted, behind rich_buffer) is a real Discord REST fan-out — one paginated reaction.users() call per emoji on the slow shared reactions rate bucket — and the old sequential walk ran ~16s median on /ask (p90 10s on the memory writer, p99 60s+ on music) as the single slowest step of those pipelines. It now resolves the top-reacted messages CONCURRENTLY under a hard _REACTOR_DEADLINE_S (5s) wall-clock cap: whatever misses the deadline is dropped and format_for_prompt falls back to the aggregate [N reactions] for those messages, so a slow bucket costs reactor names, never the surface (prefer absent over blocked). The fan-out is concurrency-capped at 5 (matching poll voters 5 / message reactors 6) because every reaction.users() call shares ONE reactions rate bucket — an uncapped 8-message burst mostly bought 429s on our own requests. /ask opts out of the lookup entirely (reactors={}, #2175 follow-up, owner call): Axiom showed the 5s deadline binding on most asks, so the p50 answer paid ~5-10s of its pre-model stage for name garnish on a USER-WAITED surface; reactor names matter for the MEMORY writer (attributed notes), not for answering, so ask keeps only the free aggregate [N reactions] weighting. Chime-in’s quiet tick opts out the same way (#1144); the memory writer, recap, and the scheduled compose surfaces keep the full named resolve under the 5s deadline. Chime-in’s quiet-tick opt-out (reactors={}, the #1144 instance fix) predates this and stays — the quiet-room judge never needed names at all. Its cheap neighbours now overlap it too (#1950 Class B): chime-in’s post path and /recap each had plain DB reads (the per-guild model knob, the who’s-who legend_index) sitting SERIALLY around the rich_buffer render; both now gather with it, so the stage costs the render alone. legend_index(guild_id) needs only the guild — only legend_for(blob) needs the rendered text — which is what makes the overlap legal. Deliberately NOT folded in recap: its Perplexity/Grok queries are built from blob[:300], so overlapping the render with THOSE needs the /ask trick (derive the query from raw messages, not the rendered buffer). Feed-channel source gathering (#1950): render_channel_source (ONE channel -> its --- <descriptor> (<label>) --- block + the messages behind it) and gather_feed_sources (every configured feed channel, CONCURRENTLY, in channel_ids order) are the single definition of how a compose surface builds its source blob. discourse and music had byte-identical serial loops — resolve -> history walk -> rich_buffer per channel, up to six deep, each one’s 5s reactor deadline COMPOUNDING instead of overlapping — and now both call these and overlap the local-room pull beside them (ordering is contractual: feed blocks in configured order, local last, because the blocks concatenate into a prompt). _FEED_SOURCES_DEADLINE_S (15s) is a hang BACKSTOP, not a routine bound — a slow healthy channel runs ~8s, and a channel that misses is simply absent from the blob rather than thinning every post with a tight cap. extract_media keeps an embed image’s own url and records its Discord proxy_url as the download fallback (vision_image.remember_proxy_fallback, #4298). fetch_as_data_uri downloads the proxy only when the url gives no image (a kkinstagram.com image URL redirects to an HTML page), with the same #4303 guards and inside the same deadline. No host is special-cased: the decision comes from the real response. A surface that hands the URL to the vision API itself (no download) is unchanged.voice_ingest.py, ambient STT: annotate_transcripts(messages, stt) writes a voice note’s transcript onto msg.content so every context reader (format_for_prompt, the memory writer, chime-in scorer, …) treats it exactly like a typed message, the only difference is the words came from audio. Transcribe-once via the shared two-tier DurableCache (namespace voice_stt, keyed by attachment id, failures cached so duds aren’t retried; durable L2 under a 7-day TTL so a recent voice note re-read after a redeploy isn’t re-transcribed, wired at boot via set_durable_db). No-ops without a provisioned ElevenLabs key. Wired into every recent_messages caller (ask/recap/discourse/music/order + the hourly memory writer & /remember backfill) and the chime-in tick buffer. Gating is provisioning only (the key) — voice notes are public channel content she already would have read if typed, so no separate experiment.video_fetch.py + video_ingest.py, video “listening” (she reads what a shared YouTube clip / video IS and what was said in it, not just that one was posted). The sibling of voice_ingest, but ambient AND async: a video is minutes-to-hours, so transcribing on the read path would stall every recap/tick. feeds.annotate_video(messages, stt) (a binder over video_ingest.annotate_video_transcripts, supplying feeds.video_sources) never awaits a fetch — for each fresh video it spawns a background task and returns at once; the resolved VideoResult lands in a process-wide cache and is surfaced inline by extract_media/format_for_prompt (via feeds._video_label: a metadata head — "title" · channel · 12min · 2.1M views — plus the summary or raw transcript, capped 1500 chars) on the next read (so a clip is bare the pass it’s first seen, rich after). video_fetch.fetch_video returns a VideoResult (metadata + transcript + source) by the cheapest route: captions first (pull the caption track via yt-dlp metadata, no ffmpeg/STT spend — flatten_json3/flatten_vtt; any language, falling back past English so a foreign clip is still transcribed), audio + Scribe STT fallback (yt-dlp + ffmpeg download, gated on an STT key and a DEFAULT_MAX_DURATION_SECS 30-min cap on the ambient path so a long stream posted in a channel can’t blow cost; the on-request read_media path allows REQUEST_MAX_DURATION_SECS (2h). Audio over _STT_PIECE_SECS (10 min) goes to Scribe in pieces: ffmpeg cuts the mp3 (_split_args, -f segment -c copy), the pieces upload 2 at a time, and the texts join in order, so a long video stays under the 25 MB upload cap and the 60 s STT timeout. A piece with no text (a silent outro) is left out; the transcript is None only when no piece has text. A long transcript is kept up to MAX_SUMMARY_INPUT_CHARS (120k) until the summarizer has read it, then _run_fetch clamps the stored copy to MAX_TRANSCRIPT_CHARS (20k), so the summary covers the whole video and not only its first 20 min. Metadata (meta_from_info: title/uploader/duration/views/upload_date/chapters) comes free from the same pull, so even an un-transcribable or silent clip gets a rich label. Long or non-English transcripts are distilled by video_ingest’s configured summarizer (a cheap Haiku claude_client.summarize_video, wired at boot via video_ingest.set_summarizer, chapters fed in, translated to English) into a compact summary that’s preferred over the raw transcript in context; short English ones go in raw. Transcribe-once: keyed by canonical_key (YouTube URL forms collapse to yt:<id>), word-less results still cache (they carry metadata + mark the key done so duds aren’t retried), bounded in-flight set + concurrency cap. The cache is two-tier (utils.durable_cache.DurableCache): a process-local L1 (read synchronously on the hot prompt-render path) over a durable, TTL’d kv_cache DB row (L2, namespace video, 30-day success / 6-hour failure TTL) so a once-resolved clip survives the every-merge redeploys instead of being re-fetched (re-paying yt-dlp/STT/Haiku); the durable tier is read-through on the async fetch path (_run_fetch restores from L2 before any live fetch) and wired at boot via set_durable_db (absent in tests → L1-only). Wired alongside annotate_transcripts (every recent_messages caller, the chime-in buffer, the memory writer/backfill). Gating: video is a permanent surface (no per-guild experiment), controlled by provisioning (yt-dlp installed + ffmpeg in the image) + the VIDEO_TRANSCRIPTION kill switch (default on, hard-disables everywhere). When transcription is enabled it both transcribes AND injects — feeds.annotate_video reads should_fetch() and extract_media reads should_inject(), both now collapsed to just the kill switch (video_ingest._enabled()). (It went through a staged per-guild rollout while it was a trial — production injected, staging transcribed-but-held, off skipped — then graduated; the gate machinery was removed.) X/Twitter bypasses yt-dlp onto the fxtwitter (FixTweet) API (utils.x_fetch, #491): yt-dlp’s Twitter extractor 100%-fails on a “Bad guest token” upstream bug (#462), so fetch_video routes is_x_url URLs to _fetch_x, which hits the public FixTweet JSON API (the service behind the fxtwitter/vxtwitter/fixupx links people already paste) — no auth, no guest token. Two fail-open layers: the tweet text is the floor (source=x_text, lands even with no video, where X was previously fully dark) and a direct mp4 (when present, within the duration cap) is downloaded via plain aiohttp + Scribe-STT’d (source=audio), no yt-dlp/ffmpeg. canonical_key collapses every X form (host/screen-name) to x:<tweet_id> so one tweet dedups to one fetch (mirrors yt:<id>). The open item is whether the video.twimg.com CDN mp4 is reachable from Railway’s datacenter IP — watch the video_transcribe source=audio vs x_text split; a CDN block degrades to text, not back to dark. Emits x_fetch (the API call). TikTok + Instagram bypass yt-dlp onto ScrapeCreators (#1272): yt-dlp resolves those two hosts poorly from a datacenter IP, so fetch_video routes is_tiktok_url/is_instagram_url URLs to _fetch_scrapecreators FIRST when set_scrapecreators is wired (boot) — TikTok via /v2/tiktok/video?get_transcript=true (a WEBVTT transcript flattened by the reused flatten_vtt + rich aweme_detail metadata → sc_tiktok_meta), Instagram via /v2/instagram/media/transcript (plain AI text). A transcript-less miss returns None and falls through to the existing yt-dlp path (no regression when SC is down/unprovisioned); emits video_transcribe source=scrapecreators only on a hit. Spaces are out of scope (different live-audio endpoint). Pure helpers (youtube_id/canonical_key/flatten_*/pick_caption_track/meta_from_info/x_fetch.x_tweet_id/is_tiktok_url/is_instagram_url/sc_tiktok_meta/sc_flatten_transcript) unit-tested; yt-dlp/network is integration-only. The module has a SECOND job, on the OUTBOUND side: it hands a wire clip’s raw mp4 bytes to the X crosspost (fetch_clip_bytes_any → x_crosspost → XPoster.upload_video, #video-upload), so a desk’s take re-broadcasts the clip natively instead of linking the wire account — X via fxtwitter, TikTok + Instagram via ScrapeCreators, every other host returning None so the caller links it. An over-long clip is CUT, not dropped (#video-trim): the @tootsiesbar tier refuses a native video past two minutes (exceeds_x_video_limit / X_MAX_VIDEO_SECONDS; all three clip paths read the length before the download: fxtwitter for X, video.duration in ms for TikTok, xdt_shortcode_media.video_duration in seconds for Instagram since #2209), and the /video/1 deep-link fallback embeds only sometimes, so utils.video_trim.trim_clip stream-copies the first x_trim_target_secs() seconds (the ceiling less a frame-boundary margin) into an X-ready mp4. The cut END snaps to a shot boundary (#2215): a fixed cut ends mid-shot, so a second short ffmpeg pass (select='gt(scene,N)' over the X_TRIM_SNAP_WINDOW_SECONDS before the cut) reports the shot changes and the latest one at or before the ceiling wins — measured on the reported clip, 118s → 115.5s for a ~0.4s pass. This is deliberately not a vision pass; stills carry neither motion nor audio, the two things that mark where a moment ends. The cut is fail-open — a miss returns None and the caller takes the same link fallback it always did — and the SNAP is fail-open inside that, keeping the fixed cut when detection finds nothing or breaks. It emits clip_trim, whose ok rate is the only sign the cut has stopped working and whose method shows where the cut landed.lru.py + durable_cache.py, the shared caching primitives. LRUCache is a tiny bounded most-recently-used cache (hit/miss-aware get, used by voice_ingest + as the L1 of DurableCache). DurableCache is a two-tier cache: a process-local LRUCache (L1, sync, zero-RTT) over a durable, per-row-TTL’d kv_cache Postgres table (L2, async), partitioned by namespace, generic over the cached type via encode/decode (typed L1, string L2 — JSON for structured values). Read-through (get: L1→L2, populating L1 on an L2 hit) + write-through (put: both, with the L2 write best-effort so a DB blip never breaks the feature); peek/set_l1 are the sync L1-only ops for hot read paths. db=None degrades to L1-only (tests / unprovisioned). Expiry is lazy on read (db.cache_get filters expires_at > NOW()) plus the daily db.prune_kv_cache sweep on the _pruner loop. This is the durable backing the video transcript + voice STT + link-enrichment caches use. ClaudeClient.classify (#4199) caches its settled Haiku labels in namespace classify. Since #4231 that cache is durable too: apple_music.set_clip_durable_db (already called at boot) also calls claude_client.set_classify_durable_db, so a verdict lasts its full ttl (up to 45 days) across deploys. The key is purpose:<hash>:<cache_key>, where <hash> is 12 hex chars of SHA-256 over the Haiku model id, the task text and the sorted label set. An edit to any of those three gives new keys, so a changed prompt never reads an old verdict; the old rows expire by their TTL. None is never cached. L1 stays bounded at 2048 entries. The namespace TTL is 15 minutes, not the label’s TTL: an L2 read refills L1 with the namespace TTL (the backend does not return a row’s remaining life), so a short caller ttl is stretched by at most 15 minutes after a restart. Measured before the change: each deploy re-asked about 565 market_sport_check and 47 market_act_check verdicts it had already settled. In markets the split is by data KIND: live-odds/price caches stay in-memory (freshness-critical, durability would serve stale data; utils/async_cache.py remains for those), and the SGO sports/score fetch (SportsGameOddsClient.get_event_odds/get_player_props) is fresh-first with a bounded stale-on-error fallback: every call always attempts a live fetch (a successful read is never stale), and only when the fetch FAILS (a 429 — the amateur tier’s per-minute + monthly quotas are tight) does it serve the last good result, and only if younger than _SGO_STALE_FALLBACK_SECS (180s — extended from 60s in #725 so a stale-served slate/score survives a throttle that outlasts one per-minute window AND covers the SGO circuit breaker’s open→probe cooldown; failure-path only, so best-case freshness is untouched); a genuine empty result (no live games) is never masked by stale, only an outright failure is. The in-season league set for the live-scoreboard fan-out is also durably cached (namespace active_leagues, ~12h TTL, warm-started — bumped from 6h in #831 since the in-season set only changes on a weekly scale, halving the 14-entity probe with no freshness impact): SGO has no “season active” flag (its /leagues//sports are static catalogs), so MarketsManager.resolve_active_leagues derives it — a cheap next_event_start probe per candidate (limit=1 + a startsAfter/startsBefore window = ONE entity each, and the window excludes a future-season schedule like NFL games months out), refreshed lazily under a lock so concurrent scoreboard calls don’t all probe. The probe returns the SOONEST game’s START epoch (not just a bool — has_upcoming_events is now a thin is not None wrapper), captured per league into _league_next_start for the negative cache below. get_live_scoreboard fans out only over the active subset of the candidate set instead of all of it; a cold cache / failed probe / empty result falls back to the full candidate set, so the filter only ever trims, never blanks. Live-scoreboard NEGATIVE CACHE (#sgo-burn / #2398): the 3-day active window kept a league “active” all day, so the fan-out re-polled a league’s live board every tick even when its next game was hours off — that empty polling was the bulk of the commentator’s SGO burn (measured: ~2,400 SGO calls/24h, all leagues returning 0 live games in-window). _fetch_live_scoreboard now SKIPS a league AFTER it comes back empty, until ~_LIVE_POLL_LEAD_SECS (15min) before its next scheduled game (from _league_next_start), capped at _EMPTY_SKIP_MAX_SECS (20min); an unknown/already-past start uses a _EMPTY_SKIP_DEFAULT_SECS (5min) skip. SAFETY: the skip is set only after the LIVE scoreboard itself returned empty, so a league with a live game (non-empty) is never skipped; an IMMINENT game (start within the lead) is never skipped (polled every tick to catch kickoff); and the 20min cap bounds any mispredicted/newly-scheduled game to ≤20min late. Freshness of a live league is untouched (it re-polls every tick). Fail-open (empty maps → poll everything, as before). SGO 403s from a datacenter IP, so this shipped without a live dry-run — verify on deploy via the market_fetch source=sgo query=scoreboard call-rate drop (#2398). The fan-out is also narrowed to WATCHED sports (#831, the dominant SGO entity saving): get_live_scoreboard(sports=) / live_games(sports=) take an optional set of internal sport labels, and the SGO provider trims the in-season leagues to only those sports, so the commentator (which passes its watched-sports union across guilds — None if any guild watches all) never spends an entity on a league no configured guild displays. Before this it fetched every in-season league every 60s, then discarded the unwatched ones per-guild AFTER paying for them. Threaded through hub → providers (only SGO honors it; API-Sports/Odds aren’t entity-capped); the bookie + ask call unfiltered (sports=None, all in-season) so their coverage + odds-freshness are untouched. Fail-open throughout (unmappable sport / catalog miss / empty intersection keeps the full set) and freshness-neutral (it drops whole unwatched leagues, never serves a stale score/odd). Shipped without a live dry-run (SGO entity cap was exhausted); verify on reset via the per-league market_fetch source=sgo events (#833). The per-tick narrowing itself reads a durably-cached leagueID→sportID map (MarketsManager._resolve_league_sports over namespace sgo_league_sports, ~24h, warm-started — the SAME /v2/leagues catalog the candidate set uses, SportsGameOddsClient.list_league_sports, so list_team_leagues is now its keys), rather than a LIVE /leagues read every tick: that per-tick catalog fetch (~1,440 SGO reads/day, mislabeled “free” in _scoreboard_leagues) was the single biggest STEADY entity drain against the monthly cap (#SGO-eff, the 63k-of-100k-in-5-days incident), running 24/7 regardless of whether anything was live. Fail-open — an unavailable map declines to narrow (returns the full in-season set) rather than blank the slate. The candidate set itself is resolved dynamically from SGO’s /v2/leagues catalog (resolve_candidate_leagues → all enabled leagues whose sport is in _PARSER_SPORTS, the sports _sgo_event_to_snapshot can render as a two-sided matchup — the team sports PLUS head-to-head individual sports (MMA/UFC, tennis, which come back in the same teams.home/teams.away + moneyline shape, just no point score); only FIELD/outright sports (golf, horse racing) are excluded as unrenderable; durably cached namespace sgo_candidate_leagues, ~24h, warm-started), so a league SGO adds is picked up with no code change. The hardcoded COVERED_LEAGUES is the offline fallback (catalog fetch failed / cold / no key / tests) and the base for the SGO_COVERED_LEAGUES env override (which pins the set and skips dynamic discovery). Concurrent scoreboard calls are also single-flighted (an in-flight asyncio.Task + lock): a burst of mentions shares ONE fan-out instead of each launching its own (the #289 stampede — a result cache can’t help since all N start before the first returns), and once done the next caller refetches fresh. Each scoreboard fetch uses SGO’s server-side live=true filter (get_event_odds(live_only=True)) so it bills only the in-progress games per league (often 0-3) instead of fetching up to limit upcoming events and discarding the non-live ones; the named-league odds fetch (_sports_snapshots) stays unfiltered (it wants upcoming odds too) but trimmed to limit=5. The live scores themselves stay fresh/uncached-on-success. But the Kalshi series index — the discovery catalog (which markets exist), not prices — IS durably warm-started (namespace kalshi_series, ~6h TTL): it’s expensive to rebuild (no Kalshi search API) and was rebuilt from scratch on every boot, leaving discovery dead until the first post-deploy refresh; now a fresh process warms instantly from L2 while the hourly live refresh catches up. Its DB ops flow through db._run, so the per-query p99 instrumentation (db_query events) times them like any other query. Note: this deliberately persists derived public content (transcripts/summaries) to the DB, a conscious reversal of the prior “nothing durable” stance, justified by it being public video content under a bounded TTL.billboard.py, the first-party Billboard chart read (epic #1981) — the Hot 100 / Billboard 200 / Global 200 / Artist 100 / Streaming / Radio / Album Sales / Digital / Country Airplay / Rap family, one registry row per chart and one parser for all of them (they share the row markup). This is the chart the bot never had: kworb.py is Spotify + Apple proxies (kworb publishes no Billboard mirror at all — kworb.net/charts/billboard/ 404s), luminate.py is the pre-filter consumption input Billboard’s published number is filtered DOWN from, and chart_data.py/wiki_hits.py are historical. So the newsroom’s chart-claim gate was checking @billboardcharts claims against Spotify rows — 25% of those lookups came back unresolved. Sources, cadence and the two governance calls: docs/BILLBOARD_SOURCES.md. artist_entries(artist, chart_key) reads the per-artist CHART-HISTORY page (/artist/<slug>/chart-history/<code>/, billboard200→tlp / hot100→hsi, #2154 follow-up) — the CAREER-ENTRY count a weekly chart page cannot carry, server-rendered like the weekly charts, so counting the rows is the entry ordinal (EXACT, no judge). It fails-open and QUIET (its own _history_cache, ~6h; a guessed-slug 404 is an ordinary miss, never a block_state() touch), because a miss only suppresses one optional “their Nth entry” clause. The newsroom’s compose (cogs/music_news.py) turns the count into a grounded fact (music_news.entry_ordinal_fact) so a debut take states “JUNGLE’s fourth Billboard 200 entry” instead of the guessed “first” it shipped 2026-08-25, and the fact grounds the career_ordinal gate so a correct ordinal ships instead of dropping. The wrong-artist guard (the reported title MUST be among the entries) is what makes a guessed slug safe — a wrong page yields no ordinal, never a wrong one. #2587 adds the PEAK column to each row ((title, debut_date, peak)) and a second, STRONGER fact, music_news.peak_history_fact: “their first #1” (current position 1 and no prior entry peaked #1) or “their highest-charting entry yet” (current position beats every prior entry’s peak). It is keyed on the CURRENT week’s position — so the claim is fresh news, never an all-time peak reached months ago — and can fire on a CLIMB, not only a debut; the compose picks the peak fact over the entry ordinal (one career clause ships, strongest wins). Same guards: wrong-artist, a missing peak or a same-titled re-release suppresses the high-stakes claim, fail-open. The music_news_entry_ordinal event’s detail.claim is peak, weeks or entry. Dry run (real page + real compose, both shipped models, n=5 each): 20/20 stated the fact cleanly, no fabrication. The peak fact has a FRESHNESS guard and a second rung (owner steer 2026-09-08). Keyed on the current position alone, “her first #1” fired every week a #1 HELD: “Choosin’ Texas” shipped as “her first #1” off a Hot 100 row reading last week #1, peak #1, 45 weeks on, and the owner pulled the post. So the weekly row’s own last_week and weeks_on now ride the settled reading (ChartRow -> resolve_chart_position -> ChartPosition.last_week / .weeks_on; None on a platform chart) and peak_history_fact fires only the week the position is REACHED (last_week above the position, or a debut); a hold, a fall, or a re-entry (no last week, 2+ weeks on) returns None. The held case gets music_news.chart_run_fact instead — “Choosin’ Texas is in its 45th week on the Billboard Hot 100”, straight off the row’s weeks column, written as a DIGIT ordinal so the digit gate grounds the take’s “45th week” / “45 weeks”. The daily-chart twin is the days-at-#1 STREAK (owner example, Pop Crave 2026-09-08: “holds at #1 on Global Spotify with 6.024 million streams. It has now spent 12 days at #1.”): kworb’s daily pages carry a (x?) column, days at the peak position, which kworb.parse_daily_chart now reads into ChartRow.peak_days; with pos == peak == 1 it is the streak, and music_news.chart_streak_fact states it as a TOTAL (“BbY WOW has 11 days at #1 on the global Spotify daily chart in total”) because kworb counts days at the peak, not a run — worded “has now spent 11 days at #1” the compose wrote “11th straight day” 2/4 (dry run 2026-09-08), and “in total” took that to 0/10. The day’s plays ride the SETTLED READING, not the clause: settled_claim for a daily row reads “#1 on the global Spotify daily chart with 5.4M streams on the day” (numfmt.abbrev, so the digit gate grounds the take’s “5.4M” / “5.4 million”), because a trailing clause is the part the compose drops — told to state the position and stop, it kept the streak and dropped the streams 5/5. The chart lane’s hint in compose_music_record was the other half: “NEVER add a stream count, sales figure, or units number” (#1705, against a FABRICATED magnitude) was obeyed against a real one too, so it now reads “a stream count … belongs in the post ONLY when THE NUMBER/RECORD line itself states one, quoted exactly” — streams present 5/5 after, 0/5 before, and the weekly no-magnitude case (Choosin’ Texas on the Hot 100) still states no figure under the reword. The digit gate stays the deterministic backstop for an invented one. The scar check on the full wired path (owner ask 2026-09-08, “dry run and check for scars”) found the bigger cause one layer up: the Perplexity BACKGROUND block. Its header (format_perplexity_for_prompt, written for the search surface) says its figures are the ground truth and “win”, so on a chart story it competes with the LIVE CHART block that settled the claim, and it carries the WIRE’s own figures. With the REAL research for Choosin’ Texas (“21 weeks at No. 1 … within one week of the all-time record”) 5/5 takes led with that over the settled “#1 … 45th week” and the self-gate dropped all 5; the pre-change path (no run clause) did the same, 5/5 dropped, so this was a standing cost, and the measured chart-lane drops carry the shape (“post states #9, ground truth is #12”). It also re-opened the stale “first #1” through a second door: the research’s “is Langley’s first Hot 100 No. 1” grounded “her first career chart-topper” and it shipped at 0.92. The fix is the #2774 rule applied to the research: once a live chart reading settles the claim, the BACKGROUND block leaves the compose context AND the gate source (bg_research in _compose_one); the verify judge still reads it. Ablation (real model, real rows, n=5 per arm): Hot 100 held, research in: 0/5 ship; research out: 5/5 (all “45th week”). Spotify held, real research in (“5.4M is not supported … 3.3-3.4M”): 4/5; out: 5/5. A conditional preamble that kept the research but told the compose to take no clause from it was tried first and made it WORSE (5/5 EMPTY): the model would not reconcile “22nd week at No. 1” with “45th week on the chart”, so the input had to go, not be argued with. The chart lane hint’s clause rule was the other contradiction: it listed “its run” among the things to leave out while the CHART HISTORY block handed it the run; it now names “THE NUMBER/RECORD line or a CHART HISTORY line” as the two sources of the one trailing clause (5/5 on both cases after). The held daily #1 was also never COMPOSED: the chart-repost gate (should_repost_chart_position, the high-water store music_news_chart_hw) suppresses a held position by design (“hate that i made you love me” at #1 eleven times). The one bounded exception now: a held #1 re-reports when its streak has CROSSED a milestone bucket of MUSIC_NEWS_STREAK_MILESTONE_DAYS since the last report (env, default 7 = a weekly “14 days at #1” beat; 1 = Pop Crave’s daily cadence; 0 = off) – buckets, not an exact multiple, so a boundary crossed on a day no wire was processed still reports once (Codex review). The mark stores the streak it shipped with (format_chart_mark, “1|14”) so a second wire the same day does not re-ship it, and it stores the streak ONLY when the delivered line stated it: a streak-triggered repost whose take omitted the clause is dropped (music_news_filtered reason=streak_unstated, marked seen) rather than shipped bare, so it neither repeats the held #1 nor consumes the milestone. The selected chart-history fact also rides the self-score’s source beside the record line, so the judge does not hard-fail a correct “45th week” as a figure absent from its ground truth. Priority in the cog: fresh peak > streak (daily) > run (weekly) > entry ordinal (a debut’s run is one week, below the run floor; a daily row has no weeks column and a weekly row no streak column, so none of these collide). Without row stats the position-only rule stands, as before. ChartWeek.top(n) reads the PRINTED rank (#2715). It returns the rows ranked 1..n, not rows[:n]. The client accepts a parse that holds half the chart (_MIN_ROW_RATIO), so one malformed row in the top ten made rows[:10] end on #11. A dropped row now leaves the list short, and top_ten / top_ten_sales stay silent that week (prefer absent). The same rule covers hits.Projection.top, format_chart_block, chart_boards.platform_streams (kworb pos), and the top-ten board’s leader (MusicDesk._top_ten_lead_keys reads by_rank(1)). Not changed: platform_top_ten folds a store’s edition listings into one release on purpose (_platform_top_releases), so it fills from lower rows by design. In 30 days of billboard_fetch events every fresh parse held the full chart, so this has not fired in production yet. A DATED read of a week that is not out yet is unpublished, not a redesign (#4769). chart(week=...) does not read a week later than expected_chart_date, and the due week read before its Tuesday flip comes back as a 200 with no rows and no week line, which it reports as unpublished with no block and no error. Billboard used to answer such a date with the current chart; since 2026-10 it serves that empty page, and music_markets._chart_page_rows read billboard200:2026-10-24 1,905 times over 2026-10-18..20 as shape_change. A failed read of a PAST week still reports shape_change, now with recoverable=True: the current page still serves the live lanes, and the caller re-reads the past week on its next tick. Each dated page keeps its own failure streak (<chart>:<week>), so a success on another page of the chart no longer resets it and the error fires on the first and every 10th failure, not on every poll. Billboard also serves the empty page for published dated pages for hours at a time (16 of 24 on 2026-10-25), so a dated week that read cleanly is cached 14 days (BILLBOARD_CACHE_TTL_DATED_DAYS).
One fetch is the whole alert lane. Every row carries rank/last_week/peak/weeks_on, so debuts, re-entries, jumps, falls and new peaks are pure DERIVATIONS (movements()), no stored history; past weeks are addressable by date (/charts/hot-100/2026-07-25/), so a week-over-week diff (diff_weeks) needs no state either. Movement.notability collapses rank × kind × move-size into ONE number so an alert lane thresholds on a single value — the live dry run showed the raw thresholds yield ~45 moves on a normal week (a weekly report), thinning to ~6 at a floor of 5.
The parse is LABEL-driven, never positional — it finds the LW/PEAK/WEEKS captions and reads what follows, so a reordered column can’t silently shift peak into weeks. Badges are only considered on rows whose LW is -, since keying on the badge alone made a song titled “New” parse as a debut and eat its own title.
Loud about being blocked (owner steer). billboard.com’s robots.txt names anthropic-ai, so being cut off is an EXPECTED end-state, and both ways it happens are silent by default: a 403/451, and a 200 whose markup stopped parsing. A THIRD silent shape is a page read MID-PUBLISH (partial_credits, 2026-09-09): Billboard flips a page in two steps — the rows land, and the artist links on the NEW entries are attached minutes later — and the one-minute flip poll is built to read inside that gap. The first read of the Sep 12 Hot 100 carried 15 of 16 debuts with an empty artist, was cached for 12h, and the standings lane posted “Take Care leads 16 new entries … from 2 different acts” (no act on the lead row; the empty string counted as an act). chart() now refuses a song/album page with ANY credit-less row exactly as it refuses a shape change (nothing served, nothing cached, the next poll re-reads, the block clears itself on the full page), and a CACHED week with a credit-less row is treated as a miss and re-read too – the cache is durable across redeploys, so the bad row would otherwise have been served for the rest of its 12h TTL after the fix deployed (Codex review, #3116); artist-unit charts are exempt because their artist cell is empty by design (measured: 5/100 on the Artist 100, 41/50 on Emerging Artists, 0 across ~1,500 song/album rows). All three emit billboard_fetch with an explicit reason plus an emit_error(recoverable=False) (first of a streak, then every 10th) plus a sticky block_state() — and the streak is counted PER CHART (BlockState.streaks, #2824): one shared counter reset on any chart’s success, so on the 2026-09-01 refresh global200’s 40-minute run of empty pages read as six one-off blips while hot100 kept working, and the error’s consecutive= lied. The ops monitor splits health BY SOURCE (a healthy mirror must never mask a dead primary) and raises its own source_blocked finding naming the required action — high for a refusal or a chart dark for ≥3 consecutive fetches / still dark at the window’s end, medium for an empty page that recovered sooner, which is Billboard republishing (the parser reads today’s page 200/200), not a redesign. It gets its own event kind rather than kworb’s chart_fetch so a Billboard block can’t corrupt kworb’s signal (the curator_frame_fetch precedent). No stand-in source (owner call 2026-08-04): the GitHub-mirror fallback was removed — itself a once-daily crawler of the same site, it added a hop and flip-day staleness, never data; a blocked or unparseable read yields nothing, loudly (source_blocked), and the caveat machinery (stale) stays for any future degraded source. Rides the MUSIC DESK as its FIRST-PRIORITY lane (#1981 owner steer: “this should ride music desk, and have its dedicated prioritized lane” – the same call as the streaming-milestone lane #1495, so it is a lane in cogs/music_desk.py, not a cog). Priority is a DECLARED rank, not append order: _LANE_PRIORITY (chart > called_shot > reveal > milestone > tracking > projection) sorts the assembled units before the base delivers them, so a future lane (radio) cannot outrank the chart by being typed higher in _sched_compose_units; an unregistered story tag sorts last rather than raising. A real chart move therefore leads the slot while a quiet week returns [] and the desk is unchanged. The per-slot cap counts units that SHIP, not attempts (bounded by _MAX_CHART_ATTEMPTS_PER_SLOT), so a move stuck under the self-gate can’t starve the moves ranked behind it – and an unshipped move is never dedup-stamped (_record_ship runs only on a ship), so it carries to the next slot automatically. No new scheduler/kill-switch/dedup table – it reuses music_desk_events under a bb:<chart>:<week>:<title> key (and every lane’s avoid-set now comes through _recent_desk_keys, which folds in the COMPOSE-FAILURE COOLDOWN (2026-09-06): a pick whose compose failed a guard or the self-gate is stamped fail:<dedup key> by _note_compose_failure – in _compose_board_unit, _milestone_candidate and _compose_record_unit – and skipped for MUSIC_DESK_FAIL_COOLDOWN_HOURS (12). Measured over 7 days before it: one radio board composed and dropped 165 times, one record card 61, one chart-peak card 73 – an identical failure every slot, paid every slot, and burning the attempt budget ahead of the picks behind it. A transient API error is never stamped; a shipped unit is never stamped; the stamp is a prefix in the same table, so no real key can collide with it), and runs the SAME compose_market_drop + music_desk_score 0.6 floor as every other desk story (priority, not a lower bar; live dry run scored a #1 debut 0.95 and a new peak 0.92). Ships the unified big-number card with the RANK as the figure. chart_context() also feeds the subject’s live standing into the desk’s MARKET compose – the join the prediction reads never had, since a chart market settles on the position while the context only ever carried the Luminate units underneath it. Being first is a CACHE property, not a scheduler one: expected_chart_date knows which Saturday-dated chart should be current (verified against the observed flip), and superseded is the ONE predicate both cache paths share – the READ path serves a cached chart only while it IS the current week (once a flip is due it goes and looks), and the WRITE path holds the current week for hours but re-checks a behind-the-due-week chart every ~20min. The first slot after a flip already has the new week, with no tighter posting cadence and no extra reads the rest of the week. The read-time check is load-bearing, not belt-and-braces: DurableCache’s L1 tier is a plain LRU with NO expiry (utils/lru.py keeps no timestamps; get() returns an L1 hit without checking a deadline), so a TTL only shortens the DURABLE row while the in-process copy is served until the bot restarts. As first shipped, the two-speed TTL was therefore decorative – the first read of the day pinned last week’s chart in memory and the flip would never have been seen (caught + fixed the morning after, regression: test_a_superseded_chart_is_refetched_not_served_from_cache). Keying on the chart DATE also asks the stronger question: is what we hold the current week, rather than how long ago we asked. The same L1 hole affects EVERY other DurableCache consumer (songstats’ 12h stream counts, markets’ league lists, reference’s 24h) – tracked separately, because fixing the shared cache changes upstream call volume on metered APIs and is the owner’s call. The model-facing framing is PURE and shared (compose_inputs / card_fields / describe_move), so the prompt text is reviewable in one place per docs/PROMPT_OPTIMIZATION.md and the dry run exercises the exact strings the surface ships; its fence is dry-run-earned – given a #1 debut the model wrote “Gracie Abrams’ first chart-topper”, plausible, ungrounded, and NOT a number, so the number-only rule missed it. A chart row carries position/movement/peak/weeks and nothing else, so any career or record claim is invented by construction; the framing now forbids them (0/6 samples after, vs a hit on the first sample before). Plan + lane taxonomy: docs/BILLBOARD_ALERTS.md. Politeness: honest UA, tiny rate limit, two-speed durable cache on a weekly resource. Dry run: python -m scripts.dryrun_billboard (--compose runs the real lane compose + gate). VERIFIED RANKING QUALIFIER — the same claim class, GROUNDED instead of forbidden (owner ask 2026-08-24, utils/milestone_qualifier.py + _qualifier_block/_drop_for_ungrounded_ranking): the framing above FORBIDS a ranking claim (“first chart-topper”, “highest debut”) because a chart row cannot ground it. But the RANK a milestone gives an artist is the engagement driver — “the first female rapper to hit the mark this year”, “their tenth release past 100M”, “the longest run on the chart”. So this lane LOOKS one up and PROVES it, then hands the caption a grounded fact instead of a ban. Two qualifier sources, tried in order. (1) The DETERMINISTIC catalog count, preferred for a per-track streaming milestone: nth_release_qualifier COUNTS the artist’s tracked songs at or above the rung off the kworb per-artist songs page (kworb.songs_for_artist → resolve_artist_id off the all-artists leaderboard, then artist_songs), so “Chanel is Frank Ocean’s 9th song to pass 1 billion Spotify streams” is exact and free — no web read, no judge (owner example 2026-08-24: “this could’ve said his 9th song to do so”). The milestone song is guaranteed counted even if kworb lags a hair under the rung. (2) The web DOUBLE check, the fallback for the cross-artist superlatives the count cannot give (“first female rapper”, “highest debut”): a Perplexity lookup of the web AND a Grok LOOKUP of X (grok_search.qualifier_lookup_pulse, owner ask 2026-09-10 – chart accounts post a ranking within hours and the web carries it a day or more later; measured two hours after Pop Crave’s Doja Cat post, the web read returned an unrelated “#41 among artists” and the X read returned the exact claim; it replaced the Grok claim_verify_pulse corroboration read, which only re-asked X about the web’s angle), a second Perplexity read only when the web alone found a claim, then the claude.qualifier_verify judge, which is biased toward ADDING a real reported rank — one credible source is enough — and drops only a CONTRADICTED or fabricated claim (owner steer 2026-08-25: the second read SEARCHES for a qualifier to add, it is not a two-source drop-gate; a milestone the reads cannot confirm ships bare). The verified text also GROUNDS the output guard output_checks.ungrounded_ranking_claims, the NON-possessive sibling of ungrounded_career_ordinals (which reads only “her fourth #1”): it catches “first … to reach” and “highest debut” / “longest on the chart”, and drops a caption stating a ranking claim the source does not carry. The NEGATIVE case (nothing verified) leaves the compose UNCHANGED — no “make no ranking claim” prompt (owner steer “be careful with negative case”, 2026-08-25): a dry run showed the base compose fabricates 0/5, and the note both PRIMED the ranking words and suppressed the artist’s OWN grounded standing rank; the guard is the deterministic backstop. The guard also does NOT police that standing rank (“the world’s 71st most-streamed artist”), whose number the number backstop grounds — “most-streamed” is deliberately not a ranking context (only a scope-qualified “most-streamed of 2026 / ever / all-time” is). Wired into BOTH the streaming-milestone lane (_milestone_candidate) and the reveal/annual/sales lane (_compose_line, only on a MEASURED count or a SETTLED reveal — a real achieved number, never a projection, so an achieved qualifier is never pinned to an “on pace for ~X” forecast), so streams AND sales captions — from kworb or the markets — carry the same checked rank. The output guard grounds a ranking claim on the VERIFIED QUALIFIER TEXT ONLY (not the number blob or the unverified research), so a caption may state the double-verified rank and no other first / highest / record claim. Gated on the milestone_qualifiers experiment (default PRODUCTION, live — it shipped dark, was validated on real data, then flipped): OFF changes nothing, STAGING looks up + emits the qualifier event WITHOUT attaching or enforcing (a real audition, no public claim, no dropped post), PRODUCTION attaches + enforces. Every outcome emits qualifier so a silent “nothing ever verifies” is visible. The regex is the TRIGGER, Haiku is the ARBITER (#3446). The guard dropped true, reworded restatements of the verified qualifier (“his second song to reach the milestone” against “the 2nd song from Chris Brown to pass 500 million”) and plain durations (“its first month at 47.8K”). So _ranking_drop sends a flagged caption to claude.claim_grounded against the same verified text, after a deterministic check that every flagged ordinal VALUE is in that text (ungrounded_flagged_ordinals, no call on a mismatch). Only a “no ungrounded claim” answer skips the drop; an error or any other answer keeps it. The bare-row career check on the chart and called-shot lanes uses the same step through _drop_shaped, against the career block, and only when the lane has one. The regex also skips an ordinal followed by a time word. Event claim_arbiter, panel on the Desks board.chart_standings.py, the ranked BOARDS off the chart pages the desk already holds (#2031), and chart_cards.py, the renderer that draws them (render_board_portrait → PNG bytes). The card, after the 2026-08-09 pass (owner, on a shipped projections board: “make this bigger and less boring”): a portrait panel whose height follows the row count (_P_HEADER_H + rows + _P_FOOTER_H, floor _P_MIN_H 700, cap 1200), the branded floor, the red-to-green hairline, the tootsies music wordmark, the _equal_tag chart tag, the hero line, the rows, and the _draw_number_stamp source pill. Four things changed in that pass and each answers one half of the steer. Bigger: the hero, the wordmark and the row type all went up, and the row type now AUTO-FITS the board (_fit_row_size picks the largest size from _P_TITLE_SIZES that cuts no more text than the smallest size would) — so a ranked top-ten board lands at 38px where a market board with a units column settles lower, from ONE renderer. The TITLE also claims the row before the credit does (_label_room / _P_CREDIT_MAX), which is what stopped a long artist name from cutting the song short. Less boring: CardRow.highlight is DRAWN at last (a tinted lane plus a solid accent edge on the one row the board is about — every builder already marked it, and nothing rendered it between 2026-08-07 and 2026-08-09), and _art_wash gives each card the COLOUR of its own subject — the cover scaled to 12px, blurred and darkened under the floor, so a Hot 100 board and a Latin albums board are no longer the same green picture with different words. The corner-art bleed came down to _CORNER_ART_MAX_ALPHA 0.22 in the same pass, because the wash now does the job of showing the subject and two treatments of one picture fought each other under the rows. Contrast was re-measured, not assumed: with the wash the worst case is a pure WHITE cover, and the accent value column holds 5.13:1 there (WCAG AA floor 4.5:1) — the numbers live in the ACCENTS comment. THE ACCENT IS PER FAMILY, not per chart (#2188, #2295, 2026-10-15). The owner said “just be consistent with the colors that we already have, or maybe just like pick a couple”. Related boards share one colour, and every colour is one of the colours the table already had: Billboard US songs + airplay green, Billboard US albums + HITS + Pollstar tours purple, Billboard global blue, each genre its own colour shared by its song and album boards, Spotify mint, Apple Music + iTunes lilac, YouTube peach, RIAA gold. accent_for resolves an exact ACCENTS row, then a family prefix in _ACCENT_PREFIXES for the keys builders make at run time (riaa_plaques:<act>:single, pollstar_<kind>, hot100_no1_singles). Before this, five live boards (Global 200, both Spotify, both Apple Music) and 68 more keys fell to the Hot 100 green (73 of the 88 keys the test lists). The contrast floor is ACCENT_MIN_LUMINANCE = 0.40 (WCAG relative luminance): country coral (0.332) and latin pink (0.383) were under it and moved to peach and rose, the lighter colour of their own hue. The worst accent now reads 3.73:1 on a pure-white cover at alpha 0.30, up from 3.07:1. tests/test_chart_cards_accents.py fails CI on an unnamed live key, a colour outside the old palette, or an accent under the floor. HOW A CARD PICKS ITS COLOURS is written out in full in that same ACCENTS comment (owner, 2026-08-09: “i dont understand how we choose colors now”) — four rules, and every colour on a card comes from exactly one: FIXED brand colours (the wordmark red, the lane grey, the title white, the hairline), the PER-CHART accent from the ACCENTS table (tag + rank column + value column + highlight lane, picked by hand per chart), the PER-CARD computed backdrop (_art_wash off the subject’s cover, or BOARD_BLOOM green with no cover), and the PER-OUTCOME ranking on the market cards only (market_chart._outcome_colors: favourite green, longshot red, cycled middles). The gap that makes it look arbitrary is the table’s FALLBACK (#2189): a chart key with no entry takes _DEFAULT_ACCENT, which IS the Hot 100’s green, so global200, sp_us, sp_global, ap_songs and ap_albums — five live board keys — all wear it. The wordmark itself was LIFTED in the same pass (owner: “can we make the tootsies brighter on all charts”): brand red 4.76:1 → 6.28:1 and the lane grey 5.60:1 → 8.8:1 against the dark field, which lifts every card, not only the boards. On a LIGHT card the lift REVERSES — the brand red only reaches 2.4:1 on the light panel, under the 3:1 floor large text gets — so the light card carries its own darker _LIGHT_WORDMARK at 4.95:1, guarded by a test. Owner steer 2026-08-05: “our individual cards and reports are good but people love ranks, and overall facts and insights not just chart movements” — the reference being a chart account’s “Drake has NINE albums out-charting ‘GNX’ on this week’s Billboard 200”, a ranked list plus one derived fact. Every lane before this one tells ONE entry’s story; none can read the chart as a whole. chart_standings is the PURE derivation half (chart weeks in, Boards out), posted by the music desk’s standings lane. Three boards after the owner’s review of the first live output: artist_entries (one act’s entries LISTED with their positions — not “Drake: 10” but the ten albums and where each sits, which is what the reference post actually shows; the card draws at most MAX_ROWS rows, so when the act holds MORE the CAPTION says “top N of M shown” as the headline does — without it the card read “11 albums” over 10 drawn rows and a reader named the missing one, Nothing Was The Same at #169, off Drake’s 11-deep Billboard 200 week 2026-08-25. That cut-off note is the ONLY caption artist_entries and album_drop carry (owner steer 2026-09-10, “only keep the caveat when there is a cutoff”): the old “11 albums on the Billboard 200 this week” repeated the tag block, the row count and the date line, so a board whose rows fit passes an empty caption and the renderer drops straight from the hero to the rows (_P_HEADER_H_BARE); the count stays in the headline and the take. MAX_ROWS is 11 since 2026-09-10, owner steer off that same shape a fortnight on: the reader wants the eleventh row, not the qualifier, so chart_cards._P_ROW_CAP went to 11 with it and the card’s height clamp _P_MAX_H is now DERIVED from the cap — the old fixed 1200 fit ten rows under a one-line caption and nothing more, so an eleventh row, or ten under a two-line caption, ran under the source pill. No row carries a credit on artist_entries or album_drop (same steer): the act is the headline, and the joint credit a collaboration used to print beside its title (“$ome $exy $ongs 4 U PARTYNEXTDOOR & Drake”) crowded the title; debut_class keeps its per-row credits because that board mixes acts). The caption rule then moved into the RENDERER and applies to EVERY board (owner, same day: “do the follow up for everywhere, we wanna apply the rule consistently”): chart_cards.card_caption keeps only a caption’s cut-off note (“top 11 of 13 shown”, normalized from whatever phrasing the builder used – “(best 10 of 23 shown)”, “top 10 of the 16 shown”, “top 10 of 59”) and render_board_portrait draws that or nothing, so no board card anywhere carries a descriptive line; a board’s caption field is unchanged and stays the DESCRIPTIVE line the compose framings read (THE BOARD (hero -- caption) in chart_boards, chart_records, riaa_boards, cinema_boards, chart_ages, chart_credits, sports_boards), which is why the builders were not blanked – the watch board’s “plays on the right” line came back for the prompt’s sake. What a caption used to tell the READER (the value column’s meaning, an RIAA board’s as-of date) now has to reach them through the take. The same three rules run on the act boards of the OTHER chart types (owner steer 2026-09-10, “make sure this works for other chart types too”): chart_boards.watch_artist_board (the Apple / Spotify / YouTube / Billboard watch board, watch_artist + watch_standing) and radio.artist_board draw no row credit and no caption unless the list is cut; chart_boards.platform_presence drops the row credit and its HERO is now the act (it was the count, “23 on the chart”, and with the caption off the card nothing would have named whose 23 they were); the count is the figure, and the card’s EYEBROW names the CHART. The board carries no tag, so the eyebrow falls back to label (“US ITUNES TOP SONGS CHART”), the same line every sibling platform board draws. It said “Most on the chart” from 2026-09-10 until 2026-09-16, when the owner reported a Macklemore iTunes board: that card named no chart anywhere – the rows said “#4”, the source pill said “iTunes”, and nothing said WHICH iTunes chart. The eyebrow’s contract is the chart’s name (chart_standings.Board.tag: a SHORTER chart name for a label too long to be one), not a descriptive line; the “most of any act” claim stays in the headline, which is what the take reads. All three cap at ACT_ROWS / _MAX_BOARD_ROWS = chart_standings.MAX_ROWS (11); the radio rows keep their <format> radio credit, which is the format the move is on, not the act, debut_class (everything that entered this week — every row carries its credit, and on a SONG chart each row’s LIFETIME Spotify total off the act’s kworb songs page, the individual-artist board’s own column, resolved after dedup by _with_debut_streams (owner ask 2026-09-09 on the shipped card: “need the artist, and streams if possible”); an act the leaderboard does not know stays blank, the compose framing swaps its no-streams sentence for the lifetime fence when the column is drawn (value_kind), acts are counted on the LEAD credit so a feature clause is not a new act, the acts holding 2+ of the class are STATED in the fact (“Rod Wave has 14 of the 16” – live dry run on the 2026-09-12 class, n=8: every take counted the drawn rows and got 8 or 9 for a 14, and the gate dropped 7 of 8; with the parenthetical “(Rod Wave 14 of them)” the take wrote 14 and the self-gate still failed 5 of 6 reading it against the 16; the sentence form shipped 5 of 6), and a class bigger than MAX_ROWS says “(top 10 of the 16 shown)”), and genre_entries (the same derivation run on a genre page, so an act that never leads the Hot 100 can still own its genre — and since 2026-09-12 it draws the SAME lifetime-Spotify-streams column the individual-artist board and the debut class draw, resolved after dedup by _with_genre_streams off the act’s kworb songs page, one read for the whole board because every row is one act’s. The owner reported the gap on the shipped Karol G Hot Latin Songs card: “where did our streams go” — the board is artist_entries on a genre page, but it ships from the STANDINGS lane, which never built a value column, so its rows carried positions alone while the Hot 100 board beside it carried streams. A title the songs page does not carry stays blank, and value_kind="lifetime_streams" makes the compose framing name the figure as a lifetime total, never as this week’s plays. Live dry run 2026-09-12 on that exact card: 11 of 11 rows filled, 3 of 3 takes shipped at 0.92). Cut in review, all owner calls: a cross-chart “on the most charts” board (people follow the Hot 100 and the Billboard 200, not a chart census), a head-to-head “above the rival’s best” board (too niche), and a top-10-only variant of artist_entries (the same board wearing a cap). A long-runners board was cut too, and the reason generalises: measured on the real Billboard 200, its top ten was 10/10 identical, in the same order, from one week to the next — only the numbers ticked up by one. A board whose rows never change is a fact about the chart’s history, not news. The owner asked for that story per RELEASE, which is a MILESTONE rather than a board, so it became the desk’s chart_run lane: milestones.LADDERS["chart_weeks"] (round fifties to 500, then round hundreds – MAJOR marks only since the same 2026-09-20 steer; the year marks 26/52/78/104 are gone, and the scan floor _MIN_CHART_RUN_WEEKS=26 stays UNDER the first rung of 50 so a row met at 30 weeks seeds at 0 and its 50th week fires) over the EXISTING generic songstats_milestone_state store under a bbrun: entity prefix — no new table, no new fetch (weeks-on is already on every row), seeded on first sight so a release met at 700 weeks never fires a stale “hit 500”. Two ladders, after the owner’s review (“50 is kinda high, e.g. 2-3 weeks should be celebrated for a single, or debut album top 10”): a top-10 debut is not a long RUN and no weeks ladder can express it, so chart_peak is the run ladder’s twin on the release’s peak column — tiers at top 40 / 20 / 10 / 5 / 3 / No. 1, with no weeks floor, since a release that just cracked the top 10 usually has very few weeks on the chart. Its scale DESCENDS (#1 is the best number), and rather than teach the shared high-water detector a second direction, milestones.peak_score inverts it once into an ascending tier score so seed_rung/newly_crossed work unchanged. The card heroes the EXACT rank, not the tier (owner steer 2026-08-27 “number the 18”): the tier crossing is only the TRIGGER, so a song that just cracked the top 20 at #18 shows “#18” under a “NEW PEAK” eyebrow, not “TOP 20”. The card fires only in the week the peak PRINTS (row.rank == row.peak, 2026-09-06): the peak column is a high-water mark, and a row sitting below it set that peak earlier – “reaching #18. It sits at #29 this week” composed for two weeks (Steve Lacy). A below-peak row is SETTLED silently (_settle_milestone: seeded on first sight, else the crossed rung recorded as passed) so it can never fire late and a later real peak measures from the right rung. The web qualifier is scoped to the milestone’s own chart (qualifier_scope=week.label, milestone_qualifier.on_chart): that card was handed “his second top 20 Hot 100 hit, joining Bad Habit” – a verified fact about ANOTHER chart – and the take fused the two 73 times in two days. A qualifier that does not name the chart is dropped before the compose, with a qualifier ok=false reason=off_chart row — the same rank-as-figure the movement lane’s cards already use. _chart_peak_candidate passes the real #{row.peak} through the shared _milestone_candidate card_figure override (empty for every other metric, so the streaming rungs still hero their abbreviated count, “1B”); the override is also grounded into the number backstop so a take restating the rank is not dropped. The SPOKEN take is unchanged — its value_line already gave the model both the tier (“broken into the top 20”) and the exact rank (“reaching #18”). The card art follows the chart’s UNIT, not a constant (owner “make sure art works”): a chart milestone (_chart_peak_candidate / _chart_run_candidate) passes art_kind=bb.chart_unit(week.chart) through _milestone_candidate — “song” for a Hot 100 / Global 200 row, “album” for a Billboard 200 row. Before this the lane hardcoded “album” for every non-streams metric, so a Hot 100 single (“Loser”, “Redbone”, “Passionfruit”) searched the ALBUM catalog, found no album of that name, and fell through to the artist PORTRAIT instead of the single cover (the #2546 class the streams lane already fixed). Verified live: resolve_art_url(..., subject_kind="song") returns the single cover where "album" returns the portrait. The weeks ladder also came down from 50 to open at 26, and a young run now has to be PLACED to count (_CHART_RUN_MAX_RANK, waived past _CHART_RUN_VETERAN_WEEKS) — every song that survives passes 26 and 52 weeks, so a bare rung fired ~10x a week, mostly records simply still being there near the bottom. Measured after both changes: 6 crossings/week across the two charts (2 peak + 4 run), capped at 1/slot. That extension added four optional kwargs to the shared _milestone_candidate (source / card source / eyebrow / value line), each defaulting to the Spotify behaviour so the streaming milestones are byte-identical. There is deliberately no movers board either — the movement lane already posts those one row at a time. Which charts: the main three run on the Hot 100 + Billboard 200 only; genre_entries runs on billboard.GENRE_CHARTS — three charts (rap, R&B/hip-hop, latin), not the five that parse: country and rock were cut on the owner’s call (2026-08-06), and their registry entries stay only because they cost nothing unused. A genre board additionally requires its subject to be on the desk’s kworb top-artist list (“i dont want too many unknowns”): a genre chart is small, so leading one is arithmetically easy and says nothing about how big the act is, where a main chart’s leader is big by construction. That check FAILS OPEN when the list is unavailable (owner call: “is kworb not available no filter just let them go”) — deliberately against the repo’s usual instinct, because the failure mode here is not a FABRICATION: the act and the count are real either way, so the worst case is a board about someone less famous, and losing the genre lane every time kworb blinks costs more than that. The counting rule’s fence is the fabrication guard. A per-act count must read “Drake & PARTYNEXTDOOR” as both acts, so joint credits split — and splitting on punctuation alone invents acts (“Earth, Wind & Fire” becomes three artists). artist_watch.credit_parties owns the rule: it credits every part that charts on its own somewhere in the weeks we hold and DROPS (never guesses) a part that does not – part by part, because the corroboration set is a WINDOW, not a census of every act that exists. So a four-way credit whose only currently-solo member is Morgan Wallen still credits him (“HARDY, Eric Church, Morgan Wallen & Tim McGraw” -> Morgan Wallen); the old ALL-or-nothing rule dropped the whole split and lumped the four into one phantom act, so his census read 2 for a 3-entry week. “Earth, Wind & Fire” still stays whole (no part charts alone, so nothing is credited and it counts as itself). Two attribution fixes came out of the #2710 sweep over 1500 live chart rows (362 distinct credits; only these two changed, so the shipped boards move nowhere else). (1) The LEAD of a feature credit is credited without corroboration. lead_artist cuts only at the four unambiguous feature markers – no act is called “… featuring …” – so the text before one is a real act by construction. Without that, a corroborated GUEST was credited while the uncorroborated LEAD was dropped, which attributes the song to the wrong artist: live, “Skilla Baby Featuring Chris Brown & Bryson Tiller” counted for Chris Brown and Bryson Tiller and NOT for Skilla Baby. A COMPOSITE lead is judged on the evidence: a band whose OWN name carries connectors is still one act when known names the WHOLE and not its parts (“Aly & AJ”, “Earth, Wind & Fire”, “AC/DC”, all on the kworb list), and it is then credited whole with its fragments suppressed so the song cannot count twice; but TWO corroborated fragments mean the lead joins separate acts (“Macklemore & Ryan Lewis”, both charting alone) and it is credited to the individuals, the same two-corroboration test lead_party uses and the owner’s “count features to both” rule. Codex #2719 caught the first half missing: a corroborated “Aly & AJ” was dropped because the fragment lookup asks for “Aly” and “AJ” while the list holds the whole name. (2) A split part is stripped of credit punctuation before it is matched or drawn (_PART_EDGE_CHARS): “HUNTR/X: EJAE, Audrey Nuna & REI AMI” splits at the “/” and again at the stray “X”, which left “: EJAE” – a name no artist has, on a card. Live check on the 2026-08-29 Hot 100: the under-count was pervasive – Morgan Wallen 2->3, Drake 5->6, Karol G 3->4, and the LEADER itself was wrong (Olivia Rodrigo 7, not Ella Langley 6). (3) A PLACEHOLDER credit is never an act (#2739): “Soundtrack”, “Various Artists” and “Original Broadway Cast” are the catalog’s stand-ins for a record with no crediting artist, and the Billboard 200 prints them on real rows. _entries_by_act drops them (artist_watch.is_placeholder_credit), so every reader of that map is covered at once – chart_census, the per-act catalog_picks / leader_entries boards, and the record watch’s deepest-act cards through entry_counts. Since CB-23 (#4557) the same credit_act verdict decides it: the desk calls st.check_board_credits before the standings, catalog and record-watch builds, which asks Haiku about every party holding 2+ entries on a week (crownable_credits); a party with no verdict keeps is_placeholder_credit. chart_boards (platform presence) and chart_records.year_race still read the list alone (follow-up). Reproduced on the real 2026-08-01 Billboard 200: “Soundtrack” held THREE albums, cleared the two-entry floor and drew as the ninth ranked act; with the filter that row is gone, Kendrick Lamar takes the tenth slot, and the headline field size reads 31 acts rather than 32. The year races took the same filter first (#2735). Notability + a floor (MIN_NOTABILITY 12, calibrated on real weeks) keeps a dull week silent; longevity alone is CAPPED, because its rows barely change and an uncapped weeks-on number would let a static fact outrank the week’s news forever. Dedup keys the FACT, not the week (bbst:<chart>:<kind>:<subject>:<figure>) — a movement is new every week by definition, a standing mostly stands still. The CARD is its own renderer, not render_market_card (owner: “the bar isn’t meant for this”, with billboard.com’s graphics as the reference). The market renderer draws a legend above a SEPARATE band of bars, which works when a colour ties the two together and fails on a ranked board — the reader has to match six colours across a gap. chart_cards puts the label INSIDE its own bar (bars, for counts) and draws a position board as a ruled table with no bars at all (list) — because a bar says “more is bigger” and #1 is the smallest number on a chart. One accent per chart, a white row for the subject, and the LAYOUT is borrowed while the identity is not: our wordmark, Billboard credited in words, no reproduction of their logo. Three dry-run-earned fences, each a deterministic backstop rather than louder wording (the has_career_claim precedent): output_checks.has_row_arithmetic fails a take that SUMS the rows or turns two rows into a MULTIPLE (the RATIO arm, owner steer 2026-09-20 “never do the ‘more than double the next’ thing”: the market board’s compose wrote “~58.7K … more than double the next closest release on the board … at ~28.2K”, every number grounded, the ratio its own, 0.92 at the gate; the board fences in chart_boards and chart_standings that said “compare two named numbers” now say to set two numbers side by side and never make a multiple or a gap of their own; HITS Daily Double, certification levels and a double album are exempt from the arm) (the model wrote “Olivia Rodrigo owns 11 spots, more than Drake and Ella Langley combined” off rows reading 7 and 5, and the 0.6 judge scored it 0.92 while printing “11 vs 7 + 5 = 12” in its own reason); the lane takes only the ALL-TIME half of the career check (has_alltime_claim), since a board genuinely carries the act’s other entries; and two misread FACTS are labelled in the data block rather than counter-ruled — Billboard’s weeks-on is a lifetime total across separate runs (so never “continuous”), and a debut class states how many distinct acts it covers (a seven-act week composed as “six more Wallen tracks”). Dry run: python -m scripts.dryrun_standings (--compose the real compose + gate + shape guards, --card writes the PNGs, --streams resolves the genre boards’ streams column off live kworb through the cog’s own method). Follow-up, not built: a season-to-date board needs stored chart history; every board here comes off the current week. 2026-08-10 additions (owner brainstorm ask): chart_census (every act’s entry count on one chart, ranked — the WITHIN-one-chart census; the cut #2031 board was the CROSS-chart one) It rides the standings lane unchanged (same experiment, dedup-on-the-fact, notability floor); entry_counts is the shared public derivation chart_records reuses. A feature_credits board shipped the same day and was CUT on measurement (owner call): collaborations spread THIN rather than concentrate, so over five real Hot 100 weeks the best any single act reached was TWO shared credits, once, against a board needing a leader on three plus a field of acts on two — it could not fire on real data, and retuning the floor would only have made it fire on a technicality. Worth knowing that its silence before #2271 was a BUG (the parser was destroying joint credits) while its silence after is the honest distribution; the interesting fact here is chart-level (how many of the week’s entries are collaborations at all), which is deliberately not built. 2026-08-11 additions (owner steer “cut mid chart moves”): two FOLD boards that REPLACE cards the movement lanes would otherwise ship one at a time. album_drop_board (+ debut_clusters) turns one act’s whole week of new Hot 100/200 entries into ONE board — measured over eight real weeks, three of them were album bombs (Ariana Grande 11 debut cards off petal, Future 16, Olivia Rodrigo 10 — 37 near-identical cards from 3 releases). The act’s best-placed debut still cards (owner: the headline entry is the story, the board is the depth); the rest fold out of music_desk._pick_chart_moves via _folded_debuts. The board names the act’s own album debut as a SECOND stated fact off the same-week album chart, and names no album when none debuted (owner: “only if theres not album ofc”). It rides the MOVEMENT lane’s stage and chart set, not the standings lane’s, because it must switch together with the cards it replaces (story album_drop, bbad: keys). The mid-chart cut itself is _within_rank_cap: a MOVE cards only in the top 20 (MUSIC_DESK_CHART_MOVE_MAX_RANK), an ENTRY to top 40, a WATCHED artist’s entry uncapped (“we can let it happen if its a tier artist”). The sibling board for the platform sweep is chart_boards.watch_artist_board (story watch_board) — one watched act’s whole presence on ONE kworb chart, folding the per-move cards the watch sweep used to ship in a burst (Pooh Shiesty #8 and #9 in one minute). That board counts RELEASES, never LISTINGS (watch_artist_rows, fixed 2026-09-22). A store chart lists one record more than once — a deluxe edition beside the standard, a bonus-track exclusive beside the plain one, one release credited two ways — so the row identity is the normalized TITLE, the same one artist_top_ten_count and presence_leaders already count with. It was song_key, which keeps the row’s CREDIT and, since 2026-09-19, its EDITION marker, and the two counts INSIDE watch_artist_board then disagreed: len(theirs) fed the figure and the headline while _top_ten_releases folded the same rows for the standing test. Measured on the live US iTunes Top Albums chart of 2026-09-22: Miley Cyrus held “Bass Persuades (The Miley Edition)” at #2 and “Bass Persuades” at #6 — one album — and the card said “Miley Cyrus has 2 albums on the US iTunes Top Albums chart”. PR #2671’s review had already refused that key for the standing count, because two such rows “manufacture a ‘2 of the top 10’ standing out of one release”, and judged it tolerable for the row list at the cost of a near-duplicate ROW; what it left open is that the board’s own count reads the same list. Dropping the credit and the edition is safe here for the reason that review gives: the rows are already filtered to ONE act, so two rows sharing a title are the same release. song_key is unchanged, so the edition still keys apart everywhere the 2026-09-19 steer meant it to (presence history, dedup, the per-chart song fold). One folded act now holds one title, which is under the board’s own floor, so the chart falls through to the single-entry number card instead. That board has TWO ways in, because the moves-only floor buried a lead (#2662). The original way is several NOTABLE MOVES by one act on one chart in one sweep. The second is the act’s STANDING: it holds MIN_TOP10_STANDING or more of the chart’s TOP 10, and then the standing alone makes the board with no second mover. The miss it fixes, live on 2026-08-28: Rod Wave held 7 of the Apple Music US songs top 10 and 24 titles on the page, but only ONE of them moved per sweep, so no group ever reached two moves and the desk posted single-song climb cards eight hours apart (“Piece Of Your Love” 13:06, “Hustle” 21:06, the second one crossposted to X). Each card was true and each was the small story; the owner read the X post and said we were burying the lead. The desk’s own judge had said the same thing that morning on a Dolly Parton board (“could’ve noted the full takeover (13 songs on chart)”). The general shape is that an act’s STANDING does not move in a sweep, but the chart page states it on every read — so the board now reads the PAGE for the takeover and the SWEEP for the moves. The floor is TWO, and it came down from three on 2026-09-05 (owner steer). Three was set on the argument that two of a top 10 is an ordinary strong week (Dolly Parton held Jolene and 9 to 5 in that same top 10 the same day). The owner’s call, off a chart account’s post that Lil Baby held 3 of the Apple Music top 7 with songs from three different years, is that two titles in one top ten is the post those accounts make and the board is worth having at that count. The same steer forbids the WORD: the board must not call the standing a takeover, so the kind, the headline and the compose steer say only the count and the chart. _WATCH_STEER['watch_standing'] and the mentions-lane fences name the banned words back (takeover, owns, runs, lock, sweep, chokehold), which is what docs/PROMPT_OPTIMIZATION.md says works where a general rule does not. A standing takes its own board KIND (watch_standing), which buys three things: a compose steer that leads with the top-10 count instead of the flat “how many titles they hold” (_PLATFORM_STEERS), telemetry that tells the two boards apart (board.kind rides music_desk_scored.matchup), and a dedup key on the COUNT rather than the movers (_watch_board_dedup_key(..., top_ten=N)) — keying it on the movers would repost the same sentence every time a different title of the act climbed, which is the exact burst the board exists to replace. The counting FENCE is per-kind for the same reason (chart_boards._count_fence): every other board forbids the model counting rows at all, because no total is handed over; a standing board hands one over in THE FACT and the whole post leads with it, so its fence forbids the model’s own tally and names the number to use — keeping both rules would have told the model to lead with a count and never state a count. That distinction is load-bearing, since a standing board draws rows BELOW the top 10 (Rod Wave’s card runs to #16), so a tally of the drawn rows returns 10 where the honest answer is 7. Three guards had to learn the same distinction, and the review found each one (#2671): (a) the compose fence above; (b) the DETERMINISTIC ship guard has_row_tally, which rejected the phrase outright – measured, 4 of 4 real composes would have been dropped before the judge, so the board would have shipped NOTHING; it now GROUNDS instead (output_checks.ungrounded_row_tally, passed the board’s own block through _drop_before_gate), and a caller that passes no source keeps the flat ban; (c) the X-MENTIONS lane, which never ran that guard at all and became count-led when this shipped, so _ship_unit runs it now too. The top-10 COUNT also keys on the normalized TITLE rather than song_key, because song_key folds the row’s credit in and one release under two credits (“Rod Wave” / “Rod Wave Featuring Luv Von”) would otherwise manufacture a standing out of a single song – which matters more at a floor of two than it did at three; the board and the desk read ONE helper (_top_ten_releases) so the threshold and the dedup key cannot disagree. The watch steers’ right-hand-COLUMN sentence now follows Board.value_kind (_WATCH_COLUMN_FENCE) for the plain board as well: it always said “MOVE”, which is false on a Spotify board whose column is the period play count and on a Billboard board drawing lifetime totals – contradictory ground truth that invited a play count to be written up as a climb. Measured live on the same chart page: the board formed off one mover and three composes all led with the count at 0.95. The STANDING PASS closes the last hole in that trade (owner steer 2026-09-05). Reading the page for the standing still needed ONE mover, because the board GROUPS were built only from the sweep’s stories – so an act whose whole standing sat still, with no title moving and none flagged new, produced no group and no board however much of the top ten it held. music_desk._watch_boards now seeds from two places: the sweep’s stories as before, and the CHART PAGE, where chart_boards.presence_leaders groups the top ten by act and every act on MIN_TOP10_STANDING is seeded directly. It is built like the Billboard artist-catalog lane (_artist_catalog_units): read the page, pick the acts, build the board, dedup on the standing itself. It adds NO fetch (the rows are already in rows_by_chart), presence_leaders is pure, and at most five acts can hold two of any one top ten. WHICH ACTS follows the card path’s own rule (_TOP_ALL_CHARTS): any act on the five major Spotify + Apple charts, watchlist-only on iTunes and radio. watch_artist_board stays the AUTHORITY – presence_leaders groups by lead_party where the board counts by credit_matches, so a seed can over-select and simply build no board, which is the safe direction. Mover seeds sort AHEAD of standing seeds, because a mover board REPLACES the cards it folds while a standing board folds nothing and costs nothing to defer. The same release-identity rule runs on the NEWSROOM’s count (music_news.resolve_artist_count, the reading artist_count_board draws): its fold reads release_type.without_edition(title), so a deluxe listing and the standard album count once between them and the best-placed listing is the entry shown. It counted 2 for Miley Cyrus on the live iTunes albums top 10 of 2026-09-22, the same wrong number the act board gave, from a second code path – found by the class sweep, not by a report. Its own docstring already named the identically-labelled duplicate (‘petal’ at #8 AND #25); an edition label is that shape wearing a name. without_edition is the one place that folding lives: pure, stdlib-only, beside edition_marker in release_type.py, scanning the same suffix forms edition_marker accepts – GROUPS and the trailing “- …” alike, the second of which Codex caught missing on #3657: edition_marker reads “Album - Deluxe Edition” through _peel’s dash arm, so a version that peeled only groups left the inflated count alive in that form. It stays conservative where it cannot tell (a title that is nothing but an edition suffix, or a dash segment that is the title’s own text, is left whole). Both lanes guard an EMPTY key, which Codex caught too: _fold and _normalize_title keep alphanumerics only, so a symbol-only title folds to “” – Ed Sheeran’s “÷” is on the live iTunes albums chart – and stripping the edition off “÷ (Deluxe)” took the news lane’s key from “deluxe” to “”, where the if t guard dropped the row and the act read absent. Each lane falls back to the stripped title itself, casefolded. song_key had no such hole only because it prefixed the artist. The identity is (LEAD, title), not the title alone, which Codex’s second pass caught: artist_top_ten_count justified dropping the credit on the grounds that the rows are already one act’s, so two rows sharing a title are the same release – and that premise expired on 2026-09-13, when row_is_theirs started counting a FEATURED act named in the TITLE. “Home (feat. Drake)” by two different leads are both Drake’s rows and are two records; a title-only key merged them and would drop a board row, or the whole board at the floor. The lead is read RELATIVE TO the act being listed: every row the act is CREDITED on shares one lead, so every credit variant of one release folds, and only a row it is merely featured on takes the other act’s first_party. Reading the lead off the credit alone is not enough and the difference is measured – first_party keeps a joint credit whole without a corroborating watchlist, so “Rod Wave & Luv Von” split off a record Rod Wave leads and manufactured the takeover out of one release that PR #2671’s review named, which is the overclaim direction. _top_ten_releases counts the SAME key, or the two counts disagree again from the other side: two rows drawn, one standing counted. The newsroom’s count still keys on the title alone and merges the two-leads case – pre-existing, measured, and filed as #3664 rather than widened into this change. The lead test asks whether the credit BEGINS with the act’s own name (_act_leads_credit), and three coarser tests were tried and each broke, in both directions: the lead off the CREDIT alone splits a joint credit off a record the act leads (the overclaim direction, caught by the #2671 test going 1 to 2); “is the act named anywhere in the credit” reads a credit naming it as the GUEST – Billboard writes “Artist A Featuring Drake” – as its own lead and merges two unrelated records; and “is the act in the credit’s FIRST PARTY” has the same hole one connector over, since first_party cannot split “Artist A & Drake” without a watchlist and returns the whole credit. The PREFIX answers all three because it never SPLITS the credit, and splitting is the step that needs corroboration: nothing in the string says whether “Earth, Wind & Fire” is one act or three, so it is read whole and leads itself, while “Artist A & Drake” simply does not begin with Drake. A row the act does not lead takes the WHOLE credit as its lead, which is what song_key keyed on. The prefix must END ON A CREDIT BOUNDARY, or a longer act’s name swallows a shorter one: “Future Islands” begins with Future’s only token, and row_is_theirs does select a “Home (feat. Future)” row credited to Future Islands for a Future lookup, so it read as Future’s own lead and merged with his own “Home”. fold_tokens maps EVERY collaboration connector to the one token “and”, so the boundary test is exact: the name either consumes the credit or is followed by a connector. without_edition cuts SPANS from the original string rather than rebuilding from peeled fragments: rebuilding reordered what it kept across a credit it had stepped over, so “Album (Deluxe Edition) feat. Guest [Remastered]” came back as “Album [Remastered] feat. Guest” and keyed apart from the plain listing. Both were Codex’s third pass on #3657. One fold rule changed with it, and it was a real bug. A SEEN board used to fold its cards straight away. That is right for a MOVER-keyed board (its key IS the set of movers, so those cards really did post) and wrong for a STANDING-keyed one: the count key holds still while WHICH titles move underneath it changes, so yesterday’s board suppressed today’s real moves – hardest on exactly the acts the standing board exists for. A seen standing board now leaves its cards alone. Every top-ten CHANGE also cards now (owner steer 2026-09-05: “post individual cards for top 10 on every change”). Every kind in artist_watch.chart_stories before this needs an EVENT – an arrival, a #1, a rung crossed, or a leap of jump_min spots – so a #3 -> #2 climb crossed no line, moved 2 spots, and told nothing, and a FALL inside the ten told nothing ever. TOP_MOVE_KINDS (top_climb / top_fall) is checked LAST, so it only ever catches a move no kind above it claimed, and its band is rungs[-1] so the move band and the rung band are one definition. The DIRECTION is the kind, because _STORY_WHAT and _STORY_EYEBROW are keyed by kind and a fall written up as a climb is the plainest wrong post; chart_story_card_fields learned the same lesson and now reads “down 2 from #4” where it used to print “up -2 from #4”. A title that fell OUT of the ten is deliberately NOT a story – it is no longer in the top ten – and the title that replaced it takes its own rung card. A FALL IS POSITION-ONLY, AND SO IS BILLBOARD’S (owner steer 2026-09-05: “never call it a slip just say where it is”). The first top_fall wording labelled the decline on every layer – eyebrow “Top 10 Slip”, blob “slipped DOWN inside the chart’s top 10, to”, sub “down 2 from #4”. The owner’s words are the SAME ones that set the radio_entry rule in 2026-08-17, so the fall takes the same shape: it joins artist_watch._POSITION_ONLY_EYEBROW, so the card wears the bare chart label and heroes the position; the blob reads “is on this chart at”; and the sub reads “was #4”, with no size and no direction word. The figure is where it sits now and the sub is where it sat, so the reader sees the decline in two numbers without the card judging it. The class sweep found ONE sibling and it was live: the Billboard move lane’s “Big Fall” (billboard._KIND_EYEBROW), whose card also read “down 11 from #9” and whose describe_move led with the verb (“falls 7 spots to #5”). It now takes the identical treatment – billboard._POSITION_ONLY_EYEBROW, a “was #9” sub, and “is at #5 this week (was #12)”. An EXIT is the one decline that CANNOT take it and is excluded by name on both lanes: the release is off the chart, so there is no current position to state and its card heroes last week’s rank instead – “say where it is” has no answer when it is nowhere. Removing the label moved a burden onto the FRAMING, and both lanes carry it: with nothing naming the direction, only a fence stops a take reading the two positions the wrong way round and writing a decline up as a climb. Each fall compose now states which position is higher, forbids the climb reading, and names the banned words back (slip, slide, fall, drop, tumble, losing ground) – the form docs/PROMPT_OPTIMIZATION.md says works. A CLIMB is unchanged on both lanes: it names its size and its kind, as every upward kind here does. THE CLASS SWEEP ABOVE MISSED A THIRD SIBLING, AND IT LEAKED THE NEXT DAY (owner steer 2026-09-06: “never say sliding down, big fall, dont narrate the downwards movement just say where it is”). The newsroom’s chart lane (music_news._compose_one) also states one title’s current chart position, off a live read that REPLACES the wire’s claim (settled_claim), and it carried no fence: its LIVE CHART block ended “Anything below reporting a different position is older than this reading”, which hands the model two positions in time order and nothing to do with the older one but relate them. It shipped “No Me Arrepiento De Sentir Tanto sits at #16 on the Billboard 200 right now, sliding down from its #8 debut” to X at 23:21Z, and the self-gate scored it high for its “grounded context (debut peak included)” – the judge grades whether a claim is TRUE, and that one is. Two changes. First the sentence now says what to do with the older position: leave it out. This was ABLATED, not assumed (owner: “fix any scar”). Real inputs, real model, n=6 per arm: the old sentence leaked 5/6; the reword alone 0/6; the reword plus a list of the banned words (slide, slip, fall, drop, dip, tumble, ‘down from’) also 0/6 – the list bought nothing and was cut as scar tissue, so the newsroom’s sentence is a cause reword and nothing more (the desk’s two lanes keep their words-back form because THEIR framing must guard the direction with no eyebrow label). The same ablation showed the RESIDUE the cut left: with the decline gone the take filled its trailing clause with the title’s run (“still hanging around months after that August debut”, 4/6 and 2/6 across the two arms) – the newsroom twin of the desk’s age-clause cut (#3038). Its cause was compose_music_record’s chart lane_hint, which invited “a trailing clause ONLY for a REAL second fact the block gives” with two examples the career fences BAN (“a career-first, a biggest-ever debut”); it now names the two facts the record line can actually carry (weeks held at #1, the record it set), says the context under it is not a second clause, and keeps the title’s age, run, debut and former position out. Measured after both rewords, n=8: 0/8 decline, 0/8 run clause, every take the bare position sentence. Second, output_checks.has_decline_narration is the DETERMINISTIC backstop behind the framing on all three position lanes – the career_claim precedent, wired through music_desk._unshippable for chart / watch_chart (_POSITION_LANES) and ship_guard.drop_decline_narration for the newsroom, all emitting phase=shape_reject reason=decline_narration. The checker matches the DOWNWARD direction only (a climb names its size by design) and every arm needs a chart-movement object beside the verb, so “the album drop”, “drops Friday”, “fall tour”, “Fall Out Boy”, “fell short of #1” and “slides into the top 10” pass; it also reads “went from #8 to #16” as a decline by comparing the two ranks. The chart EXIT lanes are excluded by name for the reason above: the departure IS their story. THE FALL TAKE CARRIES ONE POSITION (owner steer 2026-09-18: “Don’t narrate previous position if it was higher. Just say current position.”). The decline guard above matches a decline VERB, and the Billboard chart lane shipped a fall with none: “Man I Need by Olivia Dean sits at No. 10 on this week’s Billboard Global 200, after ranking No. 9 last week” (08:10Z, a fall on the Global 200). The self-gate scored it 0.85 and PRAISED the clause (“correct per ground truth: #9 last week, now #10”). It is correct; the steer is that the previous position is not a fact a fall take may state. The CAUSE was the two desk framings: each handed the model both positions (“it sits at #10 this week and was #9 last week, a HIGHER place”) and closed “The reader has both numbers and can see it”, an invitation to print both; describe_move added “(was #9)” to the story line and the watch blob added “It was #4 last period.” Every one of those is cut on a fall (billboard.describe_move / compose_inputs, artist_watch.chart_story_inputs); the previous rank stays in the grounding BLOB, which the self-gate reads, and the framing now says what to do with it: leave it out. The direction guard stays, because the blob still shows both positions. The first live dry run of that cut found the PEAK was the same shape: with “Peak #2” still on the story line, 10 of 10 takes wrote it (“55 weeks deep after peaking at #2”, “off a #2 peak”) – an earlier, higher position narrated beside the current one. So a fall’s story line now carries the run only (“55 week(s) on the chart”; a count, not a position), and the framing names the peak as out of the story too. Re-measured on the real Olivia Dean row, n=6: 0/6 named any second position, gates 0.88-0.92; the watch lane’s top_fall n=5: 0/5. output_checks.has_prior_position is the deterministic backstop (reason=prior_position, its own value so the decline-verb rate and this one stay separable): the earlier week or period (“last week”, “a week ago”), the previous rank as a number (“was #9”, “from #9”, “after a #9 week”), the past-placement verb (“after ranking”, “sat at #9”, “held #9”), the previous position named as such (“previously”, “its previous spot”), and, with the take’s own rank passed, ANY other chart position it names (the peak included). It runs on a FALL ONLY: the desk lanes pass fall_rank (the take’s rank, off _FALL_KINDS / top_fall) into _drop_before_gate -> _unshippable, and the newsroom’s chart lane calls ship_guard.drop_prior_position when the settled weekly row’s last_week is the smaller number. A climb never meets it – every upward kind names where it came from by design and keeps its peak. Not on chart_exit, as above. The newsroom marks a dropped story SEEN like its two grounding gates, because a re-roll is a phrasing lottery that costs a verify + compose + score cycle. The eyebrows are SHORT on purpose (Top 10 Climb / Top 10 Slip): the card renders <chart label> - <eyebrow> in one tag pill, and a long one used to run off the card edge and be clipped with no error. _equal_tag bounds and WRAPS the block itself since #3001, so the clip is gone – but a short eyebrow is still the better card, because a wrapped tag spends two lines on what one should say. Caught on a rendered card – the first wording, “Down in the Top 10”, needed 1711px against the ~1576px of room on the 1800px card and lost its last characters. test_every_story_eyebrow_fits_the_card_s_tag_pill measures every eyebrow this module can produce so the next one cannot clip silently. #3001 fixed the guard, and the investigation corrected the claim that filed it. The issue said reentry’s “Catalog Entry” clips today. Rendered, it does not: it draws 1590px wide and the card has room to about 1690px, so it fits with ~100px to spare. Superseded 2026-10-20: the pill now WRAPS a tag that does not fit (_fit_tag), and “Catalog Entry” is too wide on four of the nine watch-chart labels (1580-1642px against 1576px), so the US iTunes Top Albums card shipped a two-line pill. The owner call was “no overflow, didn’t need the catalog entry”: reentry is now position-only (_POSITION_ONLY_EYEBROW) and its card wears the bare chart label. The eyebrow test now asserts ONE line for every eyebrow on every watch-chart label. The Billboard twin (#4648): the same pill wrapped on 14 Billboard chart-and-word pairs (“Billboard Top Latin Rhythm Albums - New Peak”). The owner kept the kind words (#1982), so the fix is in the renderer instead: market_chart.drop_source_prefix drops the leading source name from a tag that would wrap, ONLY on a verified card, because there the source pill under the title then names the source. A tag that fits is never changed, and a rest that starts with a digit (“Billboard 200”) keeps its name. With the prefix gone, every Billboard label with every move word fits on one line (test_a_verified_billboard_tag_that_would_wrap_drops_billboard). The 1576px figure it was filed on came from reimplementing the pill’s arithmetic against a symmetric margin the card does not use. The BUG was real all the same, and the diagnosis got sharper: two of the four callers did guard, with _ellipsize against the PLAIN text, while the tag draws each glyph letter-spaced inside a padded block – measured, a 336px undercount on the clipping case – and the board and Versuz cards had no guard at all. The bound now lives in _equal_tag, the only place that knows the drawn width (_tag_width and the drawing share _tag_gap, so they cannot disagree again), and every caller passes its available width. It WRAPS rather than truncating (owner call): an eyebrow reads <label> - <kind>, so cutting the end throws away the kind – the half that carries the news, where the label is already implied by the source pill. The break prefers the “ - “ seam; an ellipsis is the last resort for a single unbreakable run. The eyebrow-width test now RENDERS a card and reads the drawn block out of the pixels, rather than reimplementing the renderer – which is what got the original claim wrong. top_for_all NO LONGER widens these kinds (2026-09-21 volume review). It used to, on the reasoning that otherwise half of one top ten would be told and half not; the owner separated the two instead, because an ENTRY is news about the chart while a MOVE is a pulse on an act you already follow. A move now needs a WATCHED act; the crown and the rung entries keep the widening. The band is top_move_rung (MUSIC_DESK_WATCH_TOP_MOVE_RUNG, default 10), its own setting rather than the entry rungs, and _TOP_TEN_POS follows it so a widened band does not push #11-#20 moves out of the 40-card budget into the 2-card one. The blob and the eyebrow name NO band for the same reason: they read “inside the chart’s top 10” and “Top 10 Climb”, which a widened band would have printed over a #15. The VOLUME is held by the dedup key, which carries the POSITION (kc:<chart>:<song>:pos<N>, where every other kind takes the bare per-song key), so a title wiggling between #4 and #5 posts twice inside the window and then goes quiet. _MAX_TOP10_MOVES_PER_SLOT (1) is the kind’s OWN per-slot budget, SEPARATE from the event cards’ cap – and it was a SHARE of that cap first, which shipped nothing at all. The reasoning for a share was that the kind fires on a far bigger population than the events, so it should not grow the lane. Measured end to end through the real lane against the live charts on 2026-09-05: 21 moves derived, 16 past the cross-chart dedup, and ZERO shipped – 15 event stories survived the same sweep against a 2-card cap, so the events filled it every time and the events-first ordering meant no move was ever reached. The share was not a bound on the new kind, it was an off switch, and the sub-cap never even bound. Two budgets is what the steer asks for; the honest cost is one more watch card on a busy slot, which is the feature rather than a side effect. MUSIC_DESK_MAX_TOP10_MOVES=0 turns the kind off without touching the event lane. The same kind now runs on the BILLBOARD charts and on the US YOUTUBE chart (owner steer 2026-09-06: individual cards for every top-10 change, on the chart, sales and video charts too). billboard.TOP_MOVE_KINDS is the Billboard twin, sharing the platform lane’s kind names so ONE wording rule covers both – including the position-only fall, which _FALL_KINDS now carries for fall and top_fall together. It is a new LAST branch in bb.movements bounded by top_move_max (10) rather than min_jump, because the fact is WHERE the title sits, not how far it went; notability needs no special case, since 100/rank dominates the score and a one-spot move at #2 scores ~51 against the lane’s floor of 8. The CHART SET widened from hot100,billboard200 to the four majors plus Top Album Sales and Digital Song Sales. Measured live on 2026-09-06: those six carry ~36 top-10 moves a week, against ~104 for all twenty charts in the registry; the genre and airplay charts are deliberately left out for now and widening is a MUSIC_DESK_CHART_LANE config change, not a rebuild. The new kinds arrive in their OWN derivation pass and the desk’s three existing passes pass top_move_max=0, so the change is purely additive: without that, a watched act’s 7-spot climb into the ten changed kind silently, because pass 1 would claim it as top_climb before pass 2 could call it a jump. The US YouTube chart (yt_us) is free: USVideoRow already carries pos_change, so it duck-types straight into chart_stories and joins _WATCH_CHARTS and _TOP_ALL_CHARTS with no new fetch and no new card shape; its stream cell is a WEEKLY view count, so its period is “week”. The other two YouTube charts CANNOT do this – kworb.VideoRow carries no previous position, so a change is underivable without storing a daily snapshot and diffing it, which is filed separately. THE SLOT BUDGET BIT TWICE IN TWO DAYS AND THE SECOND BITE IS THE ONE TO REMEMBER. The Billboard lane took two budgets from the start, having learned from the platform lane – and still shipped ZERO moves, because its attempts counter read the INDEX in picks rather than counting real composes. That was identical only while every pick was composed; two budgets mean a pick can be SKIPPED, and an index-based counter charges those skips against the compose budget. Measured live: 33 event picks sorted ahead of 25 top-ten moves, so the index passed the attempt cap of 4 long before the first move. The budget checks now come BEFORE the attempt check and attempts counts composes. The general lesson is that a cap test must OVER-SUPPLY every budget it claims to exercise: the fixture needs more events than the event cap AND more picks than the attempt cap, or it goes green on a lane that ships nothing. The lesson for the test, and it is the general one: the first regression test put ONE event against a cap of two, so the cap never filled and the test went green on a lane that could not post a move card at all. A cap test whose fixture cannot fill the cap tests nothing; test_a_top_ten_move_ships_even_when_the_events_fill_their_cap now over-supplies the events on purpose. all_stories also sorts EVENT kinds ahead of moves before the cross-chart song dedup runs – sorting by position alone put a one-spot shuffle at #3 ahead of a debut at #40 and, worse, let the shuffle suppress that song’s debut on another platform. Both rank-capped/board changes measured live 2026-08-11 (real charts + compose + the 0.6 gate): album drop 3/3 at 0.92, watch boards 2/2 at 0.92–0.95. Full plan + measurements: docs/BILLBOARD_ALERTS.md (Lane C, the top-artist watch). Non-Latin titles no longer render as tofu boxes (#2344): the bundled LiberationSans has Latin glyphs only, so a Korean or Devanagari row on the YouTube most-viewed board drew a line of □□□□. PIL does not fall back to another font on its own, so chart_cards now splits a string into runs by Unicode block (_bucket / _runs) and draws each run with a Noto font that has its glyphs (_text / _textlen are the fallback-aware draw.text / draw.textlength, used for every TITLE, CREDIT, HERO and CAPTION — the ranks and values stay ASCII). The Noto files are NOT in git; they ship in the Docker image via fonts-noto-core + fonts-noto-cjk and are read by path. A missing file degrades the run to the base font — the same tofu as before, never an error — so a Latin-only card renders the same in local dev, and the fix is live wherever the fonts are installed (production). The CJK collection carries one face per region; the KR face is used, so a Chinese or Japanese title wears the Korean regional form of a shared Han ideograph, which stays legible. A BOLD request degrades to the REGULAR-weight file when the bold file is absent (#2483): Debian’s fonts-noto-cjk ships only NotoSansCJK-Regular.ttc (the bold weights live in the separate fonts-noto-cjk-extra package the Dockerfile does not install), so a bold Korean title looked for NotoSansCJK-Bold.ttc, found nothing, and fell back to the Latin base font — tofu on the bold hero and bold row titles, the one line most likely to be non-Latin (the regular caption rendered fine). _fb_path now tries the bold file first and the regular file second, for CJK and every per-script family, so a missing bold weight renders regular-weight glyphs, not boxes. Right-to-left scripts (Arabic, Hebrew) are deliberately left on the base font (#2348): _runs draws its runs left to right in logical order, and Pillow cannot run the bidirectional algorithm across separately drawn runs, so a multiword RTL title would come out in the wrong visual order — a garbled title is worse than the box it replaces, so RTL stays tofu until it is done properly. The sibling market_chart renderer has the same gap and is NOT fixed here (#2345): its own _font / _ellipsize / _text_vcenter still draw with LiberationSans, so a market card whose label is non-Latin (a Kalshi YouTube-video leg) tofus the same way — a shared fallback util is the real fix. The TABLE card (render_table_portrait, owner ask 2026-09-16) is the board card’s sibling for a row that carries several figures: a board row is “title + one credit + one value”, and the forecast scorecard needs four figures per release (HITS, Kalshi, Billboard’s count, Billboard’s rank) — measured 2026-09-17, the board card cut the credit after the first figure on every row. The table card lays out COLUMNS (TableCell/TableRow): each figure column is as wide as its widest cell, the label column takes the remainder down to a floor (_T_LABEL_MIN_W), and the type steps down (_T_TEXT_SIZES) until the row fits; two-line cells stack a figure over its sub (a signed error, “called #N”, “artist · release date”). The chrome is shared by construction: the panel frame + hairline, the wordmark and the footer (pill + date line) were pulled out of render_board_portrait into _portrait_frame / _portrait_wordmark / _portrait_footer and both cards call them. First consumer: called_shot.scorecard_card via scripts/called_shot_scorecard.py. The board card labels its value column (#3658, 2026-10-24). Board.value_header (and SportsBoard.value_header) was set by about 25 builders (“W-L”, “LINE”, “certified units (M)”, “total streams”) and drawn by no board card, because build_board_card had no such parameter. Since the 2026-09-10 caption steer the caption no longer says what the column counts, so a column of “-1”, “+38” or bare counts carried no label on the card. build_board_card(value_header=...) now passes it to render_board_portrait, which draws it with the table card’s column-header style (18px bold, letter-spaced, dim), right-aligned over the value column, 24px above the first row. It sits in the existing gap, so the card height does not change. _draw_value_header leaves it out when no row has a value, or when the hero or the last caption line runs into its space (prefer absent over a collision). Wired at every cog that holds a Board: music_desk, music_news (four boards), music_alert, x_mentions, sports_boards. music_releases, betting_board and cinema_desk build rows with no value_header, so they are unchanged.stream_totals.py, the lifetime Spotify total for a charted title — the ONE home for the number a Billboard board draws in its right-hand column. A printed Billboard row carries a position, a peak and a week count, and no streams, so the figure is built from kworb: a SONG matches its title on the act’s songs page (chart_boards.song_total_streams), an ALBUM matches its title on the act’s albums page (kworb.artist_albums → chart_boards.album_page_total), and an album the albums page does not list falls back to the older tracklist sum (iTunes tracklist × the songs page + a Haiku recovery for a track the two sources spell differently). The albums page leads for two measured reasons (2026-08-28): it is Spotify’s own PUBLISHED per-release figure where the sum only approximates it — a track’s own total carries the plays it took as a single, so summing over-counts a release that has one — and iTunes does not return every act’s albums at all, so the sum could never resolve them (an iTunes album search for Bad Bunny returns his singles and features and none of his albums, which is why his Billboard 200 board drew move markers until the page was added). The StreamTotals resolver caches each page per artist id, so an act’s eleven rows cost one fetch per page; board_stream_totals is the board-shaped entry point, resolving exactly the rows chart_boards.watch_artist_rows says the board will draw, cut at the board’s own ACT_ROWS cap (11). The two caps drifted once (2026-09-17): the act board moved from TOP_N (10) to ACT_ROWS (11) on the 2026-09-10 owner steer, but the resolver kept slicing at ten, so Drake’s eleven-album Billboard 200 board drew “Nothing Was The Same” with a blank stream cell and no event, because no lookup ran for it. The resolver now slices with ACT_ROWS; test_board_totals_cover_every_row_the_act_board_draws pins the two caps together. Fail-open per row: an unresolved title returns None and the caller leaves that cell blank (prefer absent over invented). The artist id itself has two sources, and the desk lane needs both (2026-09-02): a kworb weekly chart row carries the id for free, but only for an act with a song on the Spotify Global Weekly top 200; a catalog act charting albums on the Billboard 200 with no current global single (Future, three albums on the 2026-09-05 chart) has no weekly row, and the desk lane used to stop there and ship the board with an empty streams column — silently, since that branch emitted no event. MusicDesk._catalog_artist_id now falls back to resolve_artist_id (the leaderboard by exact name, the mentions lane’s route), and a miss on both emits no_artist_id per row (StreamTotals.emit_miss) so the blank column shows on the ok=False rate. The module was extracted from cogs/music_desk.py when the X-mentions boards lane needed the same number for the same Billboard rows; the desk’s individual-artist board, the mentions lane and the manual artist_sweep all call it, and each stamps its own name on the song_total / album_total events so the lanes stay separable on the dashboard. A featured song can sit on the LEAD act’s page only (2026-10-02). Yeat’s “MISS MY DAWG (feat. Drake)” was on Yeat’s kworb page and not on Drake’s, so Drake’s 2026-10-01 Hot 100 card on X drew #55 with a blank stream cell. song_total now takes the row’s credit: on a miss it reads the page of each act the credit names (the lead acts, then the featured acts), and takes the first match. board_stream_totals and the desk’s individual-artist board pass the credit; the desk’s debut board already looked up the lead act. StreamTotals.resolve_artist_id also falls back to the exact Spotify artist search when kworb’s top-3000 leaderboard does not list the act (a Spotify id is a kworb id; Lizzy McAlpine is off the leaderboard and her kworb page holds 91 songs). Before, only the first-week lane had that fallback (#3678), so every other board drew a blank column for an off-leaderboard act. The ops-monitor stream_cell_blank finding lists every blank cell that still ships.billboard.CHARTS["global_k_songs"], new), and the iTunes K-Pop songs + albums genre feeds (itunes_charts.GENRE_CHARTS, two new crown lanes under the music_itunes_genre_crowns trial). The two Billboard charts earn the weekly top_ten board through _WEEKLY_EXTRA_CHARTS. Global K-Songs also feeds the per-act genre_entries board through _STANDINGS_GENRE_CHARTS, but NOT billboard.GENRE_CHARTS, because that tuple also builds KNOWN_GENRE_CHARTS and would make every K-pop chart act a “known artist” in the other desk lanes. Measured on the 2026-10-31 week: BTS’s 5 K-Songs entries scored 10, under the standings floor, so the genre board posts only on a week a notable act holds more of the chart. Billboard Korea Hot 100 is on billboard.com but was dated 2026-04-11 on 2026-10-27, so it is not read. Not read at all: Circle Chart, Melon, Genie, Hanteo (Korean-market charts, no existing parser) and kworb’s Spotify / Apple Music South Korea pages (a country chart, not a genre chart; trot acts chart there too).chart_boards.py, the PUBLICATION chart cards (owner steer 2026-08-06: “for all the main charts publish the new top 10 every week too”, “a chart for their new this week sales”, “our own daily chart projections in 4 variations … best in class view of weekly projections better than talkofthecharts and hitsdd”). Where chart_standings builds INSIGHT boards that post only when notable, this module builds the recurring chart graphics a chart account runs on a calendar — the product reference is @talkofthecharts’ weekly cycle (midweek predictions graphic → final predictions graphic → per-chart top-10 graphics, their best-performing content). Eight board kinds, all drawn by the same chart_cards.render_board_portrait card: weekly top_ten_sales — the PRINTED Billboard 200 top 10 with each album’s UNITS (owner ask 2026-08-08, “we need 200 ranks but sales for them”): rows stay in CHART ORDER (the rank is the fact, the figure explains it — that is what separates it from proj_top_sales, which re-sorts by units and covers a chart that has not printed), figures marked ~ because they are HITS estimates beside printed ranks, fenced on the SAME week alignment as debut_sales (HITS’ building document rolls to the next tracking week within days of the print, so outside that window it yields rather than pairing a printed rank with another week’s number) and yielding whole below _MIN_SALES_ROWS matched rows because a top 10 with holes is not a chart. It carries a _PRINTED_STEERS entry for the reason the projection variants do (#2098): the ranks card and the sales card describe ONE week, so this one leads with the FIGURES and the gap and is fenced off positions and moves. The gaps are STATED in the headline, in code (_sales_gap_clause, 2026-09-06): the steer asked for “how far clear the #1 album is” and the block never stated it, so the model subtracted and the number backstop dropped 53 takes in one week (2.8K, 3K, 7K, 10K – every one a computed difference). The headline now carries the #1’s margin over the board’s next-biggest FIGURE (named with its rank, because HITS’ estimates can sort differently from the printed order – Dolly at #5 carried more units than Dandelion at #2) and the board’s full spread; the steer says use them exactly and never call a bigger lower figure the leader. When a LOWER row out-units the printed #1 the board YIELDS: the data contradicts itself, and the dry run (2026-09-06, n=3 each way) showed no wording survives it – the stated inversion read to the judge as a contradiction of the printed order, and leaving it out had the model invent a “within ~4K” spread; 0/6 shipped. The aligned shape (#1 the biggest figure, the live Sep 5 rows) passed 3/3 writing “~18.8K clear of the field”. The ranks card and the debut-sales card still cover such a week. The printed #1 MUST match (pairs[0][0].rank == 1, else None): the 2026-09-04 board lost #1 and its headline said the #2 album “leads at #2”, which the take repeated and the self-gate failed as a fabricated position. The #1 was lost because Billboard prints “(EP)” on the row and HITS does not (“THE SIN : BLISS (EP)” / “WILD (EP)”), so _norm now drops a trailing EP marker on both sides. Dry run 2026-08-08 (real HITS figures, printed ranks reconstructed for the aligned window): composed 0.94 — “‘Petal’ opens the Aug 15 Billboard 200 with ~291.9K units this week, per HITS, and the gap to #2 Morgan Wallen at ~75.2K is not close.” top_ten per main chart (printed top 10 + week-over-week move markers, NEW/RE/+n/-n/= — ASCII because the bundled font has no arrow glyphs; on ship the top_ten / top_ten_sales board stamps the #1 row’s own bb: move key, plus its cross-surface debut key when the #1 is a debut (_top_ten_lead_keys, 2026-09-11) – the board’s headline leads on the #1 (“debuts at #1”), and without the stamp the movement lane carded the same debut on its own 39 hours later: “Don’t Look Down debuts at No. 1” on 2026-09-09 18:14 as the board’s take and again on 2026-09-11 08:11 as a move card, with “WILDCHILD” on Top Album Sales the same; a #1 that HELD stamps nothing, there being no move to card) and debut_sales (the week’s new Billboard 200 entries with their first-week units, per HITS, joined by debut_sales_pairs under a hard ALIGNMENT FENCE: projection.chart_date == week.week_of, because the building chart rolls to the next tracking week within days of the print); daily proj_top_rank / proj_top_sales / proj_pure_sales / proj_debut_rank / proj_debut_sales off the freshest HITS document, and off THAT DOCUMENT ALONE. The two debut boards count new albums INSIDE the document’s depth (_proj_depth, 2026-10-06): HITS projects only the top 20 (midweek) or top 50 (building), so the headline says “tracking into the top 20 of the Billboard 200”, not “onto the Billboard 200”. The old wording shipped “the Billboard 200’s two new entries” for the chart dated Oct 17, while Quavo’s QRÖMELIFE (out Oct 2) was not on the document. The depth is the document’s highest rank, not its row count. They used to carry Kalshi’s live market-implied units for the same release as compose-blob context (market_context); that function is DELETED on the owner’s steer of 2026-09-16, “never compare kalshi and hitsdd numbers”. It put two publishers’ first-week figures for one release in one block and the takes duly weighed them: “Coming Next’s release calendar puts Jhené Aiko’s ‘Westside Whimsy’ at ~78.3K first-week units, while HITS tracks ~72K” (2026-09-15). One board, one source. The two DEBUT boards confirm every row’s RELEASE DATE, and are absent without it (#3170). They read proj.new_entries(), which is the document’s no-last-week column, and that column reads exactly the same for a catalog RE-ENTRY — hits.ProjectionRow has said so since it was written, and music_desk._pick_called_shots already confirmed dates for that reason, but these boards drew the column raw. So the card for the chart dated 2026-09-19 shipped nine rows to X under the hero “Projected Sales: New This Week” and eight were catalog: Kanye West’s GRADUATION (2007) at #32, Drake’s VIEWS (2016) at #50, Beyoncé’s B’DAY (2006) at #8, Dolly Parton’s greatest-hits comp at #21. ADELA’s PRIMA, 8 days old, was the only real release. The owner caught it. music_desk._fresh_projection_entries now dates every no-last-week row through the shared utils.release_age resolver on the called-shot lane’s own 0-28 day window and its own cache, and hands the confirmed rows to projection_boards; both boards take them as a required argument and return None when it is None, so a caller that forgets gets silence rather than the old card. The floor is 2 confirmed rows (_MIN_PROJ_DEBUT_ROWS) — one confirmed release already posts as its own called-shot card, so a board of one repeats it. The three RANKING boards do not read the gate: they rank the whole chart and claim nothing about what is new, which is why a light release week silences the debut pair without taking phase=chart_proj dark. Measured in production over 21 days, the called-shot gate confirmed 1-3 releases a day, so roughly half the days now yield no debut board. The age is measured at the document’s TRACKING WEEK, not at wall-clock now — a projection is a claim about one week, so “was this new that week” is the question, and release_age_days takes an asof for it. On the document dated 2026-08-27, twenty one pilots’ MORE THAN WE EVER IMAGINED (released 08-07) is 20 days old inside that week and 36 days old read on 09-12; one release date, opposite sides of the 28-day window. Live the drift is a few days at most, but the week is the correct anchor and it makes the gate reproducible. Two resolver fixes landed with it, both measured on that document. album_first now reaches the iTunes FALLBACK (resolve_music_row(album_first=...)), which was song-first whatever the caller asked: STELLA LEFTY’s LONG WAY HOME resolved to track 12’s own single (2026-06-23) instead of the album (2026-08-21) and a real debut fell outside the window. With both fixes that document confirms 3 of 8 rows rather than 2. What it got wrong was WEEZER (GOLD ALBUM): apple_music._normalize_title drops the parenthetical, so every Weezer colour album normalises to weezer (#3180). Since #4116, apple_music.title_verified and deezer.album_title_matches separate them through apple_music.release_tag_mismatch: a requested release name in brackets must match the candidate’s own bracket name. Since #4115, the catalog row picker does the same through catalog_art.release_name_check. ED SHEERAN’s tour collection is datable by neither catalog and drops as no_date, which is the fail-closed contract working. The gate emits one proj_debut_gate event per evaluation with a reason tally; read a no_date majority as the catalogs failing, not a quiet release week (docs/OBSERVABILITY.md). proj_pure_sales is a SEPARATE board, not a re-sort of the units one (owner, 2026-08-07: “change the copies bought back to sales, you can add a new chart for that”). Units is the quantity a Billboard 200 position is computed from, so a units ranking restates the rank board; copies actually bought reorders it — measured on the Aug 15 week only 2 of 10 rows held position, Shaboozey went chart #9 → sales #2, and Morgan Wallen held chart #2 and fell out of the top 10 on ~2K bought against ~76K units. Its lead column keeps the projected CHART position so the divergence is the graphic, and it is ABSENT rather than falling back to units when the document ships no pure-sales column — a card calling streaming activity a sale is the one way it could mislead. A projected board is honestly labelled end to end: Board.projected flips the card’s date line to “PROJECTED FOR …” (never “CHART DATED”), Board.source puts HITS on the source pill, and the compose framing carries the called-shot lane’s forecast fence + attribution.credit_rule. Since 2026-09-29 that rule says the CARD names the source and the line names none (owner steer); output_checks.has_attribution_tag drops a line that names HITS, Mediabase, Luminate, Kalshi or Polymarket on every desk lane, after one _detag_credit re-ask. On a PLATFORM board the platform is the chart, so those boards name it through chart_boards.platform_chart_phrasing instead. The desk lanes (cogs/music_desk.py _weekly_board_units / _sales_card_units / _projection_board_units, stories chart_board / debut_sales / chart_proj) own pacing + dedup: weekly cards key on the chart WEEK (bbw: prefix, one card per chart per week); a HITS projection variant keys on the SOURCE DOCUMENT VERSION (bbp:<kind>:<version>, version = the projection’s tracking week + the source’s own updated_at, MusicDesk._proj_board_version) — so a fresh HITS document is postable on the NEXT 30-min slot after it lands, not the next UTC day, and the same numbers never repost. This is the owner’s “be first” steer (2026-08-19: “we need to beat everyone else, be first to publish all boards as soon as they update”), and it REPLACES the earlier bbp:<kind>:<date> daily key. That date key had two costs the owner hit on 2026-08-18: it front-loaded the whole day’s boards at 00:00 UTC (the date rolled, every variant re-opened, the day’s budget filled in the first slots) and it BLOCKED a document HITS published later the same UTC day (the date key was already stamped) — so on the Tuesday HITS drops the midweek preview (~20:36 UTC) our boards still showed last week’s building chart and trailed @chartdata by ~a day (KATSEYE #1 in the preview, our boards still leading “Stray Kids”). Version keying makes “first” a cache property the scheduler no longer fights (the two-speed HITS cache already re-reads every ~20min while a publish is due), and it removes the “same numbers every morning” repetition the date key caused. The MARKET board (market_units) keeps a per-UTC-day key (bbp:market_units:<date>) — its data is the Kalshi slate, which moves daily. There is NO per-day ceiling anymore: the old one existed to stop the date key’s daily re-flood (six Ariana boards before 09:02 on 2026-08-07, then 21h silent), and version keying removes that pressure at the source (a variant re-opens only when the document actually changes, ~twice a week), so the per-slot cap alone paces the burst. Variants rotate least-recently-posted first (_rotate_by_last_posted, read off the version keys already in the window) so the whole set of a fresh document clears across the next few slots, highest-notability first, and the default cap is 3/slot (MUSIC_DESK_MAX_PROJ_BOARDS, owner steer 2026-08-19 — the five HITS variants clear in two slots after the drop, lead board first, rather than trickling over three). Checked against the 9-day window (_WEEKLY_BOARD_DEDUP_HOURS), not a ~1-day one, so the key outlasts the ~weeklong document it names. The variants are SEVERAL READS OF ONE CHART WEEK, and the dedup stack had to be taught that (#2098). Measured on the live Aug 15 week: all of them composed and cleared the 0.6 gate, and only ONE reached the room — the other three died on text_similarity, every take having opened “Ariana Grande’s Petal is tracking…”. Three layers were fixed, each measured: (1) each board now leads with a fact only IT holds and is fenced OFF the others’ figures (_PROJ_STEERS — positions on the rank board, the leader’s units + the gap to #2 on the units board (the steer once said “the SPREAD rather than who is #1” and the 2026-09-08 take named a ~4K gap with nobody in it while Ella Langley’s “Dandelion” sat unnamed at #1 – every steer names the act on top, the ANGLE is what differs; platform_streams carried the same clause and got the same reword), the entry class on the debut board, copies bought on the pure-sales board, the pure-sales split on the debut-sales board; the owner’s rule is that the same album is fine but the intro must name the chart and which read this is). That alone dropped mechanical overlap under threshold but left the topic JUDGE catching them, in its words “just breaks down the sales mix… No new ranking movement”. (2) So the chart-card lanes are deduped on WORDING AND LINKS ALONE (_sched_dedup_wording_only, one hook on ScheduledPoster, default False) — they are named for the effect, not for a category of post. Such a unit keeps same_link + text_similarity and drops the rest: it drops the two FORMAT signals (dedup.FORMAT_SIGNALS) because a card must name its chart, week and source every time and “the Aug 15 Billboard 200, with HITS Daily Double” is a 40-char shared_run before the post says anything of its own, and it drops the judge’s CATCH direction because “is this a new development?” is the right question for a news unit and the wrong one for a recurring graphic. The judge’s OVERTURN direction stays. same_link and text_similarity are NOT switchable and still run on every board, so an actual repost is still caught on its wording. _sched_dedup_history(composed, recent) is the SECOND spine hook and the orthogonal axis: _sched_dedup_wording_only changes WHICH SIGNALS run, this changes WHAT the unit is compared against. Default returns the whole list; music_desk is the only override (#2170). One album carries a Pure Sales ladder AND an Album-Equivalent Units ladder at once (live 2026-08-07: petal at ~314.2K equivalent units and ~177.3K pure sales, ten minutes apart), and #2163 put music_alert into WIRE_DEDUP_SURFACES so those two ladders met in this gate for the first time. The topic judge suppresses that pair 5/5 while naming the metric difference in its own reason (“different metric (pure sales vs total units) but no material development”) — its DUPLICATE-vs-DEVELOPMENT axis has no slot for two PARALLEL facts about one subject, so a second true measurement reads as a restatement. It is fixed at the READ and not in the judge’s prompt because the prompt is measurably not the lever: five arms (add to the allow list, cut the market-restatement clause, reword it to name the quantity, extend the subject-collapsing line, cut-plus-extend) each scored 0/5 on the pair while every cross-domain control held 25/25 (scripts/ablate_topic_dedup.py reproduces it), and that judge serves the other four wire desks, so loosening it for one music case is the wrong tool even if an arm had worked. Dropping the line is safe rather than convenient: two posts reporting DIFFERENT quantities cannot be duplicates of each other, so nothing catchable is lost, and output_checks.album_metric_named fails closed into KEEP (a line naming neither metric, or naming both, stays in the list) so the narrowing only ever applies to the pair it was measured on. Owner steer 2026-08-08: “Separate metrics are okay to post.” Like _sched_dedup_wording_only it emits no event of its own — the behaviour is deterministic and pinned by unit tests rather than watched on a dashboard. After all three: every variant reaches the room (measured 4/4 at the time, 5/5 once proj_pure_sales was added). The debuts on the sales board also ship individually as big-number cards (sales_card_fields, figure = units, HITS-credited, capped MUSIC_DESK_MAX_SALES_CARDS/slot). Gated by music_weekly_charts + music_chart_projections (both PRODUCTION on the owner’s direct ask). The KALSHI MARKET board (market_units, hero “Market Projections”, story market_board, experiment music_market_board — PRODUCTION, #2098). It is the ONLY market board: the coalesced market_rank and market_debut cards were built, measured and then removed on the owner’s call (2026-08-07, “remove market adjusted for now” and “market priced debuts is now redundant”) — the projected debut coverage they duplicated already lives on debut_sales (printed) and proj_debut_rank / proj_debut_sales (projected). The whole coalescing layer went with them. The surviving board is the forward RELEASE CALENDAR: Kalshi’s priced first-week markets for the UPCOMING chart weeks, in DATE order (owner calls 2026-08-07: “okay lets do the upcoming weeks”, then “sort by date” — a calendar reads forward, so the chart that prints next is the top row and the reader scans down to see what is further out; units break a tie inside one week, and the top-10 cut therefore keeps the NEAREST weeks. The headline still names the biggest CALL, and that row carries the highlight rather than row 0). UPCOMING means the tracking window has not passed: from the day after a week’s end date its rows are gone (music_markets.window_closed, applied inside rank_debut_projections, 2026-09-20), so the card leads with the next week rather than with locked ladders waiting on Sunday’s print. Multiple weeks on purpose — Kalshi prices only 2-3 releases per Billboard week, so a one-week cut is two rows and usually zero: measured on the live slate that day the five covered weeks held 2, 2, 2, 1 and 1 markets, and a 3-row floor left the board dark in every one. A priced market that ran past its own ladder is on the board (2026-09-20): the slate’s confidence gate (music_markets.is_confident_projection, see the music_desk.py tracking-gate entry) used to drop a ladder whose 0.25 crossing sat above the top rung, which is how the Oct 3 calendar shipped without Carly Rae Jepsen’s “Day and Night” (~23.3K, six rungs, the window open) and a reader asked why. The gate mirrors a single missing quartile now; the board itself did not change. Spanning the weeks is what makes it a board, and the whole forward slate at once is the thing no other chart account offers. The lead column is the chart week — on a calendar “~296K” means nothing until you know which chart it lands on, and the week cannot ride in the credit column beside the artist, where the card truncated it mid-word (“Rod Wave · Sep 12 ch”). HITS’ projected RANK cannot lead here: HITS publishes one document covering one week, so a rank column would be blank for most rows; market_board_lines is the compose block, and it takes an optional week so a single-week consumer is never handed a release its card omits. The card draws no date line — no single week is true of it, and a calendar wearing one chart’s date would be false for most of its own rows. Kalshi’s numbers only on the card AND in the compose blob; a market whose period carries no full end date yields no week and is dropped rather than guessed. The blob used to carry HITS’ rank, HITS’ units and the difference between the two sources, and a fence told the model that difference was the most interesting thing on the board. The owner’s steer of 2026-09-16 is “never compare kalshi and hitsdd numbers”, so the HITS side and that fence are gone, and market_projections no longer takes the projections argument it never read. The shape it produced shipped at 0.88: “Jhené Aiko’s ‘Westside Whimsy’ is priced at ~78.4K first-week units on Kalshi, ~6.4K above HITS’ projection”. output_checks.has_cross_source_comparison is the deterministic backstop, wired through music_desk._unshippable on every lane. Dry run: python -m scripts.dryrun_chart_boards (--cards writes every PNG, --compose the real compose + gate). The PLATFORM DAILY BOARDS (owner steer 2026-08-07: “lets do charts for top ten apple music and spotify top ten too. And also new charts for those too, also streams equivalents”). Three board kinds over the kworb platform rows the watch sweep already caches (no new fetches), on the four charts the owner named (_PLATFORM_BOARD_KEYS = Spotify US + Global daily, Apple Music songs + albums): platform_top (the top 10 with day-over-day move markers), platform_streams (the same ten with the platform’s own play counts as the value column — Spotify only, since Apple/iTunes rows are rank-only and a count we don’t have is ABSENT, never estimated; the #1-vs-#2 gap is computed in code so the compose does no row arithmetic), and platform_new (the titles new to the chart, listed at their CURRENT rank: a title is new for the chart_presence 24h first-sighting window — owner steer #2139, so a release-day entry that climbs overnight is still new the next morning at the rank it climbed to — run through the SAME memory the watch lanes use so a tracked-range bounce is not re-announced; when the memory is unavailable the board falls back to kworb’s own NEW flags alone, never “the whole chart is new”; worded “new to the chart” — never “debut”, which claims a release date no chart row carries, the #2045 class). The board also DATES its shown entries (the fourth appearance of the #2045 class): kworb’s NEW flag says only “new to this chart today”, so a catalog SURGE — Dolly Parton’s whole catalog re-entering US iTunes the day she died — filled the board with 1970s songs and the compose wrote “‘I Will Always Love You’ debuting straight to #1”. The cog dates each shown row through the shared _release_age_days (fail-open per row: an undatable title stays None, so one bad title never blanks the top-10/streams boards in the same try). When the highest new entry is _NEW_RELEASE_MAX_AGE_DAYS (45) or older, platform_new_entries returns None instead of posting — a returning catalog title is not a story worth a card (owner steer 2026-09-25: the retired Catalog Surge framing measured no engagement); a fresh or undatable leader keeps the neutral “new to the chart” wording. Every header, fence and source pill names the PLATFORM (a bare top-10 claim reads as Billboard); per-kind steers mirror _PROJ_STEERS’ measured discipline (#2098) so three boards about one chart on one day lead with different facts, and the lane rides the wording-only dedup (_CHART_CARD_STORIES). Cog lane _platform_board_units: story platform_board, experiment music_platform_boards (PRODUCTION on the direct ask, the weekly-charts precedent), one post per board per day (pfb:<chart>:<kind>:<date> keys), least-recently-posted rotation, caps MUSIC_DESK_MAX_PLATFORM_BOARDS (1/slot) + MUSIC_DESK_MAX_PLATFORM_BOARDS_DAY (4/day) pacing up to 10 possible boards a day (the caps have since risen; the code comment beside _MAX_PLATFORM_BOARDS_PER_DAY holds the current values). The two Spotify new-entries boards are RESERVED (_RESERVED_PLATFORM_BOARDS, 2026-10-05, owner ask “report new release streams daily like others, or at least weekly”): they are the only platform boards that show each new title’s plays, and the rotation gave Spotify Global one turn in 14 days. They now take the first turn of the day and do not count against the day ceiling. Their daily key, the three-row floor, the catalog-leader skip, the text dedup and the self-gate all still apply. The new-entries headline now states the leader’s plays on a chart that counts them, because the take is written from the headline (and the platform_new steer names that play count as one of the take’s numbers). Three Codex findings on #3894 shaped the reserved boards: (1) their key carries the CHART’s date with a chart- tag (so an older calendar-date key cannot block a chart of the same date), not date.today(), because the presence memory keeps an entry new for 24 hours and the US board posted one lineup on 10-03 and again on 10-04; (2) the two boards are deduped apart, because a Global and a US take score 0.65 to 0.93 on the 0.6 text check, so each ignores recent lines that name only the other Spotify chart (the US pattern also takes the lowercase “spotify’s us chart” form, never the pronoun); (3) a deduped reserved board is closed out for its chart day in _sched_on_dedup. The rotated platform boards are still not closed out there, because their keys count against the day ceiling. Dry-run 2026-08-07 on live kworb rows + the real compose/gate: three kinds composed 0.92–0.95, each naming its platform. The GENRE streams + sales boards (owner steer 2026-08-08, “lets have streams and sales by genres”; the reference is Kurrco’s HITS-sourced “best-selling albums by rappers” card). Two board kinds per genre GROUP — the owner’s grouping (“combine rap and hiphop”, then “Separate rnb”): hip-hop (the rap charts), R&B (Billboard’s R&B sub-charts rnb/rnb_albums, added + live-verified for this split — the combined R&B/Hip-Hop family mixes both so it scopes neither), — both kinds cut from the ONE HITS Top 50 document (LATIN is OFF as a group, owner call 2026-08-08 “replace latin with pop … or just turn off latin”: it yielded under the row floor most weeks, and POP — the wanted replacement — has no Billboard genre album chart; Apple’s marketing feed carries per-album genre tags and is the probed path, tracked as its own follow-up) (hits.CHART_BUILDING, whose rows carry the full split): genre_sales ranks the genre’s albums by PURE SALES (album_sales — copies bought; ABSENT when the document ships no pure column, never units relabeled as sales) and genre_streams by STREAMING units (the sea column). Genre membership is Billboard CHART membership — the ALBUM on the genre’s Billboard ALBUM chart this week (chart_boards.GENRE_MEMBERSHIP maps the genre to that chart key; _genre_rows joins on the normalized album|artist key, the same key debut_sales_pairs uses, and there is deliberately NO artist_watch.Watchlist in this path — membership is the ALBUM’s own presence, never an artist-level inference, so Drake charting one R&B song cannot drag his rap album onto the R&B board) — never a guessed tag; an album on no kept genre chart is simply absent, and a genre with under 3 matched-and-valued rows yields no board (measured live: Latin yields, rap and R&B/hip-hop fill). The DEBUT fallback (owner question 2026-09-05, “no way these are the only rap or rnb albums here”): the genre chart the join reads is the PRINTED week, and the HITS document projects the week AFTER it, so an album in its first tracking week is on no printed chart and missed both boards on its biggest week — measured live, Rod Wave’s Don’t Look Down projected #1 on ~100K units and sat on neither card while the hip-hop card showed five catalog-week titles. So genre_split_boards takes a third argument, debut_groups: the cog (_genre_debut_groups) fetches the printed billboard200 beside the genre charts, takes genre_debut_candidates (HITS rows on NO row of the printed 200 — with no printed 200 nothing is a candidate, prefer absent over guessed), and reads each one’s genre from Apple’s own per-album primaryGenreName through one apple_music.search_catalog(entity="album") call, accepting the result only when it matches the row on the same album|artist key (genre_row_key, via chart_boards.apple_album_match, which drops iTunes’ “ - EP” / “ - Single” suffix). When no album row matches, a second search_catalog(entity="song") call reads the album off its tracks (apple_music.albums_from_tracks: collectionName + collectionArtistName; album_tracklist, the album ownership count and catalog_lookup share it, #4760) and the event carries via=track (#4734): the iTunes ALBUM search returns no row for many new albums. Measured 2026-10-25 on the 17 albums that read no_match in 30 days: 0 matched before, 13 after. The 4 left are a title the chart prints differently (“SOUNDTRACK” by “KPOP DEMON HUNTERS”, “UNPLUGGED” against “MTV Unplugged (Live)”, the Hamilton cast album) or an album iTunes does not list (LUCKI “Last Laugh [Archive Tape]”), and they stay absent; APPLE_GENRE_GROUPS maps the tag to the kept group at both of Apple’s levels (“Hip-Hop/Rap” AND plain “Rap”, since primaryGenreName is sometimes the sub-genre — SZA’s SOS reads “R&B/Soul”, Rod Wave’s album reads “Rap”), and a tag outside the kept groups leaves the album absent. Still a published fact about the ALBUM, not an artist inference (the Iceman leads R&B streaming rule holds: Drake’s albums chart last week, so they never enter this path). TWO SOURCES MUST AGREE (confirmed_genre_group, 2026-09-11, owner report “bday isn’t a hiphop album” then “the release isn’t the problem, it’s the genre”): Apple’s tag is simply wrong on some albums, and nothing about the album says so. Beyonce’s B’Day reads Hip-Hop/Rap on the 2006 record and Pop on that same album’s other editions, so Apple does not even agree with itself; it led the hip-hop SALES card at ~15.9K for the week of 2026-09-19. So the tag is now CONFIRMED against Deezer’s own album genre (deezer.album_genres, guarded by the same album_title_matches + _artist_overlaps wrong-match rule the other Deezer readers use), and the album joins a board only when both sources name the same kept group. Deezer writes its own vocabulary (Rap/Hip Hop where Apple writes Hip-Hop/Rap), so DEEZER_GENRE_GROUPS maps it separately; one kept group among an album’s several genres confirms it, two different ones do not. Deezer is read ONLY when Apple already names a kept group, so a pop or country debut costs no call. The two ways it can fail are separated in telemetry: genre_disagree (Deezer names another group, or none — the B’Day case) and genre_unconfirmed (Deezer holds no genre for the album yet, the release-day shape, which is NOT cached so a later slot confirms it). Live tags measured 2026-09-11: B’Day Apple Hip-Hop/Rap / Deezer Pop; Iceman, Views, Graduation, Take Care, The Diamond Collection Apple Hip-Hop/Rap / Deezer Rap/Hip Hop; Don’t Look Down, Octane, The Real Me Apple Rap / Deezer Rap/Hip Hop; SOS Apple R&B/Soul / Deezer R&B. The RELEASE DATE is deliberately NOT the guard. The first fix fenced the tag on the matched Apple row’s release date, which caught B’Day only because the wrong edition was also the old one — it gave no protection at all on the single-edition debut the fallback exists for, and it wrongly dropped a catalog rap album that re-enters off the Billboard rap chart. The owner’s steer killed it. Capped at MUSIC_DESK_GENRE_DEBUT_TAG_CAP (8) lookups per read, cached per album for the process, one genre_debut_tag event per lookup (ops-monitor misc health itunes_genre_tag). Dry run 2026-09-05 on the live document: the hip-hop card went from 5 rows to 6 with Don’t Look Down leading streams at ~90.5K, and the R&B card was unchanged. NOTE the two reads deliberately diverge: Kurrco’s “best-selling” figures are UNITS, so catalog with real physical sales (Take Care, Thriller) tops OUR sales board while the streams board matches Kurrco’s ordering — the honest split, stated per board by the _PROJ_STEERS entries (each names its measure and keeps the claim on the rows the board read). The CLAIM IS BOUNDED TO THE TOP 50, because the rows are (#2252). _genre_rows intersects the HITS Top 50 with the genre’s Billboard chart, so a genre album that misses the overall 50 is not on the board at all — measured live 2026-08-10, the rap chart’s 25 rows yielded 5 matches and R&B’s 25 yielded 5. The first version said “leads the week’s chart_records.py, the chart RECORD watch (owner ask 2026-08-10, “records that they’re breaking”): the durable crown ledger — one kv_cache row per (chart, year), namespace chart_records, each printed week compressed to WeekSummary (the #1, the top 10, the highest debut, the biggest climb, the deepest act; the top 10 is filtered on the printed RANK, never the first ten parsed rows – a parse that drops one row is still accepted at half the chart, and rows[:10] would then record rank 11 as a top-10 song, #2710) — plus the pure derivations: year_best_cards (biggest climb / deepest week of the year — there is deliberately NO highest-debut card: excluding #1 debuts makes the superlative false, including them duplicates the crown lane, so it was cut on the shipping dry run; WeekSummary.high_debut is still recorded because re-deriving it later would need a full dated-page backfill), alltime_cards (checks against the small verified RECORDS table — Hot 100 only: 42 simultaneous entries/Drake, the full-top-10 sweep/Taylor Swift, 19 straight weeks at #1/Old Town Road, the 97→1 jump/Kelly Clarkson; every value carries its provenance in a comment and an unverified record has NO row), run_rung_card (the Nth-straight-week-at-#1 beat at rungs 5/10/15/20; its blob states the year’s PRINTED WEEK COUNT and whether the run is the year’s LONGEST (year_longest_run; the 2026-09-06 dry run shipped “the longest run of 2026 so far” at 0.92 with nothing in the blob to say so, so the fact is now stated either way) too, because the take reaches for “20 of 36 printed weeks” and 36 was in nothing it was handed – 61 ungrounded_number drops in one day, 2026-09-03; the other flagged figure, the run’s “10” against the blob’s “10th”, is grounded by output_checks now reading a digit ordinal’s digits on the SOURCE side), and year_race_board (weeks at #1 per act, YTD), year_n1_songs_board (distinct #1 SONGS per act, YTD – the “most #1s in {year}” board, owner steer 2026-08-20; story year_n1_songs, fires once per printed week, needs a real race so it is silent below a 2-song leader / 3 acts), and year_top10_songs_board (the same tally one rung wider – distinct TOP 10 songs per act, YTD, the “most top 10s in {year}” board, owner ask 2026-08-30; story year_top10_songs, hero “Most Top 10s This Year”), and year_title_weeks_board (owner ask 2026-10-12: the one year board that ranks TITLES, not acts – the songs or albums with the most weeks in the top 10 this year, each row carrying its weeks at No. 1 or its best rank; story year_title_weeks, hero “Most Weeks in the Top 10”). Its dedup key hashes the ORDER of the drawn titles and not their counts, because the long-runners board was cut for reposting the same ten titles each week with every count one higher; it reposts only when a title moves or a new one arrives. The row key is the folded title plus artist_watch.first_party over the year’s own solo credits (#4265; the old local split keyed “Earth, Wind & Fire” as “earth”). The signature keeps the old cut as a hash input only (_signature_act), because a new hash on an unchanged board reposts it. The record watch reads THREE charts (_RECORD_CHARTS): the Hot 100, the Billboard 200, and – from 2026-08-31, owner ask “what about other charts” (#2729) – the Global 200. That is what gives the two song races a SECOND chart to run on, the other two record charts being the Hot 100 and the album chart. The Global 200 is the right one to add: 200 rows of real competition, where a 25-row genre chart makes a “top 10” 40% of the page and would need the genre boards’ own known-artist guard first. Adding a chart costs one backfill of its year (~35 dated pages at BACKFILL_PER_SLOT=2 a slot, about a day of slots) before year_complete lets it say anything; verified before shipping that the dated Global 200 page fetches and parses to a full 200 rows, since a chart whose dated pages 404 would leave the lane silent forever. The LOOKUP boards keep their own _LOOKUP_CHARTS pair so a new record chart does not make that lane fetch a page it never reads. The corroboration these boards split credits against is supplied by the DESK, not derived from the ledger alone (#2710 follow-up): the ledger holds one top 10 a week, so its own solo credits are ~29 acts for a whole year, and a featured act without a top-10 hit of its own was dropped from the tally while a two-act credit whose parts both missed stayed WHOLE and counted as one act under a name no artist has. _record_units now passes the same chart_standings.known_acts set the standings boards use (the full 100-row weeks plus the ~1000-name kworb list) into alltime_cards and both song boards, and into the dedup signatures so a key can never read rows the card does not draw. Measured on the live 2026 Hot 100: Future, JENNIE, Zara Larsson, PinkPantheress, EJAE, Audrey Nuna and REI AMI became credited acts, and the two phantom composite “artists” disappeared, with the drawn rows of both cards unchanged. Fail-open: a kworb outage passes an empty set and the boards fall back to ledger-only corroboration. Both song races run off ONE pure core (_distinct_song_rows / _song_race_signature / _song_race_board), so the joint-credit rule, the co-leader headline and the top-10-row dedup signature are defined once; they differ only in which entries they read (the week’s #1 vs its whole top 10), their hero/metric wording and their leader floor. The TOP 10 floor is THREE songs, not the #1 board’s two: a year prints ten top-10 slots a week, so a two-song lead there is a tie, not a race (measured on the live 2026 Hot 100 ledger, Aug 29: Drake 9, Bad Bunny 4, Olivia Rodrigo 4, then a wide band of 2s). Both boards now draw a sub naming the act’s BEST song – the highest position the act printed at in this year’s weeks, and the title that printed there (owner ask 2026-08-30, both cards shipped with bare counts). The WORDING differs per board, and _song_race_row_sub owns both: the TOP 10 board draws best #<rank> "<title>", the shape the census and credits boards use, because its rows reach different heights; the No. 1 board draws the TITLE alone, because every row there is a #1 and “best #1” would print the same two words ten times (owner call on the rendered card). Ties on these small counts now break on the better best-rank before the act name, so a four-song row with a #1 never sorts under a four-song row whose best is #7; each dedup signature hashes its own board’s rendered sub, so a named song or a moved best mints a new key at unchanged counts (the new card would otherwise never post) and nothing else does. The BEST song of an act is the one that printed at its best rank; two songs tied there resolve to the one that printed in more of the year’s weeks, then the alphabetically first title. The compose blocks are UNCHANGED: the framing already tells the take not to recite rows, and a “best #N” line inside the top-10 block would invite the year-scoped achievement claim that block’s fence forbids. Both run on SONG and ALBUM charts (owner ask 2026-08-30, “do 200s too”): _race_unit reads billboard.chart_unit, so a Billboard 200 card says “top 10 albums” in its headline, its caption and its compose block, and the earlier song-chart gate in _record_units is gone – it existed only because the wording was hard-coded. A PLACEHOLDER credit never becomes a row: the 200 prints “Soundtrack” and “Various Artists” on real top-10 rows, and is_placeholder_credit drops them, because ranking one puts a name no artist has on a board about acts (the same fault as the phantom composite credits #2710 removed). The census board took the same filter in #2739. Two fences came out of the #2710 review: a tie as WIDE as the drawn rows makes the board silent (with no row below the lead, the compose topic counts ten highlighted rows while the headline states the true larger number), and the top-10 block says a song APPEARED in the top 10, never that it “reached” it this year (a song that entered the ten in December is still in January’s weeks, so “reached” would claim a year-scoped achievement the ledger cannot see). The honesty fences are structural, not prompt rules: a year claim needs year_complete (the cog backfills dated pages BACKFILL_PER_SLOT=2 per slot until the ledger holds every week of the year), a consecutive-run count needs run_is_bounded (the ledger must have SEEN the run start), and Mariah’s 22 CUMULATIVE weeks are deliberately not in the table because a year ledger cannot measure a cross-year total. The desk lane (_record_units, experiment music_chart_records, stories record_card/year_race/year_n1_songs/year_top10_songs, dedup rcw: – the two song races and the title board key on their ranked signature, not the printed week. That key alone did NOT hold an unchanged board back: the desk reads posted keys 9 days back, so the same tally posted again about every 9 days (measured 2026-09-18 to 2026-10-07, Codex review on #3976). Since #3976 those three boards also gate on a DURABLE signature per (guild, stage, board, chart, year) in lifetime_board_state, written on a confirmed delivery through lifetime_state exactly like the lifetime boards (_year_board_state). A board whose key is already in the posted window seeds that state and skips, so the boards live at deploy do not post once more. The title board also needs a full top 10 in every week, because a parse that drops a row stores a short top 10) composes the cards through crx.compose_inputs — whose fence INVERTS the standings “no record claims” rule into “ONLY the record stated in the block, at its exact scope” — and records year-best high-waters on ship (_record_ship → record_state, kv key <chart>:<year>:state). Ships STAGING.lifetime_boards.py, the LIFETIME / career record boards (owner ask 2026-08-19, off the @billboardcharts “most No. 1s on Rhythmic Airplay” graphic → “can we do lifetime boards milestones for everything for known artists”): the evergreen career-tally leaderboards — “the artists with the most No. 1s on a chart across its whole history” — drawn as the same ranked Board card the standings lane uses (leader row highlighted, the count in the value column). WHY CURATED, not derived: chart_records states plainly that cumulative-history metrics are out of scope — no week read produces a career count, and no free source publishes the per-format all-time #1 tallies (kworb’s radio page defers format charts to All Access, Pollstar’s free tier is this week’s spins). So this is a fixed, cited registry, exactly like the verified RECORDS table: every LifetimeBoard carries its source, an as_of date, and a provenance citation, and the validate guard is the fabrication fence — it drops a board that is a leaderboard of one, is not strictly ranked largest-first, repeats an artist, has a non-positive tally, or is missing a source/as_of/provenance (a golden bad board in the tests must stay rejected). build re-sorts defensively before drawing so the drawn #1 can never disagree with the largest tally; the take is BRAND-FREE (compose_source="", the pill is the credit) and its framing is the standings fence with ONE inversion — the all-time claim is ALLOWED (that is the surface), but only the record the block states, never widened past the one board. The registry GROWS one cited board at a time. It carries the Rhythmic Airplay #1s board (@billboardcharts) plus the Billboard Hot 100 career records (epic #2436, Tiers 2/3): most No. 1 singles (Beatles 20), most top-10 hits (Drake 90), most cumulative weeks at No. 1 (Mariah 101), most Hot 100 entries (Drake 403) — each read table-exact from Wikipedia’s “List of Billboard Hot 100 chart achievements and milestones”, pill-credited to Billboard with the Wikipedia section in provenance. Genre-chart records ride the same discipline (epic #2436, Tier 2 subsets): most No. 1s on Hot R&B/Hip-Hop Songs (Drake 32), Dance Club Songs (Madonna 50), and Hot Latin Songs (Enrique Iglesias 27), each Wikipedia-sourced and cited (Pop Airplay deferred — its Wikipedia list is an unranked alphabetical page that did not parse cleanly, so it is not shipped rather than shipped from a noisy parse). The Billboard 200 album records ride the same discipline, parsed BY ACT from Wikipedia’s ‘Billboard 200’ article (the honest cut — the Beatles hold the album-#1 record as an act at 19, where “Paul McCartney 27” counts Beatles+Wings+solo as one individual): most No. 1 albums (Beatles 19), most weeks at No. 1 (Beatles 132), most top-10 albums (Rolling Stones 39). metric carries the chart SCOPE (“No. 1s on Rhythmic Airplay”, “top-10 hits on the Hot 100”) so the headline can never read as an unscoped “the most ever, any chart”; column_header keeps the card column short. credit_basis carries the FEATURES-vs-LEAD nuance: Billboard’s artist chart records count every song an act is CREDITED on — lead OR featured — so Drake’s “90 top-10 hits” includes songs where he is a guest (which is why a fan graphic that counts lead-only lands on a different number). Every SONG record (the No.1s / top-10s / entries / weeks boards) sets credit_basis="lead and featured credits", which adds a compose fence: the take must say “hits” / “songs he’s on”, never “his own songs” or “he made/wrote them all” — a featured credit counts for the record but is not a solo hit. The album boards (bb200) leave it empty; the guest-credit case does not arise the same way there. The desk lane (_lifetime_boards_units, experiment music_lifetime_boards, story lifetime_board) FIRES ON CHANGE, not on a schedule (owner steer): lifetime_boards.signature hashes a board’s ranked rows, the cog stores it durably in kv_cache (namespace lifetime_board_state, long TTL), and a board posts on FIRST sight (once — it is new to the room) and then ONLY when its signature CHANGES; an unchanged record never re-posts. The signature is keyed per (guild, stage, board) and written only on a CONFIRMED delivery or an intentional dedup, never at compose time (Codex #2437): a STAGING audition in #bot-logs stamps its own key, so the board still posts ONCE to the room after the experiment is promoted to PRODUCTION. It is per-GUILD, not per-channel, on purpose — like every desk lane, the scheduler’s guild-wide history dedup already posts a story to one room per slot, so the first room delivers and stamps while the next room skips at compose. (Per-channel keying was tried and reverted: it does not actually deliver to a second room either — that copy is history-deduped and closed out — it only spends a wasted compose; genuine per-room delivery would need destination-scoped history dedup on the shared ScheduledPoster spine, out of scope here.) The state rides Composed.meta (extra_meta={"lifetime_state": (key, sig)}) and is persisted from _record_ship on the delivery success path — and also from _sched_on_dedup, so a board the scheduler rejects as a cross-desk duplicate is closed out instead of recomposing every slot. NO WALL on a first deploy or a big batch: at most ONE board posts per slot (_MAX_LIFETIME_BOARDS_PER_SLOT), at most a few real composes are attempted per slot (_MAX_LIFETIME_BOARD_ATTEMPTS_PER_SLOT, so a gate-held board can’t walk the whole registry in one slot), and the lane sits low in priority, so new boards trickle out one at a time. (An earlier “seed silently unless updated within N days” window was cut — the per-slot cap already prevents a flood, and the date window only added a late-merge trap; LifetimeBoard.updated stays as audit metadata, not read by the lane.) It reuses the generic _compose_board_unit/_sched_deliver path. Dry run: python scripts/dryrun_lifetime_boards.py --cards DIR. Ships STAGING. Tier 1 (live, self-updating) is deliberately NOT here — a stream-total leaderboard goes stale in days, so it can’t be a fixed curated list; those live in the artist-boards lane (music_artist_boards), which posts all-time artists / videos / monthly listeners and now the all-time most-streamed SONGS board too (chart_boards.song_streams_board off kworb.spotify_songs, spotify/songs.html, functional-audio filtered). That songs board is an all-time record, so it takes its OWN story song_streams and its OWN compose inputs (chart_boards.song_streams_compose_inputs) — not the shared platform framing, whose base fence forbids “the most ever” and opens with a Billboard-printed header, both wrong for a Spotify all-time ranking (Codex #2437 P2). Its story joins _BOARD_LANES (row-arithmetic / row-tally guards) and _ALLTIME_EXEMPT_LANES (the all-time claim is allowed, exactly like lifetime_board), while the daily platform boards on the same lane still cannot make an all-time claim. Its song_streams kind is also in _CHANGE_GATED_BOARD_KINDS, so this evergreen list reposts only when its lineup MOVES, not once a day like the editorial platform boards (Codex #2437). That gate became DURABLE on 2026-09-22 (owner steer, on the shipped posts). Every other gated kind on this lane is a DAILY chart, so checking the lineup key against the 9-day dedup window is a fair ceiling on repeating a frozen lineup. This board is the all-time most-streamed SONG, a record that moves about once a year, and a ceiling is the wrong shape for it: measured on the live log it posted “Blinding Lights by The Weeknd is Spotify’s most-streamed song of all time with 5.6B streams” on 2026-08-31, 09-09 and 09-18 – three identical posts exactly 9 days apart, which is the WINDOW expiring rather than the record changing. It now uses the same durable fire-on-change state the curated lifetime boards use (_LIFETIME_BOARD_STATE_NS, keyed <guild>:<stage>:song_streams), so an unchanged record never re-posts however long it stands. The SIGNATURE is still the lineup key, which hashes the ordered row labels and deliberately excludes the value column, so the stream count ticking up daily is not a change while a new #1 or a re-order of the top is. The stage stays in the key for the reason it was appended to the old window key – a STAGING audition must not suppress the room after promotion – and the signature is written ONLY on a confirmed delivery (_record_lifetime_meta reading lifetime_state), never at compose time, which is the other half of #2437. This one read fails CLOSED, against the desk’s usual read-path direction: the read IS the dedup, so failing open reposts the duplicate the gate exists to stop, and one skipped slot costs nothing on an evergreen record. The daily platform boards keep the 9-day window. The ROLLOUT needed a one-time migration (Codex on #3634), and it is the case that would have made this change cause the very repeat it removes. On the deploy carrying it the durable state is EMPTY, so an unchanged record reads as first sight and posts: the board shipped 2026-09-18, the old 9-day window ran to 09-27, and the change landed 09-22, squarely inside it. So when the durable state is absent the lane reads the LEGACY stage-scoped window stamp, and when that stamp is present it WRITES the durable state and skips. Writing there does not break the “write only on a confirmed delivery” rule: it records a delivery that already happened, for which the legacy stamp is the evidence. The signature is the LEADER, because the leader is what the card SAYS. Both alternatives were tried on the PR and both are wrong, so the reasoning is recorded rather than re-derived. Signing EVERY drawn row looks safer and is not: the take names only the #1, so a swap deeper in the ten re-posts a word-for-word identical sentence – measured on the live page, rows 6 and 7 sit about 6M apart and close at roughly 249K a day, so they trade in about 24 days and row 11 passes row 10 in about 96, which is the owner’s original complaint at a slower cadence. Signing the leader means the card is silent while the record stands, and that is the ask, not a freeze: post only when the record changes, for a record that changes about once a year. The value column is out of the key either way, so the counts ticking daily is not a change. The state also expires far sooner than the curated boards’ (_SONG_STREAMS_STATE_TTL, 180 days against 3650): a leader signature makes a WRONG stamp expensive, and _sched_on_dedup stamps on a dedup verdict as well as a delivery, so one false duplicate against a genuinely new #1 would bury it for a decade at the shared TTL. Half a year caps that and still leaves an unchanged record re-posting at most twice a year against the forty times the 9-day window gave. Both writers route through _record_lifetime_state, which takes the ttl, so the migration path and the delivery path cannot disagree. The writer is _record_lifetime_meta; _sched_on_dedup stamps it too, deliberately, because a dedup verdict means the room already holds the story. Skipping without writing would only DELAY the duplicate to the day the window expires. Tests: test_song_streams_fires_on_change_durably_not_once_per_dedup_window, test_song_streams_state_is_stage_scoped_so_staging_never_blocks_production, test_song_streams_migrates_the_legacy_window_stamp_instead_of_reposting_once, test_song_streams_signature_is_the_leader_because_that_is_what_the_card_says, test_song_streams_state_expires_far_sooner_than_the_curated_boards.riaa_boards.py, the RIAA record boards (#2839, line B of #2800), the GENRE boards (#2860, owner ask 2026-09-02: the highest-certified singles and albums of one genre off the site’s own genre-filtered award search, RiaaClient.awards_by_genre, Diamond level and up, all acts, all time; Pop, R&B/hip-hop, Country and Rock, GENRE_BOARDS; the Latin genre is left out because it sits on the Latin program’s 600,000-unit ladder; each board on its own state key riaa_genre:<slug>:<format>, the take fenced to the one genre “as RIAA files it”), the FASTEST TO DIAMOND board (owner pick 2026-09-02: the roster’s Diamond singles ranked by the days from the first RIAA certification to Diamond off each title’s timeline, riaa.diamond_singles + riaa.diamond_path + riaa_boards.fastest_board, chart riaa_fastest_diamond; the timelines fill on the same per-slot budget as the roster walk, AFTER the eight genre searches (the genre boards land in three slots; the ~60 timelines take a day), and the board waits for every one, music_ranking reason=riaa_timelines_partial meanwhile; a title certified straight to Diamond has no road and is not ranked; the take fenced to “certified”, never “sold”, and to today’s top acts), plus the per-act NEW PLAQUES board (owner ask 2026-09-02, “an artist board for their new certifications in period”; the fold #2854 asked for): an act with MIN_PLAQUES (2) or more awards inside the 3-day window the newsroom’s cert lane reads gets one board, its titles at their new levels highest first (plaques_board, chart riaa_plaques:<act>, its own fire-on-change state per act, the SAME cached feed read), ahead of the evergreen boards in the slot; the newsroom cards only the act’s top cert batch cards awards (the tune knob, default 5) and stamps the rest seen (music_news_filtered reason=cert_batch_deferred), so a batch is at most five cards in the room and one board, never 59 cards (Rod Wave, 2026-08-31). The plaques board is MAIN program only (plaque_rows, #3308), the same scope diamond_board, genre_board, batch_cluster, artist_board and the newsroom’s cert lane (#2902) keep: the Latin program runs its own ladder (Oro 30,000, Platino 60,000, Diamante 600,000) on the same rows while Certification.award_name reads every row on the main one. It was the one sibling the earlier sweeps missed, and on 2026-09-14 it shipped a Tito Double P board to X whose eight rows were all program="LA" – a Latin 38x Platino (2.28 million units) drawn as “38x Platinum (Diamond)”, the main ladder’s words for 38 million. The count, the titles and the badge levels were all right; only the ladder was wrong. A Latin act now draws no plaques board at all. A sweep a WIRE reports late takes a second route into the same board (#3211, owner report 2026-09-12, “wheres the board”): the lane above reads awards RIAA certified in the last 3 days, which is right for a sweep the desk sees first and cannot reach one the wires notice months later. Measured live 2026-09-12: RIAA dated 50 Future awards 2026-07-16 and 4 more 07-15, the X accounts reported them 58 days later as “53 certifications”, and the recent feed held ZERO Future awards over 14 days – so the newsroom carded the wire’s COUNT as a bare number card with no titles under it, and the board lane never saw the sweep at all. cogs/music_news.py:_cert_batch_card closes that: a cert story whose FIGURE is a count rather than a level (music_news.cert_batch_count, deliberately narrow – “15x Platinum” and every other hero read as None) The board was DEAD from launch until 2026-10-14, and the Future sweep shipped THREE times as a bare “53” number card (2026-10-10 17:11, 2026-10-14 06:11 and 23:11, all to X; cert_batch fired zero times in the 30 days before the fix). Three faults stacked. (1) _build_card read the batch figure AFTER split_hero_words, which turns “53 certifications” into “53” + a pill, so no figure form except the 26-character long one ever reached the board; the read now takes the RAW figure on the first line. (2) The classifier writes the count as “53 certs”, “53 singles” or “53 new RIAA single certifications”, and the regex took only “certifications”/”plaques”; it now takes every shipped form. (3) A sweep carries no cert_id, so cert_story_keys built no exact key and only the 36h text window stood between reworded reports; a sweep now keys on act + count (music_news.cert_batch_story_keys, “future |
batch53”) in the same music_news_cert store, read before the research spend and written on the ship. A dry run on prod’s cached Future award search drew the board (54 awards, “top 10 of 54 shown”). is drawn as the plaques board instead, off the act’s OWN award search (riaa_boards.batch_cluster takes the awards within BATCH_SPAN_DAYS of the act’s BUSIEST certification date inside BATCH_MAX_AGE_DAYS (120) – busiest, not newest, because Future’s newest award that day was one feature credit dated 08-27 while his sweep was the 50 titles on 07-16). It reads the search CACHED ONLY (fetch=False): one search is up to 16 paced pages, ~100s measured, which a post cannot wait for, and the roster walk already holds RIAA’s top acts warm for a week – which is also why a climbing cert_batch reason=cold_cache rate points at _riaa_candidates, not at RIAA. The call is gated on the CERT kind, not on the figure alone: another lane can state a count of certifications and mean something else, and a record story about a career total would otherwise lose its record card to whichever sweep is cached. The figure is read BEFORE _build_card’s phrase guard, which clears anything over 24 characters – “53 new RIAA certifications” is 26. PREFER ABSENT four times over: the counts must agree within a factor of two BOTH ways (count_mismatch – too few and a three-row board carries a take saying 53, too many and the busiest-date rule found an OLDER sweep and drew it under a take about a new one); the story’s authority must be RIAA or unstated (other_authority, and cert_batch_count matches no BPI figure), because the board reads bot.riaa and wears its pill; the sweep is MAIN program only, since the Latin ladder is different while award_name reads every row on the main one; and every skip ships the number card the story would have had, so no card is lost. The last four all come from the Codex review. The plaques board rides the CERTIFICATION SOURCE’s experiment (music_riaa_certs, story riaa_plaques), not the record boards’ knob (owner call 2026-09-02, “the artist board should fire regardless”): measured that day, the certs lane was PRODUCTION and posted 14 Rod Wave cards to the room while the board, on the STAGING music_riaa_boards knob, reached only #bot-logs at 22:59Z. _riaa_board_units resolves both stages; the plaques boards are built when the certs knob is not OFF and carry that stage (their fire-on-change state is keyed on it), the record boards when their own knob is not OFF; _riaa_board_compose ships the first changed board of the slot, plaques first. riaa_plaques takes the wording-only dedup gate (it follows the act’s own cert cards into the room by design, so the full topic gate would read it as their restatement), ranks with the dated board lanes in _LANE_PRIORITY above the evergreen boards, and takes the row-arithmetic guards but is NOT all-time exempt (its claim is one window). Both readers credit an act through the one artist_watch.credited_names resolver. One recording is counted ONCE, on riaa.title_identity (#2884): RIAA lists the same recording more than once when the feature clause is punctuated differently or when one row carries it and another does not – measured live 2026-09-03, “‘Till I Collapse” three times at 8x, “Stan”/”Stan (feat. Dido)” both 4x, “What Do You Mean?”/”What Do You Mean” both 8x – and every count keyed on the raw title, so Eminem’s certified units read 29 million too high and he sat a place above Michael Jackson he had not earned. The identity strips ONLY a real feature clause (a bracket that merely CONTAINS a marker names a different recording: “1989 (Taylor’s Version)”, “All Of The Lights (Interlude)”, “Love The Way You Lie (Part II ft. Eminem)”), and the tallies de-duplicate HIGHEST LEVEL first so a folded group keeps its current certification. strip_title_feature is the display side of the same rule. The PER-ARTIST CATALOG boards (artist_board, charts riaa_artist:<act>:single and :album) are the career sibling of the windowed plaques board – one act’s catalog of ONE format, highest level first, that format’s certified units as the figure, MIN_ARTIST_TITLES (3) or no board. Singles and albums are separate charts and the rows carry no format sub (owner calls 2026-09-03: “remove the singles sub its pretty redundant”, “we shouldn’t mix album and singles on the same chart”, “ship 2 instead” – the same call made earlier on the Diamond board). The figure totals that format alone (units_tally(fmt=...)), so the two boards sum to the career total (Miley: 87.5M over 45 singles + 16.5M over 9 albums = 104.0M) and neither claims to be everything; the take is fenced to say the format word and never one that covers both, and the TOTAL rides the CAPTION because a list card draws no figure of its own. The windowed plaques board is split by format too now (owner calls 2026-09-04: “no single or album ever … that should either [be] in the board or implied”, “no plain ‘single’ string”): plaques_board(fmt=...) draws one act’s new SINGLES on riaa_plaques:<act>:single and its new ALBUMS on riaa_plaques:<act>:album, each with its own fire-on-change state and plaque_facts read. The floor is on the WHOLE batch, not per format (owner call 2026-10-10: “it should always post the board”, “separate singles & albums but both must post”): plaque_formats gives an act with MIN_PLAQUES (2) or more main-program awards in the window a board for EVERY format it holds, a one-row board included, and a batch of one single and one album ONE riaa_plaques:<act>:mixed board whose row subs open with “Single” / “Album” (the one board that names the format on a row, since its chart cannot). The desk passes min_rows=1; the wire batch card (music_news._cert_batch_card) keeps the default floor of 2. Before this, Don Toliver’s 2026-10-08 batch (six singles and the album “Life of a Don”) posted the singles board, and the album was under its own format’s floor, so it reached the room only as a cert card 26 hours later, after the cert lane’s 24-hour act cooldown (that key rename re-fired every open batch once, 2026-09-05: the state is keyed on the chart, so riaa_plaques:nickelback -> riaa_plaques:nickelback:single read as never posted, and Nickelback’s board went to the room a second time at 02:08Z, 11 hours after the first, with the SAME signature under both keys in kv_cache; Disney’s twin was stopped by the text dedup. The lesson: a fire-on-change board’s chart key is its memory, so a rename either migrates the stored rows or is announced as a one-time re-fire per open batch. A collaboration batch draws ONE board, not one per credited act: awards_by_act hands every act on a shared credit the same rows, so “Intro” 7x + “Ganga” 4x boarded as Peso Pluma at 23:04Z and again as Tito Double P at 00:02Z the same night, one signature under two act keys, and only the text dedup stopped the second. So _riaa_board_compose keeps a second state keyed on the signature alone (<guild>:<stage>:riaa_plaques:sig:<sig>, value = the chart that shipped it, lifetime_twin in the unit’s meta, written by _record_lifetime_meta on the same confirmed delivery or dedup close-out as the act’s own key); a plaques board whose awards were ALL already shown, under any act or chart, is skipped before compose, music_desk_scored phase=riaa_plaques shipped=false reason=plaques_twin, detail.twin the chart that shipped them. Since #3958 that state is one key PER AWARD, riaa_plaques:award:<award id>:<level> (rib.award_keys), written on delivery as lifetime_awards. One board per act and format in 7 days (#4683). The per-award state lets a board through when ONE award in it is new, and the board then redraws every award in its 3-day window. Zach Bryan and Wiz Khalifa each posted their singles and album boards on two days out of three (2026-10-21 to 10-23). _riaa_board_compose now skips a plaques board when <guild>:<stage>:riaa_plaques:recent:<chart> exists (music_desk_scored reason=plaques_recent); the mark is written on a confirmed delivery (lifetime_recent) with a 7-day TTL (_RIAA_PLAQUES_ACT_GAP_SECONDS). The other rule offered, “post only when the board has an award not in the last board”, is the per-award rule that already ran, so it does not stop these repeats. 7 days is longer than the window, so the next board holds only awards no earlier board in the window showed. The act’s other format still posts. It replaced a per-chart key (the board signature) because the charts change shape as a batch changes, and Codex found four ways that re-posted or wrongly blocked a board: a collaboration credited to two acts; a mixed board splitting when a second single lands; separate boards merging back into a mixed one when an older award leaves the 3-day window; and two unrelated acts’ one-row boards with the same title and level (the state lasts ten years). Award ids are the same in every shape and two acts never share one. The old :sig: key is still read for boards of two or more rows, so a twin shipped before this change stays a twin, and a board skipped because its OWN chart state matches backfills its award keys when its own “awards written” mark (<chart state key>:awards:<signature>) is missing (_backfill_plaque_awards), so a board shipped before the change cannot be re-shown by a later shape of its batch); the caption (“singles certified Sep 4, 2026”) and the headline (“… certifications on singles …”) carry the format, the take is fenced to say that word and never one that covers both, and NO ROW ever names it. The cost the open question predicted is accepted: a batch of one single and one album is two newsroom cards and no board. Its row sub is the title’s RELEASE DATE and DAILY STREAMS (owner ask 2026-09-04 on the Disney card, “replace ‘single’ with the actual release date maybe streams per day”): plaque_sub draws “Jun 14, 2015 · 1.2M/day”, either half alone when the other is missing, and NOTHING when neither resolved – an empty sub, never the format word and never a guessed date. The cog resolves a PlaqueFacts per drawn row (_plaque_facts, keyed by plaque_key = title_identity + format, so a single and an album of one name each keep their own date) BEFORE building the board, and only for the rows plaque_rows will draw, so an undrawn award costs no lookup. The date is MusicBrainz’s first-release-date (chart_ages.first_release_date, the release-GROUP’s earliest release, the field the oldest-songs board already reads, at the precision MusicBrainz holds – “Aug 21, 2001”, “Aug 2001” or “2001”, release_label), memoized per process (_first_release_cache, one paced ~1.1s read per title; a no-match is memoized for 2h, and a failed read (a 503) is not memoized at all, #2965 – 108 of 5209 MusicBrainz reads failed in the week to 2026-10-25, and each one used to freeze its row undated for the life of the process), looked up under the row’s own credit first and the act second (RIAA files a soundtrack cut under a cast cell no catalog credits). NOT the Deezer/iTunes release_age resolver the debut gates share (#2074), and this was measured, not assumed: the first dry run wired it, and on the live 2026-09-04 feed Deezer dated Nickelback’s “How You Remind Me” (2001) to a 2013 best-of, “Far Away” (2006) to a 2024 live re-release, Josh Turner’s “Would You Go with Me” (2006) to a 2011 “Best Of”, and six of Nickelback’s ten rows to one compilation date – because Deezer’s catalog for those acts holds the reissues and NOT the original albums, so no “earliest of N hits” rule could recover the date either. Right question for a debut gate (“is this out yet”), wrong one for a certification card, where every title is years old by construction. MusicBrainz dated 12 of 26 rows on the same batches, every one correctly (How You Remind Me 2001-11-12, Your Man 2005-07-26, It’s Been Awhile 2001-07-03); a title it cannot date – the Disney / High School Musical soundtrack cuts, the kids’ YouTube channels – draws an empty sub (prefer absent over invented), never Deezer’s reissue date and never the format word. An ALBUM award dates off an Album group only (#4281): _plaque_facts passes album=True to first_release_date for an ALBUM row, the same _matching_pick(album=True) rule as the Billboard 200 (#4235), and the memo key carries album:. Before it, an album award took its title-track single’s date (“Sweet Boy” by Malcolm Todd 2023, the album is 2024; “Born To Die” Dec 2011, the album is Jan 2012). Measured on 189 real RIAA album awards (the recent feed Jul 28 - Oct 15, the top-100 albums list, ten named albums), with the Haiku check on: 18 rows better (15 single dates replaced by the album date, 3 undated rows now dated), 0 worse, 1 wrong before and after (“The Beatles”, the 1968 album is not in MusicBrainz’s top 8 search results). The daily rides StreamTotals.song_daily / album_daily (chart_boards.page_daily_streams, the same title fold as the lifetime totals, on the SAME cached kworb page), read only for an act the kworb leaderboard knows by exact name – Disney is not on it, Nickelback is (4 of 10 rows carried a daily on the dry run) – so a catalog act draws dates alone. The plaques board’s signature still reads label + value only, so a daily figure moving never re-posts a board. One plaque_facts event per board (ok = at least one row dated) is the fail-open signal, on the ops monitor as riaa_plaque_dates; the MusicBrainz reads ride reference_fetch source=musicbrainz. The compose block names the bracket as the release date and the daily so the take cannot read a daily as certified units, and the SCOPE line says a release date is not the certification date. It is MANUAL only: scripts/artist_sweep.py renders it beside the chart cards (--no-riaa skips the read), for a question about one artist (owner ask 2026-09-03, after a reply on X asked for Miley’s totals; her chart sweep held one Billboard row while RIAA had 87 million units over 36 titles). Its take is fenced to the act’s OWN career: never a comparison, never a record. An act’s records are read across every NAME they are filed under (RiaaClient.awards_for_act over artist_watch.performing_names): RIAA files a PERSONA’s catalog under the persona, so one search misses it – 16 awards sit under “Hannah Montana” plus two under “Miley Cyrus as Hannah Montana”, 17 of her 104 million certified units, invisible from her own name (owner call 2026-09-03, “yes include hannah montanna”, “use all our folding logic across these charts”). The alias table in artist_watch carries the pair beside “Ye -> Kanye West” and credit_matches now reads it too, so the single-name form and the watchlist form agree on the same credit, one-directionally (a credit under the persona is hers; a credit under her name is never the persona’s). RIAA’s own “X AS Y” artist cell credits BOTH halves (riaa._PERSONA_AS_RE); “as” is not a credit connector anywhere else, because making it one globally would re-fold every stored identity containing the word. A merged read returns None when ANY of its searches comes back short, so a partial catalog never serves as the whole one. Verified 2026-09-03: her 104.0M reconciles exactly against RIAA’s own tally rows (103.0M across seven artist-cell groupings) plus her 1.0M feature on Mark Ronson’s single; the “VARIOUS |
Hannah Montana” soundtrack is correctly not hers. The two evergreen leaderboards read LIVE off RIAA’s own tally pages through utils/riaa.py – the highest-certified ALBUMS of all time (the site’s top-tallies list, certified units in millions, “40x” = 40 million) and which of the desk’s top acts hold the most DIAMOND SINGLES, and the most DIAMOND ALBUMS as a separate board (owner call 2026-09-02), plus the MOST PLATINUM SINGLES (level_tally at floor 1, chart riaa_platinum_singles: the Diamond board one rung down, a Diamond single counted as a Platinum one) and the MOST CERTIFIED UNITS (units_tally, chart riaa_units: every credited title once at its current level, Gold half a million, Nx Platinum N million, singles and albums together, main program only, the way RIAA’s own per-artist tally adds them, the figure in millions to the half million; owner ask 2026-09-03, both LAST in the slot’s order and off the same cached search, so they cost no read) and the MOST PLATINUM CERTIFICATIONS THIS YEAR (year_tally, chart riaa_year_awards:<year>: the titles each act was certified Platinum or higher on this calendar year so far, any format, a first Platinum and a 16x alike, a Gold not counted, owner call “should be ranked by platinum”; the key carries the year so the board restarts each Jan 1 on a fresh state; the take fenced to ‘this year … so far’, never a career total and never releases; owner call 2026-09-03, “that should be different from career total”, ahead of the two lifetime boards in the slot; the lifetime captions read ‘all time, as of |
riaa.display_title stays the rule and the fallback. Just before a board composes, riaa_boards.recase_board sends the card’s row labels and subs to ONE cached Haiku call (ClaudeClient.recase_titles, purpose riaa_recase, riaa.recase_texts, 30-day riaa_recase cache). riaa.recase_valid keeps a returned string only when it equals the rule string case-insensitively, row by row. So “Sza” reads “SZA” and “Good 4 U” reads “good 4 u”, and a model that changes a letter changes nothing. The headline, hero and caption get the same swaps. signature folds case, so a recased board does not re-post. Wired in cogs/music_desk.py:_riaa_board_compose and the newsroom cert-batch board.riaa.title_identity stays the deterministic key. riaa.resolve_title_collisions runs on the plaques window (and on a cert-batch cluster) before any count. It asks Haiku (ClaudeClient.riaa_same_release, purpose riaa_same_release, 30-day riaa_title_collision cache) only for a group whose base titles fold the same and whose whole titles do not. A DIFFERENT verdict puts the row’s (whole-title fold, artist fold, format) in _SPLIT_TITLES, and title_identity adds a suffix only for a caller that passes that row’s artist and format, so a verdict never splits another artist’s rows. recase_board recases subject in the same call, because the compose topic and the art lookup read it. A model error, an unreadable answer or no client keeps the merge, which is the behaviour before the fix. Measured on the 14-day feed: one group (the two BLESSINGS singles), one call.riaa_boards._bare_title drops a bracket from a Diamond/fastest row only when it does not name the recording. _IDENTITY_BRACKET_RE is the keep list: “‘s Version”, remix / mix, live, acoustic, unplugged, part / pt., interlude, reprise, intro, outro. A bracket that opens with a credit or a source (feat., ft., with, from) is always dropped, so “(feat. Little Mix)” is not a mix. An edition tag (Deluxe, Remastered) is dropped too: RIAA certifies the release, so the tag only makes the row longer. Since CB-23 (#4557) Haiku decides each clause a board prints (check_titles, purpose riaa_bracket, KEEP / DROP, cached 90 days, at most 25 new clauses a pass). The two lists are its examples and the fallback for any clause it has not read. Measured on all 927 real clauses, 2 runs: 4 better (“(STRIPPED)”, “(EN VIVO)”, a mashup, “(CHARLIE HEAT VERSION)” name a different recording), 3 worse (a subtitle or edition tag kept, the same recording), 1 flip.chart_credits.py, the producer board (owner ask 2026-08-10, “how about by producers”; the sibling songwriter board was cut on owner ask 2026-10-01): joins a Hot 100 week to Genius’ structured producer_artists/writer_artists per row. Per-song credits live in a durable cache (kv_cache: chart_credits, 45d; verified misses 7d) filled by a paced, budgeted live resolve (20 lookups/build, 0.3s pace) — steady state is the week’s debuts. The join’s fabrication fence: a Genius hit is used only when its primary artist fold-matches the chart credit (a cover’s credits on the wrong row is the one way this board lies); a confident mismatch caches as EMPTY credits. The performing artist is NOT counted on their own row (owner ask 2026-08-19, “exclude the artist”): most artists write (and many self-produce), so a self-writing star would top the board off songs the chart already prints their name on — but this board exists to surface the people it does NOT print. The tally drops the row’s own act (credit_matches against row.artist, so a featured act on its own row drops too); the SAME person still counts on other acts’ rows, so it is per song, not a global block. Measured on the real Hot 100 of 2026-08-22: both boards (producer, and the since-cut writer board) flipped from a performing artist (Ariana Grande, 11) to a real producer/writer (ILYA, 11) as leader, and stayed full 10-row boards well above the floor. Boards build only past COVERAGE_FLOOR 0.8 of the chart (below it: silence + cache-filling, never a partial-chart claim); leader needs 3+ credited songs, the field 3+ people. Emits chart_lookup (source=genius) per build; per-call telemetry rides reference_fetch. Desk lane _lookup_board_units, experiment music_lookup_boards, story lookup_board. Dedup keys on the RANKING, not the week (owner ask 2026-08-19, “only ship on changes to rankings, like youtube views”): the lkb: key ends in ranking_signature(board) (a 12-char digest of the ordered person=count rows), so a new chart week whose top credits are unchanged does not repost — the board ships only on a ranking change, the way the watch board keys on its moving songs. The 9-day _WEEKLY_BOARD_DEDUP_HOURS window still bounds it: a ranking that stays identical for longer reposts once past the window. The sibling oldest-songs board keeps its per-week lkb: key. Ships STAGING (external-join accuracy is the audition question).chart_ages.py, the oldest-songs board (owner ask 2026-08-10): MusicBrainz release-group first-release-date per chart row (Hot 100 + Billboard 200), the catalog/seasonal read weeks_on cannot make. Same shape as chart_credits: durable per-song cache (chart_ages, 120d / misses 14d), paced by the ONE MusicBrainz host limiter in reference_types.fetch_json (one read per 1.5s for every caller, one retry on a 503, #4168) under MB’s ~1 req/s etiquette, 15 lookups/build, COVERAGE_FLOOR 0.85, and a title+artist double fold-match before a year is accepted (a same-titled cover’s 1962 on a 2026 single is the board’s one fabrication mode; _matching_year takes the earliest matching group so a reissue’s 2004 never shadows the 1985 original). The Haiku check on the pick (#3444): containment either way is loose (a row “Classic” by Drake found Bob Drake’s 2015 “An Evening of Classic Guitar Stylings”), so a pick that is not fold-EQUAL on title AND credit goes to one cached claude.classify SAME / DIFFERENT label (purpose=chart_age_check, confirm_pick). DIFFERENT caches a miss; no verdict leaves the row undated and caches nothing. The resolver’s caller passes claude=self.bot.claude; with no client (scripts, tests) the old rule stands. The plaques card’s first_release_date takes the same check, and passes album=True for an ALBUM award (#4281); the debut gate (release_age, exact=True) does not pass a client. Years cached before #3444 stay until their 120d TTL. An album chart dates off an Album group only (#4235): resolve_week_ages passes album=True on a chart whose unit is “album”, so _matching_pick keeps only primary type Album (Compilation, Live and Soundtrack albums stay; Remix, DJ-mix and Demo drop, _NOT_THE_ALBUM) and an album with no Album group stays undated. Before it, an album row took its title-track single’s date (“One Thing At A Time” dated 2022, the album is 2023; “Diamonds” by Elton John dated 1974, the compilation is 2017). Album rows cache under album:<song_key>, because a song and an album can share one song_key. Measured on the live Billboard 200 (2026-10-17): 28 of 200 rows change date, 8 change year, 0 lose a date. A self-titled record has its own search (#4295): when the whole title folds to the act’s name (“The Beatles” by The Beatles), the search asks for status:official groups on a 100-result page instead of 8, because a self-titled query matches every group by that act (the 1968 White Album was result 13 of 142). The match is then exact WITH brackets, drops bootleg-only groups, prefers an original group (no secondary type) over any compilation, live set or mixtape, and leaves the row undated when two candidates of one type differ in year (Keith Urban has two albums named “Keith Urban”). Measured on 50 real self-titled album rows (RIAA album awards + the Billboard 200, 2026-10-17): 11 move to the right date, 2 move from a wrong date to no date, 0 get worse; every other row sends the same request and takes the same pick. Every row drops a bootleg-only group (#4353): before it, only a self-titled row did, so containment plus the earliest-date rule let a bootleg date any other row (“Views” by Drake took the bootleg “Views From the 6: The Prequel”, 2016-04-16). _matching_pick now applies musicbrainz.has_official_release to every candidate: a group counts when one release is Official or carries no status. The reference lookup (musicbrainz._search_release_group) uses the same rule: when its first group is not official, a later official group answers only when it has the same folded title and credit, else nothing does (in 47 real searches with a non-official first hit, 40 had that same record and 6 had only a different one, such as “S&M” after “S&M Sampler”). Measured on 2275 real reads (Billboard 200 + Hot 100, last 31 days, and 1437 RIAA album awards, 2026-10-24) with the real Haiku check, 3 runs each: 20 album rows change, 3 move from a wrong shown date to the right one, 14 from no date to the right one, 1 from a wrong date in 1 of 3 runs to no date, 0 get worse. The song board and the debut gate change on no row. The board also needs a real story: oldest row ≥ MIN_AGE_YEARS 20 back. Emits chart_lookup (source=musicbrainz). Same lane/experiment as the credit boards.sports_boards.py, the sports stats BOARDS (#2268; each league accent obeys chart_cards.ACCENT_MIN_LUMINANCE, and #4122 moved MLB from the retired coral to peach and WNBA from the retired pink to rose, the same moves #2295 made for country and latin; the sports slice of brainstorm #2262 – the music slice #2264 is the build pattern). Pure builders: rows in, a SportsBoard out, no network and no Discord. Seven board kinds over one league registry (LEAGUES: NBA, WNBA, NFL, MLB, NHL + six soccer leagues, each carrying its ESPN path, its /menu watched-sports key, its accent and its curated stat categories) – league_table_board (the top of a table), streak_board (the longest active runs in ONE direction, called once for win streaks and once for loss streaks so they are two separate cards; off the SAME standings read so it costs no extra fetch), leaders_board (a season stat category), title_odds_board (championship futures by implied probability), upcoming_board (TONIGHT’s slate with each game’s posted line), player_board (tonight’s player prop lines OR the season’s names to watch) and game_totals_board (tonight’s slate ranked by the book’s over/under). The GAME + PROPS board (game_props_board, #market-siblings) is the sports family on the sibling spine (utils/market_siblings.py): it pairs ONE game with its player props on a single card – the matchup as the hero, the favourite’s moneyline as the figure, and one prop per stat (the biggest line) as the rows. A game and its props are siblings of the same subject (the game), so _game_props_boards fetches BOTH halves from SGO (get_event_odds + get_player_props), keeps only props for a game upcoming tonight, and hands them to sibling_groups(..., GAME_PROPS_FAMILY, ...), which joins them on the shared SGO event id (variant “game” vs “prop:cogs/sports_boards.py, the sports stats board LANE: a ScheduledPoster subclass that fetches one league per slot, builds every board that league supports, composes and posts the best one. ONE LEAGUE PER SLOT is the pacing decision – the registry covers eleven leagues and fetching all of them every tick would be ~90 requests for at most one card, so a slot takes the league that has gone longest without posting (read off the sports_boards_keys dedup history) and one fetch set feeds everything: the standings read serves the table AND the streaks, the day scoreboards serve the offseason gate. Five module-level DurableCaches carry it, each TTL’d by how fast the thing behind it actually changes – standings 3h, season leaders 6h, a PAST day’s scoreboard 7d (immutable once its games are final, and the one that turns a 77-request liveness window into a handful), TODAY’s slate 15 min (it carries live book lines), the futures field 12h. It shares the sports CHANNELS with the news desk but keeps its OWN slot counter (sports_boards_slots), so a busy news day cannot silently spend the boards’ daily slots. Pacing is PER SPORT (owner steer): each league gets _MAX_BOARDS_PER_LEAGUE_PER_DAY (4) cards a day of its own, so an in-season league works through its whole set in about two days rather than waiting a week for its turn in a shared queue – and the /menu watched-sports filter therefore CONCENTRATES cadence rather than only removing leagues, since each surviving league keeps its own four. A global daily ceiling (8) sits above it as a flood guard for a guild watching everything, and the slot pool usually binds first. Within a league the order is LEAST-RECENTLY-POSTED FIRST, notability only as the tie-break – ordering by notability alone was the first version and it starved most of the lane, because notability is dominated by the board KIND rather than by how newsworthy that board is this week: measured live 2026-08-10, MLB scored results 90, title odds 64, leaders 60 and its table 45, so MLB would have posted its top-scoring board every time its turn came and its table would never have posted at all (the results board that topped that measurement is since removed, #3237). A board that has never posted has no date stamp, and “” sorts ahead of every date, so new boards lead the queue (the same rotation music_desk’s platform + artist boards use). Two experiments: sports_stats_boards (the free ESPN boards) and sports_odds_boards (the futures, slate and game+props boards, which make a price claim and spend an Odds API credit), both STAGING by default so a mod auditions them in #bot-logs first – and split so a guild can run the free lane without ever touching the metered source. Dry run: python -m scripts.dryrun_sports_boards (renders every board’s real PNG; COMPOSE=1 runs the real compose + self-gate too). X mirror (#twitter-post-failure): on a PRODUCTION room post the board card is also mirrored to @tootsiesbar through the shared crosspost path (utils.x_crosspost.crosspost_with_quote, surface="sports_boards"), so the card PNG + take ship to X with no market link. The X-only sentinel lane runs too — an empty channel picker stops the room copy but still tweets. It is OPT-IN on the /menu X picker (OPT_IN_SURFACES): a price-claim external surface stays dark until a mod ticks it on, and while the x_crosspost experiment is STAGING it auditions the tweet text in #bot-logs first. A STAGING sports board (auditioning in #bot-logs, not a real room post) never crossposts. No new event — it rides the existing crosspost event with surface=sports_boards. Coverage (#2765): the lane posts only the leagues in the room’s watched_sports (covered_sports); unset or empty means NO leagues, the same reading the commentator and the betting surfaces use. A read error keeps every league for that tick.hits.py, the HITS Daily Double forecast client (#1981) — the industry FIRST-WEEK projections + Mediabase radio feeds, read off HITS’ public Sanity GROQ API (project 8aky18h3, no key, ~35ms JSON; the access reasoning + attribution obligation live in docs/BILLBOARD_SOURCES.md §5b — every HITS number is credited to them by name, baked into format_projection_block). The BE-FIRST half of the chart frontier: midweek-20 + hits-top-50 land ~5 days before billboard.com flips, and projected_chart_date (tracking-week Thursday + 9 days, verified against the Gracie Abrams #1 call) maps a projection to the exact Billboard week it is a call ON — so scoring a called shot is billboard.chart(week=proj.chart_date), a lookup. A projected DEBUT is confirmed by RELEASE DATE, never by the document’s own column (#2038): HITS carries no last-week position for a catalog RE-ENTRY either, so new_entries() mixes real debuts with albums that fell off the chart and came back — live on the building chart dated 2026-07-30, 4 of its 7 no-last-week rows were re-entries (Drake’s VIEWS 2016 at #44, Taylor Swift’s FOLKLORE 2020 at #49, Pitbull’s GREATEST HITS at #50, Megan Moroney’s CLOUD 9 at #47). The plain rank band (40) hid all four, but the WATCH band (#2007) reaches rank 100 for exactly those artists, so the lane could have called a ten-year-old album as a projected debut. music_desk._pick_called_shots dates each candidate against Deezer and fails CLOSED on a miss — the same fix and the same fail direction as the Apple debut lanes (#1887), whose class sweep never reached HITS. Emits called_shot with the disposition (fresh |
stale | no_date). format_projection_block, the background block other lanes read, states what the column actually supports rather than calling those rows debuts, because its rows are NOT confirmed — the earlier wording put the false claim straight into a prompt. Radio rows carry their FORMAT (by_genre is the only correct way to read a rank — the docs concatenate ten format charts each ranked 1-50; flattening made ten songs all read “#1”, caught live, pinned by test). The live read takes the newest document that PARSES (#3825): projection asks for chart_entries, the chart_data TSV and _id on the newest three documents, and skips any that parse to no rows (hits_fetch reason=newest_doc_empty). Before, it read only [0] and only chart_entries, so a new document with empty entries made the whole chart return None. Columns read by LABEL never position; missing numbers stay None never 0. Guards mirror billboard/kworb (limiter + breaker integration=hits + retry + durable cache at each source’s real cadence); hits_fetch health in the ops monitor; a /debug/integrations probe catches the dataset going private. The called-shot grounding handoff keeps its selected fact (#2253): compose_inputs keeps the general chart context at ten rows, but appends the exact selected row when it ranks lower, so both the composer and self-gate see its rank, units, artist, and album through the lane’s rank-40/50 reach. The CALLED SHOT lane is built on it (Lane B, the desk’s SECOND-priority lane): a projected DEBUT inside the top 10 becomes one card per release per projected chart week, ranked behind the printed chart (a harder fact) but ahead of everything else because it is the most PERISHABLE thing the desk carries – its whole value is the lead over Billboard’s print, and it is worth nothing once the chart lands. The dedup key carries the projected CHART DATE rather than the doc, so the Tuesday midweek wins the post (being first, mechanically) and the Thursday building chart can only ADD releases the midweek’s top 20 didn’t reach. A MIDWEEK stops being a call the day after its tracking week closes (hits.midweek_lapsed, one day of grace for the Eastern-time publish). The building chart replaces it on Thursday or Friday; when HITS holds the building chart, the midweek is still the freshest document, but it no longer states HITS’ read. On 2026-09-26 (a Saturday) the lane posted “Bass Persuades is tracking for No. 2” off the midweek, one day after HITS posted “This Week’s #1 Is… We Don’t Know” (a three-way tie) and while Kalshi priced the album at 86% for #1. latest_projection now returns None for a lapsed midweek (every lane that reads it goes quiet: the called shot, the projection boards, the standing strips, the newsroom check, /ask), and the market drop’s rank rung skips it. It does NOT fall back to the older building chart, which is a call on a week that already printed. The skip emits hits_fetch reason=midweek_lapsed. It carries its OWN rollout stage (music_called_shot, default STAGING) because it is the one desk lane making a falsifiable PUBLIC claim – per-unit staging already works since the base poster routes on each unit’s meta["stage"], so a mod auditions calls in #bot-logs without muting the desk; OFF skips the lane with no HITS read and no compose. The Kalshi projection lane YIELDS to it (_hits_covered_albums): both estimate one quantity – the release’s first-week number – so posting both reads as the desk repeating itself; HITS is the industry’s own tracking estimate where Kalshi’s is a market’s guess at what that estimate will say, and the market number isn’t lost (it still drives market_drop/market_alert). Fails OPEN – an unreadable HITS leaves the older lane exactly as it was. The projection lane never weighs the two sources against each other (owner steer 2026-09-16: “never compare kalshi and hitsdd numbers”). _PROJECTION_FRAMING used to carry a third lead – “when the market and the industry tracking genuinely DIVERGE, that gap is the lead (‘market’s bearish at X, industry’s tracking higher at Y’)” – and it was the single biggest producer of the banned shape: 45 of the 55 comparisons in 30 days of real composes came off this lane, most of them shipped. The fix is the MATERIAL, not a rule. (1) The DIVERGE lead is cut. (2) _perplexity_context no longer ASKS for the industry units projection: its projection query used to request “first-week sales/units PROJECTIONS” and name Hits Daily Double as a source for them, and now asks for the reported day-one / day-two streaming partials only. That partials pull is AGE-GATED (#3613, owner report 2026-09-22). A day-one figure is the release’s state on day one, not on day three. The Miley Cyrus “Bass Persuades” card shipped to X on the release’s third day reading “opened at 6.2 million Spotify streams, with the market forecasting ~44.2K first-week album-equivalent units” – 6.2M was the first-full-day number @chartdata published on 2026-09-19, and the compose held no CURRENT stream figure for the release at all, because _streaming_context resolves the album’s biggest TRACK and no lane read the album. So past _PARTIAL_MAX_AGE_DAYS (2) the pull is not made and the general artist background runs instead – the same lesson as steps (1) and (3): the model states what the prompt hands it, so the stale figure leaves the context rather than getting a rule telling the model not to lead with it. An UNRESOLVED age keeps the partials (a brand-new release often has no date yet). THE GATE NEEDS AN AGE THIS FAMILY CAN PRODUCE, and the reading’s own age is not it (Codex on #3615). resolve_reading_age_days returns None for every first-week UNITS reading on purpose: release_stage calls it a debut BY CONSTRUCTION, so the stage note needs no date and the whole family skips the lookup. The first cut read that None as “still opening” and kept asking for the day-one figure forever, which left the reported card exactly as it was – verified against the live reading, KXALBUMEQUIV-BAS26SEP24 resolves an age of None on day four. For a first-week market the window START is the release day, so luminate.days_into_window answers it off reading.period (“September 18, 2026 -September 24, 2026” -> 4) and the compose passes that as partial_age_days, keeping the reading’s age for everything else. A None skips the partials, it does not keep them (Codex on #3615, round two): None also means PRE-RELEASE, because the sales slate deliberately carries markets whose window has not opened – three were live on 2026-09-22, the September 25 - October 1 week – and asking what first-day streaming numbers an unreleased album has posted invites the answer to come from a same-titled SINGLE, which is the confusion resolve_reading_age_days already guards against on this very release (the “Bass Persuades” single was out two weeks before the album). The partials now need a POSITIVE reading that the release is out and still in its opening days, dated on the desk’s HOUSE CLOCK – days_into_window lives in music_markets beside window_closed and defaults to house_today(), because between 00:00 and 04:00 UTC the UTC date is already a day ahead of Eastern and the evening before a window opens would otherwise read day 0 for a release that is still unreleased in New York. And the FALLBACK query is its own branch: the general music_desk template asks for “their latest release and its first-week / chart / streaming performance … with the real numbers”, which would hand back the very opening-day figure the gate just refused, and strip_industry_projection only cuts units projections. Past the window the lane asks for the comparative and milestone context with the opening-day figures excluded by name. The other half is a new free block, _album_streams_context: the release’s CURRENT album-wide Spotify total AND its latest tracked day, off kworb’s albums page via StreamTotals.album_page_streams – Spotify’s own published per-release figures, no metered slot. Measured on the live page that morning, the album stood at 31,392,212 streams with 2,706,349 on the latest day, the two numbers @PopStats02 posted for it, so the data was there the whole time and nothing read it. The METRIC decides who gets the block, never the page match. The first cut gated on age and treated an albums-page hit as proof the subject was an album; a TITLE TRACK breaks that, and this release is the case – a recent KXSONGSTREAMS reading for the SONG “Bass Persuades” matches the ALBUM row, and the block would have carried 31,392,212 album-wide streams as the song’s number (Codex on #3615, reproduced live). So the gate is a LIVE ALBUM-EQUIVALENT UNITS reading and nothing else: is_album_equivalent_metric, which is deliberately narrower than is_sales_pair_metric, because streams convert into album-equivalent units at about 1,500 to one (the ratio the block states) while Pure Sales counts copies bought, where a stream total is no yardstick and naming the other metric in the context would trip metric_foreign_word and cost the whole card; and not SETTLED, because a resolved reveal reports a final Luminate figure rather than a market line, which would make the block’s own “state the market’s figure” false. The block also carries the same newer-releases gate _streaming_context does. ONE stream scope per card, chosen in ONE place (_stream_context_pair): handing the model the album figure AND its biggest track’s got them conflated – n=8 on the real compose produced “the lead single alone already sits at 31.4M Spotify streams”, which is the album-wide number – so the pair returns exactly one block, and the FREE lookup goes first so the metered Songstats call is spent only when kworb cannot answer (the first cut ran both concurrently and discarded one, burning a slot from the ~18/day cap that the SONG cards share). It rides its own task rather than a slot in the context gather: one heterogeneous element makes asyncio.gather’s result a union across every slot. The RANKING of one figure against the other is carried by a CHECKER, not by the prompt (output_checks.has_stream_unit_comparison, Codex on #3615). The take kept calling the market low against the stream count – “well below the album’s 31.4M Spotify streams”, “the market’s lowballing this one” – and that is a FALSE claim, not only a banned verdict: streams are ONE weighted input to units (Billboard’s own rate is 1,000 paid / 2,500 ad-supported per unit, docs/BILLBOARD_ALERTS.md), counted across every service, and units also count SALES, so 31.4M Spotify streams is worth something like 13-31K units against a 44.2K forecast. Every number in the line is grounded, so the self-gate scored the measured ones 0.82-0.92. Four wordings were measured on the real compose, n=8 each, and none both tells the truth and suppresses the shape: a stop condition ranked 3/8, a neutral exemplar 4/8, a stated ~1,500-to-1 conversion 0/8, and the CORRECTED conversion 3/8 and 4/8. The 0/8 arm is the one that stated a rate which is not real – and at n=8 the gap between 0/8 and 3/8 is not a strong result either, so that arm was over-read at the time. The prompt now states the true relationship and the checker carries the list, the #1140 lesson this repo already applies in has_market_decline and has_cross_source_comparison. Validated the same way they were: 12 of 12 measured ranking lines caught, 0 false positives on 15 measured good lines and on 3,000 real desk composes from 30 days. Then a reword of that same sentence put it back to 2/8, re-measured after the metric gate landed: leading with “The market’s figure counts UNITS or SALES and these count STREAMS” named the comparison and the takes narrated it (“well below the 31.4M-stream album”, and one recited the caveat itself – “that’s the sales forecast, not the same yardstick as the play count”, the #3363 hedge-in-the-voice shape). The measured sentence went back verbatim and the narrower gate carries the pure-sales accuracy instead, at 0/8 again, gates 0.89-0.90. A sentence measured at 0/8 is a fixture, not a draft – reword it and you re-measure or you regress. (3) Sonar answers in free prose and returns the figure anyway, so perplexity.strip_industry_projection cuts that sentence out of the background before the compose sees it. The three-arm ablation is why step 3 is a stripper and not a sentence of prompt (live A/B, real prompts, n=4 per arm): with the lead cut and the raw background, 4/4 compared; with a clause telling the model not to, 0/4; with no clause and the background stripped, also 0/4, at the same gate scores. Removing the material matched the rule exactly, so the rule was scar and it went (owner: “cut scar”; docs/PROMPT_OPTIMIZATION.md: fix the cause, do not stack a rule). output_checks.has_cross_source_comparison is the deterministic guard under all of it, wired through music_desk._unshippable on every lane and through market_drop. It EXEMPTS a line that names the settled Luminate count, because a forecast measured against the real result is what the settled lanes exist to report – without that it flagged the reconciliation lane’s own legitimate shape. Measured at 87 true positives and ZERO false positives over the 9537 real desk composes of the last 30 days. Sales projections – both metrics, one lane, same window (#2515/#2519, owner steer 2026-08-23 “always do both”, 2026-08-24 “separate cards, same window, not different days”): a release carries two SALES ladders – Pure Album Sales and Album-Equivalent Units (“overall”) – and they must post as two labeled cards TOGETHER, on one cadence. A single lane drives both: _compose_sales_projections -> _pick_sales_projection -> music_markets.sales_projection_slate, which GROUPS both metrics of a release and returns the top-N releases by activity. The grouping is now the GENERAL sibling spine (utils/market_siblings.py, the “search for siblings and post the pair” ask): sales_projection_slate is sibling_groups(readings, MUSIC_SALES_FAMILY, top_n=…) – the group-by-shared-key / one-per-variant / rank / cap loop lives once on the spine, and the MUSIC family supplies four pure functions (join key = release_pair_key, variant = metric_slug, keep = _sales_projection_keep, activity = ladder rungs). Music is the first family on it; a SPORTS game↔props family plugs in the same way with an event-id join, no copy of the machinery. The picker emits one release’s cards per slot (both metrics), each on its own daily key proj:<release>:<metric>:<day>, so the pair refreshes together each day and the slate’s ranking cycles the rest across the day’s slots. Live SALES markets are EXCLUDED from the news lane (_pick_readings skips them) so they only post here – that is what stops the earlier split where album-equivalent refreshed daily on the projection lane while pure sales sat 10 days on the news lane. Robustness: (a) the two markets GROUP on release_pair_key – the shared event-ticker suffix (KXPUREALBUMS-ANG26SEP24 and KXALBUMEQUIV-ANG26SEP24 both give ANG26SEP24) — and a RE-MINTED event’s trailing letter is stripped (KXALBUMEQUIV-WES26SEP17B → WES26SEP17, live 2026-09-17: Jhené Aiko’s units board was re-listed under a “B” ticker for the same release and week; with the letter kept the two ladders read as two releases, so the slate carded them separately and readings_for_subject returned [] as ambiguous for the X mention reply and the music_forecast tool) – NOT the subject text, which Kalshi names inconsistently across the two series (artist-only “Angel” vs “Angel – Lizzy McAlpine”, “WILDCHILD” vs “WILD CHILD”; a subject match missed 3 of 9 live pairs, measured 2026-08-23); (b) the ALBUM-EQUIVALENT metric must clear is_confident_projection (a wide-band “overall” number the market doesn’t stand behind is dropped), while PURE SALES keeps its looser handling (no confidence gate – a thin pure-sales number still posts). Since #3546 the LOOKUPS share that rule (music_markets.is_projectable, read by _subject_tier_hits and so by reading_for_subject / readings_for_subject / readings_for_question): on 2026-09-20 the Bass Persuades pure-sales ladder held three priced rungs, its 0.50 crossing was well priced (~36.6K) and its 0.25 crossing read None, so the desk posted the figure at 16:09 UTC while the X mention reply lane’s lookup refused it two hours later. A first-week pure-sales read now needs only a priced, non-open median; every other metric keeps is_confident_projection. There is DELIBERATELY no tracking-window gate: the desk posts pre-release sales projections (the Carly Rae case is a pre-window Sept debut the owner posts), and gating the metrics differently would re-split the pair. Only DEBUT readings qualify – an annual (year-to-date) KXALBUMEQUIVY market is a reveal-lane fact, not a debut projection; (c) the two cards clear the topic gate because _sched_dedup_history (#2170) drops the other-metric post before the compare, reading each unit’s metric from the reading METADATA (canonicalized through the Luminate registry, so a scope variant compares equal), not the composed prose. The lane also honors the legacy dedup keys of the code it replaced for one window after deploy, so a market carded just before the change is not immediately re-posted, and it orders the slate least-recently-posted-first so a guild with few calendar slots still rotates through the pool across days instead of re-posting only the top-ranked releases. A release Kalshi opened only ONE ladder for (the Carly Rae Jepsen “Day and Night” case – pure sales only) yields a single card: the desk posts what exists and never invents the missing metric. Being first here is the same CACHE property as Billboard’s, ported deliberately: expected_tracking_end knows which tracking week a fresh doc should carry INSIDE a publish window (midweek Tue/Wed, building Thu/Fri/Sat – each widened a day because the container clock is UTC while HITS publishes on ET), superseded is the shared read/write predicate, and outside a window it returns None so a skipped publish costs one slow cycle instead of a runaway poll. Framing fences are dry-run-earned twice over: the first version produced wire copy with a trailing “per HITS Daily Double” tag – the same clunky-literal form _CONTEXT already bans for Kalshi – because the BLOCK said “credit them by name” while the framing banned the tag, and the two fought; aligning both on “credit HITS inside the sentence, in your own words” took the banned tag from 1/5 to 0/6 samples. The 0.6 self-gate could not have caught it: the copy was accurate the whole time, and the gate grades grounding, not voice. Live dry run 6/6 ship at 0.92. Remaining lanes: reconciliation + radio (docs/BILLBOARD_ALERTS.md D/E). |
stats.py, the shared percentile(values, p) primitive (nearest-rank: integer-clean, tail-biased on small windows). Hoisted out of scripts/ops_monitor.py (which now imports it from here) so there’s one definition of “p99”.numfmt.py, the one compact-number formatter — abbrev(n, *, prefix, suffix, decimals, k_decimals, keep_trailing_zero) renders a count as “140.5K” / “1.2M” / “11.5B” / “$940K”. The “1.2M”-style formatter was hand-copied into ~8 utils (luminate/box_office/netflix/feeds/genius/link_enrich/social_profile/social_search), each slightly different and most carrying the same rounding bug where 999,999,999 rendered the malformed “1000M” instead of “1B”. This is the single source of truth (rounding-correct thresholds that promote a value rounding to 1000 into the next unit; each caller’s exact output preserved via the knobs). Use it — don’t re-roll a K/M/B formatter. Also home to display_pct(p), the one probability→display-percent converter: a 0-1 probability as a 0-100 int, clamped so a LIVE price never rounds into a certainty it hasn’t earned — anything strictly inside (0, 1) lands in 1..99 (0.996 → 99, not the settled-looking 100; 0.004 → 1, not the dead 0), while exactly 0/1 (an actually settled book) still render 0/100. Born from the winner-lock card that heroed a giant “100%” over a match still being played (the take beside it said 99%) — the same round(p*100) shape lived in ~15 display sites (the single_leg_figure hero, the chart legends, market_alert/market_drop figures + blobs, betting_alert._favorite’s implied %, the markets.py/sportsdata prompt blobs, alert_triggers.top_standing), all now wired to this one converter. Use it for any percent a reader or the compose model sees; trigger/settlement MATH keeps its own precision (e.g. market_settled compares raw 0-1 prices, unaffected). Pure, unit-tested. Whole K for “~” forecast figures (2026-10-09, owner steer “no decimals over 10k”). luminate.humanize_estimate renders a market forecast or implied figure at 10K and above with no decimal (“~99.8K” -> “~100K”, “~17.7K” -> “~18K”, a half rounds up); under 10K and at M/B it keeps one decimal. Every “~” Kalshi figure uses it: the Billboard 200 Market Projections board rows + headline + compose block, the single-market card hero, the line-move / last-call / beat blobs, and the format_reading forecast. The card and the compose blob use the same function, so the take and the card show the same number. An EXACT figure (a strike, a settled count, a so-far count) stays on humanize_units, because rounding it states a wrong number. abbrev now moves the M floor with the K precision, so k_decimals=0 promotes 999,600 to “1M”, never “1000K”. The rule now lives in numfmt.abbrev_estimate (2026-10-09, owner: “I said no decimals”), and it covers the HITS projection figures too. #3950 fixed only the Kalshi figures, so the “Projected: Billboard Sales” board still drew “~91.7K”. luminate.humanize_estimate now calls abbrev_estimate. Every “~” HITS figure uses it: the projection board rows, headlines and figure fields in chart_boards (top 10 sales, debut sales, genre boards, projected top rank / sales / pure sales / debuts, the split and first-week cards, the holdover forecast), the hits model-facing projection rows, artist_watch, and the called-shot scorecard blob. Photo finish on a “~” board (_estimate_figures): when #1 and #2 round to the same whole K, every row prints its full grouped number (“~91,650” over “~91,420”). That keeps the gap visible without a decimal point, so the media gate does not drop the card (#photo-finish-board). The platform streams boards keep _distinguishing_decimals: their figures are M-scale exact counts, not estimates.song_pool.py, the shared guess-the-song core (#1497 unification) — the surface-agnostic pieces BOTH the Discord /guess music game (cogs/games.py) and the X/Twitter game (cogs/x_game.py) reuse so the two can’t drift: song_key(title, artist) (the canonical artist_key - title_key dedup/reuse identity, so a song keys IDENTICALLY into the durable game_recent_songs block on either surface; apple_music.artist_key is _norm_artist whenever that is not empty, and for a name with no Latin letter or digit it is the NFKC + casefold form, so two all-Hangul / kana / Han acts with one title no longer share a key – #4273. Measured on 2,064 real chart artist names: 0 keys moved, and no stored key had the empty-artist shape. _norm_artist itself stays ASCII because its matching callers read “” as “no artist to check”. The title half is the same shape: apple_music.title_key is _normalize_title whenever that is not empty, and for a title with no Latin letter or digit it is the NFKC + casefold form after the same credit and bracket cuts, so two all-Hangul / kana / Han titles by one act no longer share a key (#4273, second PR). Measured on 4,520 real chart title/artist pairs: 0 keys moved; the only stored keys with an empty title half are symbol-only titles (“÷”, “[Hybrid Theory]”) and they keep “”. presence_leaders counts releases by title_key too. _normalize_title itself stays ASCII because its matching callers read “” as “unreadable title”) and current_genre_hits(genre_key, *, cap, cache=None, seen=None, skip=None) (the current-hits GROUNDING sourcing extracted from the /guess game’s _current_hits_into/_charted_into: the Deezer per-genre chart leading ~2/3 + the iTunes RSS hedge, each guessable + preview-backed + not-seen + not-skipped, window-shuffled + capped, returning raw catalog rows). The genre→chart-id maps (DEEZER_GENRE_CHART/RSS_GENRE_IDS) + the bundle map (GENRE_BUNDLES) live here too, re-exported into cogs/games.py under their historical private names. Games._current_hits_into is a thin wrapper that threads the /guess-specific bits (the durable source cache, the served-set dedup, the reuse block) in and converts the rows to _Clip; XGame._pick_song calls current_genre_hits over the urban bundle’s lanes for its grounded blend — so both games ground IDENTICALLY off one function (not the lighter music.hot_chart facade X used before). Answer JUDGING is deliberately NOT here — it’s the /guess matcher (cogs.games.grade_guess, HARD) shared directly. A JOINT primary credit counts for EITHER act (cogs.games._credit_acts, owner report 2026-08-31). _artist_named reads a credit as ONE name – the whole token run, or the single longest token in it – so “Pop It Off” by “Tyga & Lil Wayne” scored NOTHING for a guess of “Tyga”: the room named the artist and the game said miss. (“Lil Wayne” banked a point only by accident, because “wayne” happens to be the credit’s longest token.) The grader now also matches each ACT of the credit, split by artist_watch.credit_parties – the ONE home for that split, so a stylized single name that merely contains a connector (“Earth, Wind & Fire”) is never torn into acts that do not exist: a part counts only where the top-artist list has seen it stand alone. The whole credit is always kept too, so the change can only ADD ways to name the artist, and an empty list (a kworb outage, or the X game, which judges titles only) grades exactly as before. Dependency-light + pure (reaches DOWN into utils only, no cog import), unit-tested in tests/test_song_pool.py. The ERA year gate on the grounded bench (#2724). The /guess era toggle only filtered the MODEL half of the pool (_resolve_into checks the year iTunes returns for each proposal). The GROUNDED half — about 90% of the bench — appended chart and playlist rows straight to the queue with no year check at all, and a hot-now genre chart is not all recent music: the live Deezer R&B chart runs from Otis Redding (1968) to this week. So a genre:urban era:2000s+ game served Bill Withers (1977), Prince (1986), Digital Underground (1990) and En Vogue (1996) — confirmed in prod, game 1788118436883, with era_drops=0 because nothing was checked. Every grounded source now runs through ONE gate, Games._era_into: a row that CARRIES a year (an iTunes catalog row, or an iTunes RSS chart row via the new im:releaseDate read) is filtered in place with no extra call; a YEARLESS row (every Deezer chart / playlist row) is resolved through _resolve_into, which looks it up on iTunes and applies the same gate. Deezer’s own release_date is NOT usable here — it reports the reissue date, so it dates “Kiss” to 2007 (see docs/INTEGRATIONS.md). A track whose year cannot be established is DROPPED, and every drop is counted into the song_pool event’s era_drops. Note what era_drops does NOT say: zero drops is a healthy current-decade round, not a skipped gate. A per-refill “was anything verified” counter was tried and REMOVED (#2724 review) — it needed the same per-call-site discipline as the gate itself, so it failed in exactly the cases the gate fails, and it drew six review findings of its own. The regression guard is the per-path unit tests; the dashboard reads era_drops next to itunes (did the grounded majority hold) instead. Two consequences to know: an era game reads _ERA_WINDOW_MULT (6x) its cap from each source, because the gate rejects most of what it reads; and the last-resort pick_clip fallback is SKIPPED for an era game, since that path returns a yearless clip it cannot verify — a starved era refill ends the game through the empty-refill safety instead of serving an unverified song. The RECOGNIZABILITY gate on the grounded bench (owner steer 2026-08-31, “limit artists to our watch tier ~1500”, then “we just dont want truly unkown songs appearing, but dont wan to exclude older classics”). A guess-the-song round only works when the answer is knowable, and a Deezer per-genre “chart” is a chart by name only – it runs deep into the catalog, so the live hip-hop lane served “Do Better” by Jazzbois, Goya Gumbani & Franky Bones (1,821 Deezer followers) into a live round. song_pool.recognizable(artist, fans, rank, watched) is the ONE gate both games run every grounded row through, and any ONE of three signals keeps a row: the CREDIT names a top artist (artist_watch.Watchlist.contains over the kworb list, KNOWN_ARTISTS names); the ACT has a real audience (Deezer nb_fan at or above ARTIST_FAN_FLOOR, 10k); or the SONG is played a lot right now (track rank at or above TRACK_RANK_FLOOR, 600k). THE ACT ARM IS THE MAIN GATE, AND THAT IS WHAT MAKES IT ERA-NEUTRAL. The first cut gated on the track’s rank alone at 300k, which measures how much a record is played THIS WEEK, so it punished old records for being old: measured live, it dropped “Summertime” by DJ Jazzy Jeff & The Fresh Prince, The Staple Singers, Maze, Incognito and “Mr Saxobeat”. A follower count accumulates over a career, so a 1971 act with an audience passes on the same terms as a current one, and the kworb list – ranked by CURRENT streaming, holding no Ginuwine, Bell Biv DeVoe or Boyz II Men – stops being the only rescue. The rank arm survives at DOUBLE its old floor for exactly one class the act arm gets wrong: the ONE-HIT WONDER, a famous song by an act nobody follows – measured on the live decade playlists, the act arm alone dropped “Who Let The Dogs Out” (4.9k followers), “Samba De Janeiro” (7.5k) and “The Way It Is” (6.0k), and 600k takes those three back while admitting nothing from the deep-catalog tail (Jazzbois ranks 199k, Swamp Dogg 37k). Measured end state over the live genre charts + every decade playlist: every decade playlist keeps 100% of its rows (43/43, 51/51, 52/52, 56/56, 57/57 – an era round loses nothing), and the genre charts drop only the unknowable tail (Andrea Lisa 156 followers, Stevie Woods 34, Byrne & Barnes 116). The known casualty is a one-hit wonder whose track ALSO ranks below 600k – “Good Vibrations” by Marky Mark And The Funky Bunch, whose Deezer act entry carries 1,975 followers. A follower read takes the artist ID off the row, never a name (deezer.artist_fans; _normalize now carries artist_id), because /search/artist ranks tiny homonyms and duplicate entries first – a name search returns 5 followers for “Vanilla Ice” and 7 for “Naughty By Nature”. Lookups are concurrent (6) and cached 30 days in song_pool.artist_fan_cache, so a cold pass over 439 rows costs 5.3s and a warm one 0.7s with no requests; a row with NO id or a FAILED lookup is KEPT (an iTunes RSS row is a hit by construction, and a Deezer outage must never empty the bench). An ARTIST round is exempt (the room named the act, so its deep cuts are the theme), exactly as the cross-game reuse block already exempts it; an empty kworb list leaves the other two arms working. Wired through the async current_genre_hits(filter_rows=) hook – run BEFORE the window and the cap, so a drop costs the bench nothing – shared by /guess (Games._known_rows, which also covers the decade-playlist path in _charted_into) and the X game; every rejection is counted into the song_pool event’s unknown_drops (known_artists reports the list size, 0 = kworb failed). A JOINT primary credit counts for EITHER act (cogs.games._credit_acts, owner report 2026-08-31). _artist_named reads a credit as ONE name – the whole token run, or the single longest token in it – so “Pop It Off” by “Tyga & Lil Wayne” scored NOTHING for a guess of “Tyga”: the room named the artist and the game said miss. (“Lil Wayne” banked a point only by accident, because “wayne” happens to be the credit’s longest token.) The grader now also matches each ACT of the credit, split by artist_watch.credit_parties – the ONE home for that split, so a stylized single name that merely contains a connector (“Earth, Wind & Fire”) is never torn into acts that do not exist: a part counts only where the top-artist list has seen it stand alone. The whole credit is always kept too, so the change can only ADD ways to name the artist, and an empty list (a kworb outage, or the X game, which judges titles only) grades exactly as before. Dependency-light + pure (reaches DOWN into utils only, no cog import), unit-tested in tests/test_song_pool.py. The ERA year gate on the grounded bench (#2724). The /guess era toggle only filtered the MODEL half of the pool (_resolve_into checks the year iTunes returns for each proposal). The GROUNDED half — about 90% of the bench — appended chart and playlist rows straight to the queue with no year check at all, and a hot-now genre chart is not all recent music: the live Deezer R&B chart runs from Otis Redding (1968) to this week. So a genre:urban era:2000s+ game served Bill Withers (1977), Prince (1986), Digital Underground (1990) and En Vogue (1996) — confirmed in prod, game 1788118436883, with era_drops=0 because nothing was checked. Every grounded source now runs through ONE gate, Games._era_into: a row that CARRIES a year (an iTunes catalog row, or an iTunes RSS chart row via the new im:releaseDate read) is filtered in place with no extra call; a YEARLESS row (every Deezer chart / playlist row) is resolved through _resolve_into, which looks it up on iTunes and applies the same gate. Deezer’s own release_date is NOT usable here — it reports the reissue date, so it dates “Kiss” to 2007 (see docs/INTEGRATIONS.md). A track whose year cannot be established is DROPPED, and every drop is counted into the song_pool event’s era_drops. Note what era_drops does NOT say: zero drops is a healthy current-decade round, not a skipped gate. A per-refill “was anything verified” counter was tried and REMOVED (#2724 review) — it needed the same per-call-site discipline as the gate itself, so it failed in exactly the cases the gate fails, and it drew six review findings of its own. The regression guard is the per-path unit tests; the dashboard reads era_drops next to itunes (did the grounded majority hold) instead. Two consequences to know: an era game reads _ERA_WINDOW_MULT (6x) its cap from each source, because the gate rejects most of what it reads; and the last-resort pick_clip fallback is SKIPPED for an era game, since that path returns a yearless clip it cannot verify — a starved era refill ends the game through the empty-refill safety instead of serving an unverified song. The RECOGNIZABILITY gate on the grounded bench (owner steer 2026-08-31, “limit artists to our watch tier ~1500”). A guess-the-song round only works when the answer is knowable, and a Deezer per-genre “chart” is a chart by name only – it runs deep into the catalog, so the live hip-hop lane served “Do Better” by Jazzbois, Goya Gumbani & Franky Bones (Deezer rank 199,228) into a live round. recognizable(artist, rank, watched) is the ONE gate both games now run every grounded row through, and it takes a row on EITHER of two independent signals: the CREDIT names a top artist (artist_watch.Watchlist.contains over the kworb list, KNOWN_ARTISTS names), or the TRACK itself is played (Deezer’s own rank at or above RANK_FLOOR, 300k). Both arms are load-bearing, and the second one is why this is not the watch list alone – kworb ranks by CURRENT streaming, so the list holds no Ginuwine, Bell Biv DeVoe, Toni Braxton, En Vogue or Boyz II Men, and a watch-only gate cuts “Pony”, “Poison” and “Un-Break My Heart” out of a 90s R&B game (measured live: a watch-only gate keeps 25% of the 1995 Billboard Year-End chart, and 69% of the grounded pool overall, against 92% for the two arms together). A row with NO rank – an iTunes RSS chart row, a Billboard Year-End pair – is KEPT: it is on a chart of hits by construction, and gating those on the watch list alone drops “Jump Around”, “Ice Ice Baby”, “My Girl” and “Sweet Caroline” (measured). The RESIDUAL that leaves is a novelty/AI-slop buy on the iTunes RSS chart, which carries no rank and so passes – the one unguessable class still open. An ARTIST round is exempt (the room named the act, so its deep cuts are the theme), exactly as the cross-game reuse block already exempts it; an empty list (a kworb outage) leaves the rank arm gating alone rather than emptying the bench. Wired through current_genre_hits(keep_row=) so /guess (Games._known_row) and the X game share one predicate; /guess also runs it in _eligible_rows for the decade-playlist path and counts every rejection into the song_pool event’s unknown_drops (known_artists reports the list size, 0 = kworb failed). Live dry run: every genre lane still fills its cap 18/18 – the gate reaches deeper into the chart, it does not starve the bench.guess_grading.py, Jev answer grading for both guess games (#4311, epic #4309). The bug: the HARD grader (cogs/games.py:_grade_guess, the default) wins a title when it is CONTAINED in the message after every space is removed, so a short title won inside ordinary chat (“my bad” won Bad, “whats up yall” won Up, “marigold” won Gold, a six-title list won Hotline Bling). The X game judges replies through the same grader (grade_guess). A whole-word string fix scored 14/27 on the research cases (“my bad” still holds the whole word bad), so the fix is a judgment. The fix: when a TypeSafe client is provisioned (TYPESAFE_API_KEY), the round asks TypeSafe’s Jev model two Nouls in one request (about 110 ms): names_title (the production Haiku judge wording from judge_song_guess, about the state’s guess and title) and several_titles. The guess goes into the STATE, never into a question. title_verdict combines the answers with three code-only checks Jev cannot do: (1) a guess naming 2+ distinct titles from the round’s pool never wins (Jev cannot count); (2) a BARE answer (the title plus only the artist’s name and joining words) wins without a Jev call, because Jev scored “mask off future” and “stay by bieber” 0.12-0.33; (3) HARD keeps its #587 “type it right” rule: the exact title must be contained, and Jev only decides whether the line is an answer or chat. EASY has no containment rule, so Jev may grant a misspelling. Both bars are 0.5 (TITLE_YES); measured 2026-10-17, hedged answers (“pretty sure its gods plan”) scored 0.40-0.45 and chat up to 0.40, and a miss is recoverable while a false win ends the round. Live, no experiment (owner, 2026-10-17): every graded guess awaits Jev (1.5 s timeout, cogs/games.py:jev_title_win) for the TITLE decision, and today’s grader (Haiku escalation included) decides in /guess only when Jev has no answer (an error, a timeout, an open breaker, a timeout pause, or no key). When Jev decides, the 4-per-round Haiku cap no longer applies, because no Haiku call is made. A Jev “no” on a contained title keeps only the artist credit (_names_credit). Each graded guess with a verdict emits one typesafe_shadow event (purpose=guess_grade, today’s verdict beside the new one, never the text) as an agreement metric, not a gate; a Jev failure emits none, because typesafe_api records it. Ordering: the round grades one guess at a time, so a guess that lands while Jev answers the one before waits in the queue and is graded after it (the first right answer still wins, tests/test_guess_grading.py). A slow call can push a guess queued in the last 1.5 s past the round end, where it is not graded; the Haiku escalation had the same edge. Artist and feature points stay deterministic. The X game has no round pool, so several_titles covers a listed set there. The X game fails CLOSED (acceptance review): a public “correct” cannot be undone, so with no Jev answer (no key, an outage, a timeout) a reply wins only on a code rule (a bare title + artist), never on today’s containment grader. Timeouts (_jev_nouls): a call cut at 1.5 s is cancelled, so it leaves no typesafe_api event and the client’s breaker never counts it. Each timeout therefore emits one error event (source=guess_grade, recoverable=True, context.paused), and 3 timeouts in a row skip Jev for 60 s (_JEV_TIMEOUT_TRIP, _JEV_PAUSE_S), so a hung upstream delays a round by 1.5 s at most three times. Eval: scripts/eval_guess_grading.py (docs/EVALS.md). Dashboard: guess_jev_shadow on the engagement board. The artist credit (CB-15, #3410). _artist_named (the whole name, the longest word of 4+ letters, or on EASY a 0.82 spelling ratio) credited ordinary chat: “my dad is a carpenter” named Sabrina Carpenter, and “see you this weekend” named The Weeknd. Now the rule only NOMINATES (_credit_hits). _jev_regrade adds names_artist and/or names_guest (the feature and remix credits, sent as guests in the state) to the SAME Jev request as the title questions. When the code-only checks already decided the title (HARD with the title not in the guess), the nominated credit is a request of its own. A credit counts only on Jev’s yes (>= 0.5, credit_verdict); no answer keeps the rule’s credit. Points come from _win_points. The X game scores no artist and is unchanged. Measured 2026-10-18 on 56 hand-written guesses over real chart credits (the game ran 0 rounds in the prior 30 days), 2 runs: false credits 15 -> 1 (HARD) and 17 -> 1 (EASY), lost true credits 1 (“carpenter”, 0.42-0.46), 2 flips, Jev p50 105 ms. Shadow typesafe_shadow purpose guess_credit.
Accented and non-Latin titles (#4389). apple_music._normalize_title folds accents (apple_music._fold_marks) before it strips to [a-z0-9 ], so “Déjà Vu” gives deja vu and a typed “deja vu” wins HARD. A title with no Latin letter (Hangul, kana, Han, Cyrillic) still gives “” there, because the matcher callers read “” as “unreadable”. The HARD title test is apple_music.title_in_guess: ASCII tight form when the title has one, else the Unicode form of title_key against the Unicode form of the guess. bare_answer and pool_title_hits still skip a non-Latin title (EASY and Jev cover it). _fold_marks is NFD plus dropping combining marks. It is not names.fold_accents, because that helper is NFKD and NFKD folds compatibility characters: a fullwidth “A” would become “A” and hide a non-Latin letter after it (“A夜” keeps its Unicode key). The fold changes song_key for accented titles once. The same key feeds game_recent_songs, the music-drop reuse table, the chart presence and chart_peak peak/told memory (cogs/music_alert.py) and the jump-lane told memory. One-time effect for an accented song that already charts: it gets a new key, so the peak lane may stay silent for it once and the jump told memory may allow one repeat. The other stored song_key users change the same way: in cogs/music_desk.py the milestone and dedup keys bbpeak: (~7863), bbrun: (~7914), track: (~9403, ~11325), tenure: (~10491) and the kc: watch-chart dedup (~10953), and the cogs/x_mentions.py:1224 row: card cache. For an accented title, the milestone ledgers see a new key and store a fresh baseline the first time (utils/milestones.py seed_rung), so one milestone crossing can be missed once. Only titles with an accented Latin letter change key. Latin titles without accents and non-Latin titles keep their keys. Unsplit letters (#4706). NFD and NFKD do not split ø, æ, œ, ß, ł, đ, so the strip used to delete them (“Søren” gave s ren, “Æon” gave on, so the guess “bonjour” won HARD). names.LETTER_MAP maps them (ø→o, æ→ae, œ→oe, ß→ss, ł→l, đ→d) inside names.fold_accents, apple_music._fold_marks and apple_music._norm_artist. Key migration: a song or artist key with one of these letters changes once (s ren becomes soren), in every store listed above for #4389. Cost: one possible repeat or one missed milestone per affected title or artist. The music title and artist normalizers with a private NFKD copy apply the same map, so keys from the two families still agree: deezer (_norm_title, _norm_artist_name), kworb (_fold_phrase, _fold), chart_boards._norm, music_markets, artist_watch.fold_tokens, x_artist_tags, spotify, catalog_art._fold_screen_title, release_board, music_news and billboard. The key-migration note above covers these sites too. Left without the map on purpose: markets._fold_text, market_art, sportsdata/names (sports and politics names), market_subject_image (another owner’s file), claude_client (both sides of each compare fold inside one function), and names.fold_for_search, ascii_fold, norm_name (Discord display names). tests/test_letter_fold_cross_source.py fails if a music normalizer drifts from the Apple one.
Deezer’s title fold and billing split (#4092, #4141). deezer._norm_title folds stylized characters BEFORE the accent fold (_fold_stylized): $ reads as s, ! as i and @ as a where each touches a letter, and ø æ œ ß ł đ ð þ map to o ae oe ss l d d th. Deezer bills “Some Sexy Songs 4 U” as “$ome $exy $ongs 4 U”, and before the fold that album was absent from its genre board. Digits are not folded. deezer._BILLING_SEPARATORS includes +, so a billing “A + B” splits into two acts.fanout.py, the one bounded async fan-out — gather_deadline(coros, deadline=, limit=, settle=): run N independent reads concurrently, results IN INPUT ORDER, None for anything that missed the deadline or raised, stragglers cancelled (never left paginating). The shape is “several paginated Discord REST reads whose results are nice-to-have context, not the answer” — the reactor fan-out (#1947), poll voters, message reactors, feed channels (#1950). Returns T | None rather than taking a default on purpose: a shared default would alias one mutable object across every missed slot. The cancellation settle is BOUNDED too (settle, default 1s): a cancelled discord.py request can await during its own unwind — with the rate bucket exhausted (a 429 burst), Ratelimit.__aexit__ runs await _refresh(), an asyncio.sleep(reset_after) — and the old unbounded settle (await asyncio.gather(*pending)) waited that sleep out, so the 5s reactor deadline measured ~10s on /ask (Axiom feeds.resolve_reactors spans clustered at exactly 5.0s deadline-hit AND ~9.7-10s deadline+reset). Total wall clock is now at most deadline + settle; a member still unwinding past the settle window is abandoned (it stays cancelled and dies on its own, with a done-callback logging any late non-cancel exception). Don’t hand-roll another asyncio.wait loop — extend this.release_age.py, the ONE definition of how old a release is (#2074) — the shared resolver behind every debut gate. A chart’s own “new” marker never means “new release”: kworb flags a catalog re-entry NEW on the rank-only charts exactly like a fresh drop, its days-on-chart column counts days on THAT chart page (so a title crossing onto a chart it had never reached starts at 1 however old it is, #2062), and a HITS projection carries no last-week position for a re-entry either. The release DATE is what separates the two, so release_age_days(title, artist, album_first=…, cache=…, source=…) is what four lanes now call: the Apple debut lanes (#1887), the Spotify releases lane (#2062), the HITS called shot (#2038) and the watch lane (#2045/#2054). Fail-CLOSED — None means we could not date it and the caller must refuse the debut claim rather than make one. TWO CATALOGS, Deezer then iTunes: Deezer is first because it is the proven path and answers most rows, so no answer it already gets right can change; the iTunes read goes through the artist-verified catalog_art.resolve_music_row, because a bare search falls back to an album match and returns the wrong record’s date. Resolver ORDER is the caller’s (album_first): a SONG resolves track-first so an album CUT dates, an ALBUM resolves album-first so a re-released title track cannot mis-date it; each falls back to the other. The per-process cache memoizes the MISS too, so a dead title is not re-probed every slot — but a miss now EXPIRES after ~2h (RELEASE_AGE_MISS_TTL_SECONDS, #2125): a drop-day release is probed minutes after it charts, before the catalogs index it, and a permanent miss-memo froze the gate at no_date until the next deploy (measured: 81 rejections across one album’s whole NEW day). A resolved date expires too, after 24h (RELEASE_AGE_HIT_TTL_SECONDS, #3176): it used to last for the life of the process, so one wrong read pinned a wrong age until the next deploy (ADELA’s PRIMA read 149 days old after one bad answer). The iTunes fallback refuses a row whose OWN title fails deezer.album_title_matches (#3176, #3998): resolve_music_row also accepts a track whose COLLECTION matches, which is right for a cover and wrong for a date, because the row carries the TRACK’s date (Ashe’s album “The Girl Of Your Dreams” dated from its pre-release single “Stop The Wedding!”). Each catalog round emits release_date_resolve (provider + reason), so a fallback-dated or refused row is visible instead of hiding inside a gate’s stale. MusicBrainz is the FIRST rung (#2958): chart_ages.first_release_date(exact=True, kinds=...) reads the release group’s first-release-date, because Deezer and iTunes date a catalog title to the reissue they rank first (The Beatles’ “Rubber Soul” read 2026-10-02 from the 2026 Mix and shipped as a Billboard 200 “New This Week” projection; Nickelback’s “How You Remind Me” read 2013 from a best-of). The debut read is stricter than the plaques board’s: titles must be EQUAL once brackets drop, the artist must be a whole credit, an album read takes only an Album/EP group and a song read only a Single group (so Miley Cyrus’ same-named lead single cannot date her album, #3483), and a compilation / live / remix / DJ-mix / demo group never counts. The SEARCH is a phrase query, and MusicBrainz answers 0 groups for a phrase that holds a bracket zone the group title does not hold, so before #4371 this rung never dated a bracketed title at all (about a quarter of chart rows): “Stakes Is High (30th Anniversary iTunes Exclusive)” and “Rubber Soul (Super Deluxe)” fell to the catalogs, read the reissue day, and shipped as debuts. An exact read now searches the bare title (chart_ages._bare_search_title) when the full-title search matched no group. It is a fallback, not the first search, because a group title can keep its own mid-title bracket (Feid’s “EL GREEN PRINT: La saga (Disc 2) — …”) that only the full title finds. A partial date (“2006”) counts only when its whole period ended over a year ago. A SONG takes the OLDER of the MusicBrainz and the catalog date (coordinator review on #4133): a Single group can postdate the song, as an album cut later issued as a single does (Fleetwood Mac’s “Dreams”: single 1977-03-24, album 1977-02-04), and a reissue is always later than the original, so the older date keeps both cases right; an ALBUM keeps MusicBrainz outright, because there the older catalog date is the pre-order listing (#3483). Every MusicBrainz read in the bot (this rung, the oldest-songs board, the plaques board and the reference library) queues on ONE host limiter in reference_types.fetch_json: one read per 1.5s and one retry on a 503 (#4168). Before #4168 this rung had its own ~1.1s bucket and chart_ages.first_release_date from the plaques board read outside it, so 32 of 100 reads got a 503. One MusicBrainz read, limiter queue and retry included, is bounded by RELEASE_AGE_MB_BUDGET_SECONDS (10s); past it the rung is skipped and the catalogs answer (reason=musicbrainz_timeout). A title MusicBrainz does not hold (a drop-day release, or a catalog MusicBrainz lacks, like Valentin Elizalde’s “Vete Ya”) falls through to Deezer/iTunes unchanged. The reads share one ~1/s token bucket (release_age._MB_LIMITER). The key carries the order so an album and its title track cannot collide. source labels the emit_error so triage keeps per-lane attribution. This existed as two drifting copies before #2074 — #2054 gave the desk’s helper the iTunes fallback and the alert’s never got it, so two lanes reading the same charts dated the same row differently. The debut WINDOWS stay per-lane (0–14d the Apple debut lanes — floor 0 because they are rank-only and kworb’s one-day NEW flag made a day-0 release unpostable under a floor of 1; 1–14d the Songstats-reading release lanes, whose day-0 skip is real; 0–28d the called shot, 0–45d the watch lane) and the module docstring is where their relationship is written down. The release date is HALF the gate: it separates a fresh drop from CATALOG, but it cannot separate a fresh drop from a RECENT release re-entering the tracked range (its age passes the window every time it bounces back) — that half is chart_presence.py, below. cached_release_date reads the date a prior lookup left in the cog’s cache back out as a date (no network), so the called-shot ledger can store the release date the lane confirmed a moment earlier.release_age.py’s iTunes band check (_band_confirmed, CB-04, 2026-10-18): the iTunes row’s own title must still pass deezer.album_title_matches, and a pass in the ambiguous band (deezer.album_match_band) now also goes to the catalog title judge (apple_music.confirm_band_rows, source=itunes). A refusal, an error or no verdict refuses the row, so the release is undated (no debut claim) rather than dated off another record. A sure pass makes no call. Real case: “Grand Theft Auto VI: The Album” took a 2010 “Grand Theft Auto IV” EP, which dated a new album at 16 years.release_age.py’s core-title retry (core_title, 2026-10-20): a chart title can carry a note no catalog holds – “Last Laugh (archive tape)”, “TRENCHES (feat. Switchfoot)” – and every rung then misses, which the release-age cut reads as a cut. A title no rung can date is asked once more without the notes that are not part of the release. A MODEL decides which notes go (owner steer, same day: “make sure we’re using model”): the CB-23 clause read (riaa_boards.check_titles, purpose riaa_bracket, Haiku, cached 90 days, shared with the RIAA boards), with a dash suffix framed as one more clause. A VERSION note stays, because a re-recording, remix, live or acoustic cut is a different recording with its own date; a feature credit, an edition tag or a film source goes. The clause read’s word list decides before boot and on a model error. The first version stripped every bracket and dash suffix by regex; measured on the 73 bracket or dash titles the desk posted in 30 days, the model kept 15 version notes the regex dropped (“1989 (Taylor’s Version)”, “UNA NOCHE EN MEDELLÍN - REMIX”, “Patient Zero (Acoustic Version)”, “Mercy (Live)”), each of which the regex would have dated as the older original, and agreed on the other 58. Across three fresh processes it flipped on 3 of 73: two edition notes (“Handwritten (Revisited)”, “The Eminem Show (Edited Version)”) and the dash suffix “petal – the complete live album”, each a borderline call whose either answer dates the same act’s release of that year; the 90-day cache pins one answer per clause. The retry runs only on a total miss, so the model is asked nothing for a title that dates, and the full title’s miss emits no release_date_resolve row (detail.core_retry marks the retry). Not fixed by it: titles absent from MusicBrainz and Deezer altogether, which only the iTunes rung can date.release_age.py’s Spotify rung (_spotify_release_date + spotify.search_release, owner steer 2026-10-20: “use itunes or spotify for backup if deezer doesn’t work”). The resolver order is MusicBrainz, Deezer, iTunes (iTunes already runs whenever Deezer has no song date, and on every album read), and now Spotify LAST: a fielded /search (track:"..." artist:"...", or album:) that runs only when the other three missed, so no title that dates today changes and its quota stays small. It has its own spotify_fetch action=search_release, so a long-429 block on it never blocks the albums or tracks reads. A candidate passes the Deezer rung’s checks (album_title_matches, and one of Spotify’s listed acts _artist_is_billed and not by overlap alone); the OLDEST verified date wins, because a deluxe or reissue is always later; a partial date counts only when it proves the title old (_first_release). release_date_resolve provider=spotify counts what it dates. Live on 2026-10-20, it dated four titles nothing else could: “Last Laugh (archive tape)” by LUCKI (2026-10-16), “TRENCHES (feat. Switchfoot)” (2026-10-15), “Kadesh” and “My Years With You”; it found nothing for a cast medley, a cast anthology and “Unshattered (Soundtrack)”.perplexity.py’s hedge check (hedged_checked, CB-22, 2026-10-18): the music desk’s background read and the newsroom’s artist pull and stat verify dropped any answer that hit the _HEDGE_MARKERS list. The list read real answers as hedges when one sentence said “no verifiable … figures” (5 of 35 real desk reads). Now a list hit goes to ClaudeClient.perplexity_found_answer (FOUND / NONE); only FOUND keeps the answer. One hedge_check event per check. market_outcome.is_no_result keeps the list: its outcome reads are settlement-adjacent (PROTECTED).title_forms.py, the ONE stripper for the title forms sources print around the same song (#4172). Mediabase and kworb’s radio page glue a feature into the track (“Jaded f/Ella Langley”, “Move On f/Shaboozey”), Billboard and the wires write “ft. X” / “feat. X”, and the radio chart labels a new single “(Radio Edit)” where Spotify prints the bare title. FEATURE_RE moved here from music_news._FEATURE_RE, and the newsroom keeps using it unchanged. bare_title uses a NARROWER marker (strip_marked_features): a bracketed credit, a glued f/ or w/, feat. / ft. WITH the dot and not after a number, or featuring. A bare “ft” or “w” is a word: the wide pattern turned “6 Ft. 7 Ft.” into “6”, which matched a different song (coordinator review on #4178). It also removes a PURE trailing radio-edit tag, and nothing else, so “Dracula - JENNIE Remix” and “(Remix) (Radio Edit)” still key apart from the base song. The strict matchers call it on BOTH sides before they build a comparison key: radio.standing (#3454 made it strict; 0 of 45 “f/” rows matched after that), and kworb._strip_feature (behind find_chart_entry, which the radio-entry lane calls strictly since #4139). song_key is deliberately NOT built on it, because song_key builds persisted keys. radio.standing emits one radio_standing row per lookup over a non-empty chart (ok + reason hit / no_title / no_artist), so a matcher that stops reading a source’s form shows as a falling ok-rate instead of a silent drop. The sibling fix for album subtitles lives in apple_music.release_tag_mismatch: a bracketed release name also agrees when its words are a subset of the candidate’s whole folded title words (“(The Anthology)” vs “: The Anthology”), which still refuses Gold vs “(Green Album)”.chart_presence.py, the first-sighting chart memory behind the kworb debut gates — the second half of the debut question release_age.py cannot answer. kworb’s rank-only charts (Apple songs + albums, US iTunes) mark a row NEW whenever the PREVIOUS SNAPSHOT’s tracked range did not carry it — a tracked-range bounce is re-marked NEW days after its real entry (measured 2026-08-07: “petal” by Ariana Grande, charting since day one, shipped as “new on the Apple Music US songs chart, entering at #6”), while a release-day title that entered early LOSES its flag the next snapshot when it is still genuinely new (measured 2026-08-08: Pooh Shiesty’s “All Eyes on Shiest” entered at #35, stood at #2 the next morning flagless, and missed the new-entries card, #2139). THE RULE (owner steer, #2139): a title is NEW to a chart for NEW_WINDOW_HOURS (24) after its FIRST sighting there, reported at its CURRENT rank; past the window it is ESTABLISHED and a NEW flag on it is a bounce or a re-entry, never a debut. kworb’s flag is trusted exactly once, at first sighting (flagged → the window opens; unflagged → established immediately, first sighting unknown — which also makes warm-up safe). One kv_cache row per chart (namespace chart_presence, keys shared between consumers via _WATCH_CHARTS’ short keys: ap_songs, ap_albums, …) maps song_pool.song_key → "first|last|gap|entry": first is IMMUTABLE while the title stays inside retention — the #2139 fix; the prior #2119 version stored one overwritten last-seen date, so the first consumer after UTC midnight (the desk sweep at 00:00:24) erased the prior-day evidence and every later consumer that day (the 10-minute alert polls, the platform cards) re-claimed week-old titles as debuts — last drives 60-day retention pruning, gap records the days absent before the latest return, and entry keeps the RANK of the first sighting (0 = unknown, immutable like first) — the second owner steer (PR #2140): the CURRENT rank is never the DEBUT rank once the title moved, so a #35→#1 climber “hits #1”, it does not “debut at No. 1” (the live crown post that raised this). presence(db, chart, entries) takes (song key, kworb-new flag, rank) triples, refreshes, and answers from the REFRESHED state — key → TitleInfo(new, gap_days, entry_pos) — so every consumer and sweep of the day reads the same verdict. Consumers: music_alert._releases_signal reads it for the ENTRY RANK only (#2224) under the shared sp_global key — chart_debuts accepts a row up to 2 days old carrying its CURRENT rank, so “entered the chart at #N” was stating a day-2 rank as an entry rank (measured: Stray Kids’ “This & That” entered at #31 and still counted as a debut at #82 the next day). The post leads with the LATEST rank (owner steer 2026-08-09, matching this module’s own rule that a consumer inside the window reports the CURRENT rank). A climb names the entry as context; a fall does not (owner steer 2026-09-23, see the music-alert fall bullet near the top). info.new gates only the “inside a day” phrasing. It deliberately does NOT suppress on info.new there: the Spotify days-on-chart count is cumulative so a bounce never reads as day 1, and the 24h window would drop every legitimate day-2 debut this lane is built to allow. music_alert._apple_debut_core skips an established NEW row outright (reason=charted on apple_debut), cuts a real-but-deep debut on the tiered depth floor (reason=deep, #2207 — see the artist_watch.py bullet), and phrases an in-window mover off its entry rank (“entered at #35 … now stands at #2 — say it hits/climbs to #2, never debuts at #2”). BOTH directions lead with the CURRENT rank (owner aligned it 2026-08-09; the FALL case used to lead with the entry instead, which reported a rank the chart no longer showed). A CLIMB carries its entry rank as context (hits / climbs to #N). A FALL does not (owner steer 2026-09-23): its blob states only “sits at #N”, bans “debuts at”, and names no earlier rank or move — see the music-alert fall bullet near the top of this file. The one exception is a RELEASE-DAY RAMP (owner steer, 2026-08-09), which takes the DEBUT wording instead: kworb’s Apple pages are near-realtime snapshots, not weekly chart dates, so a title released today starts at zero plays and rises as they accumulate — its first-sighting rank records WHEN THE BOT FIRST POLLED, not where it entered (measured the same day on the live albums chart: one album read #98 then #97 on consecutive 10-minute polls). The gate is narrow: the release must be _DEBUT_RAMP_MAX_AGE_DAYS (1, MUSIC_DEBUT_RAMP_MAX_AGE_DAYS) old or less, and the move must be UPWARD — the artifact argument says a first sighting UNDERSTATES a title whose plays were still accumulating, while a fall is a real decline off an observed rank, so a fall is never a debut; it takes the current-rank-only wording. On a ramp the entry rank is not named at all, because naming the polling artifact is what made the wording misleading. The CROWN lane is deliberately NOT changed: it is where the #2140 post came from, and its pure pos_change gate has no release-date read to tell a ramp from a climb; music_alert._chart_crown needs no presence memory — its pos_change > 0 gate already proves the title held a real prior rank, so its blob states “climbs to #1, up from #N” and forbids “debut”; the one exception is a RELEASE-DAY RAMP (#2235, the crown half of the debut-lane rule above), where a title released within _DEBUT_RAMP_MAX_AGE_DAYS rose through its release day as plays piled up, so the #1 it reaches really is a #1 debut. The lane runs _debut_release_age AFTER the pos_change gate, so it costs a lookup only when a crown actually fires and the resolver is cached; it fails to CLIMB on an undatable row, because pos_change already proves a climb while “debut” is the claim needing evidence. The ramp blob never names the prior rank — that number rides the chart_crown event for diagnosis and is kept out of the compose’s context, so the model cannot cite it. The RADIO crown has its own inline implementation, does not use this helper, and needs no ramp branch: radio adds take weeks, so a same-day release cannot top airplay; music_desk._resolve_new_entry refuses the debut claim — an absence ≤ _WATCH_BOUNCE_MAX_ABSENCE_DAYS (7) is a range bounce (no story), a longer absence on a DATED CATALOG title stays a genuine re-entry story only when it lands inside _WATCH_REENTRY_MAX_POS (10, MUSIC_DESK_WATCH_REENTRY_MAX_POS) — deeper than that it drops with reason deep, an established FRESH title is dropped. And a new_entry on the US radio-airplay chart is never a debut — it is stated as a POSITION, gated on depth (reason radio_entry, _LEADING_CHARTS, 2026-08-17): radio is a LEADING chart — stations build a song’s spins over WEEKS after release — so a first appearance on the airplay chart is decoupled from the release date, and the 45-day age gate cannot tell a genuine debut from a song radio picked up late. A 2-week-old song entering radio at #72 passed the window and shipped as “‘petal’ debuts on the US radio-airplay chart at #72” (owner call). So the lane makes NO release claim for radio: it reads no release date, and states only WHERE the song sits (“now on the US radio-airplay chart at #N”, kind radio_entry with a bare chart-label eyebrow and a leading-indicator compose fence) when the entry lands inside _WATCH_RADIO_ENTRY_MAX_POS (20, MUSIC_DESK_WATCH_RADIO_ENTRY_MAX_POS, anchored to the per-format lane’s top-15 breakout cap _RADIO_MAX_RANK). A deeper entry is deep-tail airplay churn and drops as deep (petal #72); a tracked-range bounce drops as charted. The crown / top-N (top 10, top 5, top 3) / jump kinds carry no release claim and are untouched, and utils/radio.py still tells the per-format “climbing on radio” story off the spins. chart_boards.platform_new_entries lists every ranked row NOT established at its current position. Fail direction: None = memory unavailable (no db / error, emitted to triage) and each caller falls back to its pre-memory behavior (kworb’s raw flags for the board, no suppression for the gates) — never “nothing established”, which under first-sighting semantics would read the whole chart as new. Legacy #2119 single-date values migrate as established. The Spotify charts self-heal without it (kworb’s days-on-chart count is cumulative per chart page, so a bounce keeps its count) but feed the memory anyway via the watch sweep.artist_watch.py, the artist RECOGNITION tiers + credit matching (owner steer 2026-08-04). kworb’s two free Spotify ranking pages (all-artists by streams, monthly listeners) define three tiers: WATCHED is the top ~100 (MUSIC_DESK_WATCH_ARTISTS), KNOWN the top ~1000 (MUSIC_DESK_KNOWN_ARTISTS, extended with the owner-kept rap / R&B-hip-hop / Latin Billboard charts so a genre name past kworb’s global cut still counts), UNKNOWN everyone else. recognition_tier(credit, watch, known) is the ONE definition; Watchlist.contains does the credit matching, which is stricter than kworb._fold containment — “Drake” must match “Drake Featuring Yebba” and never “Drake Milligan”, so both sides normalize to tokens with every collaboration connector folded to “and” and a name matches only on a segment boundary. The tier picks a FLOOR, never a hard block. A watched artist clears LOWER floors on the desk’s chart and radio lanes (notability 2 vs 8, 20 vs 40) because a superstar’s real move was being lost to size-blind rules; an unknown artist must clear HIGHER ones (chart notability 16, radio 80, #2097). Fail direction: an EMPTY known list means the kworb pages failed, and every credit then reads KNOWN — each gate returns to its pre-tier behavior rather than silencing an entire tier, and a suspiciously SMALL build (under half the requested size) is forced empty and sent to error triage rather than passed off as a real list. Consumers: music_desk’s chart-move, radio and exits lanes (_is_recognized), the called-shot band, and — via the cross-cog seam MusicDesk.recognition_lists() — music_alert’s Apple DEBUT depth floor (#2207). That seam exists so the two cogs share ONE build and ONE ~1h memo: a second copy of the kworb fetch in music_alert is exactly the drift _debut_release_age already paid for once. The debut floor is MUSIC_DEBUT_KNOWN_MAX_POS (50) / MUSIC_DEBUT_UNKNOWN_MAX_POS (25), with a watched artist exempt. It was added after the albums lane posted, and crossposted to X, “Lil Tony Official’s ‘ELIJAH’ debuts at #98 on the Apple Music US albums chart” — a top-100 chart’s third-to-last rung, by an artist on neither ranking page. A FLAT floor was rejected on the measured data: over the 30 days to 2026-08-09 the lanes shipped 11 posts, and a flat top-25 cut would also have dropped Pooh Shiesty’s #9 debut. Depth and recognition only answer the question together — how deep a debut may land depends on who made it. Note this REVERSES the debut exemption watch_new_entry’s re-entry floor still carries: a new title is news wherever it lands holds for a name the room knows, not at #98 for a name it does not. The table moved to utils/artist_watch.py (DEBUT_KNOWN_MAX_POS / DEBUT_UNKNOWN_MAX_POS / debut_depth_floor / debut_clears_depth) on 2026-09-08, when music_news.deep_platform_cut – the other sibling, which had exempted a debut outright – shipped a #199 Spotify “debut” and was wired onto the same floor. The Spotify RELEASES lane (cogs/music_alert.py:spotify_debut_clears_depth, #4085) is the third consumer: it uses the same table at 2x the shared floor for the top-200 Global Daily. lead_artist(credit) is the ONE-NAME form the ART lookups take (#2274) — a credit with its trailing feature clause removed (“Steve Lacy Featuring SZA” -> “Steve Lacy”). Deezer’s artist search and iTunes’ discography read each search for ONE act, so handing them the raw Billboard row searches for an artist who does not exist: the “Is It Cool?” Hot 100 exit card resolved no cover (the track is not in the catalog), then missed BOTH artist-photo rungs on the joint credit, fell to the open-web search, and shipped with the no-art bloom floor after the vision gate declined all 5 candidates. It cuts ONLY the four unambiguous feature markers (feat / featuring / ft / f/), never “&”, “,”, “+”, “x”, “vs” or “with” — those sit inside real act names (“Earth, Wind & Fire”, “Tyler, The Creator”, “Sleeping With Sirens”), and cutting one invents an act. That is the split credit_parties may make and this may not: credit_parties CORROBORATES every part against acts that chart alone, lead_artist corroborates nothing, so it only cuts where no real name can be cut. The bare word “and” is CONDITIONAL (#2899, _split_re). fold_tokens maps “and” to a connector and _ordered_runs splits segments on it, so credit_matches read “FUTURE AND HALSEY” as two acts while the party splitter did not cut there at all and credited_names returned NOBODY – the two halves of the one credit rule disagreed, which cost Future a title on the RIAA year board. Cutting at EVERY “and” fixes that and breaks worse things: measured against the live lists 2026-09-03, “Sam and Dave” credits “Dave” (a different charting act, so a 1960s duo’s plaques land on a contemporary rapper), “Ike and Tina Turner” credits “Tina Turner”, “Tom Petty and the Heartbreakers” credits “Tom Petty” – and corroboration cannot catch any of them, because the fragment IS a real solo act. The signal that separates the shapes is a LIST SEPARATOR: an enumeration carries a comma or “&” (“BEELE, Ovy On The Drums, and W Sound”), a duo’s own name carries neither. So “and” cuts only where a comma or “&” already proves the credit is a list. Measured over the 716 distinct artist cells of the 2026 RIAA feed: three cells gain a real act (including the live HUNTR/X credit, whose two acts after the bare “and” were being dropped), and none of nine real “X and Y” band names fragments. ACCEPTED MISS: a genuine two-act credit with no other separator (“ROA AND HADES66”, live in the same feed) still reads as one act, because nothing distinguishes it from “Sam and Dave” – dropping one act is a miss, crediting a duo’s catalogue to an unrelated artist is a fabrication. The RIAA reader keeps its OWN rule for a FEATURE clause (riaa._feature_guests), which this cannot serve because a clause is usually “X and Y” with no other separator: it resolves the clause whole first, then splits only when EVERY piece corroborates – one piece resolving and the other not is exactly the “Sam and Dave” shape. is_placeholder_credit(credit) gates every PORTRAIT rung (#2274). A catalog stand-in credit – “Soundtrack”, “Various Artists”, “Original Broadway Cast” – names no act, and it is lookup POISON precisely because it is a real word: an artist search happily returns a match for it. Deezer has an act literally named “Soundtrack” whose picture is a “Music From Lord Of The Rings” cover, so the Billboard 200 row KPop Demon Hunters / Soundtrack resolved a LOTR sleeve onto a card about a different record. A live A/B sweep over real chart rows caught it; the unit tests did not, which is the argument for sweeping real data before shipping an art change. COVER rungs need no such gate – they verify the matched row against the credit, so a placeholder simply fails to match. Bare “Cast” (a real band), bare “Various” and “Traditional” are deliberately OFF the list: Billboard writes the placeholder forms in full, so the compounds cover real usage without costing a real act its portrait. The 2026-08-31 reversal: the blob was investigated and deliberately LEFT ALONE. A watch-chart take posted to X reading “the cure rockets from #30 to #52 – wait, reversed: it climbs from #52 to #30”. chart_story_inputs reads “It just made a big climb, now at #30. It was #52 last period.”, which names the new rank first and the old one second, so the obvious theory was that the take fills “from X to Y” in block order. That theory is unproven and the wording was not changed for it. The reversal did not reproduce in 74 live composes on that exact blob (real _CONTEXT, the real wire history, claude-sonnet-4-6, every number pair the four failures used), against a production rate of four in six that afternoon. The gap is still unexplained. An ablation at n=12 (cut the prior rank / reword it as a directional pair / leave it) then showed the unchanged blob writing the pair CORRECTLY 11 times in 12, so the “reversible form” is not itself failing; cutting the prior rank lost it from the prose and pulled the trailing clause toward an ungrounded past (5/12), and the reword bought nothing measurable. Changing model-facing text against an unreproduced cause is the scar tissue docs/PROMPT_OPTIMIZATION.md warns about, so the fix is the deterministic guard instead: output_checks.has_self_correction now catches an INLINE correction, and a drop emits music_desk_scored phase=shape_reject. That converts an invisible hallucination into a measured drop rate – query that rate before touching this blob. The CARD never had the bug (chart_story_card_fields renders “up 22 from #52”), and utils/billboard.py:_move_phrase has always written “climbs {delta} spots to #{rank} (was #{last_week})”, direction first: copy that shape in a NEW move blob, but do not rewrite this one without evidence. row_credit / row_is_theirs are the one definition of what credit a CHART ROW carries (owner rule 2026-09-13, “features and collabs always count”): the credit line plus any act named only in the title’s feature clause, because the streaming charts file “So Good (feat. Kendrick Lamar)” under “Jhene Aiko” alone. Every counting/listing lane reads it (see line 50); attribution does NOT – who the record BELONGS to stays lead_artist. credited_names builds its name index ONCE per (watched, known) list pair (_credit_index, 2026-09-28): it used to fold all ~1300 names for every RIAA row, 17.6s for 3000 rows on the event loop, which blocked the Discord heartbeat. A “Daily” figure is trusted only when it is plausible (#3797). kworb’s “Daily” column sometimes holds a multi-day delta: on 2026-10-02 Bibi Blocksberg read 85.6M (7.7M on 2026-10-05), Benjamin Blümchen 59.6M (6.3M), Taylor Swift 596.2M (54.8M) and Drake 11.1M (64.0M), and both audio dramas entered the watched top 10. daily_share_ok tests the figure against the act’s all-time total: over DAILY_SHARE_CAP (0.3%) fails. The healthy 2026-10-05 page topped out at 0.21%; the audio dramas read 1.0% and 1.47%. build_watchlist holds a failing row out of the daily ranking unless the act is also on the monthly-listeners page (daily_is_plausible), because that page is a 28-day figure and an act with a top-1000 audience is a real music act. A held-out act can still join through the listeners half. The published daily board (chart_boards.artist_streams_board) takes the stricter test with no listener exemption, because it prints the number: one failing row among its top 10 and the board yields. Both report through artist_filter with reason=daily_implausible (kind=watch from the known-list build, kind=board from the board lane), once per bad page generation (_report_doubted_daily, keyed on the page’s refresh time plus the doubted names), because the page is cached for hours and both callers run often. The light-milestone gate does not settle a rung off the daily position of an act whose total-streams rank is inside the tier (tier=total_top, #3868), so an under-read cannot move that rung either. A “Daily” figure that is too LOW is caught by the act’s own last trusted figure (#3881). The share test cannot see an under-read (Drake 11.1M, Bad Bunny 5.5M on 2026-10-02), and three one-page tests failed on the measured 2026-10-05 data: a floor on the share (Drake’s bad read is 0.0078% of his total, healthy acts go to 0.009%), a floor on daily streams per monthly listener (Drake’s bad read is 0.117, healthy acts go to 0.063), and the act’s songs page (kworb builds the artists page from the songs pages: the two agree to the M rounding on the top 120 acts AND on the under-read rows still live that day, such as Ella Langley at 145K). So KworbClient.spotify_artists checks each FRESH artists page once against a per-act store of last trusted figures (artist_watch.check_daily_drops, kept in kv_cache through DurableCache, namespace kworb_daily_baseline; MusicDesk.__init__ binds the DB because bot.py is protected). A figure under DAILY_DROP_FLOOR (30%) of the last trusted one is marked on the row (ArtistStat.daily_under_read, with daily_last_good) and reported (artist_filter reason=daily_under_read). A REAL decline still prints: the refused read is held as pending by its total, and a later read with a different total that is still low confirms it, so a real drop of more than 70% is hidden for one kworb refresh of that act. An under-read does not repeat, because kworb’s next refresh restores the figure. Only a figure that passes the share test becomes the last trusted one. Limit: an act with no stored figure (a new act, or a store lost with the DB) is not judged. One test for every published figure: artist_watch.daily_trusted (share test AND not an under-read). kworb.format_artist_standing (/ask and the desk’s artist blob) keeps the rank and the total and drops only the streams/day part (#3869). kworb.format_top_artists and chart_boards.artist_streams_board skip a too-high row (the board yields), and print NOTHING when an under-read act’s last trusted figure would place it in their top (under_read_displaced): a top list without Drake names the wrong leader. build_watchlist holds an under-read row out of the daily ranking with no listener exemption, and does not name it in doubted_daily (the client already reported it); the act still joins through the listeners half.retry.py, the one async retry primitive — retry_async(fn, ...), the single home for every transient-failure retry (no cog/util hand-rolls a for attempt in range(...) loop). It takes a zero-arg coroutine factory and retries on a caller-supplied exception predicate (each site keeps its own notion of “transient”: an aiohttp 403/429 for iTunes, an anthropic 529/timeout for the API, a bare connection drop — a non-retryable error still propagates at once) AND, optionally, a result predicate (for fetchers like markets._fetch_with_retry whose inner call swallows the error and returns a None sentinel instead of raising). Exponential backoff (base_delay·factor**k, capped at max_delay) + optional jitter, with leading_jitter adding a pre-first-attempt jitter to de-sync a concurrent burst (the apple_music pool-prefetch fan-out, #371). An optional retry_after extractor (order #2350) lets a caller whose retried result carries the server’s own stated wait (a 429’s Retry-After header) FLOOR the next backoff delay with that value instead of the blind exponential guess — still capped at max_delay, and only ever consulted on the result-retry path, never the exception path. utils.markets.KalshiClient._guarded_fetch is the first user: Kalshi’s default retry predicate (unlike SGO’s, which never retries a 429 in-call at all) DOES retry a 429 within the same call, so a stated wait is worth honoring mid-retry, not only when widening the breaker’s cooldown for the NEXT call (_note_retry_after, order #1339 follow-up). Only catches Exception, so CancelledError/KeyboardInterrupt propagate untouched. After the attempts are spent it re-raises the last exception (exception path) or hands back the last sentinel (result path) — the caller’s fail-open handling stays in charge. Pure control flow, emits nothing: it nests INSIDE an @instrument/timed_event so one domain event fires per logical call, not one per attempt. Current callers: claude_client._create_with_retry (anthropic 529/5xx/timeout, exp backoff), utils.apple_music._request_json (iTunes 403/429/5xx, jitter + leading-jitter burst de-sync), utils.markets._fetch_with_retry (the Kalshi paginated open-events walk — a (data, status) result predicate that retries 429/5xx/network but gives up at once on a permanent 4xx), utils.stt.transcribe (ElevenLabs Scribe transient timeout/connection, one retry), every bare-read outbound client via utils.http_retry (below), and the scripts/eval_* harness _ask helpers (a result predicate that retries past the empty-answer fallback).http_retry.py, the aiohttp-aware layer over retry.py — the read clients all share ONE notion of a transient HTTP failure (a 429 or 5xx status, or a raw connection/timeout drop), so the predicate + retryable-status set live here once instead of being copy-pasted into each. is_transient(exc) is the retry_on predicate (a permanent 4xx propagates at once); retry_http(fn) is retry_async pre-wired with it + a short exponential backoff (DEFAULT_ATTEMPTS=3, DEFAULT_BASE_DELAY=0.5s) — the single entry point the outbound read clients use. The convention for a client: the inner round-trip raises on a retryable status (resp.raise_for_status() guarded by status in RETRYABLE_STATUS) so retry_http rides it out, and returns a sentinel ((None, status)) on a permanent failure so it falls straight through to the existing fail-open handling — no behavior change beyond riding out a transient blip. Keeps the generic retry.py primitive aiohttp-free. Wired into the idempotent reads only — perplexity.search, embeddings.embed, gifs.search, link_enrich._fetch_json, github.get_issue, railway._gql(retry=True) (its list_deployments read; the redeploy write deliberately does NOT retry, since a re-fired mutation could double-trigger a deploy). External writes (create_issue/comment/close_issue/redeploy) and paid-asset POSTs (tts/image_gen) are left single-attempt on purpose.circuit_breaker.py, a minimal failure-rate circuit breaker (#615) for an outbound integration that craters from the bot’s datacenter IP. Three states — CLOSED (healthy, calls flow, outcomes tracked in a rolling window) → trips OPEN once the failure rate over the window crosses a floor (default 50%) with enough samples (so a couple of unlucky early failures can’t open it) → short-circuits calls for a cooldown → HALF_OPEN lets ONE probe through after the cooldown → CLOSED on recovery / re-OPEN on failure. Pure + clock-injectable (every method is synchronous so it’s atomic within the asyncio loop; tests drive the cooldown via clock=), fail-open by construction (it never raises — wiring it wrong degrades to “no breaker”, never a crash), and telemetry-agnostic (the caller passes an on_transition hook to wire state changes to its own event stream, so the module stays dependency-free + unit-testable in isolation). The reusable orchestration lives in the util, not the call site: breaker.call(fn, success=..., short_circuit=...) is the one-stop entry point — it owns the allow→run→record→short-circuit dance (short-circuits to the caller’s sentinel when OPEN with no call to fn, records an fn exception as a failure and re-raises, classifies a returned value via the success predicate so a permanent 4xx reads as a healthy “no”), so the next integration that craters wires a predicate instead of re-implementing the loop (the low-level allow()/record() stay public for advanced use). Guards four outbound clients, each behind its own breaker instance (a crater of one never stalls the others, nor Polymarket which has none): every KalshiClient read (#615) + every SportsGameOddsClient read (#725) in utils/markets.py, every ApiSportsClient read (#725, the live-scores + settlement backbone), and every TheOddsApiClient read (#725, now load-bearing as the /bet SGO-down slate source + settlement backstop). A sustained crater trips that source’s breaker and stops the hammering — no network call — for a cooldown, while reads degrade gracefully: SGO/Odds-API to a stale-on-error cache, API-Sports’ degraded routing bookie settlement to the Odds API. The SGO breaker is the answer to the amateur-tier 429 storm (window 30, min-samples 10); API-Sports matches it; the Odds API is slightly lower-volume-tuned (window 20, min-samples 8). The success predicate is per-client, not uniform: Kalshi’s and the Odds API’s treat a permanent 4xx (e.g. an unknown-ticker 404) as a SUCCESS (“a legit not-found”), since those per-ticker/per-event GETs can genuinely answer “no such thing” — only a 429 / 5xx / network failure counts against them. API-Sports and SGO (order #45) instead only count actual data coming back as success: every SGO endpoint (/events, /leagues, /account/usage) is a filtered LIST read that answers a bad/unknown filter with 200 + an empty array, never a legitimate 404, so reusing Kalshi’s “permanent 4xx = healthy” logic there meant a sustained 403 (a datacenter-IP block or a bad/revoked key) recorded as a SUCCESS and never tripped the breaker — silently disabling the Odds-API live-scores backstop and the SGO-down market-edge fallback that gate on sgo.degraded. So for SGO/API-Sports a 429 / 403 / 404 / 5xx / network failure ALL count against the breaker. Emits circuit_breaker (with integration) on each transition.rate_limiter.py, the async token-bucket rate limiter (AsyncRateLimiter, #843 / order #34) — the proactive companion to circuit_breaker.py: the breaker REACTS to a crater after the 429s land, this PREVENTS them by pacing reads so they never blow the upstream’s per-minute cap in the first place. A token bucket (up to burst tokens, refilled at rate_per_min/60 per second); await acquire() consumes one, sleeping until one is free — a burst up to capacity passes instantly, beyond it calls drip at the steady rate. Same conventions as its sibling primitives (retry.py/circuit_breaker.py): clock + sleep injectable (deterministic tests), fail-open by construction (rate_per_min <= 0 → acquire is a no-op, so a misconfig degrades to “no limiter”, never a hang), loop-FREE (sizes ONE sleep then grants, so it can’t spin even if a caller mocks sleep instant), serialized accounting (a lock makes refill-check-consume atomic and spaces concurrent waiters FIFO into the even drip), and telemetry-agnostic (emits nothing — its effect shows as the existing market_fetch rate_limited rate dropping). Wired into every SGO read via SportsGameOddsClient._guarded_json’s _do, inside the breaker gate, so a breaker short-circuit (SGO down/degraded) and the quota-exempt get_usage read spend no token — only an actual breaker-allowed network attempt paces, so the limiter is inert while SGO craters and adds latency only under a genuine live burst. Env-tunable SGO_RATE_LIMIT_PER_MIN (40) / SGO_RATE_LIMIT_BURST (10), <=0 disables; these defaults are calibrated to SGO’s documented rookie-tier cap (50 requests/min; 100k objects/month) — a steady 40/min + 10-burst keeps the worst-case rolling minute (burst + rate) at ~50, i.e. right at the ceiling with steady-state headroom, while the burst still passes a normal single-flighted fan-out unthrottled; confirm/fine-tune against real load via /debug/usage once SGO is live (a Pro-tier upgrade lifts the ceiling to 300/min). Kalshi got its own instance too (order #1057): wired the same way, inside KalshiClient._guarded_fetch’s _do, ahead of every retry/breaker-allowed attempt (a paginated open-events page and a live single-shot read share the one limiter, so the page walk’s own explicit 250ms inter-page sleep and the token bucket both bound the same traffic). KALSHI_RATE_LIMIT_PER_MIN (240) / KALSHI_RATE_LIMIT_BURST (8) default to the SAME 4 req/sec steady rate the open-events walk had already proven safe against the ~6 req/sec public-read limit Kalshi enforces (unlike SGO’s cap, this is empirically observed via live testing, not a number published in Kalshi’s own API docs — Kalshi does not document a numeric public-read rate limit; see the walk’s own pacing comment in utils/markets.py), with an 8-call burst (matching the Kalshi breaker’s min_samples) so a normal bounded fan-out (the category/topic pools’ Semaphore(3-4)) still passes instantly — the proactive complement to the existing Kalshi circuit breaker (#615) and jittered retry (#905), so a concurrent fan-out across surfaces (live commentary + /ask + market-drop firing the same tick) can’t self-inflict a 429 before the breaker ever reacts. Generic + reusable: the next metered integration instantiates its own.markets.py:SportsGameOddsClient.over_month_pace, the SGO month-pace governor (#3229). SGO moved from the paid rookie tier (100,000 entities a month) to the free amateur tier (2,500) on 2026-09-11, and the scoreboard + Bookie fan-out then spent each month’s 2,500 in about one day. The governor reads month_entities used/limit from the health cog’s usage poll (note_usage) and adds each successful /events read’s event count between polls. While usage is ahead of an even spread through the UTC calendar month (_month_pace_fraction, floored at one day’s share), every SGO entry point (get_event_odds, get_player_props, get_game_board, next_event_start, list_league_sports) skips the network through _paced_out (market_fetch reason=quota_paced, cache_hit=True), and degraded reads True. That one flag is what every consumer already gates its fallback on, so the scoreboard and upcoming fan-outs bail, the commentator’s market-edge beat reads The Odds API, and /ask says SGO is down. ESPN (always on, #3569) carries the games, The Odds API the pre-match lines, and the prediction markets the in-play line. Order of the gates: _shed_heavy_call (near-cap, heavy calls only) runs first, then the pace gate. Fail-open: no usage reading -> not paced. The reading survives a restart (#4187): after each poll the health cog calls save_usage, which writes {used, limit, year, month} to kv_cache (namespace sgo_month_usage, key reading, 40-day TTL). The first _paced_out of a new process awaits _load_stored_usage, which seeds the pace state from that row unless a poll already landed. Before this, a deploy had no reading until the first poll (~4s after boot), and in that time the active-league probes sent ~13 HTTP reads (~18 market_fetch events) against a spent cap, on each of ~27 boots a day. A row from an earlier UTC month counts as no reading; a failed poll keeps the row; a poll with no finite monthly cap deletes it. The clock is the module seam _pace_now so tests freeze the date. Hub order (owner steer, #3229): bot.py hands the hub API-Sports, then ESPN, then SGO, so the score feeds win score, status and schedule wherever they cover a game. SGO is DEPTH: _enrich_odds carries its line onto the kept row, its own row survives only for a game no score feed carries, and props and the deep board stay SGO reads. tests/test_sportsdata_hub.py::test_bot_wires_score_feeds_ahead_of_sgo pins the order.markets.kalshi_event_meta, #2665). fold_kalshi_winner_snapshots (the /ask Kalshi tools) and betting_board.kalshi_snap_by_ticker (the bet-board Kalshi card) build one snapshot for a whole event from legs[0], which is whichever leg the fetch returned first. They used to copy its whole meta, so the event carried that one leg’s yes_label, ticker, market_subtitle, price fields and volume. market_subject_image._featured_label reads yes_label FIRST, so a bet-board card could name the underdog as its subject, and /ask printed one leg’s 24h volume and move on the event row. The helper drops the per-leg keys (_KALSHI_LEG_ONLY_META) and sums volume / volume_24h / open_interest over the legs. The drop and alert folds (market_drop._kalshi_leaderboard, market_alert._race_snapshot) already rebuilt meta from scratch. Consequence: /ask’s kalshi_market_detail on an aggregate now renders the event’s outcomes instead of fetching an arbitrary leg’s book./events?with_nested_markets=true DOES nest the raw market rows (#2704). KalshiClient.get_series_events_raw(..., with_markets=True) asks for them and music_markets.nested_event_markets reads them. Measured 2026-10-25: across 52 events in 10 music and RT series, open and settled, the nested set equalled /markets?event_ticker= ticker for ticker and key for key, and a 133-market event nested all 133. The music-desk sweep (_run_sweep), the field-market read (_fetch_field_markets) and the chart-history daily snapshot now read the nested rows and skip the per-event fetch. A live desk sweep went from 121 per-event fetches to 25 with identical readings; the 25 left are events with zero markets, where Kalshi omits the markets key, so the code falls back to the fetch (absent is not the same as empty). An old comment said the /events view did not nest; that was false.kalshi_ladder.py, the ONE definition of what a Kalshi scalar LADDER is (#ladder-forecast) — i.e. “is this leg one RUNG of a ladder, or a standalone proposition?”. A scalar market isn’t one contract, it’s a ladder of “Above N” rungs over a single underlying number (one film’s Tomatometer, one album’s first-week units), and the rungs are a survival curve, not independent propositions. One subject per ladder (the KXMLABELSHARE guard, 2026-08): Kalshi also ships threshold-SHAPED events whose legs are thresholds over DIFFERENT subjects (“UMG above 56%” / “SME above 28%” / “WMG above 16%” — three labels’ market shares in one event); shape-wise they pass is_threshold_ladder, and the monotone clamp flattened the cross-subject prices into a plausible curve, so ladder_forecast shipped a fabricated “forecast ~19.8%” for “the leading label” while UMG was the actual leader. Two guards now refuse the read (absent over invented): rungs_share_subject (the rung labels’ non-numeric residues must agree — the semantic tell) and a clamp tolerance in implied_estimate (LADDER_CLAMP_TOLERANCE 0.15 — a rung priced 15c+ above a lower strike’s is a contradiction no one distribution produces, not thin-book noise; refuse, never repair). A crossing read across a HOLE in the market is not a forecast (2026-09-07, the Bass Persuades pure-sales card). The price-trust rule drops a rung with no trustworthy price and the curve interpolates across the gap, which is right for one or two thin rungs (the petal case) and wrong for a board nobody has traded: the Bass Persuades (Miley Cyrus) first-week PURE SALES ladder, 20 rungs from 20K to 400K, carried exactly two prices (20K at a lone 0.75 print, 400K at a 0/3c book) and eighteen 0c/97c never-traded rungs between them, so the 0.50 crossing drew a straight line 0.75 -> 0.015 across the whole 380K span and read ~149.3K. It shipped to X as “Market’s calling for ~149.3K pure sales” one hour before the SAME album’s fully-priced album-equivalent-units ladder (23 of 23 rungs priced, thousands of contracts) posted ~52.1K – and pure sales are a component of album-equivalent units, so the pair was impossible on its face. implied_estimate(brackets, strikes=...) now takes the ladder’s FULL strike list and refuses the read when more than MAX_UNPRICED_GAP (LADDER_MAX_UNPRICED_GAP, 2) unpriced raw strikes sit inside the interval the crossing was interpolated between (unpriced_in_crossing_gap); ladder_forecast and luminate.parse_event_reading pass it, the candle-trajectory readers (which only see rungs that traded) do not. It is a GAP rule, not a coverage floor, on purpose: a board with three priced rungs adjacent around its crossing has formed exactly where it matters and still reads, while a 60%-priced board whose crossing falls in its unpriced block does not. Measured on the live population the day it shipped: 63 of 3,394 readable ladders refused (1.9%), every one a handful of prices with a run of empty rungs between them (index price-at-time boards, quarter totals, state gas prices); of 84 music ladders, 2 – both Bass Persuades (the pure-sales board and its 6-of-10-priced album-duration board). The music sales pair also carries the sibling tell the incident exposed, now a backstop of its own (#3069): a pure-sales forecast above its own album-equivalent-units forecast is impossible (pure sales are one component of units), so music_markets.contradicted_pure_sales names the pure reading of any such pair (joined by release_pair_key, never subject text) and fetch_desk_readings drops it at the ONE sweep every consumer shares (the desk slate, the newsroom’s reading_for_subject, the chart boards) with a market_filtered reason=pure_over_units event; sales_projection_slate repeats the drop on the pure side for a caller that hands it readings from elsewhere. Only the pure side is ever dropped: on every live pair measured the units ladder is the deep, traded one. The cap is the units band’s UPPER crossing (estimate.high), not its point forecast, and an open-ended units band caps nothing (Codex on #3073: a thin units ladder’s point figure is too noisy to be a hard ceiling; a pure figure above the units’ upper quartile is a contradiction the units market itself stands behind – Bass still trips it, ~149.3K against a units high of ~81.8K). The band crossings get the same gap test as the headline (Codex on #3074): implied_estimate reads a 0.75 / 0.25 crossing interpolated across more than MAX_UNPRICED_GAP unpriced rungs as None, so a hole under the upper quartile yields an unknown bound (no cap, and is_confident_projection False) rather than a fabricated one. The same-series shape (both metrics as two events under ONE series) is NOT joined, by measurement: on 2026-09-07’s 11,306 open events KXPUREALBUMS and KXALBUMEQUIV each held 29 events and no series carried both, and sales_projection_slate has always joined on the suffix for the same reason. The alert cog does NOT seed a baseline off a live reading with no estimate (music_markets.has_baseline, Codex on #3073): baseline_for would store 0 and classify_music_signal’s ref_median > 0 gate never overwrites it, so the event would lose its move lane for good; the first readable tick seeds instead. A release with only a pure ladder has nothing to contradict it, so the owner steer that a thin pure-sales number still posts stands. And a partial-resolve event whose active ladder the gap guard refuses keeps its MEASURED count (#3070, Codex on #3065): parse_event_reading returns the reading with estimate=None and value_so_far / crossed_strike set, format_reading reports the measured count without a projection, and every projection consumer already treats a None estimate as nothing to project (the 11 reading.estimate.<field> reads in utils/ + cogs/ were audited: all guard None). The pure-sales lane is deliberately NOT confidence-gated (_sales_projection_keep, owner steer: a thin pure-sales number still posts) – the gap guard is a different question (“is there a curve here at all”), so the steer stands. Such an event gets NO ladder forecast and does NOT confirm as a ladder (is_threshold_ladder = shape AND one subject), so market_alert’s not-a-ladder path reports each leg as its own named binary with the event-leader standing clause (“UMG leads”) — honest coverage instead of silence. The market_drop lone-leg binary guard no longer asks about SHAPE at all: it counts the event’s LEGS (len(siblings) > 1 -> refuse), because a binary is an event with ONE market and the picker’s own filter counts PRICED ones. The shape-only is_threshold_family test it replaced saw a threshold family but not a family whose legs are NAMES – the same bug with no numbers in it, and the one that shipped the WARDOGS card (#2663) (see market_drop._kalshi_binary and the entity_image place/thing rung below). A lone rung with NO siblings in its own event is untouched, so the companion-ladder path (#2490) still owns it. Reading one rung as a standalone market is the trap this module closes: an at-the-money rung is a knife-edge that amplifies a tiny move in the underlying into a huge move in its own digital odds. Live-measured on KXRT-SPI: the figure Kalshi publishes held flat at 90 while the “Above 90” rung swung 66%→48%, because the distribution merely tightened AROUND 90 (rung 89 85→73, rung 90 68→54, rung 91 39→15) — and cogs/market_alert read that rung and posted “a favorite falling to a coin flip” against a market whose published number hadn’t moved in two weeks, three times in three days off the same film. is_ladder_rung(meta) is the tell, hoisted from cogs/market_drop._is_threshold_leg (#1011, which already refuses to narrate this shape) so the alert surface applies the SAME test instead of growing a second copy — two tells, since Kalshi marks ladders inconsistently: a NUMERIC strike (numeric strike_type / floor_strike / cap_strike) OR a threshold-PHRASE label (“Above/Before/More than X” — the only signal on a DATE ladder, which carries no strike metadata). An ENTITY leg (a plain candidate name) is neither, and stays headline-able. What a ladder DOES forecast — the survival curve’s 0.50 crossing (ImpliedEstimate.forecast), the figure Kalshi prints — now lives HERE too (ladder_forecast / implied_estimate / is_material_forecast_move), hoisted out of utils/luminate.py where it was proven on the music ladders and re-exported there for its existing callers, since the math is generic over any (strike, price) ladder. That’s what lets cogs/market_alert alert on the thing that actually moved instead of just suppressing rungs. The headline is the CROSSING, not E[X] (#2817, 2026-09-01). The headline was the mean E[X] from #1863 to #2817, chosen on the Tyla A*POP fixture (median ~4.8K vs a published ~9,028) and calibrated on boards that were TIGHT (Spider-Man RT, the Bieber views ladder), where the mean and the crossing agree — so the choice was never tested. The Ellie Goulding album-units board tested it: a thin ladder (~$4.5K volume) whose 20K/25K/30K rungs all sat at 0.25 read E[X] 19.4K, shipped to X as “~19K” under a ✓ Kalshi badge, while Kalshi’s page printed 11K. Rebuilt from ten days of hourly candles, the crossing tracked Kalshi’s line (10.5K–11.4K) within ~3% every day; E[X] doubled 13.6K→26.8K the afternoon someone bought the tail rungs from 3c to 25c, and Kalshi’s line did not move. The alert that shipped was itself a mean artifact: ~$300 of trades took the 50K rung 0.11→0.05, the mean fell 19% (over the 15% bar), Kalshi’s figure moved ~200 units. Across the 60 open album ladders the mean runs 1.2x–3.1x the crossing on most boards; only the two $100K+ boards agree. So every headline consumer (market_drop companion projection, market_alert forecast_value / forecast_move / outcome grading, the music desk’s projection figure + ledger, music_news, chart_boards, luminate.forecast_series) reads est.forecast — the un-rounded float crossing (a CPI ladder’s 3.438 keeps its resolution, #1864) — and est.mean stays on the dataclass as a DIAGNOSTIC only (dry-run scripts print both). The cost the mean was chosen to avoid (the crossing hops a strike bucket when a thin low rung matures) is absorbed where it already was: the alert’s two-tick persistence + 24h pacing, the music lane’s PERSIST_PERIODS + is_confident_projection gate. Stored baselines name their statistic: market_forecast_alerts.ref_stat / music_alert_state.ref_stat (default 'mean' for rows that pre-date the switch); the readers treat a non-'forecast' row as unseen and the seeds upsert the value while keeping the row’s clocks, so the switch re-baselines every open ladder silently instead of firing a 30–50% “move” on every one the first tick after deploy. Kalshi exposes no API field for the printed forecast (v1/events/{ticker}/forecast_history exists but rejects every parameter shape tried), so the candle rebuild is the calibration method; tests/test_luminate.py carries the Ellie ladder as the golden. The crossing is not a perfect reproduction of Kalshi’s formula either — on the Spider-Man RT book it reads 90.6 against a 90.1 print (E[X] read 90.06), a 0.6% gap inside one rung’s interval — but its error is bounded by one strike gap, where the mean’s is bounded only by tail-rung liquidity (the 2x miss). Kalshi’s exact method is unknown; if a board ever shows the crossing off by more than a strike gap, re-derive from candles before changing the statistic again. The E[X] right tail is scaled by the ladder’s own SPAN (top − bottom strike), not the top strike (#forecast-card-copy): the old E[X|X>T]=2T tail is only sane on a from-zero ladder (span≈T — the Tyla 1K..10K calibration held under either model), and on a NARROW-BAND board it fabricated — the live Bieber weekly-views ladder (15.25M..17M, top rung ~0.31 survival) read 21.9M against the ~17M Kalshi’s own card printed; span-scaled it reads 17.1M (+0.7%), Tyla 8.46K (−6% of the published 9,028), and a thin-top ladder (Spider-Man, S(top)≈0) is untouched since the term vanishes. Strikes are FLOATS end to end (same PR): an int(s) key collapsed every fractional-strike ladder’s neighbouring rungs onto one integer (live: natgas $2.705/$2.75 → “2”, the board reading “forecast ~1” against its own 83%-to-clear-$2.705 rung) — invisible while the only readers were the integer-strike music boards, caught by the awakened alert path’s population sweep; and the alert’s rendered figure follows the BOARD’S OWN units read off the rung label (market_alert._forecast_figure: a unit-scaled Netflix “At least 18 million” board renders “18.9M” not a bare “19”, a $-commodity board keeps its $). A FAT-TOP ladder posts the OPEN form, never its point E[X]: when the survival curve never crosses 0.5 inside the board (open_high — the market’s own median lies beyond the top strike, e.g. the NCAA season-wins boards topping at 18 with that rung at ~0.98 for a ~25-30-win answer), the board only honestly says “above crossing_rung_tickers / crossing_rung_tickers_from_legs name the rungs that set the 0.50 crossing (the highest rung priced at 0.50 or more, and the lowest under it, priced with survival_price so they are the rungs the forecast read), and move_is_traded decides: at least market_move_min_contracts traded in the last market_move_window_min minutes (per-guild tunables, defaults 1 and 60; 0 contracts = off). The ONE call every move lane makes is utils/markets.py:traded_behind_move: it reads KalshiClient.get_recent_volume (one batch read of 1-minute candles through the guarded fetch), decides, and emits market_filtered reason=no_trades on a cut. Callers: market_alert _pick_forecast_move (crossing rungs; after the persistence debounce, which stays standing), market_alert _fire for a Kalshi swing or conviction break (the leg’s own ticker; after _verify_move), and music_alert _run_readings for a line_move (crossing rungs off reading.rungs; after the cooldown and album deferral; the pending count stays). A FAILED trade read fails OPEN: it is a read path, and a candle outage must not silence every move lane; the ops monitor’s kalshi_trade_read rate watches it. Why: the music ladders are thin. On 20 open first-week units ladders (2026-10-23), moving the crossing rung 5c cost a median $32, at most $92. On the owner’s case (Watch Dis, 2026-10-12) new quotes on the 10K and 15K rungs moved the reading to 14.3K and music_alert posted “14K” at 14:23 with zero contracts traded in the hour before it (the test fixture _WATCH_DIS_BOOK). Phase 1 measured 239 shipped forecast posts against minute candles: no depth attribute (open interest, 24h volume, spread, 5c sensitivity) separated the 14 that moved 10%+ in the next hour from the stable ones, so there is NO depth floor. The owner chose this traded-move rule: it cut 25 of 58 forecast moves and 1 of 23 swings in the window. The same rule on every READING would cut 152 of 239 (64%), so readings are not gated. Not covered: the lead_change lane, market_drop boards and the scheduled projections (readings, not moves); music_desk has no move lane.kalshi_ladder.companion_pair_reason, emitted from market_drop._companion_forecast). Every lone-leg pairing emits companion_pair with reason paired / tie / no_events / unparsed / no_partner. unparsed = the companion series has events and not one sub_title has the shape the match reads (companion_sub_title_parses): the Kalshi title-template change that used to break the pairing with no trace. The ops monitor flags unparsed only as kalshi_companion_template; no_partner (a ladder not open yet) and no_events (nothing open in the series between periods) are normal states, not fails. The ladder math and the match rules are unchanged.market_outcome.py, the decided-ladder OUTCOME lane’s pure core (#2655): when ladder_is_decided fires (the event happened, Kalshi unsettled — the GTA VI runtime settle-lag window), don’t just suppress the stale forecast (#2654), HUNT the real result and post it against the market’s forecast, hours before Kalshi settles. The trust model (owner steer 2026-08-28 “kill grok if you have to”): the WEB leg (Perplexity) must fully land — a non-X publisher citation + a number extracted by its own Haiku call (claude.outcome_extract) — and the confirmed value must match the COLLAPSED BOARD (market_consistent): the press and the market’s own traders are the two independent authorities. GROK x_search is the BONUS corroborator, not a gate — the first dry run showed X doesn’t carry niche numbers (3 of 5 real settled outcomes declined on the X leg while the web read matched settlement exactly every time) — so a silent X leg proceeds (recorded as x_miss/corroborated for telemetry) while an X read that SPEAKS a different number still vetoes (disagree, never post through an active conflict). Each read gets its OWN extraction call so a transcription slip can’t manufacture agreement; the confirmed value is the WEB figure (never an average nobody reported). market_consistent is the wrong-subject guard, with a corroboration-GRADED tolerance: a decided board is near-certain about its value, so a confirmed number far from the collapsed E[X] means the search answered a DIFFERENT question (a sequel’s runtime, the company comp instead of the brand comp) — decline, never “the market was wrong”. X corroborated → ~1.5 rungs (the board is a third voice); X silent → the strict half-rung bar sources must meet, because the board IS the second source (the live URBN catch: an uncorroborated 8.4 company-comp read against the 6.2 brand board sat inside 1.5 rungs and would have posted wrong). The card grades the PRE-collapse forecast (market_forecast_refs, the same baseline the forecast-move lane alerts off): the collapsed board’s E[X] copied the answer, so quoting it as the forecast would be fake precision — it serves only the consistency gate. compose_inputs/format_block follow rt_reconcile’s doctrine verbatim (say the number plainly in every branch, two publishers never blurred). card_fields matches it again on WHICH figure heroes. rt_reconcile was inverted on owner steer 2026-08-31 to hero the FORECAST, and the owner reversed that on 2026-09-14 off two live posts – The Uprising heroed its ~59.8 forecast over the 52% that printed, Runner its ~53.3 over 71% (“these numbers are wrong… it should obviously be the actual final value, not the forecast”). Every settlement card now heroes the number that PRINTED and demotes the forecast to the standing strip: called_shot (the chart position), market_outcome (the reported result), and rt_reconcile (the settled Tomatometer). RT’s tag took the called_shot shape, “Rotten Tomatoes - Final”, and its chip credits Rotten Tomatoes, who published the hero – which stamp_is_redundant then suppresses, since the tag already carries the name. The forecast keeps KALSHI’s name on the strip, so the card still never credits one publisher with the other’s number. The rule to apply to a NEW settlement lane: the fact is the hero, the claim it checks is the context. The shared rule is the one underneath: the eyebrow and the source chip name whoever published the BIG number — so the RT card’s labels followed its hero BACK to Rotten Tomatoes on 2026-09-14, and this lane’s hero stays the result and keeps crediting the result’s publisher, and the card’s source chip credits the RESULT’s real publisher — the web read’s first cited domain (cited_host) — never Kalshi, which is unsettled and supplies only the forecast. Every decline is a named market_outcome event and falls back to #2654’s suppression (absent over invented). Cost bounds: the market_outcome experiment (default STAGING), a durable per-(guild,event) attempt state (market_outcome_attempt kv namespace via DurableCache — posted/rejected terminal under a 30-day TERMINAL_TTL; declines carry a 6h retry deadline ENCODED in the value — encode_retry/attempt_blocks, restart-proof against the L2→L1 default-TTL re-pin), 1 paid hunt per guild tick (one budget across every room + pool). Round-2 review hardening: the web leg must cite a NON-X publisher (cited_host skips _X_HOSTS — an X-only web read can be the very post Grok reads, one rumor counted twice); a stored PRE-collapse market_forecast_refs baseline is REQUIRED to post (and the move lane never seeds a decided ladder’s collapsed E[X] as a baseline — a false forecast history would grade itself exact); format_value carries the unit on every rendered figure (‘26 minutes’, ‘$56.25’, ‘3.5%’), with ladder_unit accepting only a residue that TRAILS the number (a leading residue is the SUBJECT — ‘petal’ — not a unit) and recovering %/$ symbols. The cog half lives in cogs/market_alert.py (_maybe_post_outcomes → _resolve_and_post_outcome → _post_outcome, the _post_reconciliation spine on a search-confirmed result); it rides the market_alert tick — the tick’s pools ARE the collapse detector, so market_alert OFF / a spent alert cap idles the hunts, and the trending pool’s contested-band narrowing is bypassed for discovery by the snapshot-price pre-tell (_collapsed_candidate_events) so a slow collapse only visible in trending is still found. The ENTITY twin (the Emmy incident, 2026-09-06; trial key market_outcome_entity, default STAGING): ladder_is_decided needs numeric strikes, so a WHO-WINS field (an award, a race – every leg a NAME) could never enter the lane. Live, the Guest Actress in a Drama Series market went 55 -> 98 during the Creative Arts ceremony: the swing lane posted “tracking toward” at 88 mid-collapse, every later tick dropped the 98 leg as soft_price (a decided book is one-sided by construction – everyone holds YES), and nobody posted that she won; the Guest Actor sibling (Ernest Harden Jr., +82) never posted at all. The owner’s reference shape is a wire desk’s one-liner: “Ernest Harden Jr. is a first-time Emmy winner.” Three parts, one class: (1) the tell, entity_is_decided, read off the SAME event fetch the race path already makes (_RaceInfo.decided): exactly one leg a lock (>= DECIDED_LOCK_PRICE, pinned equal to SETTLED_LEADER_PCT) whose price the book stands behind – lock_supported: either the live conviction test (bid_supported, a bid within 10c) or the DECIDED-BOOK signature (ask >= 0.98 and bid >= 0.70: nobody sells a known YES under the line while bids relax overnight; live at 03:50Z three of the night’s four decided Emmy fields sat at bid 0.76-0.88 under a 1.00 ask and the tight-bid test alone refused them; the phantom-96% shape, ask 0.96 over bid 0.20, passes neither) – every sibling dead (<= 5%), and the lock NEW – its previous_price under the lock line, so a category that has read 97% for a month is not a fresh result and the tell switches itself off a day after any real one (a leg with no prior is refused: unknown is not new). (2) the hunt, resolve_winner: the same two-source shape extracting a NAME from the market’s CLOSED slate – claude.winner_extract can only return an index into the candidates or null, so a search read can never introduce a name the market did not price; the press pick must match the lock leg (names_match, the wrong-subject guard, or market_contradicted), X corroborates or vetoes (disagree). The lane runs only on an event Kalshi marks mutually_exclusive (the flag rides every pool leg from the events payload; absent = refuse): an inclusion board (“Artist(s) with a #1 song”) can settle several legs YES, so a lone lock there is one artist’s fact, not the event’s result. A decided field seen only in the TRENDING pool is discovered by _collapsed_entity_events, the entity twin of the ladder pre-tell, off the raw pool before the contested-band narrowing. The cog CLAIMS a decided event whatever the hunt returns: its legs leave the swing pool (market_filtered reason=decided_entity) and its fold leaves the lead-change pass, so a lock is never narrated as a forecast and never posted twice; OFF leaves the pool exactly as before. (3) the final notice: the moment the tell fires the cog writes a market_outcome_calls ledger row (free, idempotent), and the settle sweep on reconcile_tick reads each pending row against Kalshi’s raw markets (settled_winner: a leg result=yes under determined/settled/finalized) and posts the OFFICIAL result – source chip Kalshi, official=True – only when the press hunt never shipped (a row whose attempt state is posted closes silently; the two delivery paths run on different ticks, so each stores a 15-minute HOLD state – retry:hunting: / retry:settling: – before it composes and re-reads the state right before it ships (held_elsewhere / _taken_elsewhere), the other side standing down on a live hold; the sweep reads the whole field in one 1000-row page and leaves a page-filling field unjudged rather than read void off a cut page; the queue orders by checked_at so a field that never settles cannot starve a newer one; on a flip – Kalshi settling on a leg other than the lock – the settled leg is the official winner and posts, with detail.flipped=true for the panel; a field settled with no YES closes void). The card heroes the winner’s NAME over the winner’s photo (the art snapshot names the leg via yes_label, so the resolver photographs the person, not the category), eyebrow the CHART the market’s words name (market_chart_tag: “YouTube Global music videos”, “Billboard Hot 100”) else the outcome word “Winner” — it read a generic Final until 2026-09-09 (owner, on the Dai Dai card: “replace ‘final’ with the actual outcome or chart”; the market number card had moved its tag onto the chart the day before and the FINAL card had not; the numeric card_fields card wears its metric or “Result” the same way), NO sub-line; the art snapshot also carries the winning leg’s raw Kalshi subtitle (market_subtitle, 2026-09-07, the Choosin’ Texas card): on a Billboard chart market that subtitle names the crediting artist (“:: Ella Langley”), and market_subject_image.subtitle_catalog_hint reads it as the deterministic cover rung. Before this the lane’s snapshot named the leg and nothing else, so the KXTOPSONG-26SEP19 winner card resolved through the classifier (no artist named, so no subject), the photo search switched off, and the platform-logo rung shipped the Billboard WORDMARK to X as the art (market_subject source=wiki_logo, the X media gate passed it as coherent because the card’s TEXT matched the take). The subtitle rides EntityLeg.subtitle -> DecidedEntity.subtitle -> WinnerCall.subtitle on the press-hunt path and SettledField.subtitle -> WinnerCall.subtitle on the settle sweep, so both winner cards get the cover; an awards field has no subtitle and the ladder runs as before. The drop and alert lanes had stamped the same hint off their leading leg since #thin-ahead-art; this lane was the sibling that dropped it; the compose framing asks for one plain sentence – the winner, what was won, as a done thing – with no market and no number anywhere, and forbids the career tally the block cannot ground (“4x winner”). No market, no percentage, on the line or the card (owner call 2026-09-06: “remove the market sell on a percentage, and the percentage”). Mechanized twice: the composer’s block carries no number and no source (“(per Kalshi’s settlement)” came straight back as “the market … just settled on him” on the first live sweep), and has_market_talk drops a line that quotes a %, names Kalshi or a market, or uses the odds/forecast words – after ONE re-ask that names the offending word back to the composer (market_talk_phrase; the measured pattern: naming the phrase removes it, “vary it” primes it). The self-gate is the lane’s OWN judge, claude.winner_line_score: a persona-less Haiku faithfulness check (it also carries the #3427 working rule since #4228, reason WORKING:) (“does this sentence state that WINNER is the result of QUESTION, and nothing else?”, told to treat the fact as true and never use its own knowledge of who won). The path to it is the record: the bare line scored 0.00 under the scorecard rubric (it requires a forecast figure) and 0.20 under the report rubric; the report judge, which runs with the persona attached, then failed three TRUE winners in three live ticks on the Creative Arts Emmys – two as “not her lane” (the constitution’s STAY IN YOUR LANE line read as a topic gate) and one by asserting a different winner from its own memory (“the actual winner was Jon Hamm”) – the judge-error class docs/PROMPT_OPTIMIZATION.md names, and one no rubric wording moved (two rewords measured). The faithfulness judge behaved on 9/9 cases (five true winners 0.95 stable across n=3, a different name 0.0, a career count 0.35, a hedge 0.15, a trailing question 0.2), and the live re-run then shipped four of tonight’s winners at 0.95 and the G7 official final. The report rubric keeps the two clarifications made along the way (a fact the ground truth states is grounded even when it is news to the judge; the subject is never a reason to score low), measured at its settled-out baseline (15/20). The prior (WinnerCall.prior, the ledger’s prior) is telemetry only. Both lanes share _ship_outcome_card (compose -> shape guard -> number backstop -> the lane’s pre-gate + scorer -> art -> _ship_card) and the market_outcome_attempt state (an event is one shape or the other, never both); the entity lane has its OWN per-tick hunt budget (_MAX_ENTITY_HUNTS_PER_TICK, 3 – results cluster on an awards night, six fields in one hour, and one per tick posted the last winner two hours late). Telemetry rides market_outcome with kind=entity; the panel splits on it.kalshi_price.py, the ONE definition of “is this Kalshi yes price trustworthy”, shared by every reader of a Kalshi book — the market snapshot (markets._kalshi_market_to_snapshot), the candle trajectory (markets._candle_mid), and the survival ladder (luminate._survival_price). Kalshi hands three price signals per market (the two-sided yes_bid/yes_ask book + the last TRADE) and neither is trustworthy alone, so the rule lives here once: a TIGHT two-sided book (spread ≤ WIDE_SPREAD 0.10) → the MID; a WIDE/one-sided book → the last trade only when it’s a real in-book print (within LAST_TRADE_TOL 0.05 of the book, since on a wide book the mid is a price nobody would trade at — bid 0c / ask 97c mids to 48% on a rung really worth ~95%; for a CANDLE that trade is the period’s CLOSE, never its volume-weighted mean — 2026-09-21, a 50.76c mean over a 6c/50c book passed the check and ended the Drake Oct 3 trajectory at 51% while the last print was 22c); otherwise NO price, and the caller drops that sample (a dropped rung/candle beats a fabricated one — the ladder interpolates across the gap, the chart omits the point). traded_price is the companion guard: Kalshi reports last_price_dollars of exactly “0.0000” on a market that has NEVER traded — a no-trades SENTINEL, not P=0 (the 1c tick is the floor, so a live market can’t sit at a true 0). Reading that 0 as a probability is what cratered the petal (Ariana Grande) album-units forecast from ~371K to ~208.8K: four never-traded mid-ladder rungs briefly lost their bid, _survival_price fell through to the 0, and implied_estimate’s monotonic clamp propagated it up the WHOLE ladder — truncating the survival curve just above 200K, which shipped a false “steep cut” line-move alert and a forecast chart that plunged at its anchored right edge. Dry-run-verified against the live ladder: the same bid-pulled state now reads 358K vs the healthy 356.6K (a ~0.4% wobble instead of a 44% crater), and the healthy figure moved 371.4K → 356.6K, i.e. CLOSER to Kalshi’s own published 351K, because the far tail now reads its real ~0.005 book instead of stale 0.02–0.05 last trades. Swept across EVERY live Luminate music market (34 readings, 47 series): 6 MORE were collapsed by the same bug at that moment (KXARTISTSTREAMSY yearly-streams events, each recovering 55–93% — Bad Bunny 32.2B→50B, Bieber 18.6B→31.9B, Billie 11.4B→20.1B), 15 were unchanged, the rest moved <10% (the far-tail cleanup). The second failure shape the sweep exposed is an EMPTY market: a zero-volume event quoted 1c/97c on every rung (PRIMA by ADÉLA) has no market price at all, yet the old read shipped a FABRICATED figure either way — the 1c/97c mid read as 49% on every rung (~10K), and with the bid at 0 the never-traded 0 read as P=0, whose trapezoid first segment is exactly the “~500 pure sales” the desk posted. Now every rung is unusable → NO reading → the desk posts nothing, the fail-safe direction (a dropped rung beats a fabricated one, and a whole dropped market beats an invented number). Pure, unit-tested. Two more readers wired in (2026-08-10, the Kendrick 4% print): the rule is about BOOKS and PRINTS, not about one exchange, so it now also backs (a) the music desk’s FIELD markets (music_markets.field_outcome_pct, which quoted the raw last_price_dollars) and (b) Polymarket’s two-sided case (markets._extract_market_yes_price, which took the mid with no spread check). The incident: one 266-contract order printed 4c on KXALBUMRELEASEDATE-NEWKEN-27JAN01 at 17:26 UTC while the book never left 59/62; the next trade cleared back at 59c and the market still reads 59c. @Kalshi_Music tweeted the 4c snapshot, the newsroom composed “before 2027 at just 4%”, and only an unrelated gate stopped it — nothing on that path questioned the price, because the raw last trade WAS the price. The shared rule reads that same market at ~60%. Measured before wiring, on Polymarket: 85.5% tight (unchanged) / 1.5% wide-with-an-in-book-last (now the trade, was the mid) / 0% dropped, over 400 live open markets. Polymarket’s ONE-SIDED fallbacks are deliberately left alone: book_yes_price reads a half-book as no book and would change 13% of live Polymarket markets, which is a separate call with its own blast radius. The market_drop wired in (2026-08-11, #2299): the drop’s Kalshi paths read the raw meta["last_price"] at six sites, and those sites DISPLAY the number – a leaderboard board builds outcomes and chart_specs from it, and the binary path renders it as the question’s percentage. #2287 called them ranking sites and left them; they rank AND render. market_drop._leg_pct is now the one place the cog asks for a leg’s percentage, it returns snap.probability, and every caller DROPS a leg the rule refuses (a board with one fewer bar beats a board with one invented bar). Measured over the 7380 legs of the 1008 live leaderboard events the drop picks from: 2561 legs (34.7%) rendered a percentage more than a point off their own book, 171 (2.3%) more than ten points off, 69 showed a price the rule refuses outright, and 48 boards (4.8%) led with the wrong leg. The clearest live case is the Peyton Watson next-team board: it led with “Milwaukee 51%” off a 51c print while Milwaukee’s book said 20%, so the board named the wrong team and overstated it by 31 points (it now leads Cleveland 29%, Milwaukee fourth at 20%). Coverage is close to flat – live, leaderboard events go 1007 -> 940 and binaries 890 -> 900, because the trusted rule also ADMITS legs that never traded but carry a firm book. Still on the raw print, deliberately: market_alert’s last/previous_price/price_change trio and utils.market_triggers move detection. A trusted DELTA needs a trusted PRIOR price, and Kalshi’s previous_price is a bare trade print with no book beside it, so there is nothing to validate it against – that is its own piece of work, not a rename (#2299 step 3). The change LINE too (2026-08-11, #2308): the snapshot’s price_change was last_price - previous_price, so after the drop started rendering probability a board could state a percentage and a move measured to two different numbers – and the change line reaches a post through _format_change (“up 7 pts 24h”). It is now probability - previous_price. Live, of the 674 boards rendering a change line, 322 (47.8%) were off by more than a point, 48 (7.1%) by more than five, 17 (2.5%) by more than ten – and the worst were self-contradictory, not merely imprecise: KXNFLSEASONRECYDS-27C1000 showed “11%, up 62 pts” (up 62 from a 18 prior is 80, not 11; the real move is 7), and KXIPODISCORD-27JUN01 showed “69%, down 35 pts” when the market had RISEN 10 points, so the arrow pointed the wrong way. The PRIOR stays raw on purpose: Kalshi sends previous_price with no book beside it, so the snapshot builder has nothing to validate it against. The alert path solves that properly and already did – _verify_move re-reads the prior off the candle series, takes the current price from probability, writes all three fields back, re-classifies, and FAILS CLOSED with no history (#1829/#2288), which is why every market_alert site reads corrected numbers and needed no change here. This is the cheap screening delta, so it fixes the half it can see. Screening effect: swing-bar candidates 381 -> 357 (43 dropped, 19 newly surfaced, 338 kept), and since the alert re-verifies every candidate against candles this moves mover COVERAGE, never what a verified alert asserts. trusted_price on the meta, and the class sweep that came with it (#2308, caught by the PR reviewer): price_change measures to the trusted price, but market_triggers.classify_market_event takes a META DICT and read now off the raw last_price – and EVERY directional branch in it is a LANDING test on now. The mix made the landing judge a number the delta was never measured against: a 0.76/0.80 book whose last trade and prior are both a stale 0.94 gives a -0.16 delta, but now=0.94 fails the now <= 0.80 test, so the 94%->78% near-lock crack never fires – and at 16 points it sits under the 20-point swing bar, so nothing else picks it up and it is never candle-verified either. The snapshot now carries trusted_price beside the raw print; the classifier prefers it, falls back to last_price only when the KEY IS ABSENT (a Polymarket or hand-built meta, where the raw field is the only price), and treats a present-but-None as no event rather than reaching past it. _verify_move sets it alongside its other corrections. last_price stays the raw trade deliberately – #2301 made “the refused print is still on the meta” a guarantee. Live screen effect: 75 legs newly caught, 61 dropped, all still candle-verified downstream. The sweep also found betting_board’s Kalshi card, whose own docstring says it mirrors the market-drop construction “from last_price” – #2306 fixed the original and left the mirror, so it built outcomes + chart_specs from the raw print. It reads probability now, and a refused leg drops off the card. bid_supported, the CONVICTION test, and the market_drop tiers that needed it (2026-08-29, #ella-dandelion). book_yes_price is generous on purpose – on a WIDE book it accepts the last TRADE, because for a chart line or a ladder rung a lone real print beats both a mid nobody would trade at and a dropped sample. A surface that ASSERTS what the market BELIEVES needs more than that. market_alert has sat such a leg out since the phantom-96% fix, using firm_book; the DROP’s three conviction tiers (_frontrunner, _is_locked_leader, _clear_winner_lock) never got any guard, and they make the same kind of claim. The shipped case: the “#2 on the Billboard 200, Week of Sep 5” board. DANDELION quoted bid 6c / ask 58c and last traded 40c EIGHTEEN HOURS earlier, so the 52c-wide book bracketed that stale print and book_yes_price returned 0.40. In the same session Ultimate Dolly Parton took 958 contracts at 90c, and its own last price read 14c only because a 5-contract NO print cleared 150 seconds before the drop. So _frontrunner crowned DANDELION, clearing its 0.25 gap bar by a single cent, and Toots posted “DANDELION at 40% is the market’s top pick for #2” – two minutes before the music desk posted the HITS projection with Dandelion at #3, behind Dolly at #2. The test is the BID, not the spread – and the reason is PRECISION, not safety. firm_book (the predicate market_alert uses) asks whether the two QUOTES are close, which is a proxy for the same thing and a SAFE one: it is strictly STRICTER than bid_supported, never looser. That is structural, not luck – on a tight book book_yes_price returns the MID, which sits at most half a spread over the bid, so firm_book implies bid_supported by construction. Measured across the 71,314 priced legs of the 12,000 live open Kalshi events (2026-08-29): ZERO legs where firm_book passes one bid_supported refuses, and on the >= 95% lock tier the two cut the IDENTICAL 119 boards. What the bid test buys is the absence of an over-cut: firm_book refuses a leg whose bid plainly backs its price but whose ask is lazy – 3.6% of priced legs, and 27 of the drop’s frontrunner boards, e.g. Choosin' Texas for the Sep 12 Hot 100 at bid 80c / ask 93c, whose 81% the book obviously supports. Both predicates refuse every case that actually matters (the phantom-96% leg’s 96c ask over a 20c bid; the live locks quoting “99%” over a ZERO bid; DANDELION’s stale 40c over a 6c bid). So bid_supported is the one to reach for where a wrongly-silenced surface has a real cost, and firm_book reads as the conservative sibling, not a broken one. The first draft of this fix used firm_book and the prose justifying it claimed the predicate was unsafe; the 71,314-leg sweep is what showed the real difference is an over-cut – worth remembering as a case where the measurement corrected the rationale after the code was already right. The FIX is a SKIP, and gating only the tiers was not enough – that is the lesson here. The first attempt muted the three conviction tiers and let the board drop with its leaderboard read. Running the real card builder showed why that fails: with number_figure left unset, market_cards.leader_figure re-heroes the identical leg one layer down (#2 / DANDELION / "40% · ..." against the shipped #2 / DANDELION / "... · frontrunner at 40%") – the same card minus two words. A board’s LEADER is what the board claims: the model blob leads with it, the card heroes it, and the tiers report it, so gating any single layer just moves the claim down one. So _kalshi_leaderboard now SKIPS a board whose top-priced leg is not bid_supported (_soft_priced_leader) and the picker takes the next candidate – live, 8.9% of leaderboard-eligible boards, and the slot still posts because the picker walks candidates hottest-first. _kalshi_binary carries the same guard on its lone leg, which renders its price AS the answer (“Will X happen? 13%”). A soft TRAILING leg never costs a board its slot – only the leader speaks. The regroup still stamps meta["soft_priced"] (_soft_priced_labels, judged only on legs carrying the raw book fields, so a non-Kalshi source passes untouched) and the tiers still refuse a soft leader, kept as defence in depth because _netflix_gateable reaches those pure predicates directly and the stamp is what the skip computes from anyway. Both skips emit market_filtered reason=soft_price_tier, so a board stepping aside is never silent. A bid near the price is not a live price (the HABIBTI drop, 2026-10-19). The drop pulls its pool with min_volume_24h=0, because the picker needs every leg to tell a field from one leg of a family; that also turns off markets.KALSHI_ACTIVE_VOL_24H, the 50-contract floor every other Kalshi pool applies (market_alert reads with it, so a dead leg never leads its race). No drop guard put it back on the leg it states. The 06:02 UTC drop “For the Billboard 200’s Oct. 31 No. 1, HABIBTI sits first at 11%” passed the 10% leader floor by 1c and bid_supported by 0c (11c over a 1c bid, on a 28c ask), and went to the room and to X. The 11% was one 10-contract trade four days earlier, 20 contracts in the leg’s life. So _kalshi_leaderboard (on the leader after the re-read) and _kalshi_binary (on its lone leg) now skip a leg whose book is WIDE and whose 24h volume is under that floor (_stale_print -> kalshi_price.stale_wide_print, market_filtered reason=stale_print). A TIGHT book passes with no trades, because two resting quotes are a live price (a just-listed YouTube daily board a market maker quotes 20c / 22c). Only the leader speaks here too: a stale TRAILING leg does not cost the board. Measured on the live exchange the day it shipped: of the 18 distinct boards the picker would serve across all drop categories, one had a wide-book leader, and it was HABIBTI. A wider pass (every board and lone binary in those pools that already passed the leader floor and the bid check, no horizon cap) found 1,563; 45 had a wide-book leader, and the rule cut 33 (2.1%), e.g. weather bins that traded 1 to 44 contracts and an SNL-host leg with no 24h trades on a 48c / 75c book. Every wide-book leader that traded 50+ contracts in 24h was kept. The alert’s RACE fold was the third reader that never asked (the Emmy “Tie” incident, 2026-09-07). _soft_priced sits a soft leg out of the MOVERS pool, but the lead-change lane and the leader rule read the race rebuilt from the event’s own /markets fetch (_race_snapshot / _event_leader), and that fold took each leg’s probability as given. Every Emmy category board carries a “Tie” leg that prices ~1%; the Supporting Actress in a Comedy board’s Tie leg held one 95c print against a ZERO bid and a 95c ask, book_yes_price accepted it (an in-book last trade on a wide book), and the fold crowned “Tie” at 95% over Kate O’Flynn’s 80% on a real 76/84 book. It shipped as “Tie has overtaken Kate O’Flynn as the forecast Emmy winner” to the room and to X, with a Kate O’Flynn headshot under a “Tie” hero, and the leader rule then suppressed the board’s real legs as trailing behind it. Live sweep the same hour: 28 Emmy boards, this was the only one with a soft top leg. Two changes. (1) _race_price is the number the RACE reads a leg at: the trusted price when bid_supported backs it, else the BID itself, and None when nobody bids – so the Tie leg leaves the race and Kate leads at 80. The bid rather than a drop, because a real leader with a lazy ask (bid 80c / ask 99c / last 95c) fails bid_supported too, and dropping it crowns the runner-up: a fabricated overtake, and a second one when the book firms back up. The fold stamps the priced legs it left OUT (no bid at all) as meta["soft_priced"] (the drop’s key, _soft_race_labels) – not the legs read at their bid, which are still in the race at a real number, so a firm leg overtaking one is a real flip the lane reports (Codex review). (2) The fired alert had RESET the durable baseline (market_leader_alerts) to “Tie” at 95, so the corrected fold alone would fire “Kate O’Flynn overtook Tie” next – _pick_lead_change now RESEEDS a baseline that names a soft-priced leg to the current leader (db.reseed_market_leader_ref, leader + pct only, the alert clock untouched) and emits market_filtered reason=soft_baseline instead of measuring a flip from it. The other Kalshi folds were swept: markets.py’s player-prop fold (/ask data, no conviction claim) and the sports aggregate fold (its snapshot goes through _soft_priced per leg) were left as they are. book_backs_move, the check for a STATED move price (#3870, 2026-10-01). The artist_market_move lane shipped “Ye performing at Allegiant Stadium this year is priced at 9%”. At the 01:24 UTC read the book was bid 11c / ask 74c (its usual 28c bid was pulled from about 01:11 to 01:27), and the last trade was 0.01 contracts at 9c. book_yes_price accepted that trade: the book is wide and 9c is inside LAST_TRADE_TOL of the bid. bid_supported did not refuse it either, because it only refuses a price far ABOVE the bid. book_backs_move(bid, ask, price, rising=...) adds no new rule. It requires the stated price to sit inside the live bid/ask, and it applies bid_supported to the side the claim leans on: the YES bid for a rise, the NO bid (1 - ask) for a fall, i.e. bid_supported(1 - ask, 1 - price). The Ye price fails both: 9c is under the 11c bid, and the NO side bid 26c against a 91c NO price. With no book at all the trade stands, as in book_yes_price. music_markets.artist_market_move calls it; a refused market is no read and goes into the optional unbacked list. Every surface that STATES a Kalshi price now reads it through these helpers (#3878, the sibling sweep of #3870). Two additive helpers carry the reads. clamp_to_book(bid, ask, price) moves a price inside the live quotes (under the bid -> the bid, over the ask -> the ask). bid_backed_price(bid, ask, price) is the race rule from market_alert._race_price: the price when book_backs_move (rising) passes, else the bid, else None when nobody bids. Live 2026-10-05, over 89,077 priced legs: 410 read under their own bid and 772 over their own ask, because book_yes_price accepts a wide-book print up to LAST_TRADE_TOL outside the quotes. The sites:
market_alert ending-soon lane: _pick_ending_soon skips a candidate when _ending_soon_unbacked says book_backs_move refuses the price the blob states (market_filtered reason=unbacked_price kind=ending_soon). The head-to-head read states the OTHER side’s 1 - prob, so it is tested with rising=False (the NO bid 1 - ask backs it). Live: 161 of 3,785 candidates, e.g. Knights vs Tuskers “sitting at 75%” on a 31c / 74c book. A skipped leader does not crown the runner-up: _apply_leader_rule already removed the non-leaders from this pool.market_alert._race_price reads a print UNDER the bid at the bid (bid_backed_price). The leader’s own read only rises, so a real leader is never refused (the Emmy “Tie” lesson). A RUNNER-UP can rise past the leader, though (5 of 9,095 live races on 2026-10-05), so the race fold also stamps the leader under the old read (meta["print_leader"]) and _pick_lead_change posts a flip only when the new leader leads on both reads (market_filtered reason=read_only_flip). bid_backed_price returns None on a crossed book (bid over ask).market_drop: the soft-leader skip is unchanged. Every leg the board then STATES goes through bid_backed_price (_stated_outcomes): a trailing stale print reads at its bid, an under-bid print reads at its bid, and a leg priced over 10c with no bid leaves the board (market_filtered reason=unbacked_leg, one row per board with count; a board left under its minimum is skipped with the same reason). A 1% longshot on a zero bid keeps its 1%. _kalshi_binary reads its lone leg the same way. Because the re-read can change the board (Codex review on #3887), the stated map also drops a leg re-read under _MIN_OUTCOME_PRICE, the shape guards (_is_flat_wall, _is_unpriced_board) run again on it, the headline price_change comes only from legs whose stated price is their trusted price, and the cover catalog_hint names the leader after the re-read. The ending-soon check skips a rung _reframe_ladder_rungs stamped with its ladder’s forecast, because that post states the forecast and no rung percent. A confirmed race leader (_is_confirmed_leader, the same test _fire routes on) is checked on its own YES side even when its title reads “A vs B” and its price is under 50%, because the leader blob states its own price. A lone binary read at its bid drops its price_change. clamp_to_book returns None on a crossed book, so field_outcome_pct drops that leg (Codex review on #3887, second round).music_markets.field_outcome_pct applies the in-book half only (clamp_to_book): it is context, not a conviction claim, and the bid half would drop every 1% longshot and then whole fields under _FIELD_MIN_OUTCOMES.market_outcome.lock_supported (a >= 95% lock cannot print under its bid on a wide book, and the post states the winner and the day-ago price, not the lock price) and markets._daily_mids_by_ticker (14 of 1,282 live artist-market daily points sit 1-5c under their candle bid, none the baseline of a shipped move card).luminate.py’s ALBUM_METRICS registry, the ONE source of truth for WHAT a Kalshi music market counts — metric_from_text matches a title/scope against the rows in REGISTRY ORDER and the first hit wins, so the order IS the precedence rule. A cue that names a METRIC outranks one that names only a TIMEFRAME (#3008). first_week_units’ cue is "first week", which says WHEN Luminate counts and not WHAT, so every First Week <metric> title carries it; ranked third it beat streams and swallowed Kalshi’s whole KXALBUMSTREAMS series — all 29 live events read "I Know Too Much: First Week Streams" -> "First-Week Units". The label is not cosmetic: release_stage calls a first-week UNITS market a debut by construction and guards that branch on "stream" not in metric, so the wrong label also walked the streams ladder into the units projection slate (music_markets._sales_projection_keep, which reads the stage with age_days=None). Live on 2026-09-06 that put Ellie Goulding’s 1.5M first-week STREAMS forecast on an X card reading “~1.5M first-week units”, for an album whose units ladder prices P(above 10K) at 8.5% — a 150x overstatement, and the media-coherence judge passed it because the art and the take agreed with each other. The diagnostic tell is that one album posts BOTH labels in one day: the series’ own metric was already right (series_metric reads KXALBUMSTREAMS’ title “how many streams will album have” -> "Streams"), and event_metric OVERRODE it with the title read, so a surface reading the SERIES metric posted a correct “2.3M first-week streams” at 10:09 while the units lane posted “~1.5M units” for the same ladder at 13:06. first_week_units is now LAST: the fallback for a title naming a first week and no metric at all. Adding a metric row means placing it ABOVE the timeframe fallback. The metric says WHAT is counted; luminate.stream_scope says WHOSE (#3666, 2026-09-22). Kalshi’s stream families do not all measure the same territory and every one of them is labelled Streams, so a reader comparing two desk figures was comparing a worldwide number to a U.S. one without being told. Read off each family’s OWN rule text on the live API: KXARTISTSTREAMS (“above 46M Worldwide Streams”), KXARTISTSTREAMSY (“Above 13.0B Worldwide Streams”), KXARTISTSTREAMSU and KXALBUMSTREAMSU are all WORLDWIDE; KXALBUMSTREAMS carries no territory word and is the U.S. week Billboard prints, which is why its printed count matches to within 11.7% where a worldwide one could not. luminate.scoped_metric is the ONE label every surface states (format_reading’s head and tail, and the LINE MOVE / LAST CALL heads beside it), so the blob and the alert cannot disagree about what was counted. A family whose rule text nobody has read is NOT labelled – prefer absent over invented, a wrong territory word being worse than none. This was live, not latent: KXARTISTSTREAMSY had 50 OPEN events the day it was found, and the blob for one read 2Pac | Streams (Luminate) | January 01 - December 31, 2026 | year-to-date | 2.8B so far this year with nothing saying the 2.8B was worldwide. The subject check (#3436). The cue rules (metric_cues, _SUBJECT_RE) settle almost every Kalshi music event. resolve_event_subject asks one cached Haiku call (ClaudeClient.luminate_subject) only on the residue subject_is_ambiguous names: no metric cue or two different ones, no leg title that parses, a leg whose “ by “ clause the regex could not read as an act (“Carnaval De Luz by U2”, #3591), or two acts in one comparison (“Lost Weekend vs WILD” was credited to Phoebe Bridgers and labelled Sales while the legs count album-equivalent units). Every model value must appear in the text; a two-act comparison credits no act; a model error keeps the cue result. The sweep (music_markets.fetch_desk_readings) runs it when the caller passes claude, and every posting caller does, like billboard.retrospective.py, the ONE definition of “is this wire post a throwback, and how do we FRAME it” — retrospective_frame(text) / is_retrospective(text) (detection) + retrospective_note(text) (the compose instruction), shared by all four wire desks (music_news, cinema_news, pop_desk, sports_desk). The desks gate a candidate on the TWEET’s age, not the age of the EVENT it describes. @chartdata’s “1 year ago today, HUNTR/X’s ‘Golden’ reached #1 on the Hot 100” is a fresh tweet about a year-old milestone: it passed the recency gate, the fact is TRUE so the verify step confirmed it, and it shipped as breaking news (“‘Golden’ by HUNTR/X hit No. 1 … ever”). No downstream gate can catch this class — verify checks whether a claim is TRUE, not whether it is CURRENT, so a real anniversary is confirmed every time. The fix REFRAMES, it does not drop (owner steer: “I didn’t need you to cut them, just not report them as current”). Every wire desk now CUTS a throwback (owner steer 2026-10-20), so the reframe below survives only as code on music_news. pop_desk, cinema_news and sports_desk call cut_throwback as the FIRST gate in _compose_one (before the Grok news-age read): it runs prime_throwback (regex first, Haiku on a regex miss), and on a throwback emits wire_retrospective reason=cut and drops the story. Every lane of those desks (breaking, biggest, the event watcher) composes through _compose_one. Their reframe block and their retro_unframed guard were removed. Dry run 2026-10-20, live Haiku over 250 real posts from 14 wire accounts: 12 cut (7 regex, 5 Haiku only), 0 current posts cut. The X reply-draft lanes (cogs/x_reply_draft.py) are not covered: they write drafts for the owner, not posts, and the x_replies experiment is off. The music newsroom cuts a throwback of any story kind is dropped in _compose_one (cuts_throwback, emits music_news_filtered reason=throwback, marked seen) right after the throwback read, before the news-age gate, the verify and the compose. History: on 2026-10-19 the owner asked to cut chart anniversaries (“15 years ago today, X hit No. 1”), so the first cut took only the chart / first_week kinds plus a Haiku chart-position read on the other kinds. On 2026-10-20 a cert anniversary shipped to X (“23 years ago today, Amy Winehouse’s ‘Frank’ was certified 4x Platinum”; ‘Frank’ came out on 2003-10-20, so the take also tied the release date to the certification). The owner expected all historical posts gone, so the cut now takes every kind and the Haiku read was removed. The music_news framing code below the cut is kept, and the tests turn the cut off to keep it checked. No wire desk ships a throwback any more. The composed take is checked too (#4697). cut_throwback and cuts_throwback read the SOURCE post, and the compose can still write the frame: “Mariah Carey’s ‘Emotions’ reached No. 1 on the Hot 100 35 years ago this week” shipped on 2026-10-14 from a chartdata source that no source check flagged. So each of the four wire desks reads its own take right after the compose with date_hook(line, look_back_only=True). music_news first removes the story’s title (the subject before “ - “); the other three pass no title, because their subject is a cut-down headline, and quoted text is not read anyway. look_back_only skips the birthday, anniversary and bare “on this day” labels: current news happens on those days (“Zohran Mamdani turns 35 today”; “Life of a DON earns 7 new RIAA certifications on its 5-year anniversary”; “The film will open on this date next year”). Only an interval with a date word or a year-checked date frame counts. It also does not read single-quoted text, or an interval whose unit is capitalised (“28 Years Later gets a new trailer”), because a wire desk has no title to pass (Codex review on #4726). It also skips an interval inside a clause that leads with celebrate / mark / honor / commemorate (“To celebrate the album she released ten years ago today, Beyonce announces a deluxe”), the same lead rule the source frames apply. The source frames alone were measured as the composed-take check and rejected: they miss 3 of the 27 shipped throwbacks, among them “Emotions”. A hit is a refused compose: <surface>_scored phase=declined reason=throwback_composed with the take in post_preview. music_news also marks the story seen, like its source cut. Measured on 30 days of shipped posts (2026-09-24 to 2026-10-24): 27 of 1,392 wire-desk posts match (22 music_news, 3 pop_desk, 2 cinema_news, 0 sports_desk), and all 27 are throwbacks. 26 of them also carried a source-side wire_retrospective mark, so #4652 cuts them now. Over all 2,346 distinct scored takes, 55 match and none is current news. The chart desks (music_desk, music_alert, music_releases, cinema_desk) are not wired: they compose from chart rows, not a wire post, and their only matches in the window were titles (“Stakes Is High (30th Anniversary iTunes Exclusive)”). The rest of this bullet describes the older reframe path; on the other three desks that path was removed. The desk detects the throwback and hands the compose retrospective_note — a past-tense look-back instruction that quotes the source’s own time frame — so Toots posts “a year ago today, HUNTR/X hit No. 1 …” instead of “hits No. 1”. music_news applies the note in _compose_one AND skips the LIVE-CHART settle for a throwback (settling reads the CURRENT chart, which would state a year-old event’s position as it stands now, or drop it as “moved”); the classifier extracts the throwback normally (it is a real chart story, the desk frames the time). cinema_news/pop_desk/sports_desk prepended the note to their compose blob in _compose_one until 2026-10-20, when the cut replaced it. All FOUR desks ALSO drop a fresh-tweet-old-news RECAP via the Grok is_stale_recap gate (a separate mechanism, the utils/news_age.py gate) run as the first gate in _compose_one. music_news was the one desk this gate had NEVER been wired into (#2505) — the class-sweep miss that let an old box-office figure (the Eras Tour concert film’s $93M opening, October 2023) re-posted by a trusted touring account ship as current news. It now runs the same gate on every story kind (until 2026-10-22 a skip set exempted the kinds said to read CURRENT data; each exemption let old news through, see the “Janice STFU” bullet) and skipped for an explicit throwback (which the reframe above ships AS past), so the reframe and the drop never fight over the same post. ONE residual hole in the “chart reads CURRENT data” reasoning was found later (#golden-hour-part5): the chart lane reads current data only when the live chart actually settles the claim, and a WEEKLY chart (Billboard 200, Hot 100) read as ABSENT does NOT settle it — the album fell off the current chart, or the wire’s title/artist did not match a row. _chart_claim_too_stale treats a weekly absence as a match miss and ships the claim, which is right for THIS week’s chart and wrong for an old one. So ATEEZ’s “GOLDEN HOUR : Part.5” — No. 1 on the Billboard 200 dated 2026-07-11, re-posted by a fresh tweet on 2026-08-25 after it had dropped off the chart — read absent and shipped as “lands at No. 1”, current news. The tweet was fresh, so tweet-age could not catch it, and the verify step confirmed the number because the number is TRUE. _compose_one handed exactly this residual case (music_news.absent_on_weekly_chart) to is_stale_recap with event_age=True until 2026-10-22; since then the first news-age gate reads every chart story with the event-age prompt, and that helper is gone. The event-age prompt (grok_search._EVENT_AGE_PROMPT) dates the CHART WEEK, not the chatter, and calls STALE only past ~10 days — so a current week’s chart (including a this-week debut whose row we merely mismatched, reported a few days late) reads FRESH, while a 45-day-old #1 reads STALE. It is fail-open, so it only DROPS a story that ships today, never invents a new one. Each reframe emits wire_retrospective (surface + frame + subject; no post text, data minimization). The match is narrow: every date frame is anchored on BACKWARD evidence and must LEAD the post — an “ago” (“N years/weeks/days ago today”, any count incl. “twenty one”), a STRICTLY PAST year (“this day/date in <year>”; a future or current-year date does NOT fire), a “today marks … since / anniversary” lead, or a throwback tag (incl. the compact “#ThrowbackThursday”, matched anywhere). Punctuation is never proof, and an anniversary embedded MID-SENTENCE in fresh news (“To celebrate the album released ten years ago today, X drops a deluxe”) does not fire — the frame must lead, so real announcements still ship as current. wire_sources.rank_wire_posts (the shared ranker behind wire_material, read by discourse + the music desk) does NOT filter retrospectives: it is context material for a composed take, not a single post restated as breaking, and its own throwback handling isn’t needed there. THE NOTE IS AN INSTRUCTION, NOT A GUARANTEE, so the composed take is checked back against it (#3270). @Genius posted “eight years ago today, 6LACK dropped ‘east atlanta love letter’”; the desk detected the throwback, told the compose to write a look-back, and the take that shipped to X on 2026-09-14 read “East Atlanta Love Letter debuted at No. 3 on the Billboard 200 with 77,000 equivalent album units in its first week”. The model obeyed half the note: past tense, no time frame. Past tense is not the frame — a wire desk reports every story in the past tense, so a reader sees this week’s debut for a 2018 album. No gate below could catch it either: the self-gate grades whether the claims are GROUNDED and every word of that line is true, so it scored high and shipped. The fix has two halves, and both were measured on the PRODUCTION compose model with the real inputs (the real claim block, the real Perplexity BACKGROUND, the real grok X CROWD READ), n=12 per arm. (a) The note was reworded, not added to. The old note asked for two things in one clause (“past tense, and make the time frame clear”) and closed with two “never” rules; it framed 4/12, and three of the failures reproduced the shipped line verbatim. The new note names the STOP CONDITION (“you are done when a reader who sees only your line knows this happened THEN, not today”), hands the source’s own phrase back as the SHAPE to write, and cuts both “never” clauses — 12/12 framed on the production model, 4/4 on Sonnet (Sonnet framed 4/4 under the old note too, so this is a per-model failure the note now covers for both). The cause of the old note’s failure is worth keeping: it only broke once the X CROWD READ block was in the context, which tells the model what people are saying “RIGHT NOW” — the frame lost to a later, present-tense block, exactly the layer-fight docs/PROMPT_OPTIMIZATION.md warns about. (b) A deterministic backstop — frames_as_past(line) asks whether the take states ANY look-back marker (a backward interval, an anniversary, a “N years since”, a throwback TAG, or a year with a date around it), and the wire desks dropped a throwback take that states none (since 2026-10-20 only music_news keeps this guard, behind its cut), as <surface>_scored reason=retro_unframed. The checker is deliberately GENEROUS where the input-side match is narrow: it reads Toots’ own line and the consumer DROPS on a miss, so a frame worded a way we did not list would throw the post away — and the owner’s steer on this class is still “I didn’t need you to cut them, just not report them as current”. It accepts the throwback TAGS off the same _TAG_RE the input frames use, because the note hands the matched phrase back as the shape to write: a tag the note asks for has to be a tag the check takes. The one place it is NOT generous is a bare YEAR, and that is correctness, not taste (Codex review): a four-digit token on this desk is as likely to be a TITLE as a date — Prince’s “1999”, Taylor Swift’s “1989” — so a bare year would have passed “Taylor Swift’s 1989 debuted at No. 1” as its own frame and shipped the exact take the guard exists to stop. A year counts with a date preposition (“back in 2018”), a possessive (“2018’s”), or a release noun after it (“its 2018 debut”); and EVERY year match is read, not the first, so a line naming this year before a past one still frames. One interaction had to be fixed with it: the note tells the compose to write “8 years ago today”, which states a NUMBER, and BOTH of music_news’s grounded gates — the digit gate and the self-gate — hard-fail a figure their ground truth does not carry, so the desk would have dropped the take it just asked for. The frame is therefore checked IN ITS OWN LANE and then removed from the line the value-set gates read (retrospective_values + look_back_values + strip_look_back). The first attempt put the interval in the general grounding block instead, and that was wrong in the other direction: ungrounded_numbers compares UNTYPED value sets, so an “eight years ago” source authorized the value 8 in every role and a take reading “debuted at No. 8” off a No. 3 claim passed the fabrication gate. A number the frame supplies is evidence for the frame and for nothing else, and a take that invents its era is dropped as retro_interval. The MODEL scorer still reads the phrase as prose (retrospective_grounding), which carries the wire’s own words plus the numerals the frame fixes: the grounding side expands a single spelled cardinal, so “eight” grounds “8” on its own, but it cannot compose “twenty one” into 21 or read “a year” as 1. It carries BOTH READINGS of the anniversary, because the note offers the model both — an “eight years ago today” wire grounds the year 2018, and an “on this day in 2016” wire grounds the interval 10, so the take that writes “back in 2018” is not dropped as inventing a year (the shape Sonnet actually wrote in the dry run). The VALUE SET carried only one of those two readings, and that cost a real post (#3271 follow-up). _frame_numbers DERIVES numbers. An “eight years ago today” wire yields the interval 8 and the year 2018. A “today in 2007” wire yields only the interval 19, because the year is already inside the phrase the grounding prose quotes. The deterministic gate reads the VALUE SET, not the prose, so 2007 was not in the set. On 2026-09-15 @Genius posted “today in 2007, soulja boy’s ‘crank that (soulja boy)’ reached no. 1 on the billboard hot 100”. The note hands that phrase back in quotes as the shape to write, the take wrote it, and the desk dropped the take as retro_interval with detail.numbers=2007 — for stating the year its own source printed. The date was right and the anniversary was lost, because music_news marks the drop SEEN. A YEAR frame carries 22% of this desk’s throwbacks (13 of 58 in the 30 days to 2026-09-15), so this was not a corner case. retrospective_values now adds the year the source PRINTED to the numbers it derives, so both readings are allowed in both directions; the gate still drops an invented era (“back in 1999” off a 2007 wire). The gate is stray_look_back (#4532): a year the source states as a date OUTSIDE its frame (“the first female rapper with a solo #1 since Lauryn Hill in 1998”, off a 2017 throwback) is allowed too, but only when the take also states its real frame. The compose kept that record year in its take 3 of 3 times, and main dropped every one. A take framed ONLY by the source’s other year (“Back in 1998, …”) states no real frame and is still dropped. The dry-run harness had three cases and every one stated an INTERVAL, which is why the year direction shipped unmeasured — the Crank That wire is now its fourth case. A count it cannot read in full states no numeral at all: “half a year ago today” and “a few years ago today” both parsed to 1, and that numeral would have grounded a take restating a vague interval as an exact figure. FIVE markers were cut or narrowed for one reason, and it is the lesson of the whole review: a marker a TITLE or a QUANTITY can satisfy is not a frame. “The third reader, markets._kalshi_market_to_snapshot, is now wired in too (#2288). It had re-implemented the rule INLINE and the copy had drifted: on a wide book with an out-of-book last trade it KEPT the untrusted mid where book_yes_price returns None, and its own comment claimed parity (“same thresholds as every other book reader”) — the thresholds matched, the fail direction did not. An existing test asserted the drifted behaviour explicitly, so this was a documented divergence rather than silent rot. What it shipped: an EMPTY Kalshi market quotes bid 1c / ask 99c and has never traded, and the copy midded it to a confident 50% — a fabricated coin flip. Measured over 2043 live open NON-MVE markets: 480 (23.5%) read a price only because of the drift, and of those 480, zero had any 24h volume, any lifetime volume or any resting liquidity (one had open interest); their spreads are EMPTY books, not wide ones — median 94c, p90 99c. So the affected population is entirely markets nobody has ever traded, and every surface that picks a market gates on real volume, so wiring the rule in costs no live coverage. Sampling note for anyone re-measuring this: the raw /markets?status=open listing is ~97% KXMVE combo rows, which the snapshot builder drops for unrelated reasons — sampling it unfiltered measures the MVE filter, not the price rule (it reported a misleading 6.9%). The snapshot builder drops MVE combo/parlay rows (KXMVE tickers) at the per-market boundary; the NON-LEADER chart-board cut is a separate, EVENT-level decision at discovery. Owner steer (2026-08): keep music and sports “#N” boards, cut the rest — the Netflix and Google-search chart runner-ups. _is_cut_runner_up_event(event_title, event_category) decides it in get_events_for_series, and it is keyed on Kalshi’s own EVENT category field (present on the /events?...with_nested_markets=true payload) plus the event title. It cuts an event when the category is Entertainment, the EVENT title names a slot below #1 (_names_non_leader_slot — “runner-up” or “#N” for N ≥ 2), and the title is NOT music (_looks_like_music). This is why it is precise where an earlier per-leg title scan was not (measured against 6000 live open events, 2026-08): SPORTS is a separate category, so a “College Basketball #2 Ranked Team” is never cut here; ELECTIONS is a separate category, so a “Will Initiative #85 pass?” ballot event is never cut; and because the rank must be in the EVENT title, a video-game event (“Video games released this year”) whose only “#N” is a per-leg identifier (“Final Fantasy VII Remake #3”) is not cut. Music stays because every music runner-up event names its platform in the title (“#2 Song on Spotify”, “#2 on the Billboard 200”, “Runner-Up Daily Music Video”), which _looks_like_music matches. Since CB-19 (#3410) the music cue is NOT a fast keep when the Haiku runner-up check is attached: _nominates_runner_up_cut sends every Entertainment non-leader title to confirm_runner_up_cut (purpose=market_runner_up_check), whose task lists music boards as OTHER and carries _MUSIC_MARKET_CUES as examples. With no judge, the cue list keeps the board as before, now on whole words (“Trap” no longer holds “rap”). Against the live catalog this cuts exactly 5 events — Netflix Movie/Show #2 (US + global) and the Google-search #2 — and keeps 19 music runner-ups + every sports rank board + the ballot measures + the video-game list. A SECOND, SURGICAL cut sits in the same get_events_for_series spot (owner steer 2026-08-22): _is_cut_netflix_views_event(event_ticker, event_title, event_category) drops the Netflix VIEW-COUNT boards (reason=netflix_views) — the “how many views will the #1 show/movie have this week” boards (KXNETFLIXTOPVIEWSTV, KXNETFLIXTOPVIEWSMOVIE), a dry number with no narrative. They name no rank, so _names_non_leader_slot never matched them and they posted a viewership guess. The Netflix RANK/leader boards (KXNETFLIXRANKSHOW “Top US Netflix Show this week”, which name the #1 title — e.g. Outer Banks leading at 98%) are KEPT: a real story and a rich card. The cut is keyed on the NETFLIXTOPVIEWS substring in the ticker, with a narrow Entertainment-scoped title fallback (“how many views” + “netflix”) so a rank board is never caught and a Netflix STOCK market (tickered NFLX, in Companies/Financials) is never swept in. It runs BEFORE the runner-up cut and is gated on the CUT_NETFLIX_VIEW_MARKETS env flag (default on; set falsey to let the view-count boards post again). It is why a mod who wants only the Netflix viewership-guess cards gone gets a targeted code cut instead of the /menu topics page, which cannot go finer than the whole Television tag (and would take out the good rank cards with it). A THIRD cut in the same spot is a whole-SHOW blacklist by ticker PREFIX (_BLOCKED_TICKER_PREFIXES + _is_blocked_ticker, reason=blocked_series, 2026-08-26). Big Brother is the seeded case: it kept posting to Entertainment rooms after a mod deselected it, because the /menu subcategory picker is an ALLOW-list (a market posts if it matches ANY one whitelisted tag) and the Big Brother eviction series carries Television alongside Big Brother — so a room that whitelisted Television re-admitted it no matter what. A tag cut cannot fix this: Kalshi tags a show’s series INCONSISTENTLY, and the S27 winner series (KXBIGBROTHER27) carries ONLY Television, so no tag catches every Big Brother market. The STABLE key is the series ticker — every Big Brother series starts with KXBIGBROTHER, so one prefix drops eviction / winner / rank / S27 / Brazil alike (verified against the live catalog 2026-08-26). It cuts at every discovery arm so none bypasses it, each returning [] (clean skip) and emitting market_filtered reason=blocked_series: the series arm — list_series_by_category (so a blacklisted series is never even ranked — matters for the board path, which ranks the whole category by series before resolving markets; this is the emit site for the drops/alerts/trending path, since the series never reaches the fetch) and get_events_for_series (the series fetch); and the event-first arm the /ask + compare/combined tools use — kalshi_search drops blocked events from the FTS candidates before the last-mile picker (the FTS index still holds every blocked event), and, when the caller passes the game’s kickoff (start_ts, every game-line caller since #3560), drops every candidate whose ticker encodes another Eastern date or a start more than 4h away (kalshi_is_other_game, market_filtered reason=other_day), adds the date tokens to the FTS query so the all-terms tier ranks that day first, and names the date in the picker’s query – so a series game (the same two teams on consecutive days) prices off ITS day’s market, and get_event_markets guards the direct event fetch. Big Brother was the seed, but it has since MOVED into the per-guild menu (owner steer 2026-08-26), so _BLOCKED_TICKER_PREFIXES ships EMPTY — the always-on mechanism is kept for a series that must be gone for EVERYONE across ALL surfaces (add a prefix and it cuts drops, alerts, /ask, trending at once), but nothing uses it today. The STEAM CHART BOARDS were briefly entries here (2026-08-31) and are NOT any more. They posted a game title and a price with NO ART — the two cards that prompted the steer were “Counter-Strike 2 51%” and “STAR WARS Zero Company 31%”, both shipped to X as bare number cards. The first response was a global cut; the owner’s steer was to fix the art instead, and put the boards on the menu picker so they can be filtered later by choice rather than by hardcoded cut. So they are a SHOW_CATALOG row (steam_charts, four roots: KXSTEAMTOPSELLER, KXSTEAMWEEKLY, KXTOPSELLERS, KXGAMERANK), UNMUTED by default — and unlike Big Brother there is no seeding migration, because nothing was muted for anyone in the first place. The Steam AWARDS series (KXSTEAMGOTY, KXSTEAMBS, KXSTEAMLOL, …) share the KXSTEAM root and are deliberately NOT in the row: an awards board carries a story a weekly seller rank does not, so muting the charts must not silence the awards. Each root was verified against the live catalog (11,415 series across every category, 2026-08-31) to match exactly ONE series. Note the catalog is now AT 25 entries, which is Discord’s hard cap on select options — a 26th makes _MutedShowsSelect invalid, so adding one means paginating that select first (the subcategory picker already does); test_show_catalog_fits_one_select guards it. A PER-GUILD sibling lets mods self-serve (#mute-shows): the /menu market-topics page has a server-wide “mute shows” picker (_MutedShowsSelect) over markets.SHOW_CATALOG (a stable key -> display name + Kalshi ticker prefixes); a guild’s picks are stored in the muted_series table and resolved to prefixes by muted_prefixes_for. Unlike the global blacklist (which cuts deep in the shared, guild-agnostic client), the per-guild mute is applied at the GUILD-AWARE layer – market_drop._drop_muted_shows filters the pick pool and market_alert filters its candidate pool (both via the pure partition_muted_snapshots), emitting market_filtered reason=muted_series. It is scoped to the AUTOMATED surfaces (drops + alerts) ONLY, never to an explicit /ask – a viewer asking Toots about a show should still get an answer. Big Brother now lives in SHOW_CATALOG (moved off the hardcoded global block, owner steer 2026-08-26): a one-time marker-guarded DB migration (seed_big_brother_mute_v1 in db.py) seeds it muted for every already-configured guild so removing the constant does not let it post again, and it is a normal menu row a mod can toggle. The tradeoff of the move is REACH: the per-guild mute covers drops + alerts, not /ask or trending, where the old global block did. Why per-guild is applied at the cog layer and not the four global cut sites: the shared KalshiClient holds no guild context, and threading it through every discovery path would be a large change for a filter the two scheduled cogs can apply on the already-fetched pool. The pure _names_non_leader_slot rule (with its seed / auction “#N” exemptions, _SEED_RANK_RE/_AUCTION_RANK_RE) is unchanged; it is now read only through _is_cut_runner_up_event. The cut is at discovery (get_events_for_series, the boundary the topic + global pools fan out through), so a Netflix #2 board never enters the trending / movers / ask pools; an explicit single-event or single-ticker lookup, which carries no category, is not filtered. The music desk’s field-market context (music_markets._fetch_field_markets) reads RAW events and, being entirely music, does not filter runner-ups either, so it can show a “#2 Song on Spotify” as “related live markets” odds. Card rank rendering (market_cards.named_rank) is untouched, so a runner-up leg that reaches a card labels “#2”.
markets.py:qualify_platform_metric, the ONE place a Kalshi title is told what its metric is measured ON. The same boundary also cuts Kalshi’s rules phrase “after issuance” out of a title (strip_issuance_clause, 2026-09-29): “Will Drake release new album after issuance and before Oct 24, 2026” printed verbatim as a card title on X. The exchange writes titles that name a metric and drop the platform — “Morgan Wallen: Highest daily view count (July 27 - August 2, 2026)” — while the rules that SETTLE that same market spell it out (“above 5.75M Global daily views on YouTube”). Every market surface renders the exchange title verbatim, so the one blank reached the alert’s embed heading, the number card’s caption AND the grounded blob the compose writes from at once: a live alert shipped a 6.6M hero with no unit anywhere on it and the room asked “views on what” (owner report). So the weave happens at the ONE snapshot boundary (_kalshi_market_to_snapshot, on both the leg title and the event_title) rather than per surface — every consumer of meta['event_title'] (~30 reads in market_alert alone, plus market_drop / /ask) inherits it, including the ladder path that re-stamps the title off _LadderInfo (it reads the already-qualified value off the snapshot). Grounded, never guessed — the platform must be named in that market’s own rules_primary, and EXACTLY one must be, so a rules text weighing Spotify against YouTube is left alone rather than labelled with whichever matched first. The series TICKER is deliberately not consulted: prefix-matching KXYT* looks obvious and is wrong (KXYTHUG is Young Thug, not YouTube), which is the invented-over-absent direction. A title that already names its platform (Netflix’s “How many views will the #1 Movie on Netflix have”), carries no ambiguous metric noun, or arrives with no rules passes through untouched, so it is inert on every family but the handful that need it; the ambiguous nouns are views / streams / subscribers only (NOT the singular “view”, a verb, nor “video”/”song”, which would take the platform in the wrong clause of “Daily Top Music Videos USA”). rules_primary is read in passing and still never stamped onto the meta (detail-only, per market_detail), so this adds no event field and no fetch. Pure, unit-tested; live-verified across the KXYTVIEWSW / KXYTVIEWSHIGH / KXYTVIEWS families (qualified), Netflix + Oscars (untouched). The series settlement source (#4246, 2026-10-16). Some rules name no platform at all; only the SERIES does, in settlement_sources (“Will @MrBeast reach at least 1 billion subscribers”, series KXYTUBESUBSBEAST, source YouTube). note_series_platforms stores the one platform a series’ sources name, from the category series list _category_series already reads (warm cache or fetch, so no new fetch), keyed by series ticker. _kalshi_market_to_snapshot looks the event’s series up (the event ticker’s prefix, as a key, never read as text) and passes it as series_platform. It is used ONLY when the rules name no platform. A series whose sources name two platforms stores nothing, so the source never breaks a tie and the B11 check is not asked about it. Measured on 58,280 live snapshot titles: 6 change (4 distinct, the MrBeast and PBD Podcast YouTube boards), all right; 0 series with a bare-metric title name two platforms. A snapshot built before any category read keeps today’s title.KalshiClient on the bot’s own ClaudeClient, which MarketsManager reads off the bound intent_classifier (bot.py passes only bound methods); with no judge attached (tests, scripts) the old rules stand. (1) The runner-up cut: _is_cut_runner_up_event now only NOMINATES an event; confirm_runner_up_cut asks a cached RUNNER_UP / OTHER label and cuts only on RUNNER_UP. An error or an unreadable answer KEEPS the event (fail open) and emits market_label_check reason=judge_failed. Music boards are cut too (owner steer 2026-10-27, “it’s pretty underwhelming to report on number two”): the music cue list and its fast-path keep are gone, and the task names music as a RUNNER_UP chart. The #1 board of each chart is its own market and still posts. The case: the Billboard 200 No. 2 board of Oct 31 shipped two X alerts that each named an album that did not finish No. 2 (#4808). Sports #N boards are a separate Kalshi category and are not touched. (2) The platform rewrite: when a leg’s rules name two or more platforms, confirm_event_platform asks Haiku for one from the closed list + NONE and stores it per event ticker in _PLATFORM_VERDICTS, which _kalshi_market_to_snapshot reads, so every later read of that event gets the same title. qualify_platform_metric uses the pick only when the rules name it. Rules that name no platform get no call. The classify verdict is in the durable classify cache (kv_cache, #4231), so it survives a restart. _PLATFORM_VERDICTS is still an in-memory memo per event, so a restart rebuilds it from the cached verdicts with no new model call.sport from its product_metadata.competition with the substring ladder _kalshi_competition_to_sport. That tag only SCOPES a game’s Kalshi search (search_kalshi_events ... AND sport = $4; a scoped search that comes back empty falls back to the unscoped one). The ladder left most soccer leagues untagged (EPL, Liga MX), so an EPL matchup scoped to soccer saw only another competition’s rows, and it mis-tagged EuroLeague basketball, eSoccer and the Czech soccer “National Football League”. Before the hourly FTS rebuild, KalshiClient._apply_sport_checks asks check_competition_sport (purpose=market_sport_check, closed labels + NONE) once per competition, over the name and 3 event titles, and rewrites the tag on the rows and on open_events. Verdicts live on the client for the process’s life (_sport_verdicts), so only the first refresh after a deploy makes calls (about 560, about $0.02). An error or unreadable answer keeps the ladder tag and retries next refresh. Each check emits market_label_check (reason same / changed / judge_failed).alert_triggers.py, the shared alert TRIGGER primitives (#generalize-alerts) — the pure movement classification every alert surface reuses, so FLIP / SWING / lead-change is defined ONCE instead of re-implemented per surface. Each alert cog keeps its OWN fetch, durable baseline, compose voice, card, and gating; this owns only the pure decision “did the leader/number move enough to be news, and how?”. Standing (who leads a 2+-way market: leader + integer % + margin over the next), top_standing(outcomes) (the leader of a multi-outcome {entity: prob} map — the leaderboard analog of betting_alert’s _favorite), and classify_leader_move(ref, cur, *, swing_pp, flip_margin) → "flip" (the LEADER changed AND the new leader clears flip_margin over the runner-up, so a near-tie jitter isn’t a flip) |
"swing" (same leader, its % moved ≥ swing_pp) |
None. Consumers: cogs.betting_alert (a bettable GAME’s moneyline flip/swing — the FIRST implementation, now a thin adapter over classify_leader_move) and cogs.market_alert (a prediction-market LEADERBOARD lead-change — a new artist/name overtaking the prior #1 on a Kalshi ranklist; the SECOND consumer that earned the extraction, rule-of-two). Percentages are integer POINTS so both surfaces compare like with like. Pure, unit-tested. |
tmdb.py, the guarded TMDB client + pure parsers for the cinema desk’s RELEASE calendar + title metadata (#cinema-desk): now_playing (theatrical, popularity-ordered — the clean release signal), discover_streaming (new-on-a-platform via watch-provider), new_tv (discover/tv by first_air_date), releases_between (the coming-soon calendar – it DROPS any row TMDB dates outside the asked-for window, because the region+type filter matches a re-release on its US re-release date while the response still carries the film’s PRIMARY date; that is how Avengers: Endgame dated 2019-04-26 drew on a live “Opening Next” card, #3285; emits tmdb_calendar_filter when it drops), movie_detail (budget / lifetime WORLDWIDE revenue / runtime / genres — the enrichment, NOT the daily beat), search_movie, and trailer_url (the OFFICIAL YouTube trailer for a title off /{movie,tv}/{id}/videos — the video twin of poster_url, an AUTHORITATIVE lookup rather than a search, which is what lets utils.watch_link route a film/TV post deterministically; prefers an official Trailer, then any Trailer, then an official Teaser, YouTube-only so the link unfurls into a player. It is community-maintained, so it LAGS a trailer that just dropped – #trailer-lane measured the reported “Awarapan 2” post: at post time that title’s /videos held ONE clip, a six-week-old Teaser flagged official:false, which this correctly refuses, while the real trailer was on the studio’s own YouTube channel. That lag is why watch_link puts a search rung UNDER this lookup rather than treating an empty /videos as “no trailer exists”). Auth = the v4 bearer TMDB_ACCESS_TOKEN (authorizes v3 too; TMDB_API_KEY the fallback). TMDB is free + unmetered (no quota header, verified), so NO usage poll — just the standard guards (AsyncRateLimiter + breaker integration="tmdb" + retry). Emits tmdb_fetch; fail-open (a miss → None/[]).reference_movie.py, the movie source of the /ask reference library (#cinema-facts): TMDB detail + credits and OMDb scores for a film question routed by movie_matches. The TMDB search hit is checked before its facts are used (#3430, B15). TMDB search returns some film for almost any query, and the old source took the top hit: a live probe of 30 film-routed questions (2026-10-16) got the wrong film on 18, for example “The Bear (1988)” for the TV series and “Vera Drake (2004)” for Drake. _pick_film now hands the first five hits to one cached Haiku pick (ClaudeClient.pick_reference_film, purpose reference_film_pick); the model can only pick a real hit or NONE. NONE or no verdict returns None, so Wikipedia answers (absent over invented). There is no word-match fast path: an exact title match was wrong on 2 of 6 live questions, because a person can share a film’s title (“Rihanna (2025)”). The judge attaches at boot through reference.set_durable_db, with no bot.py line; tests reset it in tests/conftest.py. The full B7 router (one Haiku call to pick the source) was measured and not built: 5 reference_lookup calls in 30 days, each to the right source. Since CB-25 the router exists (see reference.py below), and only its FILM route reaches this source.reference.py, the /ask reference library dispatcher (lookup_reference). The source is picked by one cached Haiku read (CB-25, #3410), ClaudeClient.reference_route (purpose reference_route): a route label (CREDITS / SAMPLES / MEANING / IDENTIFY go to Genius with that render, PERSON / FOUNDING to Wikidata, RELEASE to MusicBrainz, FILM to TMDB+OMDb, CHART to the Wikipedia chart extractor with a chart key, ARTICLE to the Wikipedia reader), plus a tables flag for the reader. Wikipedia is always the last source. The old intent regexes of each source are the read’s examples and its fallback: with no client (tests, scripts) or no verdict, the _SOURCES chain runs as before (reads fail open), and the event’s detail.routed says regex. The reader’s sub-article is picked by a second read (reference_subpage) over the article’s links that name the subject; the old word weights decide on no verdict. The lead-image rung (wikipedia_lead_image, used by entity_image) keeps the token rule _title_names_subject as the fast accept; a title it rejects goes to wiki_title_subject YES / NO with the caller’s subject kind, and no verdict keeps the reject (a wrong picture is worse than none). ARTICLE questions do NOT go to TMDB: measured, the film pick then took the Eras Tour concert film for “The Eras Tour total gross”. The song_credits tool takes a kind enum the /ask model fills. Measurement: CB-25 in docs/CLASSIFIER_AUDIT.md.box_office.py, the guarded Box Office Mojo scrape client + the pure parse_chart HTML parser for the cinema desk’s box-office grosses (#cinema-desk): weekend_chart(year, week) / daily_chart(day) → ranked BoxOfficeRows (period gross + running total). No API/quota; the guards are a GENTLE rate limiter + breaker (integration="box_office", trips on an IP-block/outage — the thing that 403s us on The Numbers) + retry + a short in-process TTL cache. Emits box_office_fetch; fail-open ([]) after HTTP failures and exhausted network retries, so a dead BOM read omits its story without aborting the later TMDB, Netflix, OMDb, and news lanes. Parses cleanly from our host (verified).netflix_top10.py, the guarded Netflix Top 10 TSV client + pure parser for the cinema desk’s streaming numbers (#cinema-desk): latest() → this week’s Top-10 rows (weekly hours viewed + views, across Films/TV × English/Non-English). Reads the whole all-weeks TSV (oldest-first, so the latest week is at the END — must read fully) + caches weekly; free, no key, guarded (breaker integration="netflix_top10" + retry). Emits netflix_top10_fetch; a TSV shape change shows as a fail rate. Fail-open ([]).omdb.py, the guarded OMDb client + pure parser for the cinema desk’s critical-scores lane (#cinema-desk): by_title(title, year) → an OMDBRatings (RT % + Metacritic + IMDb, parsed out of OMDb’s Ratings array; a brand-new film may carry Metacritic/IMDb before RT populates). Free tier (OMDB_API_KEY); guarded (rate limiter + breaker integration="omdb" + retry + TTL cache). Emits omdb_fetch; fail-open (None). The one differentiated cinema FACT Kalshi’s thin score markets can’t cleanly give.cinema_numbers.py, the cinema desk’s pure data layer (the movie/TV analog of utils.music_markets): the story BUILDERS (box_office_story — release-centered, leads with the biggest new opener; streaming_story — the #1 film/show by hours; scores_story — a film’s RT/Metacritic critical scores off OMDb, “reviews are in: 94% on RT”; release_story — the notable new theatrical/streaming/TV drop), each turning already-fetched rows into a CinemaStory (headline + a grounding blob the compose model quotes + a dedup_key + a story-priority score), plus rank_stories (order + dedup) and the async fetch_cinema_stories orchestrator (the guarded client reads → the builders; the scores lane looks up the freshest notable theatrical release’s scores). Builders/ranker are pure + unit-tested.cinema_boards.py, the cinema desk’s ranked BOARD builders (#2266): pure derivations over BoxOfficeRows into a CinemaBoard (the fact, the rows that prove it, a notability score, and the card’s head lines), plus the model-facing compose_inputs. It carries its own CinemaBoard record rather than reusing chart_standings.Board, whose source DEFAULTS to Billboard – a cinema board that forgot to override that would credit Billboard for a Box Office Mojo figure, the one mistake a source pill must never make. Three fences come straight from the source pages (docs/INTEGRATIONS.md): the year board prints only the Gross column BOM ranks the page by, because the page’s second gross column disagrees with it for a carry-over title in a direction that cannot be established from outside; the per-screen board reads the WEEKEND page only, because the year page’s theater count is the MAXIMUM the film ever played; and a row is dropped below three screens, where a ‘per-screen average’ is one venue’s take wearing the word average. The board is RANKED by per-screen average, but the row’s HERO number is the film’s WEEKEND GROSS (humanize_gross, compact $61K/$39M); the screen count and the per-screen average ride in the sub-line (N screens · $X/screen). So a three-screen platform release still leads the board on its per-screen figure while the small gross at the top of the card shows what it actually made – the contrast the board exists to show. Keeping the per-screen RANKING (not flipping to gross) is deliberate: it is what keeps this board’s take distinct from the weekend_top_ten gross board and the box-office number lane, so it is not the board that keeps getting deduped (owner steer 2026-08-24). The weekend_top_ten board leads with the weekend’s DEBUT (owner steer 2026-08-25): its story is who OPENED. _debut_lead picks the biggest new opener (a row _move marks ‘new’, i.e. no week-over-week change in its first week) above a _DEBUT_LEAD_MIN_GROSS $3M floor – the same wide-opener floor box_office_story uses – and the board’s subject FOLLOWS that lead, not always the number-one film. Two reasons the subject matters: the card’s corner poster is resolved from subject, so a take about a #2 debut beside the #1 holdover’s poster would be a wrong-art mismatch (Codex review, #2569); and the compose block NAMES the lead on a LEAD WITH: line so the take, the poster and the desk’s pick cannot disagree (the #2082 supply-the-field pattern). The fence tells the model to follow that line and not pass a debut over for a percentage swing on an older film – with NO ‘wide’ claim (the block carries no screen count) and NO ‘adding screens’ cause (no theater-change), both of which the block cannot substantiate. A live take led “Tony is the story” off a +576% swing on a $5M week-3 film while two real debuts sat above it. The swing is a FALLBACK, not banned (owner refine 2026-08-25, “the swing is okay if no debut”): on a debut-less weekend _debut_lead returns None and the subject follows cinema_numbers._notable_holdover – the sharpest hold or cliff, the same pick box_office_story makes – so the swing lead is chosen DETERMINISTICALLY and bound as the subject, and the LEAD WITH line names it. Only when there is also no standout hold or cliff does the subject fall back to the number-one film. That binding is the second Codex fix on #2569: without it the fence could send the take to a lower row while the poster stayed on the #1 film. Pure + unit-tested (tests/test_cinema_boards.py). A TMDB CALENDAR board rides alongside the box-office four (#2266): coming_soon, the US theatrical releases for the next fortnight, laid out in THREE columns – the release DAY as the lead (a #1 would imply a ranking of quality), the title with its DIRECTOR under it, and the STUDIO in the right-hand column (owner ask 2026-08-10). The two were packed into one credit line first and ran to fifty characters, squeezing the title beside them; the row already had a right-hand column and nothing else on this board wanted it. ONE director, ABBREVIATED to an initial plus the surname (short_director: “David Robert Mitchell” reads “D. Mitchell”) – TMDB credits a duo as “Jon Lucas, Scott Moore” and a solo director in full, and both cost the TITLE its width in the row they share. The abbreviator keeps a surname PARTICLE (“Lars von Trier” stays “L. von Trier”, not “L. Trier”, which is a different person) and a generational suffix, and leaves a one-word name alone since an initial identifies nobody. The studio is trimmed of the corporate suffix nobody says (short_studio: “Warner Bros. Pictures” reads “Warner Bros.”). The early-score column is GONE – the studio owns that space, and the score was measured unpublishable anyway. Neither is a DISTRIBUTOR: TMDB carries none, and an unreleased film has no Box Office Mojo row to read one off. A list endpoint carries no credits either, so the desk spends one movie_detail call per drawn row, fail-open per title. A TRENDING board was built here and CUT on the owner’s read (“kill most looked up that’s kinda lame”): the honest version could carry no numbers at all, since TMDB publishes no figure that IS that ranking, so it was a numberless list of films the room had already seen on the box-office boards. tmdb.trending_movies stays in the client with nothing reading it. The coming-soon board renders its early-score column only when _MIN_SCORED_ROWS rows carry one: measured on a real window ONE row of ten had a score, and the model then wrote about which rows did and did not have one, twice getting it wrong and shipping past the self-gate. A lone value in a column of blanks is not a data point, it is an invitation to count the blanks. Its per-day counts ARE handed to the model in the blob, because a calendar invites ‘four open on the 14th’ and supplying the count is the fix where fencing it off is not (the #2082 lesson). The CRITICS SCOREBOARD is the lookup-gated board (the chart_credits pattern: a per-build lookup cap, a coverage floor, one chart_lookup event). Each row shows BOTH scores as metacritic / rotten-tomatoes (owner steer 2026-08-10, “consider a _ / _ format”), with a dash where OMDb has no Rotten Tomatoes score – a column of blanks reads as a rendering fault, a dash says there isn’t one, and the caption names the order so no row carries a label. It ranks on METACRITIC, not Rotten Tomatoes, and that reverses the epic’s own wording on measurement: across a real weekend top 12, OMDb returned Metacritic for 10 titles and Rotten Tomatoes for 4, so an RT-ranked board resolves at a third of the field and would have been silent nearly every week. RT still rides on the row when OMDb has it, and a row without one SAYS so rather than showing a zero. The two scales are never merged: Metacritic is a weighted average of critic scores, Rotten Tomatoes the share of critics who were positive.instrument.py, the one consolidated timing tool. Instrumenting anything is a single line and the util owns the clock/ok/duration/emit (no call site touches a stopwatch). Four primitives: @instrument("kind") (decorate an outbound-client fn → ONE domain event on exit with ok+duration_ms; set fields inside via the ambient event().set(...)/event().fail(...), the ambient handle resolved through a contextvar so a helper can reach it; used by github/railway/tts/stt/image_gen/gifs/apple_music/video_fetch/link_check/…), timed_event("kind", ...) (the block-scoped form for a dynamic kind or a partial-function span: db _run, pplx_<purpose>, market_fetch), @timed("name")/timed_span("name") (a heavy fn / flow step → span event), and Stopwatch (the escape hatch for the two calls that must read elapsed time at several points in one operation: the claude_api retry loop and @track_command). event() is always safe (a detached no-op outside any scope); everything is best-effort (a metrics failure never breaks the wrapped call) and the call’s own exception propagates (auto-tagged ok=False+error). Replaced the hand-rolled start = time.monotonic() / int((...) * 1000) pattern every client had copy-pasted.context_beat.py, the shared LIVE CONTEXT BEAT (#cinema-facts) — the reusable primitive that pulls the current buzz / reception / discourse around a post’s subject from Grok (the X crowd) + Perplexity (web / critics / Reddit / TikTok / YouTube, swept in ONE Perplexity call so “other socials” ride along cheaply — owner steer) and merges them into ONE block a compose surface weaves into its take as SENTIMENT + the sharp ANGLE the bare numbers can’t carry (a fandom civil war, a second-season-slump framing, a critical split). The generic sibling of grok_search.market_topic_pulse (which is market-framed): a neutral “what’s the buzz” read that fits ANY subject. CONTEXT, NOT GROUNDING — the consuming surface still quotes every HARD NUMBER from its OWN grounding blob, never from the beat (the compose_market_drop x_context slot already enforces “the ONLY numbers you quote come from the grounding block”, so a surface just passes the beat there). gather_context_beat(bot, subject, *, purpose, extra, framing) runs both sources CONCURRENTLY, provisioning-gated per source, fail-open (a missing/erroring source contributes nothing; both missing → None). Emits nothing itself — the underlying pplx_<purpose> + Grok search events carry the telemetry, so purpose attributes cost to the calling surface. framing picks the Grok leg: "general" (default) a neutral “what’s the buzz” read (cinema/film/show/song); "market" reuses the proven market-tuned grok_search.market_topic_pulse (crowd-vs-reality) so a MONEY surface keeps its EXACT existing X read and only GAINS the Perplexity layer; "breaking" (the wire desks’ breaking lane, #sports-desk) leans HARD on Grok’s real-time X for the freshest just-broke chatter (_GROK_BREAKING) and pulls the Perplexity leg at recency="day" for max freshness — Grok beats the lagging web index on a just-broke story. The Perplexity PROMPT is the same across framings; only its recency tightens for breaking. Adopters: the cinema desk (framing="general") and market_drop (framing="market" — it already had the Grok pulse, so the beat’s only net-new call there is Perplexity; no Grok is doubled). Music + discourse deliberately do NOT use the shared primitive — they already pull their OWN Grok + Perplexity (interwoven with surface-specific enrichment), so routing them through the beat would DUPLICATE those calls; they’re left as-is. The live sports commentator is deliberately excluded (owner steer) — it’s latency-critical + already live-fed, so a buzz round-trip per goal/interval isn’t worth it. Pure gather/merge logic unit-tested in tests/test_context_beat.py; the Grok/Perplexity I/O is the clients’.audio_tags.py, the v3 performance-tag sanitizer (voice “directing”): a voiced line can carry inline cues that ElevenLabs v3 reads as delivery direction. ALLOWED_TAGS is the audition-confirmed set, [laughs]/[sighs]/[sarcastic]/[whispers]/[excited]/[curious] (reactions/tone) + [sings]/[singing]/[singsong]/[humming]/[hums] (melodic/wordless). Two invariants keep it safe: filter_to_allowlist(text) drops any non-allowlisted [tag] before synth (v3 reads an unknown tag literally, so a hallucinated [angrily] must never reach the API — applied inside tts.synthesize, filter_tags=False bypasses it for the audition); strip_all_tags(text) removes every [tag] before any text path (the text fallback, dedup, the stored memory/discourse rows, the self-gate scorer) so tags are audio-only and never render as text. The regex only matches lowercase-bracketed tokens, so [2024]/[NBA] pass through. ALLOWED_TAGS is the curated set; a new tag earns a spot only after scripts/audition_voice.py (key-gated, never in CI) confirms it renders on the real voice vs gets spoken literally. Distinct from the <voice>/<sing> control signal in voice_signal.py (those choose the delivery channel; these are performance within the line). The voiced prompts (ask SPOKEN DELIVERY + chime-in SPOKEN OPTION) teach the allowlist as “seasoning, most lines have none.”voice.py, canned quip pools (rate limit, permission denied, pipeline red, etc.) with pick() for random selectionbot_logs.py, structured logging to the guild’s #bot-logs channelimage_gen.py, ImageClient for OpenAI GPT Image (the picture-equivalent of tts.py): an async aiohttp wrapper gated on OPENAI_API_KEY, fail-open (None on any miss → text fallback), model/quality env-overridable (OPENAI_IMAGE_MODEL/OPENAI_IMAGE_QUALITY, defaulting to gpt-image-2/medium). Two calls sharing one POST/decode/emit core (_execute): generate (text→image, JSON) and edit (remix an existing image, multipart upload to images/edits). Emits image_generated with mode=generate|edit. Relies on OpenAI’s own moderation; the prompt is composed under the constitution.openai_chat.py, OpenAIChatClient for OpenAI Chat Completions — the OpenAI TEXT backend (#openapi-parity), the generation sibling of image_gen.py/embeddings.py: an async aiohttp wrapper gated on the same OPENAI_API_KEY, fail-open (a CLASSIFIED OpenAIFailure on any miss, #973 — category routes the failover: provider/quota fail OVER to Claude, auth/request/malformed surface; a 429/insufficient_quota out-of-credits is classified quota and is NOT retried, the OpenAI analog of Anthropic credit exhaustion), model env-overridable (OPENAI_CHAT_MODEL/OPENAI_CHAT_CHEAP_MODEL, the best/cheap pair gpt-6-sol/gpt-5.6-terra — the opus/sonnet analogs; the Haiku-tier gpt-5.6-luna is an env-override only). Per-model request rules on the OpenAI side (#cheap-model-pricing, the twin of claude_client’s capability sets): is_reasoning_model keys on the gpt-5 / gpt-6 prefixes (_REASONING_MODEL_PREFIXES — a new generation lands there first, or it gets no reserve and no effort and its default thinking eats a short surface’s budget), and gpt-6-astra rejects reasoning_effort: "none" with a 400 (low/medium/high/xhigh only, measured 2026-09-07), so openai_reasoning sends low there instead (_NO_EFFORT_NONE / min_effort); every gpt-5.x id accepts none. Sampling: every 5.5 / 5.6 / 6 id 400s on temperature, and no caller passes one to the OpenAI path. complete(*, model, system, user, max_tokens, purpose, temperature=None) returns a ChatResult (text + finish_reason + token counts), emitting an openai_api event (model/purpose/tokens/finish/preview, never the prompt; ops-monitor keys it openai:<purpose>). The router (claude_client._call → _call_openai) maps it onto ClaudeResult, so a GPT-routed surface is invisible to every downstream guardrail. Two paths: tool-free → complete (Chat Completions, max_completion_tokens; o-series rejects max_tokens); tools / server-side web_search → create_response (the Responses API) driven by claude_client._call_openai_responses’s function-call loop, with the pure parse_responses_body / responses_function_tool helpers here (split out + unit-tested, alongside _decode_chat_completion). The built-in web_search tool returns url_citation annotations + a sources field that flow straight onto ClaudeResult.web_search_urls for the link guardrail. Image input (vision) is served on the Responses path: responses_input_content maps an assembled Claude vision message onto input_text/input_image parts (_image_part_url recovers a URL or repacks a base64 source as a data: URI), so a GPT-routed surface with images answers on GPT instead of falling back to Claude.embeddings.py, EmbeddingsClient for OpenAI text embeddings (the semantic-recall sibling of image_gen.py): an async aiohttp wrapper gated on the same OPENAI_API_KEY, fail-open (None on any miss → keyword/FTS fallback), model env-overridable (OPENAI_EMBEDDING_MODEL, defaulting to text-embedding-3-small, 1536-dim). embed(text) returns a float vector (emits an embedding event with chars/dims, never the text). Plus the pure, unit-tested helpers the recall path uses: encode_vector/decode_vector (the JSON ⇄ list bridge for the memory_notes.embedding TEXT column), cosine, and rank_by_similarity (cosine-ranks candidate notes against a query vector, drops anything under SIMILARITY_FLOOR, returns the same (tier, summary, span_start, span_end) shape the FTS methods do). Chunked retrieval (#865, the searchability lever): a whole daily note (~13k chars) embedded as ONE vector averages its many topics into a diluted centroid, so a narrow query (“tay keith”) scores weakly against the blur (~0.27) and sometimes the WRONG day’s note edges out. So besides the prose + tag vectors, the write path also stores per-chunk vectors (chunk_for_embedding splits the note into moment-sized pieces → embed_batch → encode_chunk_vectors → memory_notes.chunk_embeddings), and rank_by_similarity does a two-stage rerank: rank all candidates by whole-note vector, then re-score the top-chunk_rerank_top (#50) as max(whole-note, best chunk cosine). A narrow query then matches its specific moment-chunk (~0.59-0.75 vs 0.27), surfacing the RIGHT note — a measured ~+70% top-hit recall on real prod notes, and a correctness fix (the diluted whole-note vectors were mis-ranking days). A chunk hit can only RAISE a note’s score, never demote it, so it’s strictly ≥ the whole-note baseline; bounded to the top-_CHUNK_RERANK_TOP candidates to cap decode+cosine; fail-soft (a NULL/bad chunk column falls back to the whole-note vector). Backfilled onto existing notes by the /remember reindex (notes_missing_embedding now also picks up chunk_embeddings IS NULL). The constants are grounded in the live corpus, not guessed (the dry-run caught a naive first cut): MAX_CHUNKS_PER_NOTE=256 sits above the real max (prod daily notes: median 45 chunks, p99 96, max 194) so a busy day is never truncated; _CHUNK_RERANK_TOP=300 covers ~6× the worst observed whole-note rank of a right note (47 of 190 — the diluted score this stage gates on can run deep, exactly the dilution chunking fixes); and the chunk vectors are stored base64-packed float32 + scored with numpy (not JSON like the single prose/tags vectors), because at recall the JSON parse of dozens-of-vectors-per-note was the bottleneck (~1.8s/recall over the full corpus vs ~143-185ms for base64-f32, ~2× smaller too). JSON-in-TEXT + in-process cosine, not pgvector, deliberately: at this data scale retrieval is the constraint, not storage, and a CREATE EXTENSION vector that isn’t installed on the host would break the every-startup schema init. Embedding fenced prose is purely additive — it doesn’t touch the constitutional memory fence.image_signal.py, parses the model’s <image>…</image> (generate) / <remix>…</remix> (edit) candidate tags off a composed line (sibling to voice_signal.py), splitting into the caption (text outside) and the prompt/instruction (inside), with is_remix distinguishing the two. strip_image_tags scrubs any malformed/stray fragment from text replies. Pure, unit-tested. Only the typed-mention ask path teaches the tags (allow_image/allow_remix).utils/media_edit.py + utils/media_signal.py, the mechanical file edits behind the <media op=...> tag on the same path. media_signal is the pure parser (the op plus its numbers; it tolerates 1:12 clock times, quoted values and the near-miss spellings a model reaches for, and strip_media_tags is the leak net for a truncated tag). media_edit is the executor: an op registry that pairs each op with the input kind it accepts and its own output ceiling, PURE ffmpeg argv builders returning a LIST of passes (a gif is two – palettegen then paletteuse, because a one-pass encode bands every gradient on the default web palette), and Pillow for the photo ops. Two params are MODIFIERS rather than ops, because a person asks for them alongside an edit: mute drops the audio on any clip edit and factor retimes it (a gif takes it too), and to=png|jpg|webp names the output format on any photo edit. That is measured, not guessed: asked for two things at once, the model wrote an invented mute=true, or TWO tags of which only the first runs, and then SAID it had done both – a claim that did not match the file. Only the first tag ever runs, and the prompt says so. Three decisions worth keeping: a gif counts as VIDEO (every useful op on one is a video op); trim STREAM-COPIES from zero but RE-ENCODES from a start offset, because h264 only lets a copy begin at a keyframe and a measured copy of “2s to 4s” came back 4.09s long; a copy KEEPS THE SOURCE’S OWN CONTAINER (_COPY_CONTAINERS) and falls back to a re-encode once if it still fails – every copy used to write toots.mp4, and mp4 cannot hold VP8, Vorbis or GIF, so “trim this” on a webm or a gif returned None and did nothing (2 of 5 REAL inputs; the synthetic test clips could never show it, which is why the real-file matrix earns its place); the image ops apply the EXIF ORIENTATION first, because a phone writes landscape pixels plus a tag, Discord honors the tag and our output carries no EXIF, so a resize used to come back rotated 90 degrees from the picture the person was looking at; and the speed ceiling bounds the OUTPUT, so a 0.25x slow-down reads a quarter of the window. kind_of reads the magic bytes (the ftyp brand splits an iPhone HEIC from an mp4) and only falls back to the name, because a Discord upload can arrive with an empty content type. Emits media_edit + media_reply.market_cards.py, self-built Discord embed cards for market posts (the link-unfurl fix). kalshi.com sits behind a Vercel anti-bot wall that serves Discord’s link crawler a “security checkpoint” page instead of OpenGraph tags, so kalshi.com links never unfurl in chat (Polymarket’s native unfurl is inconsistent too). Rather than depend on either site’s crawler, cogs/market_drop + cogs/market_alert build their OWN embed (build_market_card → (content, embed, file)): the take is the embed description (her voice lives IN the card, so the post is ONE self-contained card and content comes back EMPTY — nothing is re-listed as message text, since the chart owns the ranked numbers and the take owns the read), the embed carries the clickable market link (on the embed url, NOT a bare link in the text, so Discord doesn’t also try+fail to unfurl it) and an image. A SETTLED market attaches NOTHING, on every surface (owner steer, #1851): when the card’s leading CHARTED outcome sits at or above SETTLED_LEADER_PCT (0.95 — literally market_drop._LOCK_LEADER_PRICE, imported so there’s ONE definition of “effectively decided”), market_settled drops the link — no clickable heading, no link Button, and, since the crosspost takes its link off card.url, no link in the tweet either. The race is over, so pointing the room at a 97% locked market is a dead CTA. The rule lives in this shared builder rather than in a cog, so it holds across every market surface (market_drop, market_alert, betting_board, betting_alert, betting_value, music_alert, music_desk) instead of each one remembering it — the hoist earned by the music desk + music alert having already hand-rolled it (luminate_snapshot(link=...) drops the Kalshi link on a SETTLED reading; those stay, since a RESOLVED reading is settled by its CLOSE, not by a 95% leader, so they catch a case the price test can’t see). It’s judged off the CHARTED outcomes — what the card actually shows — so a locked PROP leg inside a bundled Polymarket game can’t unlink a live moneyline (_chartable_items already dropped it) and a leg inverted to the FAVORED side (_favor_outcome) is judged as displayed; a scalar FORECAST line is skipped (its pct is a normalized trajectory, not a probability) and a market with no charted probability falls back to its binary yes-side (a 3% YES is as decided as a 97% one). A card showing exactly ONE line is judged as a BINARY too (max(p, 1-p), #1876): a lone leg has no field to lead, so its own extremity IS the verdict — taking the raw max there read a leg WIPED to 0% as “still live” and shipped a go-bet-on-it link to a market with nothing left to bet (the BTS #1-song alert). It’s the same rule the no-outcome fallback already applied, reached one step earlier; a real multi-leg board still leads with its top leg. The market_alert settled-OUT report (#1876) is the editorial half on that side of the line: a candidate whose move ENDED the question — it fell to 1 - SETTLED_LEADER_PCT (5%) or below from a real prior — stops being a market read and becomes a sewn-up FACT, the mirror of market_drop._is_report_lock (97% is sewn up FOR, 0% is sewn up AGAINST, and neither has a live line left). It routes to the report_out compose angle, is graded on drop_score(report=True) (generalized to score a settlement in EITHER direction — the winner-shaped rubric was docking a settled NO for “not naming what DID happen”), heroes “NO” on the number card instead of the “0%” the take was just told to drop, and passes attach_market=False. It never fires on a NUMERIC rung that has rung NEIGHBOURS – a threshold or a bucket, kalshi_ladder.is_numeric_rung, on an event whose resolved legs hold two or more rungs (_settled_rung, 2026-09-21). A LONE numeric binary keeps the report: is_ladder_rung’s own sweep found 250 live single-leg events carrying a strike, and there the leg IS the market, so its fall to zero is a real settled fact (Codex review on #3585). A rung that settles NO says the number landed outside its strike, and the story is that number — the ladder’s forecast, its top settled rung, the LEAD — never the strike a rung missed; the settled-out report cannot state it, so the leg is cut from the pool (market_filtered reason=settled_rung). The post behind the rule: “Fuerza Regida did not clear 24M for its highest daily YouTube view count this week”, shipped to X while the act had cleared 23M on the same board (owner: not worth reporting, and it broke the report-the-leader rule). The rung reached the pool because a ladder was losing its identity as its rungs settled: Kalshi settles the rungs of an “at any point during the week” views ladder (KXYTVIEWSW, and the monthly KXYTVIEWSHIGH the same way) as the value passes them, and get_event_markets read status=open only, so on the last day the fifteen-rung ladder came back as TWO legs — under _MIN_LADDER_LEGS — and _resolve_races crowned the surviving 24M rung the “leader” of a two-horse race. Both resolvers now fetch the event’s settled legs too (get_event_markets(include_settled=True), and market_drop’s companion-ladder read the same), and _kalshi_market_to_snapshot prices a settled leg at its RESULT (1.0 / 0.0, a tight book at that value, settled_result on the meta), so the survival curve reads 1.0 up to the value, the event stays a ladder, its rungs stay suppressed, and ladder_is_decided fires where it should (the same read the outcome lane had already recorded as market_now 23.5M twenty minutes earlier). Verified live: the open-only read of KXYTVIEWSW-FUE26SEP20 returned 2 legs, the unfiltered read 15. THE CARD REPORTS THE OUTCOME, NOT THE QUESTION AND NOT THE PERCENTAGE (owner steer, 2026-09-06). The steer came off a live pair of posts on the same release: the market card titled itself "Will Rod Wave's 'Don't Look Down' debut at #1" and its tweet led with “now a 99% lock”, while the music card beside it read "Don't Look Down - Rod Wave" over “No. 1 on the Billboard 200” — both reported the same fact and only one of them said it. A question is what the exchange needed to open a contract, not what a reader wants read back. So outcome_split(question) splits a market question into its SUBJECT (the card TITLE) and its OUTCOME CLAUSE (the sub-caption), and the percent survives only as a live-market QUALIFIER on that clause ("55% to debut at #1"), never as the caption’s whole content and never once the outcome is DECIDED ("debuts at #1"). It is a VERB REGISTRY, not a parser: Kalshi words a question as “Will <subject> <verb> …?”, so the split is one boundary — the first word that is a verb — and _OUTCOME_VERBS is keyed base-form → third-person, which is also the conjugation the decided caption needs, so ONE registry answers both. THREE GATES keep the anchor out of a NAME (a noun that doubles as a verb: “the Sahm Rule trigger”, “‘A Quiet Place Part III’ be released”, “Boise Airport set a new passenger record”), each measured rather than reasoned in: CASE (a real verb is lower-case in Kalshi’s sentence-cased questions, an all-caps one is real — “Ramp or Brex IPO first”), SUBJECT SHAPE (a subject cut mid-phrase ends in a function word or a possessive, or leaves an opening quote unclosed), and REMAINDER (a verb right after the anchor, or a leading "of" — a NOUN takes “of” where these verbs do not, which is what keeps “take control of any AI company” anchored on “take”). The scan then tries the NEXT anchor rather than giving up, and that is what turns those same questions into CORRECT splits. The fail direction is absent over invented: a question that clears no gate keeps the title it always had, rather than ship a subject cut out of the middle of a name. scripts/dryrun_market_outcome_title.py is the sweep that measures it against the whole live board — 2026-09-06: 519 of 544 live “Will …?” questions split (95.4%), no wrong split in the printed output, and 1241 of 1448 question-titled cards (85.7%) no longer ask. That second number is the one the rule is judged on, and getting its DENOMINATOR wrong flattered the result badly. The sweep first measured over EVERY card on the board, but most of the board is already titled with a plain subject ("Kylian Mbappe Total FIFA World Cup Career Goals"), so counting those as wins measured nothing — it read 9% where the real figure on question-titled cards was 19.5%. Only a card whose market title actually ENDS IN A QUESTION MARK can say a question, which is also the test production stamps titled with. Reading that corrected report is what found the two shapes that carried the rest of the reach, and neither was a “Will …?” question at all: 762 of the 866 misses were a NOUN PHRASE wearing a stray question mark ("US real GDP growth in 2036?" — already a subject, so statement_title drops the mark, gated on no interrogative word appearing ANYWHERE so a real question is never stripped into a statement it is not), and 78 were "When will ...?" — a FIELD question whose legs are dates, so when joins who/what/which in _FIELD_HEAD, and its verb may END the sentence ("When will Joe Flacco retire?" → "to retire"). A bare COPULA ending one is the exception that stays: "Who will the next Pope be?" would card as "to be", which says nothing. The 207 that still ask are the How much / How many / How high families, prefixed questions, and verb-at-the-end shapes — a question beats a mangled statement. Re-run it before adding a verb, and read it for the WRONG splits, not the coverage number: a new anchor can only move the boundary EARLIER, so it can only create that failure, never fix one. The second interrogative shape, a FIELD market ("Which country wins Eurovision 2027?"), yields the CLAUSE only — its legs are the candidates and the card already titles itself with the leading one. decided is the CALLER’s to set and is never inferred from pct: only the caller knows which SIDE settled, and a market decided AGAINST its question is just as extreme a price (that lane heroes "NO" through the settled-out report). On the leader rung this matters most — the hero there is a NAME and a leader can lead on 22%, so live it reads "to be the next Pope" and only a decided caller gets "wins Best Picture", with the percent dropped entirely. market_card_hero’s folded titled field is the rate that watches it (see docs/OBSERVABILITY.md). The rule reaches 93.5% of question-titled cards, and getting there took three shapes that are NOT “Will …?” questions at all (measured 2026-09-06, scripts/dryrun_market_outcome_title.py): a NOUN PHRASE wearing a stray question mark ("US real GDP growth in 2036?" -> statement_title drops the mark, gated on no interrogative word appearing ANYWHERE so a real question is never stripped into a statement it is not); a "When will ...?" DATE field (when joins who/what/which in _FIELD_HEAD, and its verb may END the sentence – "to retire" – though a bare COPULA ending one keeps its title, since "Who will the next Pope be?" would card as "to be"); and the QUANTITY family (how_much_title: "How many House seats will Democrats win in Alabama?" -> "House seats Democrats win in Alabama", which hands its tail back to outcome_split as a "Will ..." question so it inherits all three gates rather than re-rolling them). The DENOMINATOR is the lesson here: the sweep first measured over EVERY card and reported 9.0%, but most of the board is already titled with a plain subject, so counting those as wins measured nothing – the real figure was 19.5%. Only a card whose title ends in a question mark can say a question, which is also the test production stamps titled with. A leg that NAMES NOTHING never titles the card either: market_subject_image._is_generic_outcome already decides that for the ART path (“the card’s subject should come from the title, not this leg”), so the title wires that home in rather than re-rolling one – without it a bucket board carded "10+ / Between 2025 and 2035", a date range standing where the subject belongs (the spelled between X and Y range was added to _GENERIC_OUTCOME_RE for both consumers). The proposition is dropped from the caption when the TITLE already came from the question, so a card never says the same thing twice, once rewritten and once raw. subject_hero(question, label, pct) is THE one “the subject heroes the card, the % is the subtitle” ladder (owner steer #1918; it lives here, beside proposition_clause, because it is the same rule on every market surface): the hero is the subject’s OWN number when the market’s own words carry one — the chart RANK (#2 off a Billboard question) then the THRESHOLD (80+ off “clears an 80”) — else the SUBJECT itself, with the % always on the sub-caption. A ranking AUTHORITY named in the question rides the source PILL, not the prose (split_rank_authority, owner steer “that attribution is what the pill is for”): Kalshi titles a rank market after the site that decides it — "TelevisionStats: #1 Movie on Aug 26, 2026" — and that authority is a SOURCE, so subject_hero lifts the "<Authority>: " prefix out of the caption into number_figure['source'] (the ✓ TelevisionStats pill, the way a music card pills Luminate) and the leftover proposition frames the rank as the authority’s own CHART, "#1 on the most-popular-movies chart on Aug 26, 2026". The lift is general in SHAPE but gated twice — the remainder after the colon must be a named_rank (so "Spider-Man: Brand New Day" and "First Week Sales: 358K" are left alone) AND the prefix must be a REGISTERED authority (_RANK_AUTHORITIES), which is what stops a retained sports-seed board ("Pro Football Playoffs: NFC #2 Seed", whose #2 clears named_rank) from crediting the event name as a source (Codex #2612); a new authority is a one-line registration, earned only after a dry run proves its chart phrasing is take-safe. The CHART framing (not a bare "#1 movie") is dry-run-load-bearing: the real composer turned a naked "#1 movie" into "#1 at the box office" 4/4 (the market is an attention-score popularity list, NOT box office), and the chart phrasing held 8/8 clean while keeping the owner’s most-popular word. The same split_rank_authority also cleans the authority out of the alert’s model-facing blob (market_alert._ending_soon_subject) so the TAKE never names it either; the caller merges the lifted source LAST over its Kalshi/venue default (_alert_number_figure, market_drop._report_number_figure, leader_figure). market_alert._subject_figure is a thin snapshot adapter onto it (question = event title, label = the moved leg), and market_drop._report_number_figure’s locked-BINARY branch calls it with subject_fallback=False — a binary’s label IS its question, so the subject rung would hero a whole sentence; None there falls back to that surface’s % hero. The label handed in must be the BARE leg name (the Dai Dai card, 2026-08): the ending-soon lane used to pass its BLOB subject (_ending_soon_subject, “Dai Dai (YouTube Charts: Weekly Top Song USA)”) as the card subject, subject_hero heroed the whole composite, and render_number_card’s shrink loop bottoms out at ~size 56 — the figure drew past the canvas and the tweet shipped “Dai Dai (YouTube Charts: Weekl” clipped mid-word at the frame edge. The override is gone (every priced kind heroes _swing_label; the question already rides the card TITLE), and the renderer now ellipsizes a still-too-wide figure to the text width after the shrink loop — the worst case is an honest “…” inside the margin, never ink past the frame. Two lessons are baked in. The drop shipped a rank-only COPY of this ladder (#1936) before it was folded back here (#1939) — the copy silently lacked the threshold rung, which is the standing argument for wiring the home in rather than re-deriving a rung. And there is deliberately NO negation guard, which is the more interesting one: the copy carried a cue-list guard against “Will X miss #1?” (where a locked YES means X did not hit #1, so heroing “#1” states the opposite), and scripts/dryrun_subject_hero.py swept it over the live board — it fired 57 times across 45,295 questions and was wrong all 57, because the cues are ordinary names (“Ole Miss” ×44, “Not Like Us”, “Lose Control”, “YoungBoy Never Broke Again”, “members lose their primary”), with zero genuine negations in Kalshi’s vocabulary. The guard cost 57 real cards their hero to defend a case that doesn’t occur, so it was removed; the residual risk is bounded because the question stays on the card as the title under the hero. Re-run that sweep before re-adding one — it is the worked example of measuring a fail-safe guard against real data instead of reasoning it in from the fail-direction rule. The blob is rebuilt WITHOUT percentages (_settled_out_blob), so there’s structurally nothing left to quote — the shipped post had read “cratered to 0% from 22%, wiped fully off the board”, i.e. a post about the price of a question rather than its answer. Rare by construction (exactly 2 of 30 days of production alerts landed ≤5%) and never a NEW alert — it only reframes one that already qualified. Dry-run-measured on real market families (scripts/dryrun_settled_out.py, n=5): 20/20 lines clean of price/market language, and the gate ships the sound cases 15/15 while holding a report whose underlying EVENT hasn’t happened yet (a “will X be evicted” market at 0% says the room thinks X survives, not that the episode aired) — the fail-closed direction working, not a gap to tune out. Two callers are CARVED OUT via link_when_settled (owner steer, #1856), because “decided” doesn’t make their destination dead: a value read whose link is a resolved SPORTSBOOK slip rather than the prediction market (the book decides whether that bet is still open, not us — with no book override it falls back to linking the market and the rule applies as everywhere else), and a bet_board card for a game that hasn’t STARTED, where a 98% favorite is still an open, tradeable book (resolved off snap.live inside _card, so both board lanes are covered and a game decided IN PLAY keeps the rule). A caller can also veto explicitly with attach_market=False — which still WINS over the carve-out, since the veto is an editorial call the price test must never override: market_drop’s sewn-up report angle (_is_report_lock) does, as the EDITORIAL half of the same rule (this post reports a fact, which is reason enough even if the price test ever stops agreeing). The source still gets its footer/pill credit — attribution, not an attachment; a contested market keeps its link exactly as before. market_image carries market_attached so a surface quietly going link-less (or the rule over-firing on live markets) is a queryable rate. The image is a priority ladder, first hit wins — charts-first, but a chart only earns the slot with real movement, so a no-history market prefers a real native image over a flat line: chart (real history) (the self-rendered card from utils/market_chart.py with actual price-history lines — Kalshi candlesticks AND, now, Polymarket via the CLOB /prices-history per outcome, #polymarket-chart) → native image (the source’s own curated image, Polymarket S3 — used when a Polymarket market has no plottable history) → chart (flat) (a flat-line chart still carrying name + outcomes + %s, better than a generic banner when there’s no native image, e.g. Kalshi with no candles) → banner (a committed branded floor banner, assets/market_cards/banner_default.png, regenerated by scripts/gen_market_banner.py — the never-blank floor). (Wikipedia/iTunes topical images slot in around the native-image rung in a later PR.) build_market_card builds the chart’s Outcome list (_chart_outcomes) from snap.meta['chart_specs'] (a per-outcome {label,pct,series_ticker,ticker} list the Kalshi leaderboard threads → a real Kalshi candle line per outcome via get_candle_summary’s series), else snap.outcomes (a multi-outcome event — a Polymarket game/futures now renders a REAL line per outcome when snap.meta['poly_tokens'] carries the per-outcome YES CLOB token ids: _polymarket_series → PolymarketClient.get_price_series → the CLOB /prices-history points; else flat lines. The token id is the clobTokenIds[0] captured in _poly_event_to_snapshot/_poly_yes_token — verified live that /prices-history?market=<clobTokenId> returns points while the gamma market id returns 0), else probability/last_price (a binary/swing alert → the one yes-side, real Kalshi line). Candle fetches are capped at _CHART_MAX_OUTCOMES (5, the render row cap). Per-surface chart ZOOM (owner report: alert charts weren’t zoomed to see the change): the price-history window is chosen by SURFACE (_chart_hours) instead of the source defaults (Kalshi 72h / Polymarket 24h) that squished a recent move into a wiggle at the right edge — MOVE/EDGE-now surfaces zoom TIGHT so the change fills the chart (bet_alert/bet_value 6h, alert=market_alert’s 24h move window), CONTEXT reads stay wide (drop 72h, bet_board 48h), default 24h; the window threads into both get_candle_summary(lookback_hours=) + get_price_series(hours=), and the y-axis then auto-scales to whatever’s IN that window, so a tighter window zooms BOTH axes onto the change. Primary-leg filter for bundled game events (_chartable_items): a Polymarket US-sport GAME (MLB/NBA/…) bundles its moneyline PLUS every prop (spread / O/U / 1st-5-innings / NRFI …) into one snapshot’s outcomes, so charting them all drew ~18 unrelated lines spanning 1-100% and no zoom could make the game legible; the moneyline leg is named EXACTLY like the event title ("Cleveland Guardians vs. Miami Marlins"), so when a title-matching outcome exists it’s a bundled game → chart ONLY that head-to-head win-prob line. A clean multi-way market (soccer’s home/Draw/away, a futures field) has NO title-matching outcome → charted in full (the fail-safe). Scalar ‘Kalshi forecast’ line (_forecast_outcomes, #charts-vision-caption-kalshi): a numeric-ladder music market (first-week units/streams) collapses its per-rung candle histories into ONE forecast line (the MEAN E[X] Kalshi plots). Two deterministic guarantees keep the LINE matching the caption + figure: (1) zoomed tight to the move — the forecast plots the TIGHTEST window (ladder 48h→7d→14d→30d, tightest first) that still contains a real move (E[X] swing ≥ _FORECAST_MOVE_FRAC of the figure), so the change fills the chart; a fresh move reads tight while an older one still gets found by widening (this fixes the original flat-at-24h bug from both directions — a fixed 24h window either squished or missed a weekly move); and (2) time-grid-aligned, full-ladder E[X] (luminate.forecast_series_grid) — the prior index-right-aligned rebuild (mean_forecast_series) read E[X] off whatever INCOMPLETE rung subset returned candles (values diverging from the headline figure — the Kanye 18.6B-vs-11.2B bug) and collapsed to ONE point when any rung was short (the flat-at-24h pts=1); the grid rebuild reads E[X] at each common-grid time from EVERY rung (forward/back-filled) and anchors the final point to the exact figure (forecast_specs['mean']) so line-end == legend == caption. With NO candle history even at the widest window, _chart_outcomes returns nothing and build_market_card falls back to the big-NUMBER card (a figure with no trajectory can’t contradict a ‘moved’ caption — no fabricated flat line). The candle fetch now returns series_ts (per-point timestamps aligned to series via the shared _downsample_indices) so the rebuild can grid-align on real time. Fail-open throughout (a render/fetch error degrades to the next rung). Emits market_image with the rung that answered + outcomes/with_series (how many lines had REAL history vs flat). (Polymarket’s image/icon is threaded onto the snapshot meta in utils/markets.py; utils/bot_logs.py:post takes an optional embed/file so a staged audition shows the same card.) BARS are for SPORTS only; every other lane is a NUMBER report (owner steer, 2026-08-08: “no bar charts except on sports this should be a number report like everything else”). The reported card was a music-video runner-up board: two ranked bars, “petal” at 17% over “Choosin’ Texas” at 6%, on a post whose job was to say where one track stands. _is_sports_card(surface, category, meta) decides — a bet_* surface is always sports (every betting card reads a game line), a drop/alert card is sports when snap.meta['category'] is Sports, and the MARKET’s own sport signal counts too (#2164: Kalshi’s meta['sport'] series label / a Polymarket sports tag slug — the GLOBAL drop lane deliberately stamps no category, so a sports market it pulled used to read as not-sports and shipped a number card; the drop’s two regroup folds now carry sport forward like catalog_hint). A card that is not sports goes to leader_figure, which stamps number_figure off the TOP-priced leg. A field heroes the SLOT the market names (owner steer: “should be #2 as big numbers”) — “#2” off a runner-up board, “314M+” off a threshold — with the LEADER as the subject line and its percent in the caption. That is the shared subject_hero ladder, so a field card and a lock card come from one rule. With no rank or threshold in the market’s words an unranked field heroes the LEADER’S NAME, the percent riding the caption (owner steer, 2026-08-19: “if something is a clear leader by far it should just report the leader vs the percentage” — the “Tires: Season 3 / 20%” card, where a giant 20% over a scattered field read as a headline number). The ladder runs for a SINGLE leg too, but only when that leg is the FAVOURITE (the 2026-09-03 sweep, _SINGLE_LEG_HERO_MIN_PCT = 50). The rank and threshold rungs read the market’s TITLE, so they answer a one-leg market as well as a field, and fencing them behind a field meant a single-leg “above 8M daily views” market heroed its percent while the identical multi-leg board heroed the threshold. The favourite floor is what the LIVE DRY RUN added, and it is the whole reason the change is safe. The first cut lifted the guard outright; run over 216 real Kalshi/Polymarket markets it changed 176 of them, and two classes were plainly wrong — it carded “Avengers: Doomsday” for a 2% Best Picture leg and “90+” for a 1% Rotten Tomatoes rung, each reading as the thing HAPPENING under a number saying it will not. The owner steer behind the name rung is “if something is a clear leader BY FAR”; a FIELD’s charted leg is its top-priced one, so that is true there by construction, while a one-leg market leads nothing and is as often a longshot. Below the floor the percent keeps the hero, which is also the shape those cards already shipped. With the floor the same 216-market run changes 63 (percent heroes 183 → 120) and every change is the market’s own number or its favourite. Two shapes changed, both toward the rule. A single-leg market whose question names a rank or a threshold heroes that number, with the QUESTION as the subject line and the percent as the whole caption — subject_hero builds those rungs around the LEG label, which is never a subject on one leg (“Yes” on a binary; a ladder rung’s “Above 60” restating the figure itself), so the caller takes the title and the caption back rather than carding “60+” over “Above 60” with the question repeated underneath. And a bundled GAME charted as one leg heroes the TEAM with the percent in the caption, which answers the #1835 attribution gap at the top of the card instead of in the caption — and retires the title-ORDER coin-flip the old caption rule had to special-case, since heroing the leg settles attribution in either order. It passes subject_fallback TRUE for a real name; the fence still holds for a leg with no name to hero — a bare Yes/No, or a label restating the title (redundant) — which keeps the percent hero, the shape a single leg / binary ships. leader_figure is the old single_leg_figure widened, because both rules pick the same leg by the same caption rules — one function, not two. A SINGLE leg that is one DATE WINDOW keeps the percent hero (kalshi_ladder.date_rung_deadline on the label, 2026-09-29). On a nested date board a window leads nothing; its price is the whole claim. The live Drake album-date standing card shipped to X with “Before Oct 24, 2026” as the hero, the raw question as the title, and no percent anywhere, so it read as a forecast of that date while the market had it at 51%. A FIELD of dates (“When will X?” with several windows) still heroes its leading window. The leader-name card carries a MARKET-IMPLIED RANK strip (field_rank_standing, owner ask 2026-08-27 “add rank to all cards”, narrowed to “only count cards, not already-ranked cards”): the clear-leader card never said WHERE the leg sits, so the strip reads the rank off the market itself — the priced legs sorted high-to-low, the leader is #1, and the margin over the runner-up is named so #1 is not a bare word that reads the same on every card ("#1 in the field · 13-point lead", rendered through render_number_card(standing=), the same context-strip slot the music desk’s Spotify standing uses). It is deliberately narrow: only the leader-NAME shape gets it, so a card already heroing its own rank (#2 off a runner-up board) or a threshold (80+) keeps that figure and is never doubled; a binary / single leg has no field to rank and gets nothing (prefer absent over an invented #1); a tie is not a lead; and a forecast value_text leg is excluded (it carries a figure, not a probability). The FIELD SIZE is not printed (#1 of N) because the card is built off the top few priced legs (_CHART_MAX_OUTCOMES), so N would understate a large market — the margin needs no field-size and is always exact. A caller that stamps its own number_figure upstream (the drop’s lock/frontrunner report, the alert, the forecast) is untouched, so the strip only reaches the cards the shared builder itself heroes. named_rank is the one home for reading a rank off a market’s words: an explicit “#N”, or the word “runner-up”, which is how Kalshi titles a second-place board and carries no “#N” for the old regex to find. title_rank (moved here from market_drop) wraps it for the callers that need an int with a winner-market default of 1 — that default is why the two are separate, since a caller reading an arbitrary market must get None rather than a fabricated “#1”. The clear-winner lock uses the take’s own test (2026-10-06). market_drop._clear_winner_lock used to require every runner-up at or under 5%, while the take’s report angle (_is_report_lock) needs only the runner-up under 90%. On the Hot 100 board for the week of Oct 17, Patient Zero sat at 95% and Choosin’ Texas at 15%: the take said “takes No. 1” and the card heroed “95%” (owner: “should say #1 not percentage”). The lock now calls _is_locked_leader plus the exclusive-field test, so the card heroes the slot exactly when the take states it. The DECIDED tiers no longer take that default (2026-09-09). market_drop._report_number_figure’s clear-winner lock and the dominant-frontrunner report below it used to hero #{title_rank(title)}, and the default printed “#1” over Jean Smart on an “Emmy Winner: Outstanding Lead Actress in a Comedy Series” frontrunner card — a rank the market never named (owner: “shouldn’t say #1, should say Emmy Winner and category”). Both tiers now go through one helper, market_drop._slot_figure, which heroes what the market’s own words name, in order: winner_rank (here) — named_rank, or “#1” for a Top <region/period> <show|movie|…> board (_TOP_BOARD_RE, the field-side twin of the drop’s _RANK_WINNER_TITLE_RE), else None, never a default; then award_slot (here) — an "<Award>: <Category>" title whose head names a winner/award/champion splits into the AWARD as the hero, the leader as the title line and the category + standing as the caption (“Emmy Winner / Jean Smart / Outstanding Lead Actress in a Comedy Series · frontrunner at 90%”; gated on the head so “Spider-Man: Brand New Day” and “Grady Emerson: Debut Date” stay whole); else the LEADER’S NAME heroes, titled with the outcome clause or the statement — the same shape the shared ladder’s last rung gives (checked against 152 live Entertainment/Sports fields: 14 award, 138 leader, identical figure + title to subject_hero). The locked single-leg BINARY branch is untouched — its _RANK_WINNER_TITLE_RE gate means the title names the slot, so title_rank is honest there. The settled FINAL card (market_outcome.winner_card_fields) reads the same winner_rank: a question that names a rank heroes it with the winner as the title and the statement question as the caption (“#2 / Boston / #2 on the Billboard Hot 100 chart for the Week of Sep 19, 2026” — owner, same day: “art should say #2”; the card had heroed “Boston” over the question), while an award FINAL keeps the winner’s name as the hero (owner call 2026-09-06). Whether the award FINAL should take the award shape too is an open owner question, not a decision this change made. The bars renderer stays for the sports field, and _BARS_ONLY_SURFACES is unchanged — that set governs the price-history FETCH, which stays skipped on every surface. The number card brands the LANE, not the surface key (_wordmark_lane): the number rung used to hand render_number_card the raw surface, which printed itself — “tootsies drop”, “tootsies alert”, “tootsies bet_value” — beside cover cards reading “tootsies predictions”. That was a rare path while the number card was the exception, and making it the default for every non-sports market made it the normal wordmark, so both rungs now share one lane rule (the MUSIC family passes through, since the renderer maps that family to its own ‘music’ lane, and number_figure['brand'] still overrides). The number card’s accent TAG is a LADDER, so the block is never lost (number_card_tag, #2711): the card used to tag itself with snap.meta['category'] alone, which is the ROOM’s configured drop category, and two lanes stamp none — the GLOBAL drop lane skips it on purpose (“Global” is not a useful word on a card) and an alert with no routed category has none — so those cards shipped with NO accent block at all, which is the plainness the owner reported (“why don’t these markets have a green bar”). THREE rungs sit ABOVE that ladder. First the tag the FIGURE’S OWN BUILDER stamped (number_figure['tag'], owner report 2026-09-14): every rung under it reads the card’s TITLE, and a card whose number is read off ANOTHER event has a title that does not name the number. The drop’s companion-ladder projection is that shape — the card titles itself “Miley Cyrus’s ‘Bass Persuades’” and heroes the units ladder’s forecast, so the chart rung read no chart, the event rung read no event, and the block fell to the room category and shipped ENTERTAINMENT over a first-week-units number. The builder is the one place that knows what the number measures, so it stamps the words: the units rung takes the companion event’s own metric through market_chart_tag (FIRST WEEK ALBUM EQUIVALENT UNITS), and its sibling RANK rung takes hits.projection_eyebrow — the same block the desk’s own called-shot card wears (BILLBOARD 200 PROJECTION - WEEK OF OCT 03). A companion title naming no metric stamps nothing and the card keeps the ladder it had (absent over invented). Then the two rungs in chart_card_fields: the CHART the market settles on (owner steer 2026-09-08 — see the RANK card entry under music_alert.py): when the market’s own words name it, the chart is the block. Then the EVENT the market is part of (owner steer 2026-09-10, against a live alert card that read “MARKET ALERT / Alba Rohrwacher / Venice Film Festival: Coppa Volpi for Best Actress”: “the blocks should say what the event is — Emmy winner, YouTube chart, just like the others”): Kalshi titles its award / festival / election families “outcome_split reads the model split first and the _OUTCOME_VERBS regex second. The regex had two faults: a verb outside the list (“perform”: Bond casting, residencies, festival line-ups) left the raw question as the card title, and a NOUN that is also a listed verb moved the cut (“Will Alexandra Eala win SET 1 …” -> subject “Alexandra Eala win”; “… current-dollar GROSS domestic product …”). prime_outcome_splits runs before the hero in market_drop._compose and market_alert._fire. For every question (not only the ones the regex misses since 2026-10-18), it asks Haiku once, with the verb list in the prompt as examples (ClaudeClient.market_outcome_split, purpose=market_outcome_split) and caches the answer 30 days (kv_cache namespace market_outcome_split_v2; “does not split” 7 days). ground_outcome_split accepts an answer only when Will + subject + verb + rest IS the question text (curly quotes folded, the parts cut from the question’s own words), so the model cannot add or move a word; the third-person verb must be a regular form. An error, an unreadable reply, a “does not split” and a refused answer all fall back to the regex split, so a caller that does not prime (the music lanes) reads exactly the old split. Dry run 2026-10-16: 80 unsplit live questions -> 65 split, 15 kept the question. The field shape and the subjectless quantity tail (#4209). A field question _field_clause does not read goes to the SAME split in a “Will …” form (_field_candidate): “When will X market_chart.py, the Pillow renderer for the market chart card (render_market_card → PNG bytes). The look is matched to a real Kalshi reference card (owner steer “make ours this crisp”): a near-black card with a small letter-spaced category eyebrow (optional, eyebrow=, threaded from the drop’s category via snap.meta['category']), the market name big + bold, a stacked legend below it (one row per outcome: a colored dot + label + bold %), the co-brand top-right “Tootsies” (big, red) on {source} (small, source color), and a full-width price chart at the bottom — crisp thin STEP lines (_step, the prediction-market close-price look), dotted horizontal gridlines (_dashed_hline), each line ending in a glowing dot, volume bottom-left. Crispness is the point: the whole card is drawn at _SCALE×2 and LANCZOS-downsampled, so the thin lines + text are anti-aliased and sharp instead of jagged single-pass draw.line output; bold weight is a real bundled font (Liberation Sans Bold), not a stroke_width fake. Colors are a consistent semantic schema (_outcome_colors): the favorite (highest %) is green, the longest-shot red, the middle field cycles a DISTINCT palette (one color per line) so each line maps to its legend row by color. The y-axis auto-scales to the data range so movement fills the chart. A no-history market (no price series — Polymarket, or Kalshi without candles) renders horizontal probability bars in the bottom band (length ∝ implied %, color-matched to the legend, on a faint track) instead of the old dead flat lines, so the no-history card’s bottom half is a clean ranked magnitude strip rather than empty horizontals. Pure + dependency-light (Pillow only; the type is bundled Liberation Sans in assets/fonts/ — SIL OFL 1.1, full extended-Latin coverage so accented names like Pérez/Mbappé/Dončić render instead of tofu boxes (Pillow’s built-in Aileron default has no diacritics — the bug this fixed), real Regular + Bold weights, fail-safe to load_default if the file’s missing) and does no fetching itself (the cog/market_cards supplies the data), so it’s unit-testable. The runtime base is python:3.11-slim (no system fonts) — bundling the TTF (vs leaning on a system font that isn’t in the slim image) is what makes the diacritics fix hold in prod. ART FIT — a LOGO is CONTAINED, a photo is COVER-cropped (#wrong-moment): the portrait cover (render_market_cover) full-bleeds its subject art via _cover_crop (object-fit: cover), which is right for a poster/album cover/press photo but MANGLES a logo — a wide 960×260 wordmark scaled to fill the 3:4 frame is blown up ~4.6× and center-cropped, leaving one illegible letter filling the card with the title text stranded on top of it. So render_market_cover(art_fit="contain") instead keeps the branded dark panel and centers the mark WHOLE (_contain_fit, the aspect-preserving sibling of _cover_crop) in the band the art owns — below the wordmark, above the bottom-anchored content block (measured first, so the band’s height is known). market_cards._art_fit maps the resolved image RUNG to the fit (_CONTAIN_SOURCES = the logo rungs — wiki_logo + the raw team crest team_logo, whose square badge loses its top and bottom to a 3:4 crop; every photo rung stays cover), so it follows the resolver instead of guessing from pixels. Default is cover, so every existing caller is byte-identical. Every crop and fit reads the EXIF-rotated pixels (#3305). A camera JPEG can store a portrait frame as landscape pixels plus an orientation tag, and Image.open does not rotate for it. _cover_crop, _contain_fit and bound_bare_art all decode through the one helper _open_upright, which applies ImageOps.exif_transpose and keeps the unrotated image when the tag is malformed. The arcade’s artist_portrait.to_square_jpeg does the same for Wikimedia photos. An image with no tag renders byte-identically. figure is OPTIONAL on render_number_card (#wrong-missing-images): pass an empty string and the number hero is omitted. When there is no number, the TITLE becomes the hero instead (#inconsistent-sizing): it renders as a big auto-fit WRAPPED headline over the cover (_fit_title_hero picks the largest size, up to 128px logical, whose word-wrap fits 3 lines; it shrinks in 8px steps to a 40px floor and then keeps every line, never cutting one – rule (3) under the 2026-09-10 entry above), bottom-anchored under the accent TAG with the caption below it – and on this headline card a caption that does not fit two lines whole is DROPPED, not ellipsized (owner steer 2026-09-02, the Troye Sivan live card: “i’d rather nothing on the photos”; the take under the card carries the sentence; the caption itself is gone since 2026-09-10). This is the right shape for an ANNOUNCEMENT — a release or a tour opening carries no number — and it reads at the SAME visual weight as a stat card’s giant figure, instead of the small fixed 40px caption it used to get. Two lanes therefore produce two card SHAPES from the one renderer: a stat card heroes its short figure token near the 188px cap, and a no-number card heroes its title. Over a photo the big headline gets the figure’s own depth treatment (a soft dark blurred shadow under the crisp fill) so it reads; the small 40px stat title keeps only its hard drop shadow. A caller with NEITHER figure NOR art must still return None; a figure-less, art-less card is an empty panel. An optional CONTEXT STRIP sits under the caption (standing=, ranking-context): one short accent line with a thin rule over it — the artist’s Spotify standing (their most-streamed RANK + monthly listeners, per kworb) — so a raw streams number carries the SCALE a reader needs to gauge whether it is major or middling (“hard to gauge if each card is major news”, owner). It draws only when set; every other number card passes None. The music desk resolves + gates it in ONE home (cogs/music_desk.py:_artist_ranking_strip), reached from TWO card paths: the Luminate streams card (_streams_standing → luminate_snapshot’s number_figure['standing'] → market_cards._num_render) AND the Spotify streaming-milestone big-number card (the _watch_milestone_units deliver path → build_number_card(standing=…), #charts-ranking-display). Two honesty rules are BAKED IN, not prompt-side. (1) It is LIVE — the music_stream_ranking experiment defaults PRODUCTION (owner call 2026-08-25 “add to spotify … turn it on now”), so the strip attaches on both cards; a mod can still dial it OFF/STAGING on /menu, and the strip adds a PUBLIC claim that can mirror to X and can be wrong (a stale rank, a name-fold mismatch), so it stays fail-open. (2) The labels are SELF-DESCRIBING (‘most-streamed’, ‘monthly listeners’) on purpose: the card’s figure is usually a YEAR-TO-DATE or a per-track streams count while this rank is a CAREER-total position (kworb has no YTD leaderboard), so the strip must read as the act’s overall standing, never as a rank OF the headline number. On the LUMINATE path it is gated to ARTIST streams readings only (annual / weekly-artist, whose subject IS the artist); an album reading gets no strip (subject mismatch, prefer absent). Every milestone rung is a Spotify streaming metric (streams / career_streams / monthly_listeners); a per-TRACK streams milestone shows WHERE THAT SONG’s number ranks instead of the artist (owner ask 2026-08-27 “a count card should show where the number ranks”), via a LADDER in _song_ranking_strip — the song’s ALL-TIME most-streamed rank (kworb.find_song_entry + format_song_standing_tag, “#42 most-streamed song of all time”), else its DAILY Spotify chart position (kworb.standings(strict=True) + format_chart_standing_tag, “#5 on Spotify’s US Daily · 1.2M plays” – STRICT so a published position never attaches an unrelated same-named track’s rank, “Future” vs “Future Islands”), else the artist strip; every OTHER metric keeps the artist strip keyed to the milestone’s artist. Fail-open (kworb miss / down → no strip, the card still ships), and every outcome emits music_ranking (with song_attached / daily_attached reasons for the new rungs) so a silent “never attaches” is visible. Formatters: kworb.format_artist_standing_tag / format_song_standing_tag / format_chart_standing_tag. (The artist strip in this passage was REMOVED 2026-09-29; see the note above. The song rungs stay.) The strip never restates the hero (owner, 2026-09-20: “duplicate in number and below”). A monthly-listeners milestone card heroed “50M” over a strip reading “#40 most-streamed artist · 50M monthly listeners”; the deliver now hands the card’s metric to the strip (_artist_ranking_strip(hero=...) -> format_artist_standing_tag(hero=...)), and a monthly_listeners hero keeps only the rank. The compose blob had the same doubling: “It’s sitting around 50M right now” after “just crossed 50 million”, which the take restated (“crosses 50 million …, now at 50M”); when the rounded live figure IS the rung figure the blob carries the headline alone (withholding the input, #1140). A YouTube DAILY-CHART market card carries the VIDEO’s live standing too — market_subject_image.youtube_chart_standing looks the featured leg’s video up on kworb’s most-viewed chart (find_video_entry, the same refuse-when-ambiguous lookup the art rung uses) and build_market_card stamps number_figure['standing'] = “#3 on YouTube today · 12.3M views” (format_video_standing_tag). The pos is NAMED by the page it ranks on (VideoRow.chart, stamped on merge): the global top-500 reads “on YouTube today”, the realtime-anglo cut “on YouTube’s anglophone chart”, so a position from one page is never presented as the other’s (they rank a video differently – Codex #2636). kworb parses no all-time video ranking, so this is a DAILY read only, and it rides the shared chart_fetch health (the same feed the youtube-art rung reads) rather than its own event. ALL context lines REMOVED 2026-09-29 (second owner steer, “let the chart do that job”): market_chart.render_number_card ignores standing, so no card draws any line under the title (song rank, daily chart position, radio rank such as “#69 on US radio · 7.7M weekly audience”, YouTube video rank). The post text does not carry them either; an attempt to append the artist standing to the post (#3741) was closed as too wordy. The upstream lookups that still build standing strings are dead weight and are a follow-up to remove. REMOVED 2026-09-29 (owner steer): the ARTIST standing strip is gone from every card – the market artist-count card, the desk and alert Luminate streams cards, the desk’s listeners / career milestone cards, and the newsroom’s artist milestone card. A song milestone keeps the SONG rank rungs and, with no song rank, ships with no strip (it no longer falls back to the artist). The trigger was an Omar Courtz 100M card reading “#437 most-streamed artist · 31.8M monthly listeners”. Removed with it: ranking_strip.artist_ranking_strip, music_desk._artist_ranking_strip / _streams_standing, music_alert._streams_standing, market_subject_image.artist_chart_standing / artist_subject_name, kworb.format_artist_standing_tag / format_youtube_standing_tag, music_markets.renders_number_figure and scripts/dryrun_artist_ranking.py. The history below is kept as the record. An ARTIST COUNT market card carries the ACT’s standing instead (owner ask 2026-08-30, “lets add artist ranking”, off a bare Michael Jackson YouTube-views card): Kalshi mints one event per act in the KXYTVIEWSHIGH / KXYTVIEWS families, the card heroes that act’s forecast count, and nothing on it said how big the act is. market_subject_image.artist_subject_name reads the act off the exchange’s own title – both shapes it writes, “render_number_card splits a figure with numfmt.split_hero_words (“6M units” -> “6M”) and moves the words into the accent block with numfmt.merge_pill (the newsroom pill and phrase guard read the same two helpers). Words over 20 characters are a clause and stay whole. A block ending in a lane noun (“RIAA certification”, “YouTube milestone”) has the noun replaced (“RIAA Platinum”, “YouTube subscribers”); a run length under a chart block reads “Spotify days at #1”; any other block has the words added after it. A word hero (“Top 3”, “Diamond”) and a clause (“13 tracks, all 100M+”) stay whole. emit_card_hero logs the split number. The newsroom also takes the claim’s own phrase for the unit (“career units”, music_news.figure_unit_phrase). A bare unit block is too plain (owner, 2026-09-30), so the newsroom names where the number is from with music_news.unit_label: the credited source, else the milestone_id platform, else the ONE platform the claim names (two names give none), plus “career” on a career total (“SPOTIFY CAREER STREAMS”, “RIAA CAREER UNITS”). With no platform a milestone keeps its kind (“STREAMS MILESTONE”). A tour block keeps “tour” (“POLLSTAR TOUR TICKETS”).clip_pick.py, the YouTube moment picker + clip cut (owner ask 2026-09-08, “clip youtube videos, MVs and trailers and discourse youtube, as part of the skill”): where video_trim keeps the START of a wire clip, this finds the MOMENT of a music video / trailer and cuts a window around it with the same stream-copy + shot-boundary machinery. Signals in order, each a measurement and none a model guess: YouTube’s own replay heatmap (most_replayed_peak, read through ScrapeCreators /v1/youtube/video; present only on a well-viewed video), the loudest window of ffmpeg’s ebur128 momentary loudness (parse_loudness + loudest_window: the chorus, the drop, the climax), else the start. window_start places the peak ~30% into the clip; the start snaps BACK to the last shot change within 4s so the clip opens on a cut and the end snaps per #2215. The download is video_fetch._ydl_download_video (avc1 + m4a mp4, <=720p, max_filesize-capped) so the cut is a stream copy X accepts. It runs ON THE BOT behind the token-gated POST /debug/clip (utils/healthcheck.py), because a session’s egress cannot fetch YouTube media (measured 2026-09-08: googlevideo 403 from a session; yt-dlp’s android client shows a 360p format but the download 403s too; production downloads YouTube audio 100-190 times a day). The owner asked for an AI prompt to find good clips: deliberately not yet – a music video has no usable transcript and stills carry neither motion nor audio; a caption-timestamp prompt for a talk video is the third rung if the two signals prove thin. The music-drop skill’s --clip calls the endpoint and hands the owner the mp4; nothing auto-posts (a native re-upload of a label’s video is a copyright call the owner makes per post). The heatmap’s opening is skipped (CLIP_HEATMAP_SKIP_SECONDS, 10): measured on “Not Like Us”, marker 0 scored 1.0 and the chorus at 99s scored 0.91, so a pick that honoured the opening would clip the intro every time. The first live run (2026-09-08, after #3083 deployed) failed on every video: the bot wall on the first info read, then a CDN HTTP Error 403 on the media download once the info read passed – the same 403 a session gets, the signature of formats that need a proof-of-origin token. Two levers followed: a player_client knob on the request (a yt-dlp client list, so a session can try clients from production’s IP without a deploy per attempt) and YTDLP_COOKIES (video_fetch._ydl_cookie_opts, a Netscape cookie file’s text as a Railway variable, threaded into every yt-dlp call; unset = today’s behaviour). Emits clip_pick. Pure parts unit-tested in tests/test_clip_pick.py.watch_link.py, the watch-link resolver (#music-video-linking, #trailer-lane, #lane-commentary-video): the VIDEO twin of entity_image.resolve_desk_image, and deliberately the same shape – classify the post into a structured subject, route its KIND to an authoritative source, search-and-judge only below that. RUNG 0 sits ABOVE the classifier (#watch-link-source, owner steer “trust the link”): if the SOURCE POST already links a video in its own text (“Watch: youtu.be/…”), trust THAT link and skip the classify-and-search entirely – the source’s own pointer to the exact clip needs no model and no search, and it catches the case the classify path refuses by design, a LIVE PERFORMANCE the wire linked (the reported Taylor Swift Grammy-Museum post: the classifier correctly declined not_music_video, so nothing linked, while the youtu.be link sat in the post body). The link comes from SourcePost.link_urls – the expanded entities.urls, captured at the twitterio boundary because the tweet body carries only the t.co shortlink – and source_video_link keeps only the YouTube/TikTok/Instagram hosts X unfurls, never an x.com quote. Below rung 0: claude.video_subject (Haiku) reads the SOURCE POST (not her take: the take names the people, the wire post names the release) into {kind, title, artist}; music_release routes to a YouTube search + claude.pick_music_video, film/tv to TMDB /videos and then, on a miss, the same YouTube search + claude.pick_trailer. The question stays POST-LEVEL – “is this post ANNOUNCING a video”, never “does a video exist for this subject”, which would be true of nearly every film and song and would attach a link to almost every take, at X’s ~$0.20 link price against ~$0.015. CALLERS: discourse, all three wire desks (cinema_news, pop_desk, sports_desk), and the music DROP (cogs/music.py, #2555), each stamping its own surface; the desks ask only when the source post carries no clip of its own, since re-sharing the wire’s own mp4 natively beats any link. discourse gets rung 0 for free (#2553): fxtwitter expands the t.co in the tweet text, so extract_urls(source.text) yields the real video link with no link_urls plumbing. The music drop is a LISTEN recommender that links the song on Apple Music; #2555 makes a VIDEO drop ALSO link the video – it resolves off the source tweet the model names (XPOST: ... <url>), and the watch link rides LAST so X cards the video (the payoff) while the Apple listen link stays tappable. Fail-open: no source tweet / no video -> the drop ships listen-only, unchanged. That caller list is the #trailer-lane fix: the resolver already handled trailers and was wired into ONE surface, so a cinema-desk take reading “the Awarapan 2 trailer is out” shipped a poster and nothing to watch, with no watch_link event at all because nobody asked. The film lane’s SECOND rung exists because TMDB’s /videos is empty exactly when the post is most current (see tmdb.py); pick_trailer runs THREE candidate tests where the music picker runs two – title identity, IT-IS-THE-TRAILER, provenance – and the third is earned, since a SONG off the film’s soundtrack is uploaded by the studio’s own channel and so passes identity AND provenance. Query shape is measured, not guessed: "<title> trailer" puts the studio trailer in the top few, "<title> official trailer" demotes it and pulls in an AI-made farm upload, and the bare title leads with soundtrack songs. MEASURED (n=5/case, live lookups, scripts/dryrun_watch_link.py): the reported Awarapan 2 post resolves the studio’s real trailer 5/5 via the search rung, the two titles TMDB does carry still resolve 5/5 via the lookup rung (the fallback did not swallow the deterministic one), off-lane holds 35/35 with every case declining at the SUBJECT stage, and pick_trailer on its own is 10/10 on the two real candidate lists – taking the studio trailer over the official teaser ranked above it, over three reaction videos, and over both soundtrack songs. FAIL-CLOSED throughout – it only ever ADDS a link to a post that would otherwise ship without one, so every uncertain path returns None and leaves the post exactly as it was. On a hit the link rides INSTEAD of the photo (X gives the media slot to an uploaded image over a link’s unfurl, so shipping both renders a still where the player should be). A sports GAME clip is out of scope as a KIND (a clip lookup needs both teams and the date, which wire prose doesn’t carry – the commentator posts those from a live game object); the sports DESK still calls it, for documentary and series trailers. Emits watch_link with the source that answered (tmdb_trailer |
youtube_trailer |
youtube |
youtube_moment), so a deterministic rung going silent is visible as its share collapsing. Dry run: scripts/dryrun_watch_link.py. A THIRD LANE, moment (#lane-commentary-video, owner ask 2026-09-14 “why not use youtube to find the actual videos for all lane commentary”). The resolver used to answer only for a music video or a trailer, so the ordinary thing the three wire desks post – an interview answer, a press conference, a performance, a viral clip – had no video lane at all; those takes shipped whatever clip the WIRE account had attached, unchecked. The moment kind captures {anchor name, the moment in searchable words} and routes to a YouTube search judged by claude.pick_moment_clip, whose three tests are SAME OCCASION / it-is-the-footage-not-talk-about-it / it-is-that-moment-alone – a moment has no owning channel, so the provenance test that carries the trailer lane cannot carry this one. Its deterministic rung is a RECENCY filter, and that is the load-bearing half: fresh_moment_results drops every candidate over 14 days old, and every undated one, BEFORE the judge sees it. A name-plus-words search returns old footage of the RIGHT person, which reads as a fine candidate to anything comparing titles – and an eight-year-old clip of the right person is the shipped failure this lane answers. A moment a wire reports today cannot have been filmed years ago, so the filter settles that class with no model call. The post-level restraint that replaces “is this ANNOUNCING a video” is the CAMERA TEST in the classifier prompt: a post that would read the same with no camera in the room (a transfer report, a chart position, a written statement, an injury) is refused. A sports GAME HIGHLIGHT is still not a LOOKUP (Highlightly needs both team names and the game date, which wire prose lacks); this lane searches by name instead, and its judge rejects a highlight compilation. The clip the WIRE itself attached is not this module’s job – a wire post with its own mp4 never reaches the resolver, because re-sharing it natively beats any link. It is checked at the delivery chokepoint by x_crosspost._clip_gate, whose judge asks about the OCCASION rather than the identity – the blind spot the 2026-09-14 report exposed, where the photo gate returned ok=true on a still it had itself described as an “‘F2 vs Odell’ soccer trick shot video” under a take about a Giants press conference. The reported post came through the FOUND-PHOTO path, which keeps the identity question for now; this lane is what fixes that post, by giving it the real interview clip so the found still is never the media. |
wire_lanes.py’s per-desk breaking config (BreakingConfig / BREAKING_LANES / breaking_config(surface), owner steer 2026-08-10): the breaking lane’s tuning is keyed by SURFACE instead of living in module globals. Why: the desks’ wires sit on different engagement scales, so one magnitude number cannot mean the same thing on all of them — measured live across each desk’s top 10 handles (600 posts / 568 ranked stories, 2026-08-10), engagement VELOCITY per minute of age ran p50/p90/p99 of 4/62/186 on pop_desk, 2/40/468 on sports_desk and 1/35/121 on cinema_news. Sports peaks ~4x cinema, so a single threshold lands at a different PERCENTILE per desk and behaves as three rules while reading as one. window_hours + half_life_hours are live today and every desk starts on the SAME values the old globals used (order_for_breaking(config=…); explicit window_hours/half_life_hours still override, so a test pins one number without building a config) — the shape lands with zero behavior change. min_velocity is DECLARED but not yet read: it is the floor a trigger-driven lane will apply, landed ahead of the event lane (#1742) so that change plugs a value into an existing shape instead of introducing per-desk config and a new posting path at once. Its values are a deliberate uniform placeholder — the measurement above CANNOT set them (its sub-10-min buckets held 3/1/9 stories, since ten handles yield one or two posts per 10 minutes, and one snapshot cannot measure a tail), so the real numbers come from a shadow-mode run, the same measure-first step #1741 used for break latency. Every wire desk is registered explicitly even at default values, pinned by a test, so a missing desk is a visible omission; breaking_config itself fails OPEN (an unregistered surface gets today’s behavior) so a lane can never go dark on a map miss.wire_lanes.py’s key collision check (collision_allowances, CB-29, 2026-10-18): the three wire desks drop a story whose 5-token signature key was posted in the last _RECENT_MOMENT_HOURS. The key is a prefix, so different moments share it (“Drake & Don Toliver’s ‘Solar Eclipse’” opened four different stories in one sample). Each *_events row now stores the wire headline beside its key. A same-key post in the same words (folded) is a free drop; in other words it goes to claude.topic_duplicate, and it passes only when two calls both say “not a duplicate”. A duplicate, a split vote, an error, or a key from a row with no headline keeps the old drop. One wire_collision event per judged pair per process (panel wire_key_collisions). Measured on 6229 real wire posts: 226 such pairs; the shipped path allowed 23, all distinct moments by hand; 9 pairs (4%) changed outcome between two runs, all of them distinct moments, so a flip only restores the old drop.banger_lane.py’s spam call (spam_ids, CB-30, 2026-10-18): the posts that clear every other banger floor go to Haiku purpose=banger_spam (SPAM / REAL, cached 3 days per tweet id). The old _SPAM_RE words are the SPAM examples in SPAM_TASK, and the regex decides a post the model could not label. Why: the regex matched words, so it dropped official “Watch now on Prime Video” posts and news naming Telegram, and passed leak farms and crypto giveaways. On 304 real Top-search results for the lane’s own queries the model fixed 8 and was worse on 1 (a gossip post labeled SPAM, which only skips one candidate).sports_stories.py’s sport DIVERSITY re-weight (classify_sport / sport_penalty / apply_sport_diversity, #sports-news-diversity, owner report 2026-09-04 “soccer dominates our sports news cause it has the biggest posts”). The sports desk ranks moments by ENGAGEMENT, and engagement is not spread evenly across sports: football wires post far more often than any other beat and draw far bigger numbers, so ranking on magnitude alone hands football nearly every slot. Measured over the 14 days to 2026-09-04 (120 shipped posts, Axiom sports_desk_scored | where shipped == true): about 73% football, 13 NFL posts, 2 NBA posts. The desk read as a transfer wire. The fix is a re-weight, not a quota. classify_sport(handle, headline) tags each moment’s beat – the HANDLE first for a wire that covers one sport (@fabrizioromano is always soccer, @shamscharania always nba), then a word-bounded keyword scan of the headline for the general aggregators (@sportscenter, @espn, @bleacherreport post every beat). apply_sport_diversity counts how many of the desk’s last _SPORT_HISTORY_LIMIT posts (inside _SPORT_HISTORY_HOURS) share a story’s beat and multiplies its magnitude by SPORT_DIVERSITY_DECAY ** n – 0.5 after one recent post on that beat, 0.25 after two. So the beat that just posted has to be MUCH bigger to post again, while a genuinely huge moment still wins: a Messi retirement beats a full six-post penalty, a “Dibu Martinez takes shirt number 1” transfer note does not. It runs in _pick_stories and _event_candidates, BEFORE the lane orderings, so the biggest lane and the breaking lane both read the re-weighted score – the same place wire_lanes.rank_stories applies the emergence boost, and the same kind of edit to .score. A round-robin was the alternative and is worse: a strict rotation hands the slot to a tiny StatMuse stat line over a real break just because the rota says “not football”. The penalty keeps magnitude in charge and only changes the price a beat pays to repeat. An UNKNOWN beat pays no penalty and is never counted (fail-open: prefer a miss over suppressing a moment the classifier could not read), and unknowns are never pooled into one bucket that would penalise them together. That carve-out is what the model fallback exists to shrink, after the owner re-reported the same symptom on 2026-09-13 (“i only see soccer news, never any other sport … we’re only reposting Fabrizio”). An unknown does not merely escape its own penalty: it carries a FULL 1.0 into a field where every correctly-tagged rival has been marked down, so it outranks them. Read the live sports_diversity leaders over the four days to 2026-09-13 and the winner is an unknown in six slots of ten — one slot ranked ?:1.0, soccer:0.0625, ?:1.0, soccer:0.0625, where the re-weight had driven soccer to the floor (0.5^4) and two untagged stories beat it anyway. The penalty was working; the unknowns were routing around it. So sports_desk._resolve_sports asks cheap Haiku (claude.classify_sports_beats) for EVERY story’s beat, the model first and the rules as the fallback (#3426, the inverse of the order that shipped first), batched (at most _SPORT_BEAT_BATCH new stories per pass, _SPORT_BEAT_CALL per call), and cached durably by story SIGNATURE so a wire post that is re-ranked across ~32 slots a day costs at most ONE classification. The rules used to be the fast path and the model was asked only about the residue, which meant a WRONG rule tag never reached the model: measured on 951 live stories 2026-09-18, the keyword table left 175 of 309 unsettled stories unknown where the model left 116, disagreed with the model 6 times and was wrong every time (“the Winnipeg Jets suspended goaltender Connor Hellebuyck” read as nfl), and the handle map was wrong 23 times of 515 (WNBA games on @nba and @nbatv, an NFL take on an NBA analyst’s feed). The rules now decide only a story the model did not place (meta['sport_via'] = rules); the old note stands for that residue, which measured 9 of 42 shipped posts in the 8 days after the re-weight shipped. A real Haiku dry run on those exact live headlines tagged 16 of 16 correctly, and correctly OMITTED both a non-sport post and a genuinely ambiguous one (“Mainoo pulling the strings”) rather than guessing. Every failure path — no cache, DB error, model error, unparseable answer, a tag outside SPORT_TAGS — leaves the story exactly as the rules left it, so the fallback can only ever ADD tags. When Haiku writes a second, “corrected” object after its first answer, the parser reads the LAST object that decodes, so the correction wins and one bad object no longer drops the whole batch (#4292). apply_sport_diversity honours a stamp already on the story instead of re-deriving it, which is how the answer survives into the penalty math. Measured on a live 480-post wire batch, 87% classify and the remaining 13% are mostly college clips – no football leaks through the unknown bucket. The keyword table is word-bounded BY CONSTRUCTION rather than by hand, because substring matching is this rule’s failure mode: the live dry run caught nfl matching inside “influencer” and a bare “world cup” tagging a FIBA basketball post soccer. college is checked before the pro leagues (a college story routinely name-drops the NFL; a pro story rarely says NCAA), and the WNBA is its OWN beat rather than a sub-case of the NBA, so an NBA post cannot suppress a WNBA one. The desk remembers the beat it posted in the sport column on sports_desk_events, written beside the dedup key on a successful ship. The two knobs are env-overridable (SPORTS_DIVERSITY_DECAY, SPORTS_DIVERSITY_LOOKBACK) rather than /menu tunables, matching the neighbouring lane constants. Read the result on the sports_beats panel (shipped posts per beat) and the sports_diversity event (which beat led each ranked batch, and the penalty its leader paid). This is an OUTPUT-side fix and does not touch SUPPLY: SPORTS_WIRE_ACCOUNTS is still football-heavy and _FETCH_LIMIT still pulls 20 posts per handle, so a football item is still the most common candidate – the re-weight only decides which one wins the slot.sports_desk.py’s qualifier check (#4437, epic #4412, 2026-10-17). A sports wire tweet often carries a qualifier on its fact (“the first player ever”, “the most points in history”, “a record”), and the desk copied the tweet into the compose and every gate, so a false qualifier the wire stated passed them all. _compose_one now calls utils.qualifier_check.check_source_qualifier(surface="sports_desk", research_purpose="sports_desk_qualifier") on the WHOLE tweet, right after the stale-recap gate and before the thread identity, the confirm search and the beat, so a dropped story pays for none of them. The rules follow the pop desk pilot (#4415):
SourceCheck.drop (a wrong qualifier with no clean cut) drops the story.SourceCheck.changed, SourceCheck.claim replaces the tweet in the compose headline=, in the blob, in the confirm query, in the beat subject and in the number grounding. SourceCheck.note goes at the end of the blob, so the self-gate grades the corrected text.qualifier_trim.restates_cut) is dropped as sports_desk_scored reason=cut_qualifier score=0.0, before the number gate and the self-gate.qualifier_check reason=unchecked).story.subject / story.headline, the same text on this lane) on purpose. They name the moment and do not state its claim, so a cut qualifier does not change them.
Tests: tests/test_sports_desk_qualifier.py drives the real _compose_one for each branch.entity_image.py, the SINGLE image resolver — image_subject (the ONE Haiku classifier, claude_client.image_subject → {kind, name, artist?, sport?}) + the _deterministic_art router serve BOTH the wire desks (resolve_desk_image, used by pop_desk/sports_desk) AND the market/betting cards (market_subject_image.resolve_market_image), replacing the old per-surface market_image_subject + music_release_subject extractors (both deleted). The classifier tells us WHAT the one visual subject is AND which authoritative source to hit; _deterministic_art(bot, subj) routes by kind: music_release → catalog COVER by ARTIST (resolve_catalog_art, whose row pick is described under catalog_art.py below), Deezer ARTIST photo as the unreleased-album fallback (fed artist_watch.lead_artist(...), never the raw credit — see the joint-credit rule under music_markets.py); musician → the Deezer ARTIST photo (a music person lives in the music catalog, not a movie DB — the “what type of person lookup” distinction; the most-POPULAR name match by nb_fan, since Deezer’s search ranks tiny homonyms first, and the downloaded bytes are compared against Deezer’s no-photo placeholder — the CDN 200s a broken image hash with the same grey-silhouette pixels, the BTS-silhouette ship — with a placeholder match rejected so the walk falls onward; a SECOND placeholder shape — a solid-BLACK square at a real md5 URL, Deezer’s no-photo fill for an artist it dropped (Kanye West) — passes both the URL and byte-compare guards, so _deezer_photo_bytes also rejects a near-solid image by CONTENT (catalog_art.is_blank_image, the SAME check the music-desk path had; #ye-card was fixed there only, and the newsroom’s shared resolver shipped a black-box card until the check moved to this one chokepoint). Under Deezer, the lead act’s Wikidata photo (#3319, rung wiki_portrait). When neither the lead nor the featured act has a usable Deezer photo (an act Deezer has no photo for, or a Deezer outage like the 403s from 2026-09-14), _credit_portrait_bytes asks music_markets._wiki_portrait – the music cards’ rung, so the same 8 s budget and the same Haiku namesake check (#4433: two same-name music acts go to a label, UNCLEAR gives no photo). The bytes get the blank and blur checks. The rung is never exact and is absent from _IDENTITY_VERIFIED_ART, so the delivery media gate keeps authority. The release fallback passes the release title as namesake evidence. Measured 2026-10-18 with Deezer forced dark on 110 real portrait credits from 30 days: 99 resolved (p50 0.5 s, max 1.7 s), 0 wrong faces on a visual check, 3 namesake checks (Michael Jackson and ROSÉ picked, Chris Brown UNCLEAR, so no photo). Rejecting the black square then left a JOINT-credit card bare (a “Kanye West ft. Ms. Lauryn Hill” chart card shipped a number card with no art, owner report), because the lead-act portrait was the only one tried; _credit_portrait_bytes now falls to the FEATURED act’s real portrait on that miss (artist_watch.featured_credit, the inverse of lead_artist — the featured act is who the card is also about, and Ms. Lauryn Hill has a real Deezer photo), each candidate content-checked so a placeholder never ships); team → the API-Sports LOGO (api_sports.team_logo) composited into the broadcast card; person → the TMDB person PHOTO (tmdb.search_person); film/tv → the TMDB POSTER, with an OMDb poster fallback (omdb.poster, the “does OMDb have images” fallback for a title TMDB missed); org → the WIKIDATA LOGO (P154, wikidata.logo_image → wiki_logo) FIRST, falling back to the Wikipedia lead image (#wrong-moment): the lead image is the wrong picture for this kind twice over — a fair-use CORPORATE logo is excluded from Wikipedia’s pageimages entirely, so a major brand resolves to NOTHING (Netflix → no image, which fell all the way to the open-web search, which matched the Kalshi market page and put the market’s own QUESTION TEXT on the card), and where a lead image does exist it is routinely the headquarters BUILDING (P18) or, for UEFA, a member-associations MAP. Returned as a RASTERIZED Commons URL (?width=) since most logos are SVG, which the card renderer can’t decode. A bare logo NEVER ships plain (#card-logo-center, owner steer “add the logo to the center of a square tootsies card like we do with sports teams”): on the DESK path _card_bare_logo renders the resolved mark into the SAME square branded card a team crest gets (market_chart.render_logo_card — the mark centered on a radial glow in its OWN identity color + the tootsies wordmark; render_team_card is now the brand_suffix=" sports" flavor of it, and a sports desk’s org brands the same way), returning logo_card. A dark org mark flips the desk card LIGHT (#too-dark), never onto a plate, because an org logo is typically MONOCHROME and near-black (SpaceX, UEFA, HBO), invisible composited bare on the near-black card. The first cut seated such a mark on a rounded contrast plate (plate=True, #1841); the owner rejected the plate look both times it shipped — on the market cover (“bordering is ugly, would rather a light card”) and again on the desk card (the plated SpaceX pop card: “this should use light version”) — so render_logo_card now does what the market covers already do: _mark_is_dark (mean opaque luminance under _LIGHT_CARD_LUM_FLOOR, the ONE gate shared with _needs_light_card) flips the field to the light palette (_branded_floor(light=True)) and the mark composites BARE — no plate, no box, no drop shadow (a blurred black smudge reads as dirt on the light field); the red tootsies wordmark carries over, the suffix dims to _LIGHT_SURF. A light or multi-colored mark (a team crest) clears the floor and keeps the dark identity-glow card byte-identical to before — the transparent-background UEFA mark still can’t crosspost as an EMPTY BLACK FRAME, it now lands on the light field instead. Only the desk path cards: the market/betting cover calls _deterministic_art DIRECTLY and composites the bare mark into its own branded panel (art_fit="contain"), so it keeps receiving raw wiki_logo rather than nesting a card in a card. A render miss returns no art (falling to the vision search) instead of shipping the mark that just failed to decode; video_game → STEAM (steam_art.game_art_url, #2741) BEFORE the lead image, because the lead image cannot serve one at all: pageimages EXCLUDES non-free files and a game article leads with its copyrighted box art, so the rung returns nothing for EVERY game (measured 2026-08-31 on Counter-Strike 2, Minecraft, Half-Life 2, Elden Ring, GTA VI; Eiffel Tower and Taylor Swift still return one). This is why #2663’s _title_names_subject guard, which refused the WARDOGS mis-resolve, could only ever stop wrong art — it could not produce right art, and the two 2026-08-30 cards then shipped bare. The dividing line for the whole thing kind is whether a FREE PHOTOGRAPH can exist: hardware (PlayStation 5, iPhone 17) and venues (Sphere, MSG) are photographable, so they keep Wikipedia and never reach this rung. video_game is its OWN classifier kind, split out of thing (#2741), and the gate is that kind — never a name match. The first cut ran Steam for every thing on the theory that exact name matching was self-limiting. It is not: exact equality proves the STRINGS match, never that the SUBJECT is the game. Measured over 27 adversarial non-game things, “Uno” (the card game) matches the Ubisoft video game exactly — and reordering Wikipedia first does NOT save it, because UNO’s article has no free lead image either, so a card game would have shipped a video game’s art. The prompt already named video games inside the thing clause, so splitting them out was a REWORD, not an added counter-rule (the docs/PROMPT_OPTIMIZATION.md discipline). Dry-run on the real Haiku classifier, n=2 each over 9 real market texts: 4/4 game markets classify video_game, and thing/place/org/musician are unchanged — an A/B against the baseline prompt confirms only the game rows moved (iPhone 17 and Labubu stay thing; a pre-existing [] on “Will Taylor Swift win Album of the Year?” is identical before and after, so it is not a regression from this edit). The 400px cover floor is enforced on the Steam bytes in _deterministic_art, because resolve_market_image’s deterministic rung does NOT size-check what the router hands back (unlike its market_leg rung): Steam’s screenshot fallback is not always large — Portal’s first screenshot is 640x360, and a 360px short edge upscales visibly on a full-bleed cover — so an undersized asset falls through instead of shipping soft. The match is EXACT on a folded store name (pick_app), never Steam’s top hit — storesearch is a fuzzy ranker, so “Counter-Strike 2” also returns CS2D and a tractor DLC, “Minecraft” (not on Steam) ranks Minecraft Dungeons, and “Grand Theft Auto VI” (unreleased) ranks Vice City; all three wrong answers are refused, and a refusal just falls through. Art is the portrait library_600x900_2x capsule, else the first SCREENSHOT — not background_raw, which is the store page wallpaper and renders as a near-empty starfield (judged by LOOKING at the rendered card, and guarded by a test since it passes a naive size check). Live dry run 2026-08-31: 15/15 games resolved, all above the 400px floor, 9/9 non-games refused. film/tv titles are taken LITERALLY (#2741). The classifier substituted a recognized FRANCHISE for an unfamiliar title, and the film rung then resolved a real but WRONG poster — worse than no art. Measured on the real Haiku classifier with the production extraction shape: “Coyote vs. Acme” → “Looney Tunes” 10/10 (shipping the Back in Action poster), “Wicked: For Good” → “Wicked” 3/3, and “Ransom Canyon: Season 2” → “Ransom Canyon” 3/3 — the prompt’s OWN worked example. The trigger is the Market ticker: line in _extraction_text: the same title with a neutral ticker classified correctly 4/4, with the real ticker 0/10. That line is NOT cut, because it is load-bearing for the opaque sports matchup it was added for (“Team Spoon vs Team Coop” + KXWNBAALLSTAR → WNBA); instead the film/tv kind line is REWORDED to demand the title exactly as written. A/B, n=3 each: 9/18 → 18/18, every previously-correct title unchanged and the sports matchup still resolving to WNBA. The film rung has no title verification of its own — unlike the music rung, whose _artist_matches refuses rather than ship a wrong-artist cover — so a misnamed title has nothing downstream to catch it; that asymmetry is the open follow-up. place/thing (everything else) → the WIKIPEDIA article’s LEAD image (reference.wikipedia_lead_image, a bounded pithumbsize thumbnail — the canonical venue/landmark/festival/product/game photo or a governing body’s crest — the kinds with no dedicated catalog; org is a governing body/league/company that ACTED in the story (UEFA/FIFA/a label), the class that classified to nothing and shipped a bare tweet; Wikimedia requires the descriptive UA catalog_art._UA_HEADERS sends). The resolved article must NAME the subject (reference._title_names_subject, #2663): MediaWiki search ranks by article TEXT, so it answers every query with something, and the rung took that on trust – a Kalshi leg named “WARDOGS” (a Steam game with no article) resolved to “Dogs in warfare” and put a US Air Force military working dog on a prediction card. The guard is a two-directional token-subset test, so a title that ADDS words (“Coachella” -> “Coachella Valley Music and Arts Festival”, “Sphere” -> “Sphere (venue)” – a disambiguation suffix needs no rule of its own) or DROPS them (“Mortal Shell II” -> “Mortal Shell”) still resolves, while a title sharing no name is refused; punctuation is REMOVED rather than split on, so “S.T.A.L.K.E.R. 2” matches its own article. Dry-run over the live API: 8/8 real venue/product subjects keep their image, and of the six Steam legs only WARDOGS is refused by the guard (the other five have no lead image at all). A refusal returns None like any miss; a null/miss falls to vision (resolve_subject_photo) on the CLEAN entity name (not the raw hype tweet). RANKED SUBJECT LIST (#thin-ahead-art, owner steer “extract a list of subjects in relevance order and try the existing resolution on them in order — no new branch”): subject capture is ONE classifier call, claude.image_subjects — the RANKED list of the text’s visual subjects (main subject first, then the other entities with authoritative art), parsed by _parse_image_subjects — and resolve_desk_image is just a LOOP: each subject, best-first, runs the SAME full resolution (_resolve_primary, incl. its own open-web photo search — owner steer “multiple searches are fine, we want the best image”) until one yields art; an empty list runs the loop once subject-less (the open-web search on the raw headline, the old null path). The fix for the “Bilge Ebiri revisiting Scorsese’s The Age of Innocence” bare tweet — the critic (no photo) was the pick while the film’s poster sat right in the headline — and the whole “resolvable entity present but not the primary pick” class, rather than a per-mis-classification prompt rule. (Two intermediate designs were tried inside the same PR and folded away per owner steers: a separate image_alternates second query, then a deterministic-only cheap-tail walk.) Fully fail-open (a Haiku blip → the raw-headline search; no art at all → (None, None, "none"); the take then ships as TEXT on both surfaces — owner steer “if the post is good don’t skip”, so no boundary filter drops a good artless take). image_subject (the single-subject shape the market-card path + market_drop catalog hint use) is now a thin first-of-list accessor over the same one prompt, not a second query. RANKING an INDIVIDUAL-at-an-event (#wrong-moment): a red-carpet/premiere headline (“Zendaya at the Spider-Man: Brand New Day premiere in a Tamara Ralph gown”) ranked the FILM first, so the walk hit the TMDB poster on iteration 1 and shipped the MOVIE POSTER instead of a photo of her — the person was in the list but never reached (the existing #wrong-premiere-photo occasion-gate only fires once person is already the pick, so it couldn’t help). Fixed by GENERALIZING the classifier’s existing statement/quote rule (“a line that is a PERSON’S statement has that SPEAKER as its main subject”) to cover what a named individual said, did, wore, or showed up to — so the work/event they were there for goes SECOND — rather than bolting on a premiere-specific counter-rule (the docs/PROMPT_OPTIMIZATION.md discipline). The rule says “INDIVIDUAL … still kinded by who they ARE (musician/athlete/person)” because an earlier wording (“leads with THAT PERSON”) was read as the KIND person and misrouted a red-carpet MUSICIAN off the deterministic Deezer catalog. The paired examples deliberately show BOTH orderings (person→film for the carpet, film→person for the Ebiri essay) so it can’t over-correct into person-always-first. Dry-run-verified n=3 on the real Haiku classifier: baseline 0/3 on both premiere cases (film first), fixed 9/9 across the premiere cases + every control (essay-about-film, film news, music release, athlete, org). The market’s OWN artist beats asking the model (#thin-ahead-art): a Kalshi music market names the crediting ARTIST in its market SUBTITLE (":: Morgan Wallen" under the “I’m The Problem” leg), so market_subject_image.subtitle_catalog_hint reads {title=yes_label, artist=subtitle} straight off the payload and feeds the verified-cover rung — deterministic, no model. Every lane that builds its OWN art snapshot must carry market_subtitle onto it (the drop/alert leaderboard fold stamps the leading leg’s hint as catalog_hint; the entity outcome lane’s winner card carries the raw subtitle, 2026-09-07) — a snapshot that names the leg without its subtitle sends a chart market to the platform-logo rung, and the card ships the Billboard wordmark instead of the record’s cover. This replaced asking the classifier who made a record, which it answers from MEMORY and gets confidently wrong (“Lizzo” for Morgan Wallen’s album — shipped twice as a wrong-artist photo, since a missed cover falls back to photographing the artist). An upstream luminate hint still wins; a non-music subtitle yields None; resolve_catalog_art still verifies the artist, so a bad read misses to a photo, never a wrong cover. The wire desks resolve on the wire headline, pre-compose, so the take can react to the picture (the ranked-list capture handles noisy text — the Ebiri essay classifies [film, Scorsese] at the source); cinema_desk’s news lanes and discourse’s crosspost resolve from the composed take (their only text). A TAKE RETRY is the wire desks’ LAST rung (resolve_desk_image_from_take, #chart-roundup-art) — this REVERSES the earlier resolve-ONCE rule, which held that ranking the headline’s subjects handles a noisy headline at the source. It does, for a headline that names an entity; it cannot reach a headline that names NONE, because there is nothing in it to rank. @tootsiesbar shipped “Ella Langley’s ‘Choosin’ Texas’ is projected to hold No. 1” bare off a “Midweek Billboard Hot 100 Predictions” headline: the classifier returned [], the wire’s own image was a rankings table the source gate correctly rejected, and the open-web search had only “Hot 100 Predictions” to search on — while the take names a record with a catalog cover. So each wire desk re-runs the SAME resolution on the composed take when, and only when, the headline pass returned none, after the self-gate (only a line that ships pays) and with NO source_url (the wire photo already had its first refusal — re-offering it just re-buys the same paid verdict). Live dry-run scripts/dryrun_take_retry_art.py: the shipped miss now lands its catalog_cover, two same-shape roundups land catalog_cover/tmdb_poster, and a control whose headline already resolves never reaches the rung. The take cannot SEE this image (it is already written) — the accepted cost, since the alternative on this path is no picture. Rare by design (2 of 179 pop-desk posts over 7 days), pinned across all three desks by tests/test_wire_lanes.py, and watched via desk_art phase=take_retry; a residual miss still ships as text (the *_posted source=none rate). The SOURCE photo’s gate is framed by PROVENANCE, not identity (#source-media-reuse): the wire’s own photo gets first refusal (#1832), but it was judged by the same test as an unknown open-web hit — “does this genuinely SHOW <entity name>”, with a hard reject bias — so the picture the wire published WITH the moment was thrown away whenever the subject wasn’t face-recognizable in it, and the desk went and found a stock portrait instead. A post about the first Street Fighter clip shipped a red-carpet headshot of Jason Momoa because the clip still of him in full Blanka prosthetics failed “is that Jason Momoa” (measured 0/5); the same framing killed a Rashford-holding-the-trophy photo on his Barcelona-goodbye post (0/5). So pick_subject_image(source_post=True) keeps the identical reject list but flips the default to TAKE IT, and judges against the MOMENT (the wire headline) for every kind — a real photograph counts in costume/character/prosthetics, as a film or clip still, masked, in kit, mid-action, or shot wide. The junk bar is unmoved and re-measured: a text card, a chart, a bare logo, and a rankings/stat table (even laid over a photo) all still reject 0/5, as does a real photo attached to a plainly different moment. Both tail clauses were ABLATED, not assumed (docs/PROMPT_OPTIMIZATION.md discipline, n=5 on the real images): the “rejects still stand / DESIGNED GRAPHIC” paragraph is LOAD-BEARING (without it a rankings table over a photo is taken 5/5), while a drafted clause saying a photo “with a caption/score bug/logo laid over it” counts was CUT as bloat-without-payoff — dropping it changed nothing (7/7 either way, a real photo under a “HERE WE GO” overlay still taken 5/5) and it was the clause pulling toward the rankings table the paragraph then had to push back on (two rules fighting). The ablation also caught a near-miss worth remembering: a first pass “proved” the paragraph was scar tissue, but it had been measured on a tidier moment string than production passes — feeding the real _clean_text headline flipped the verdict, and the leaner wording turned out to sit on a knife-edge (a trailing period alone flipped it 0/5 ↔ 5/5) where the shipped wording is stable in both forms. gated_source_photo also now takes EVERY fetchable photo on the post in the ONE existing call and returns the one it picks, so a multi-photo moment whose first still is the least legible isn’t abandoned on the strength of that frame (image_urls on each wire story; image_url stays the first for the single-photo callers). Measured on a live 20-post wire slate: the wire’s own photo lands 17/20 vs 10/20 (scripts/dryrun_source_photo_gate.py, the labeled 10-case A/B). Pure-ish router; unit-tested tests/test_entity_image.py. The music cards’ OWN ladder (music_markets.resolve_art_url) now has a PORTRAIT FLOOR under its title+artist path (#2274). It used to return None the moment its three cover attempts missed — its artist-level rungs sit BELOW that branch and were unreachable whenever a title was present — so a track the catalog lacks resolved to nothing at all and the card’s only remaining hope was the model-judged web search. It now falls to the LEAD artist’s Deezer portrait, and ONLY the portrait: the artist-level ALBUM rungs stay unreachable from here on purpose, because a different record’s sleeve under a card titled “Is It Cool?” is exactly the wrong-cover class pick_album_art_url’s title guard exists to stop. A photo of the lead act is not a wrong cover — it is who the card is about. Measured effect on the reported miss: the art now resolves deterministically at the deezer_artist rung (3/3 runs), skipping one Perplexity search, two Haiku picks and two Sonnet vision calls. The portrait rung retries the FIRST party of a “+”-joined credit (#2544). lead_artist strips a trailing feature clause but deliberately keeps a co-billing credit whole. Mediabase’s radio joint credit “Busta Rhymes + J Dilla” reached Deezer whole, matched no act, and the +14 most-added card shipped bare (desk_art no_art). The shared deezer.artist_picture_for_credit now retries first_credit_party (“Busta Rhymes”) ONLY after the whole-credit lookup misses. This is the ONE definition of “get a Deezer portrait for a music credit safely” (#2547): music_markets.resolve_art_url (the music-card resolver) AND entity_image (the newsroom/market art path, which had the identical lead_artist-only gap) both call it, so a joint-credit entertainment subject no longer ships bare on either path. The retry splits ONLY a spaced “+” whose TAIL is a proper name (first_credit_party, beside lead_artist in artist_watch.py). Two guards keep a real band from being mis-split on a miss (a lookup MISS does not prove a collaboration – Deezer also misses on a placeholder image or an outage, so a wrong portrait would be worse than no art): “&”/”,” are excluded outright, because they live in single band names far more than they join acts (“Earth, Wind & Fire”, “Tyler, The Creator”, “Aly & AJ”); and a “+” splits only when the part after it is NOT an article phrase, because a “+”-band’s tail is an article (“Florence + the Machine”, “Nick Cave + the Bad Seeds”) while a real co-billing’s second act is a proper name (“Busta Rhymes + J Dilla”, “Chris Brown + Tyga”). So a “+”-band is left whole and ships absent on a miss, never a wrong “Florence” portrait. The definitive guard is CORROBORATION: the retry passes the full credit as deezer.artist_picture(first, within=credit), which keeps a match only when every token of the matched artist’s name appears in the credit. So a proper-name band (“Dan + Shay”) that DID get split can never resolve to an outside homonym (“Dan Bull” is rejected); the real band passes if the search returns it, and the first act of a true co-billing passes because its name is in the credit. Corroboration closes the joint-vs-band ambiguity by construction, so no connector heuristic has to be exhaustive. Because the retry is miss-only, it can only ADD art to a card that had none, never mangle a working lookup. The portrait rung has a SECOND provider under it (#deezer-403). Deezer’s artist picture was the only source of an artist photo anywhere in the repo, and on 2026-09-14 Deezer began answering 403 to Railway’s IP — an IP-level block a descriptive User-Agent does not lift (the integration probe sends one and still gets 403 in 27ms, an edge reject). Deezer was therefore dark for every surface at once. The card that exposed it was the Urban-radio most-added “+8 Where You From? — David Banner” on 2026-09-15: a new radio single is exactly the case the cover rungs cannot answer (Apple does not carry the title yet, and the title guard correctly refuses every other sleeve), so the portrait was the only rung left and there was only one of it. resolve_art_url now falls one rung further, to wikidata.artist_portrait (wiki_portrait), in BOTH branches. It stays BELOW Deezer rather than beside it: Deezer answers from the music catalog and keeps a current press shot, while Wikidata’s P18 is whatever Commons holds, often an older live photo — so it is a floor, not a replacement. The guard is what makes it safe to add at all. A name search alone ships the wrong picture and ships it loudly: the live entity search ranks the Marvel Hulk FIRST for “David Banner” and the ape first for “Bonobo” (with a typeface second). So a hit is used only where a name it answers to — its LABEL, or the ALIAS the search matched, since Wikidata files many acts under a legal or de-stylised name (“P!nk” matches the alias “P!NK” on an item labelled “Pink”) — is exactly the queried name, AND Wikidata’s own claims say the entity is a music ACT. WHAT the entity is decides first: a GROUP passes on its P31 class alone, and anything else must be a HUMAN (P31 Q5) carrying one of three music-only signals — P264 record label, P1303 instrument, or a music P106 occupation. The kind test is not decoration, it closes a hole the signals alone left open: an ALBUM carries a record label too, so a signals-only guard passed the 2024 album “Tyla”, and a SELF-TITLED album carries the act’s own name and a P18 of its SLEEVE — a record’s cover shipping as the artist’s photo, arriving through the guard built to stop exactly that. It was caught by an in-session /code-review pass, because the automated reviewer was in the #3316 failure state. The union covers different shapes of act (a brand-new rapper has P106 and no label claim; a band has a class and no occupation) while none of the four fires on a non-music entity: measured across bands, DJs, rappers and singers P264 alone was present every time and absent on the ape, the typeface, the film, the Hulk and the archaeologist. An entity passing none of them resolves to NOTHING, which is the standing fail direction — the ape is a worse card than an empty panel. Measured live on 15 names, 15 correct, in 0.3s to 1.5s each: the rapper resolves, the musician Bonobo resolves ahead of the ape, P!nk and Tyla resolve through the alias and the wider search window, and “Hulk” / “Various Artists” / a nonsense name all refuse. The whole rung carries ONE 8-second budget (_WIKI_PORTRAIT_BUDGET_S), because it is up to four sequential 12s reads on the last rung of the ladder every card takes while Deezer is dark, and none of its three callers imposes a timeout. The search window is 50 hits and the CLAIMS reads stay at 3: the hit count is one call whatever its size, the claim reads are one call each. The hit’s own one-line description (“American rapper”) only ORDERS the candidates, never decides one, which is what keeps the normal case at one claims call instead of three. The cover guard is SUBJECT-KIND aware (#2278). pick_album_art_url takes subject_kind (“album” default, “song”), and it decides which field of a SONG-ENTITY row the title guard reads: a song-entity row carries the TRACK in title and its release in album, so the album-subject reading asks “Billie Jean” to equal “Thriller” and can never match. Every song that exists only as an album cut therefore resolved NO cover, and the desk’s song lanes could only ever match a track also released as a same-named single. The kinds cannot be merged into “match either field”: for an ALBUM subject a same-named TRACK is exactly the A*POP borrow the guard was added to stop, because the album is missing from the catalog precisely when a same-named track is what would match. billboard.chart_unit(key) reads the chart’s own unit and card_fields/exit_card_fields stamp it as art_kind, so the Billboard lanes pass their real kind and every other caller keeps the “album” default. Live A/B over real chart rows: 24 of 45 song rows moved from artist_portrait to the true catalog_cover, 18 already had covers and kept them, and all 20 album-chart rows were unchanged. The PLATFORM-chart lane was left on that default, and it shipped a wrong cover (the Mac Miller dog card, 2026-08-31). artist_watch.chart_story_card_fields stamped no art_kind, so an Apple Music US songs-chart card resolved its art as an ALBUM. The unsatisfiable comparison does not always end in no art: it rejected Mac Miller’s real “The Divine Feminine” row and the walk continued to a junk compilation, so the card carried a stock photo of a golden retriever to X. The fix stamps the chart’s own unit (already “song” |
“album” on every _WATCH_CHARTS row, the same vocabulary subject_kind takes), which is what the Billboard lanes have done since #2278. The second half of that card is in apple_music._title_matches, and it reaches every title guard, not this lane. _normalize_title drops every character outside [a-z0-9 ], so a title in a non-Latin script normalizes to EMPTY, and the matcher’s “empty means unverifiable, accept” branch passed it. Every non-Latin row therefore cleared every title guard, and the first one in the search results supplied the cover – here a Thai compilation carrying the track. A row that shares no alphabet with a Latin request is a positive MISMATCH, not missing data, so a present-but-unreadable name now returns False; a genuinely blank or absent name keeps the fail-open True. Both halves are needed and each is a guard on its own: with the matcher fix alone the album default falls to a Mac Miller PORTRAIT (right person, wrong picture); with art_kind it resolves the true Divine Feminine cover. |
_deezer_photo_bytes already refused Deezer’s two placeholders (the silhouette by byte-compare, the solid-black square by is_blank_image). A third check refuses a defocused art-shot portrait: catalog_art.portrait_sharpness (Laplacian variance over intensity variance, whole image at 256px) under MIN_PORTRAIT_SHARPNESS (0.010) returns None, so _credit_portrait_bytes tries the featured act and then the caller’s next rung. A branded floor beats a blurry face, so the photo is dropped, never sharpened. The floor is calibrated on 219 real Deezer portraits (2026-10-15): the one blurred face (Drake’s current photo) scored 0.003, the lowest sharp one 0.019. The obvious metric, raw Laplacian variance, does not work here: it tracks contrast and darkness, so sharp portraits of Lady Gaga (38) and Shaboozey (4) scored under the blurred one (79). Measuring at native size fails too, because film grain adds fine detail on top of a defocus. Event portrait_sharpness. Scope: the Deezer portrait rung only; the music-desk and music-card ladders (cogs/music_desk.py, utils/music_markets.py) read Deezer portraits through their own code and do not run this check yet.entity_image._artist_rung): deezer_artist_exact when the winning Deezer row’s normalized name EQUALS the name the take uses, deezer_artist for the loose token-overlap match. Only the EXACT rung is exempt from the delivery-time media gate (x_crosspost._IDENTITY_VERIFIED_ART). Why the split exists: a Kalshi card carrying Taylor Swift’s own official Deezer portrait — her “The Life of a Showgirl” press photo — was flagged by the vision judge as “a woman submerged in water who does not appear to be Taylor Swift”, twice, so the confirmed drop shipped the card bare (crosspost mode=number_fallback, 2026-08-29). That is #2238’s false positive on the rung #2238 did not cover. An exact catalog-name match settles identity upstream and deterministically, which is the same standing an iTunes credit has; the LOOSE match does not — it really returns a namesake (“Michelle Hadley” → “Michelle Marly”, live-checked), so it keeps the gate’s full authority. Three things are never reported exact, because in each the FULL requested identity was not the thing that matched: a FEATURED act’s portrait (the take is about the lead); the first-party retry on a joint credit (Busta Rhymes + J Dilla → a portrait matching only Busta Rhymes, and first_credit_party is a heuristic that can cut a real “+”-named act like Florence + the Machine); and a name that differs by a stopword — the exact test uses deezer._norm_artist_name, which keeps every token in order, NOT the album-title normalizer that collapses “The Band” and “Band” to the same set. The exempt rung also needs the name GROUNDED in the source text (artist_grounded, stamped by claude_client._mark_grounded_artists, which now covers musician on its own name as well as music_release on its artist). The musician stamp uses the STRICT, case-sensitive _artist_named_in_strict: the permissive _artist_named_in documents an ordinary-word false match as “the SAFE direction”, which held while grounding only decided whether to ATTEMPT a portrait, and stopped holding once it also grants the gate exemption — “Future”, “Common” and “Air” all match ordinary prose. Only musician can reach the exempt rung at all: the music_release missed-cover portrait is always deezer_artist, because the release→artist pairing is the classifier’s assertion rather than a verified credit, so a mispaired artist on a take naming two acts would otherwise hand back the wrong act’s exact portrait. An exact Deezer match proves the portrait is of the name we ASKED for; it says nothing about whether the classifier asked for the name the TAKE uses. Without this a drift the gate used to catch — a take about Ella Langley classified as Ella Fitzgerald — would resolve Fitzgerald’s portrait exactly and ship it unchecked. Grounding does NOT gate the portrait for a musician: an ungrounded name still gets its picture, it just keeps the gate. The market_subject_img cache namespace was bumped to v14 with this change: a cache hit returns its stored src verbatim and never re-runs _deterministic_art, so v13 portraits would keep the old label — and keep being dropped — for up to the TTL. A FOURTH thing is never reported exact: a match on an OBSCURE act (#2810). An exact name match is only an identity check when the act it names is the one the world means by that name. Measured live 2026-09-01: a short or nickname credit exact-matches a tiny namesake far more often than it finds the real artist — “Kendrick” → a 73-fan Kendrick, “Bey” → 10 fans, “Cudi” → 10, “Ari” → 59, “Doja” → 342, “Snoop” → 3,668 — and all six came back exact, so all six would have shipped a stranger’s real face past the media gate. The genuinely correct short names sit orders of magnitude higher (SZA 1.32M, ZAYN 2.70M, Diddy 606k, Kanye West 4.54M), so deezer._EXACT_MIN_FANS (50,000) is the floor a row must clear before its name match counts as identity. LENGTH is the wrong discriminator and was the first thing tried: “Kendrick” is eight characters and still collides, while “SZA” is three and is right — audience separates the two cleanly with about two orders of magnitude of headroom either side. Below the floor the portrait still SHIPS; it is just reported deezer_artist, so the gate keeps its authority. The ALIAS table is now read on the art path too (artist_watch.canonical_credit, applied in deezer.artist_picture_match_for_credit — THE one definition of “a Deezer portrait for a music credit”, so every caller is covered by one line). It had a single consumer, the watchlist tier match, and that split was the live misattribution: Deezer holds a separate artist literally named “Ye” (id 4099199, ~20k fans; Kanye West is id 230, ~4.5M), and the alias is what lets a “YE”-credited wire post through the tier gate in the first place — so the alias imported the story and the art path then misattributed it, exactly, on the exempt rung. Both halves read one table now, and a test pins that they cannot drift apart again. The candidate FILTER now folds too (#2693). It ran on _norm_title, the ALBUM-title helper, which strips a diacritic instead of folding it — so “Beyonce” ({beyonce}) shared no token with Deezer’s “Beyoncé” ({beyonc}) and the real act was dropped BEFORE the exact test ever saw it. The issue recorded that as “no portrait resolves”, and that was a fixture artifact: live, Mc Beyonce (14,537 fans) does share the bare token, so it won the row and the card carried the WRONG artist’s portrait (measured 2026-09-01; the real Beyoncé has 12.8M). The same strip emptied a wholly non-Latin name, so those acts matched nothing at all. deezer._norm_search_tokens is _norm_artist_name’s folding (diacritics + the Unicode-aware word class) with _norm_title’s set-and-stopword semantics, because the filter needs both halves and neither helper alone has them. This is the SAME lesson album_cover’s artist-id rung already learned and wrote down — “the tokens come from _norm_artist_name, NOT _norm_title” — applied to the portrait filter, which was left behind on the old helper. Folding only ever makes MORE rows candidates, and the guards behind it are what keep that safe: the exact test still decides the rung, _EXACT_MIN_FANS still gates the exemption, and the existing most-popular preference is what then beats the namesake (12.8M over 14.5K). The fold went in at the ROOT, _norm_title itself, because the portrait filter was one of FIVE sites and fixing only it would have repeated the sweep miss it came from. _norm_title’s [^a-z0-9 ] strip DELETES an accented character rather than folding it, which both splits a token where the accent sat (“Björk” -> {bj, rk}) and leaves an accented name sharing no token with its plain spelling. A wire writes “Beyonce” and Deezer answers “Beyoncé”, so every comparison in the module that runs on these tokens failed for every accented act, always in the direction that REJECTS the right answer. Measured 2026-09-01: _artist_overlaps read Beyoncé, Rosalía, Björk and Céline Dion as 0% overlap with their own names and refused their own albums; album_cover’s credit check refused the right row (live: RENAISSANCE by Beyoncé scored artist_ok=False before, True after, while the imposter renaissance, but lofi by Lucille Hurt stays refused both ways); _split_billing handed the contributor test mangled tokens; and album_title_matches refused a title whose accent the source omitted (“Mananas” vs “Mañanas”). NON-ASCII letters are still stripped, which is load-bearing rather than an oversight: album_cover reads an empty artist token set as “cannot token-verify” and refuses rather than risk a wrong cover, while the portrait FILTER wants a name it can still search and so uses _norm_search_tokens. The same gap existed in eight more normalizers across five other modules and is now fixed too (#2821) — deezer._initialism, music_markets._album_key / _art_norm / metric_slug, billboard._norm, hits._norm_name, radio._norm, milestone_qualifier._fold. Each returned a different value for the accented and plain spelling of the same name, always failing in the direction that rejects the right answer. utils.names.fold_accents is now THE one definition of the fold primitive and all eight route through it: the idiom was hand-rolled in ~8 places already, and the reason these eight drifted is that no shared primitive existed — every existing helper folds and then immediately does something specific (strip to [a-z0-9], split to tokens, keep only ASCII), so a caller needing plain “same name, no accents” had to re-roll it. milestone_qualifier._fold is the instructive one: it carried a comment saying it deliberately omitted the accent step kworb._fold has, because “a title rarely needs” it — wrong in the direction that MISSES, since a plain spelling then never meets an accented one. The KEY-churn risk was checked, not assumed: metric_slug feeds the persisted proj:<release>:<metric>:<day> key (cogs/music_desk.py), but its input is registry metric text (“Album-Equivalent Units”) and the key’s other half is a Kalshi ticker suffix (PET26AUG06), both ASCII by construction — so the fold is a no-op on every real input and no stored key changes shape, pinned by a test. _album_key is in-memory only (seen_albums within a call, fired_albums within a tick). The one residual: _sales_legacy_seen compares _album_key against a persisted LEGACY set, so an accented album carded in the narrow window before deploy can miss its fading legacy key and re-post once — accepted, and those keys fade by design. The portrait search reads 25 rows and ranks by (exact, fans) (deezer._ARTIST_SEARCH_LIMIT, 2026-09-17): Deezer ranks its artist search by its own relevance, and for a MONONYM the first five rows can all be namesakes — “Adele” at limit 5 was Adele & Andy, Adele & Robin, a 12k-fan “Adele”, Adele & Zalem and Adele & The Chandeliers, with the 15.5M-fan Adele absent, so the loose rung handed a gospel act’s cover to the gate. Fans ALONE across a wider page regresses (“Cher” → Cher Lloyd 945k over Cher 562k, “Tyla” → Ty Dolla $ign), so an exact-named row that clears _EXACT_MIN_FANS outranks every loose overlap and fans only break ties inside a tier; the floor keeps a 73-fan exact “Kendrick” losing to Kendrick Lamar. Measured live on 31 names: Adele, Prince, ROSÉ and LISA were the misses at 5, none regressed at 25. _cover_from_artist_albums (the album-cover rung that walks the act’s discography) reads the same page width with the same key; a wrong act there only costs a miss, because its title guard refuses another act’s discography.artist_watch.judged_first_party, called from deezer.artist_picture_match_for_credit). first_credit_party splits only a spaced “+”, so a credit joined by “&”, “,” or “With” (“Drake & 21 Savage”, “Karol G With Drake”) missed every portrait search and the card shipped bare: 77 of 79 Deezer misses on 707 real chart credits (2026-10-16). Only after the whole credit and the “+” retry both miss, first_party_candidate cuts ONE name (before the first comma or spaced “&”, then before a spaced “with” / “duet with” / “x” / “meets”), and one cached Haiku YES/NO (claude.classify, purpose=credit_first_party, 7 days per credit, in memory) decides whether it is the full name of a music act. The candidate is always text the credit contains, so the model can confirm a name but never supply one. Code refuses a candidate when the text after the cut opens with an article (“Tyler, The Creator”, “Florence & The Machine”; the real model said YES to “Tyler” on both golden runs) or is a placeholder. The rescue keeps only an EXACT Deezer row (whole name, _EXACT_MIN_FANS): a YES on a fragment of one act’s name (“Sam” of “Sam & Dave”) otherwise reached a namesake whose name IS the fragment (“SAM”, 48.7K fans), which the token corroboration cannot stop. It is still reported NON-exact, so the delivery media gate also judges it. Dry run: 0 -> 46 portraits on 79 real misses, 0 wrong. NO, no verdict, or no judge (scripts, tests): absent, as before. The judge turns on in MusicDesk.__init__ (aw.enable_party_judge); tests/conftest.py resets it per test. NOT changed, measured: wikidata.is_music_act rejected 2 of the 79 misses’ Wikidata hits, both albums (right), and _PLACEHOLDER_CREDITS matched 3 credits, all real placeholders; a rescue rung there has nothing to rescue.music_markets.resolve_art_url -> _discography_track_cover -> apple_music.artist_tracks, #3508 / #3509 / #3511). The Apple Music US songs card for “MISS MY DAWG” (Yeat & Drake, #69 -> #8, 2026-09-19) shipped as a bare #8 with desk_art no_url: the song is an album CUT on COCOON, a record the iTunes keyword search did not index yet, so the search rungs returned other acts’ “Miss My Dawg” and the album-discography rung compared the SONG title to the ALBUM title. The rung runs for a SONG subject after those three miss. It settles identity ONCE, by the exact iTunes artist id (resolve_artist_id, exact normalized name), reads that act’s newest _ART_TRACK_ALBUMS collections’ tracklists newest first, and keeps a row that is the same EDITION of the title (apple_music.title_edition_key: letters only, accents folded, Unicode kept, credits and punctuation dropped, the version tag kept, so “Love” takes neither “Love Again” nor “Love (Live)” and “A*POP” equals “APOP”) and credits the act looked up. Three names are tried: the whole credit, then its first act cut at the list separators (artist_watch.lead_run, “AC/DC”), then the fully split first party (artist_watch.first_listed_party, “Shakira”). A hit on the whole credit reports catalog_cover (gate-exempt); a hit through any other name reports catalog_cover_split (TRACKLIST_SPLIT_RUNG), which is absent from x_crosspost._IDENTITY_VERIFIED_ART, so the delivery media gate judges the image – the same split the Deezer portrait makes (deezer_artist vs deezer_artist_exact). The gate runs on BOTH copies: the X crosspost reads the label as it always did, and because the ROOM copy ships first, every desk that sends a card to a room before it crossposts (music_desk, x_mentions) runs the ONE shared pre-room gate (x_crosspost.gate_split_art over gate_room_image) on that one rung, degrading a confirmed drop to the face-free number render (market_cards.face_free, the branded fallback X uses) or to text (Codex P1s on #3515). #3509 first tried to PROVE a split-found row was the record from credit text (every-party corroboration, set equality, credit-zone parsing); five Codex rounds each found the next hole (“Drake” inside “Drakeo the Ruler”, “Fire” out of “Earth, Wind & Fire”), so #3511 removed the heuristics and let the gate, which exists for exactly this, carry the doubt. Bounded by _TRACK_RUNG_BUDGET_S; that budget cancels an in-flight iTunes read, and apple_music._request_json releases the breaker’s HALF_OPEN probe on CancelledError (Codex P1 on #3509). The newsroom row resolver catalog_art.resolve_music_row has the same tracklist fallback (#3510), but STRICTER, because its row feeds the newsroom’s cover and listen link with no rung label and no gate: the EXACT lead only (a “&” co-billing that names no single act misses rather than be read as its first act), song rows only, a “Lead feat. Guest” row must credit the guest in its title’s feature zone, and the row still passes _pick_artist_row.music_markets._portrait_rungs / _wiki_portrait / _pick_namesake / pick_album_art_row, #4134 / #3319 / #4349, 2026-10-17). (1) The Deezer portrait passes the ONE blur floor, catalog_art.portrait_blurred (MIN_PORTRAIT_SHARPNESS 0.010, calibrated on #2716). Only entity_image applied it before, so this ladder and the music desk’s own artist-photo rung (MusicDesk._fetch_art) shipped Drake’s defocused art shot (0.0033) on the DRAKE card of 2026-10-12. The ladder holds a URL, so portrait_url_blurred downloads it once to check; a failed check download keeps the URL. A blurred photo falls to the Wikidata rung; on the desk it falls to the album-cover rungs, as a blank photo does. (2) wikidata.artist_portraits returns EVERY same-name music act with a P18 photo (after the first act it reads only music-looking descriptions, inside _ARTIST_CLAIM_READS). One act needs no call. Two or more is the Steve Lacy case (the jazz musician Q504641 ranks ahead of the R&B singer): one cached Haiku classify (purpose wiki_namesake) reads the card’s credit, title and each Wikidata description. A number picks that act; UNCLEAR gives no photo (absent); a model error or no judge keeps the old first-match rule. wiki_portrait stays out of x_crosspost._IDENTITY_VERIFIED_ART, so the delivery media gate still judges it. (3) pick_album_art_row refuses a row whose title names a DIFFERENT bracket release (catalog_art.release_name_check == “mismatch”: “Weezer (Green Album)” for “Weezer (Gold Album)”). Exact, so no call; an “unsure” row keeps the old rule here (the Haiku half of #4115 stays in resolve_music_row).catalog_art.resolve_music_row bridges an iTunes SEARCH miss by the release UPC (deezer.track_album_upcs -> apple_music.songs_by_upc). The US iTunes card for “STORM II” by “GENER8ION & Yung Lean” (#18 -> #6, 2026-09-29) shipped as a bare #6 to X: market_subject source=none. Apple sold and charted the song, but every iTunes search term returned nothing for it. Deezer carried the track, but on a release titled “STORM” and credited to GENER8ION alone, so deezer.album_cover_match (which matches the RELEASE title) refused it. The photo search then found no usable picture. The bridge runs after every search term misses and before the tracklist fallback. Deezer names the releases that carry a track of the same EDITION (apple_music.title_edition_key, which keeps word order and repeats, so “Talk” is not “Talk Talk”) whose lead is the whole credit, or one whole act of a real collaboration (deezer._lead_is_billed, on deezer.credit_key: accents folded, case and whitespace dropped, punctuation KEPT, in any script; both sides compare with this one key). It splits the billing with the repo’s one party split (artist_watch.listed_parties, so “f/”, “+” and “/” split like “&”), searches the whole credit first, then every act of a collaboration, reads rows whose lead IS the whole credit before rows by one act (so band-name fragments cannot spend the reads), and stops when its two album reads are spent. A credit with a name that starts with a connector (“&ME”, “+44”) is refused on both sides (deezer.connector_glued), because the party split drops the symbol and “&ME” would read as the act “Me”. Apple’s Lookup API answers ?upc= for the release even when its search does not. Deezer verifies nothing here. Apple’s rows must pass _upc_row_verified, which is STRICTER than a search hit’s guard, because Deezer may name a solo release by one act of a joint billing: the requested parties (the credit plus any feature the requested TITLE names) must EQUAL the row’s credit plus its title’s feature clause (artist_watch.title_feature, bracketed or bare, so a solo release and an extra “feat. Guest” both fail), each party keyed by deezer.credit_key, so a non-Latin or mixed-script credit (“DJ 소다”) still compares, punctuation is kept (“will.i.am” is not “William”; a spelling variant such as “Jay-Z” and “JAY Z” therefore misses, the safe direction), every feature clause counts (a second “[feat. Other]”, a glued “f/Guest” in the title or the credit, and “(w/ Guest)” included; feature zones are removed before the edition comparison), and the track title must be the same edition (apple_music.title_edition_key, Unicode kept). The verified rows of every UPC are ranked together by _rank_rows, so an artist’s own single beats a compilation, and rows verified before a deadline are kept. Song rows only, so an album_first release-date read never takes it, and never for an artist-level card (title == artist), which needs a cover the artist owns. Deezer has its own 5s deadline that records a failed deezer_lookup; the whole bridge is bounded by _UPC_BUDGET_S (8s), and a timeout there records a failed catalog_search source=itunes_upc carrying the in-flight UPC as query, limit and duration_ms, like a completed lookup. Fail-open. These limits come from two rounds of Codex review on #3745.cogs/music_news._EXISTING_RECORD_KINDS). _row_is_a_different_record refuses a matched catalog row that is too old for the story, and falls back to the artist’s photo. It exists for release stories: a wire naming “Faint” for the 2026 live version must not carry the 2003 Meteora cover. It also ran on milestone and certification stories, where the old record IS the subject. On 2026-09-29 it refused the correct 2023 cover for “Seven” at 3.1B streams; the portrait rung found a namesake, the media gate dropped it, and the card shipped bare. The same day it swapped five more milestone/cert covers for artist photos (“This Town”, “End of Beginning”, “The Fame”, “Prism”, “Play It Again”). _artwork_and_link now takes the story kind and skips the guard for milestone, cert and novelty. release, first_week and no_chart keep it, and so does market (a market can forecast a record that is not out yet) and chart: an unflagged chart claim about an old row can be a new version charting, and the throwback flag (meta["retro"]) already covers the old-record chart case. A chart story about a title ALREADY on the chart keeps its old row (#4131, 2026-10-16). “Stick Season holds at #9 on the Billboard 200” matched the 2022 row, and the guard shipped Noah Kahan’s photo. mn.chart_story_is_same_record reads the classifier’s chart_event (#3418), which the compose now carries on meta["chart_event"]: hold, move, none and exit keep the row. debut, reentry, a missing or unknown event, and an edition word in the claim or subject (“deluxe”, “anniversary”, “reissue”, “version”) keep the guard. No call is added. Measured on 30 days of real firings, re-classified by the live Haiku prompt: 16 chart-position stories get their cover back (Stick Season, The Art of Loving, The Divine Feminine, The Life of a Showgirl, SOS, BabyDoll, and others). The one real new-edition case, “‘Rubber Soul’ re-enters the Billboard 200 at #4 … following its ‘Super Deluxe’ reissue”, read reentry and stays guarded.x_crosspost._IDENTITY_VERIFIED_ART). That exemption reads MarketCard.art_source, so a builder that drops the caller’s rung disarms it. build_market_card rewrote EVERY caller-supplied image to the literal “provided”, and build_board_card reported no rung at all. The cost: the music desk resolved Carly Rae Jepsen’s own “Day and Night” cover on the release rung – iTunes artist-AND-title matched, and already in the exempt set – but _fetch_art returned bytes alone and the builder stamped “provided”. The vision judge then read the sleeve by face, said “that’s Florence Welch” twice, and the confirmed drop shipped a bare number card (crosspost mode=number_fallback, 00:09Z). The sleeve prints “DAY AND NIGHT” and “CARLY RAE JEPSEN” on it. The fix is the plumbing, not the exemption set: build_market_card takes artwork_source and build_board_card takes art_source, music_desk._fetch_art returns (bytes, rung) like its sibling _art_bytes already did, and the standings lane stops discarding the rung it resolved. A caller that names no rung still reads “provided”, so the gate keeps full authority over art whose origin we cannot state. Measured over the 30 days to that date: 185 gate checks arrived as “provided” and 925 music_desk checks arrived with no rung at all, so the exemption could never fire on either path. Two surfaces had already worked around the same gap locally – cogs/sports_boards.py stamps image_source="sports_api" at its own crosspost call, and cogs/music_news.py assigns card.art_source after the build (#3224) – which is the signal that the builder should have carried it. When a new builder grows an art parameter, thread the rung with it.Music LINKS belong to two surfaces only (owner steer 2026-08, #2216): the music DROP (cogs/music.py — a take plus its Apple Music / Spotify link, into the links-only channel) and the GUESS GAME’s reveal (cogs/games.py / cogs/x_game.py). Everything else reports rather than recommends, so it sends a reader nowhere to listen. The newsroom broke this: music_news._build_card passed url=listen on EVERY story kind, so a market / milestone / chart / cert / touring card carried an Apple Music button, and the crosspost put that link in the tweet (measured: 186 of 215 music_news crossposts over 14 days, 87%). That is why a Drake PREDICTION-MARKET post had an Apple Music album link at all — and, because the link comes off the same catalog row as the cover, the mis-picked row pointed it at KAROL G’s album. The link is now gated on kind == "release", the one music-drop-shaped lane. Dropping it on the stat lanes retires a whole wrong-link surface rather than one instance of it. A TOUR is gated out BY NAME on top of that (#tour-announcement). The first pass said a tour needed no special case, because no catalog row matches a tour NAME. That reasoning only covers a tour the SUBJECT names. A tour whose subject is the bare ARTIST matches that artist’s own catalog, so on 2026-09-01 a card about three announced Kanye West concerts carried a link to “808s & Heartbreak” (music.apple.com/us/album/heart...) in the tweet. url and button_label now both read raw_kind == "release" and not is_tour. The release lane then lost the card instead of the link (owner steer 2026-09-12: “new releases shouldn’t have a card and music link. only a music link, probably, if it exists. it’s one or the other”). A drop is the one newsroom story a reader can act on, so the LINK is the post; the card competed with it, because Discord draws the card image and hides the Apple Music unfurl behind a button, and on X the baked cover and the link preview showed the same record twice. _build_card now RETURNS None for a release that resolved a link (stamping it on composed.meta["listen_link"]), and _sched_deliver sends the take plus the link on its own line in the room and passes the link — with no image — to the X mirror. So NO newsroom card carries a url any more, and build_number_card is called without url/button_label at all. The “or” side is unchanged: a release with no link keeps the card, which covers a TOUR and a LIVE moment (neither resolves a link by design) and a drop the catalog has not indexed yet. The split is queryable as desk_link surface=music_news phase=release: reason=link_only against no_link, plus no_card for the double miss (no link AND no art, so the take ships bare). It also warns that music_news_posted carded=false no longer means a degraded post on this lane. ART used to HIDE the link, so the link is resolved FIRST now (Codex review on #3188). Both image-carrying lanes return before _artwork_and_link runs, which is where the link came from: the deterministic new-release candidate carries its own cover, and a first-party feed item carries the outlet’s editorial photo. Each arrives as feed_image, which the card path prefers – so the two lanes most likely to BE a drop resolved no link and carded. _build_card now asks _release_listen_link BEFORE any art: it reads the same artist-verified row _artwork_and_link picks and takes the same link off it, and a drop with a link returns there, so no art is fetched at all. The new-release lane skips even that read – _sched_compose stamps the candidate’s own album page (collectionViewUrl, off its permalink) as release_link, and only that lane: a wire post’s permalink is an article or a tweet, not a record. _release_listen_link REFUSES two shapes, both prefer-absent-over-invented: a subject with no artist credit (_artwork_and_link resolves a representative row for ART on purpose, but a representative LINK points a reader at a different record), and a row from another era (_row_is_a_different_record, #3181 – as wrong a link as it is a cover). Each keeps the card. The drop route is NEW records only (Codex review, round five). Two more shapes ride release and are not a drop, so each keeps its card: a THROWBACK (“Music Box released 33 years ago today” – the era guard deliberately KEEPS an old row for these, since the 1993 sleeve is the right art, so the link resolves and the look-back would have lost the period cover it is built around; read off the composer’s retro flag and off the claim, since the card build sees only the claim), and a FILM / documentary / merch announcement (the classifier’s event_shape of nonaudio, #3413; it was _claim_is_nonaudio, a phrase list), where the catalog’s same-titled album is a different product from the thing announced – the Eras Tour shape, one name over a tour, a concert film and an album. Those cues are the tour ANTI-cues minus the three that are audio: a concert album, a live album and a tour edition are records and keep the link. The throwback test is the ANCHORED one (retrospective_frame, the same question the composer’s retro flag asks of the wire post), NOT the era guard’s states_past_interval: that one is deliberately unanchored, so it also fires on an anniversary clause inside fresh news – “to celebrate the album released ten years ago today, she announces a deluxe edition” is a new record and keeps its link. Wording alone cannot settle it, so the ROW’s age does (Codex review, round seven). The anchored frame misses a subject-led anniversary (“Music Box was released 33 years ago today”), and the unanchored one over-fires, so _release_listen_link tests the matched row with _row_is_an_old_record – the age half of the era guard WITHOUT its throwback carve-out. An old row is never a new record, whatever the wording, which closes the subject-led shape and also stops the 2006 album being linked as if it were the deluxe edition a story announces. An UNREADABLE date keeps the row: iTunes leaves release_date empty on plenty of genuinely fresh rows. Every desk_link row also carries delivered (room / staged), because the card is built before the stage branch and a STAGING audition runs the same route. A TITLE TRACK links the record, not the song (#3192). resolve_music_row is song-first, so an album whose title track shares its name (“Enigma POV”) matches the TRACK, and the request matches that track name, so _listen_link’s song branch made ONE song the whole destination – which matters more now the link IS the post. When the request matches the row’s ALBUM as well as its title, the subject names the record, so the album page wins. The collection name must be PRESENT to count: _title_matches answers True for a missing one (right when REJECTING a row, wrong here), and without that check every album-less row went to the album page. A single loses nothing either way – its collection is that song’s own release, so the album page is the same page minus the track parameter. Deliberately NOT read off the claim’s wording: “the lead single from her upcoming album” is a SONG story whose claim says album, and a claim-driven rule would send it to the record. The collection test is an IDENTITY one (_names_the_collection), NOT apple_music._title_matches: that is the fuzzy catalog-SEARCH predicate (substring containment, a difflib ratio as low as 0.6), which is right for “could this row be the request” and wrong for “does the request name the record” – the song “Love” on the album “Love Yourself” contains it, so the first cut sent a song story to the whole album (Codex review on #3206, caught after merge). Both sides lose a trailing single/ep so a single still names its own release, and a parenthesised edition needs no case since _normalize_title already drops it – a deluxe IS the record. A VIDEO release also links the VIDEO (2026-10-01). The artist_pull lane posted “Bruno Mars and Karol G’s ‘Dance With Me’ music video is out now.” with only the Apple Music link, because music_news never called the watch-link resolver. _sched_deliver now calls _release_watch_url for every release that ships as text (no card). It passes the story’s CLAIM, not the source text: an artist_pull source is a Perplexity answer that can hold several stories, and the claim names the one this post reports. It also passes the source post’s own links (source_urls, rung 0). A found video goes on the LAST line in the room and last in the X text, so Discord shows a player and X cards the video while the Apple link stays tappable. No video, or an error, ships the post unchanged. Each attempt emits watch_link surface=music_news.
The NEW-RELEASES BOARD (cogs/music_releases.py + utils/release_board.py, #release-board) is the roundup sibling of the music DROP — where the drop recommends ONE track with a link, the board posts a scannable LIST of the night’s fresh drops (artist - title), the “who’s getting your first listen tonight” roster. It rides ScheduledPoster (master switch + mood + /menu calendar + slot pacing + dedup), targets the guild’s music_channels (shared with the drop — both are music-room posts, on their own cadences), and gates on its own music_releases experiment (STAGING by default, so it auditions in #bot-logs before a room). Data: utils.apple_releases.top_albums alone (Apple Music US Top Albums; NO Deezer since 2026-09-05, owner steer “never use Deezer”). The history matters because it is why: Deezer’s public API is OPEN (no key, no OAuth) and its chart is POPULARITY-RANKED, which is exactly what the board needs: recent_releases reads the album popularity chart (/chart/{genre}/albums), looks up each album’s exact release_date (/album/{id} — a chart entry carries no date), and keeps the ones inside the window. The chart is ranked, so the notable acts arrive already sorted — no known-artist filter is needed (that was a workaround for a source with no popularity signal). Release dates are immutable, so the per-album lookups are cached in-process (~one lookup per charting album cold, ~zero after). This is the THIRD source pivot (#2447), and the RIGHT one. The first design read Spotify’s /browse/new-releases, but that Browse endpoint is 403 for any app created after Spotify’s Nov-2024 Web API deprecation. The second read MusicBrainz’s RELEASE date feed — complete, but MusicBrainz has NO popularity signal, so the board was either obscure noise (no filter: “Septic Mutilation - Visions of Despair”) or too thin (a blunt top-1000 known-artist filter that still missed rising acts the source tweet featured). Deezer solves both at once: it knows what is popular AND what dropped, so a real drop (Denzel Curry, PARTYNEXTDOOR) outranks a bedroom release with no filter at all. Deezer alone was not enough, and the board went fully DARK on it (2026-09-04). Its all-genres chart is LONG-TAIL, so the set “albums in this top 100 that dropped today” is nearly always empty: the board ran 35 times between 2026-08-27 and 2026-09-04 and posted NOTHING, every run phase=source_thin. On Friday 2026-09-04 the chart held three albums released that day (Sleep, Rubber Band Gun, Paris Paloma) — under the Friday floor, which was 6 at the time — and it MISSED Beyoncé’s B’DAY (20th ANNIVERSARY DELUXE EDITION), the day’s biggest drop. So #2950 added a SECOND chart: utils.apple_releases.top_albums, Apple Music US Top Albums — 100 rows ranked by current plays, each carrying its own releaseDate, in ONE keyless request (no per-album date lookup at all). Apple held four of that day’s drops, Beyoncé’s among them, and the two were merged rank by rank. Then Deezer came OUT (owner steer 2026-09-05, “never use Deezer”): beside Apple’s rows Deezer’s were the long-tail noise on the card (Sleep, Rubber Band Gun, Paris Paloma), and its tracks chart was weeks stale (#2956). The board now reads Apple alone — a thin Apple night is a thin night — and deezer.recent_releases, its album-date cache, the deezer_fetch event, the monitor branch and the panel entries were removed rather than left dormant. The stream-stat’s song-date check (_standout_stats) moved from deezer.track_release_date to the iTunes Search API (apple_music.song_release_date, strict title + billed-artist match, fail-open to no date). Apple source-health rides the apple_releases_fetch event. No provider still serves a dated RELEASE RADAR this bot can read (re-measured 2026-09-04): Spotify’s /browse/new-releases returns 403 for this app even with a valid client-credentials token, Deezer’s editorial /releases feed returns an empty list, and Apple publishes most-played only — its new-releases / most-recent / coming-soon feed names all 404 and the legacy iTunes RSS newreleases path 400s. So the board reads POPULARITY charts and keeps what dropped inside its window, and reading two of them is how it sees a release day. A second unit rides the same slot on Fridays (#2956, owner ask 2026-09-04): the TOP DROPS card. _sched_compose_units yields up to two Composed units in order — the albums board, then the top card — each with its own lane, label (= the telemetry category), floor, roster-dedup key (<lane>: prefixed) and source_thin skip, so a thin unit never costs the other and a second slot re-posts neither. _compose_top_drops takes the night’s albums roster, fetches kworb’s Apple Music US Top Albums chart once (apple_albums, revalidated) and matches each drop through kworb.album_chart_entry — the STRICT match, artist credit + exact folded title (#2520: a near-name or a same-titled reissue lower down is a wrong rank on a public card, so an unmatched album is left off, never guessed) — then ranks by the drop’s chart position (rb.rank_drops_by_chart), cuts to TOP_DROPS_ROWS (5) with a floor of TOP_DROPS_FLOOR (2), and renders #5 Top Albums (the chart’s name, owner steer; the platform rides the source stamp, and the caption reads Apple Music Top Albums (US)) + the drop’s date on each row; the take stays numberless through the same compose_music_roundup + release judge. Why the album’s chart position and not the artist’s streams (owner steers 2026-09-05): the first cut ranked by the artist’s Spotify career total off kworb’s all-artists page and printed it on the row. That figure is not the drop’s — Beyoncé’s 35.6B is twenty years of catalog, not B’DAY’s first night — and beside an album title it read as the album’s number, the same misattribution class as #2950’s “Deezer” source stamp; a bare “#41 on Spotify” then read as the album’s chart position. And only a top-1,000 act resolves, so four Friday rosters yielded 2, 0, 2 and 1 rows. The albums chart is the drop itself, the same day: 6, 0, 5 and 5 of those rosters sat on it (past weeks read against today’s chart, so they understate). Streams-so-far was measured and left off: the existing album-streams reader (stream_totals.album_total) returned 1.99B for B’DAY deluxe — the 2006 original’s lifetime total, matched by title, the reissue misattribution — and nothing for Wisin or Nemzzz (no kworb artist page); a per-release figure needs a per-release source. The X-reception pulse stays on the albums board only, so a two-unit slot spends one metered grok call. A SINGLES board was built for the same slot, measured, and CUT (#2956, #2512): no source this bot can read sees a single the day it drops. Deezer’s tracks chart (/chart/0/tracks, 68 of 100 rows pass a single-vs-album-track test) carries release dates weeks old — a 22-day replay found an in-window single on ONE day; kworb’s Global Daily held ZERO fresh debuts on 2026-09-04 (192 of 200 rows were 8+ days on chart, and the eight sub-week rows were catalog re-entries: Bee Gees, Cranberries remasters, TLC); Apple’s most-played songs feed held none in the Thu+Fri window (its twelve fresh rows were one Aug 27 album’s tracks). A lane that cannot see its subject is the dark surface #2947 was, so it did not ship. It needs a dated release feed (#2506), not another popularity chart. The pure core utils.release_board picks the fresh drops: fresh_releases keeps only rows with a parseable release_date inside the plan’s window, in the SOURCE’S POPULARITY order (NOT re-sorted by date — so a big drop dated a day earlier, like PARTYNEXTDOOR’s Thursday P3, is not buried under same-day obscurities and cut at the cap), deduped — fail toward FEWER, an undated or stale row is DROPPED, never guessed onto the board. It also DROPS a side version — a live / remix / sped-up / acoustic / instrumental / edit re-cut of an existing song — via the shared utils.release_type.is_side_version classifier: a watched artist has no notability floor on the music lanes (a watched act posts at any depth), so without this a throwaway re-release (“Mercy (Live From Montreal)”) posts as if it were new music (owner steer 2026-08-21). The classifier is CONSERVATIVE (markers matched only in a title’s parenthetical / bracket / trailing-dash suffix, never the body) and keeps a DELUXE edition and a RE-RECORDING (“Taylor’s Version”) as genuine. It is the ONE definition of “is this a new release?”, reused as a SKIP on the other announce lanes (music_alert’s Spotify-releases, Apple-debut, and radio-entry lanes — a side version never leads them, and the debut lanes count it as its own side_version disposition so a re-cut-only day is not misread as a Deezer no_date failure) and as a DOWN-RANK in MusicDesk._pick_chart_moves (a side version sorts below every genuine move of equal notability, so with the per-slot cap it posts only when nothing genuine competes — a re-cut that genuinely charts is a real chart event, deprioritized, not skipped). The single-candidate music_alert crown/jump movement lanes are left as-is: a lone charting side version there is the real event. The board lists at most CARD_ROWS (10) drops — the card renderer’s own cap — and the card, the composer’s grounding, and the telemetry count all read that SAME capped set, so a take can never name a drop the card omits. board_plan is DAY-AWARE (owner steer 2026-08-23): Friday posts the NEW MUSIC FRIDAY batch (floor 3), every other day a DROPS TODAY board of just today’s drops (floor 2) that honestly SKIPS when nothing notable dropped — most non-Fridays, since the release cycle bunches on Friday. Both read a 1-day window anchored to today (a Deezer release_date is a plain calendar label, so “today” needs no timezone slack); the weekday picks the label + floor, not a second lane. The Friday floor was 6 and that was too high (owner steer 2026-09-04, cut to 3). A high floor cannot make Friday fat — the SOURCE does that and CARD_ROWS bounds it — so all it can do is SUPPRESS a Friday board, which is backwards: on a week when four notable albums dropped, four IS the news. Measured on Apple’s chart, the recent Fridays carried 5, 8, 5, 5 and 3 rows in the Thu+Fri window (still charting weeks later, so each understates the day itself) and Deezer adds roughly three more, so a floor of 6 rejected a genuine 4- or 5-album Friday outright. Friday still sits above the two-row list minimum because the label promises a batch and a two-name board under that headline reads thin. The take composes through compose_music_roundup — a NUMBERLESS multi-title roundup composer (the music twin of compose_cinema_roundup), NOT the number-desk compose_market_drop, which would mandate a chart position or stream count the roster does not carry and so either reject the clean post or force a fabricated figure (#2069/#2076: 0/13 shipped on the number path) — self-gated on music_desk_score(kind="release") at the shared music-desk 0.6 floor (fail-CLOSED). Early X reception (#2510): when grok_search is provisioned, the cog fetches grok_search.release_reception_pulse for the top 2 standouts (one grok call per shipped board) and passes it as the composer’s x_context — SENTIMENT ONLY, so the take LEADS with the buzziest drop and reacts in voice, but still names drops only from its grounded roster and quotes no fact/name/number from the pulse (the composer’s x_context block enforces this). Fail-open: no key / a miss just drops the garnish. Verified first-night STREAMS on the CARD (#2510): the cog’s _standout_stats credits a drop with its biggest FRESH-charting SONG on kworb’s Spotify Global Daily, drawn straight from data onto the card — the model never quotes it. The board reads Deezer’s ALBUM chart while kworb charts SONGS, so a new album’s own songs are what chart, under titles the album name never carries: for each of the top few drops (_STAT_SCAN), kworb.fresh_artist_entries finds the artist’s fresh-DEBUT rows (1 <= days <= _CHART_DEBUT_DAYS 3 — a blank kworb Days cell is 0/unknown and fails CLOSED) by a STRICT lead-credit artist match (so “Future” does not admit “Future Islands”, a “Future & Metro” collab still matches, and a solo name that itself carries a separator like “Tyler, The Creator” matches as the whole string; and a credit that is itself a RANKED act, “Macklemore & Ryan Lewis”, stays whole, so a member does not take the duo’s song, #2615 KworbClient.ranked_act_keys), and keeps the top one whose Deezer date equals the drop’s release date (so an old song, or a same-day but different release by the artist, cannot borrow the slot). A REISSUE / anniversary edition is skipped (_REISSUE_RE on the drop title) — its songs are years old and re-chart off the reissue, not a first-night number (“deluxe” is NOT a reissue, it routinely carries new songs). _stat_card_rows renders the figure as the drop’s row value (abbrev(streams)/day) with the charting song as the sub, and RANKS the card by streams (owner steer): the drops with a verified figure lead, biggest first, the biggest highlighted; a drop with no charting song has no free day-one number and trails in popularity order. It rides the CARD, not the take, deliberately (owner steer): the release JUDGE rejects a song’s chart figure woven into album news (measured on real data: a faithful take scored 0.15–0.20 every time because the two-part figure + the album-vs-song framing confuse it), while a figure rendered straight from data needs no judge and no composer — grounded by construction, with no fabrication surface (which is why the earlier take-prose approach’s whole deterministic-backstop + rubric-rework machinery was removed). The take stays a NUMBERLESS roundup through compose_music_roundup + music_desk_score(kind="release") at the shared 0.6 floor (fail-CLOSED, unchanged). Both underlying calls (kworb.global_daily, deezer.track_release_date) are already instrumented, so the stat path needs no new event. Coverage limit (#2524): a Friday release’s first-day Global Daily rows are not published until Saturday, after the window moved, so the stat mainly fires for a THURSDAY drop on the NMF board — a same-day drop has no settled row yet (on live data this is narrow: a typical week has ~3 fresh debuts in the Global Daily top 200, so 0–2 board drops get a stat). board_blob carries the release DATE (- artist - title (out YYYY-MM-DD)): the release rubric hard-fails a status claim it cannot ground, so a dateless roster made the judge read “dropped tonight” as a fabrication and reject even a clean numberless take (#2520 dry run: 0/5 dateless → 3/6 dated). The card is build_board_card (the ranked-list chart-graphic). Slot pacing rides its own release_board_slots table; post history rides the shared post_dedup_history under surface music_releases, plus a per-night ROSTER identity (music_releases_key = channel : stage : date + sorted drop keys) so a second slot the same evening does not re-post the same drops — scoped by CHANNEL (a guild with several music channels posts to each, not just the first) and by STAGE (a #bot-logs audition must not block the same roster from shipping once a mod promotes the experiment to production). A text-dedup drop gets a second read (#4350). The spine’s text dedup compares wording, so it dropped a take that named an act the feed added later in the night as well as a take that only reworded the earlier post: on Friday 2026-10-16 it dropped the first take naming Babyfxce E (13:01 UTC), Gucci Mane (17:00) and Lucky Daye (21:01), and the board shipped once that night. The board now sets TOPIC_DEDUP = True with TOPIC_CATCH = False and overrides _topic_verdict: only a take the text dedup DROPPED goes to claude.release_take_adds_new (Haiku, ADDS_NEW / SAME, cached), with the recent posts. ADDS_NEW ships it through the spine’s overturn path (dedup_overturned) and then writes its roster key, so later slots with that roster skip the compose; SAME or no verdict keeps the drop. The roster read was NOT changed. The issue’s flip (the Apple feed returning 8 then 9 fresh rows on back-to-back reads, seen once in #4325) did not reproduce: 0 roster changes over the reads measured on 2026-10-17, and the release-version check answered the flipping title the same 8 of 8 times. With this check a flip costs at most one compose and a SAME drop, never a repeat post, so a second feed read per slot was not worth its cost. NOT wired (deliberate, proportional): no dedicated /menu channel picker (reuses music_channels), no X crosspost of its own at first (a one-way external door; since 2026-10-26 every card goes to X through the release_board_x lane described below), no numeric tunable (caps are code constants; cadence is the calendar). The FIRST-WEEK card (owner ask 2026-09-22, “on a daily basis show how many streams a new release has … for the first week”). A third unit on the same slot (_compose_first_week, lane week, category first week), every day. It reads Apple’s Top Albums feed, keeps the drops 1 to 7 days old (release_board.first_week_releases; day 0 has no kworb daily yet), and reads each drop’s latest DAILY and all-time TOTAL Spotify streams off ONE row of the artist’s kworb ALBUMS page (StreamTotals.album_page_streams). A joint credit (“Future & Metro Boomin”) resolves through each member’s page (kworb.credit_members), and an edition-qualified title must match its own row (album_page_row(exact_edition=True)). A drop whose act has no kworb artist id, or whose release the page does not list, is left off the card, so the line claims only the top of THIS list. The slot reads Apple’s feed once and every unit shares the rows. Rows rank by all-time total (owner steer 2026-09-23), and each row shows its latest day’s streams as “+X” (what that one day added to the total): one day’s figure, never an average, and not dated because kworb updates artist pages on different days. The card’s headline and footer read “New Releases” (owner steer; the footer matches the drops board’s) and the tag above the headline reads ALL-TIME STREAMS, because the rows no longer label the total and an unlabelled total under a bare headline read as a first-week number. The source stamp says Spotify alone: Apple’s chart only picks which drops are listed. The floor is 2. The total is labelled all-time, never “total” beside the day: kworb’s album figure includes the plays its singles took before the album dropped (live 2026-09-22: Tove Lo’s ESTRUS read 38.3M on day 4 at 1.2M a day). The line above the card is built from the top row (release_board.first_week_line), not composed: the numberless roundup passed the release judge 2 times in 6 on the live roster, because that rubric grades a “dropped tonight” take. Dedup is the DATE key (music_releases_key = channel : stage : week:<date>), checked before any fetch, so the card posts once a day per channel; _sched_dedup_history returns no history for this lane, because the next day’s fixed line with new figures would otherwise read as a copy. No /menu row (owner steer), no new event, no new table. The SONGSTATS FILL (#3896, owner ask 2026-10-05, “the most important thing to use it on”). kworb lists a new album 2 to 4 days late and has no page for most mid-size acts, so on 2026-10-05 the card carried 2 of 11 drops. For each drop kworb misses, the fill finds the release on Spotify (spotify.artist_albums + release_board.pick_spotify_album: edition-exact, and within 2 days of the roster date), reads its tracklist (spotify.album_track_ids), reads each track’s Songstats Spotify history and sums them (release_board.songstats_album_stat). The sum is all-or-nothing: every track must report the same latest day, or the album gets no row (a short sum would undercount). Songstats runs at 6 live calls a minute, so the reads happen in a background loop (songstats_warm, every 20 minutes, at most MUSIC_RELEASES_SONGSTATS_PER_PASS=20 live calls a pass and MUSIC_RELEASES_SONGSTATS_BUDGET=250 a day since #3935, 150 before), and the card reads only finished albums. kworb wins a drop both sources hold. A drop whose Spotify albums list has no matching album is kept as a miss for the day (_songstats_misses), so a pass does not read that list again; an empty list is a failed read and is retried. Before that rule, every 20-minute pass re-read the list for each miss, and Spotify locked /artists/{id}/albums with 429 QUOTA_EXCEEDED and a Retry-After of about 7.4 h (2026-10-06). utils/spotify._api_get now honours a Retry-After over 60 s: it blocks that action until the time passes and emits spotify_fetch reason=rate_limited. Since #3920 search_artist_id goes through _api_get too, so a long 429 blocks the search, and the fill logs album_total reason=rate_limited (not no_album / no_tracks) when a blocked read returned []; spotify.blocked_for(action) tells the two apart. The budgets survive a restart (#3908). The client’s daily count (SONGSTATS_DAILY_CAP) and the fill’s budget each sit in one kv_cache row per day, namespace songstats_spend (keys <UTC date> and music_releases:<ET date>), through the shared songstats.load_spend / store_spend helpers. A load takes the larger of the stored and in-process counts; a DB error emits a recoverable emit_error and the in-process count stays in use. The fill keeps its own row rather than a per-surface split inside the client: that leaves the client API unchanged. The fill detects a live call by a change in SongstatsClient.live_calls, not in the day counter, because a DB load also moves the day counter. The Spotify reads and the fill’s day state survive a restart too (#3934). /artists/{id}/albums allows about 100 calls in a rolling day (measured 2026-10-07: 100 ok, then a 429 with Retry-After 49595), and each deploy used to read the same albums lists again. spotify.artist_albums now keeps a list in kv_cache for 24 h (namespace spotify_albums) and album_track_ids keeps a tracklist for 7 days (spotify_album_tracks). Only a complete, non-empty read is stored: a failed or blocked read, a list with a failed page, and a tracklist past 50 tracks are not. 24 h is safe because the roster holds only drops at least one ET day old, so a list cached before a release expires before that release enters the roster. Each action’s long-429 block is one kv_cache row (spotify_block, value = the wall-clock unblock time); _api_get reads it once per process on first use, so a new container sends no request while a lock holds. The fill keeps its no_album misses and its finished Songstats WeekStats in one row per ET date (music_releases_fill, 2-day TTL), loaded once a day at the start of a pass and written at its end when changed, never before the load succeeds. The fill attaches the DB each pass (spotify.bind_db). Every DB error falls back to the in-memory behavior; the block and day-row paths emit a recoverable emit_error (spotify_block, music_releases_songstats). The release_board_songstats experiment shipped at STAGING (PRODUCTION by default since #3929). At STAGING the room’s card stays kworb-only, and a second card WITH the fill’s rows goes to #bot-logs once a day (key ssweek:<date>), after the loop has finished a pass or from noon ET. Each album read emits album_total with phase=songstats (reason no_album | no_tracks | pending | incomplete on a miss), which the existing album_total panel already splits. It reads no Deezer (the board’s 2026-09-05 rule) and no Kalshi figure (owner steer 2026-10-05). Reissues are off the first-week card (#3929, owner decision 2026-10-07): _without_reissues drops a roster title that _REISSUE_RE matches (an anniversary, reissue, remaster, N year, “Super Deluxe” or “Expanded Edition” title) and emits album_total phase=first_week reason=reissue. On 2026-10-07 “The Divine Feminine (10th Anniversary)” led the room card at 2.8B all-time. A plain “Deluxe” still counts as new music. The Songstats fill defaults to PRODUCTION (the master guild had already stored PRODUCTION), because kworb covers about 3 of a week’s albums. The card stays ONE board of up to CARD_ROWS (10) rows, sorted by the all-time total, each row day N +daily beside the total: every row carries the same two figures. The HITS projection stays on the music desk’s own projection boards (chart_proj), not on this card. The first-week card on X (owner ask 2026-10-08). The release_board_x experiment (OFF by default: a public post cannot be fully taken back) sends the card to @tootsiesbar after the room post. The X surface is release_week, not the cog’s music_releases, so an empty music channel picker does not open an X-only lane for the whole board. STAGING posts the tweet text and the card to #bot-logs (crosspost delivered=staged) and sends nothing to X. PRODUCTION calls crosspost_with_quote, which applies the home-guild lock, the /menu X picker (the “new releases card” row must be ticked: release_week is in OPT_IN_SURFACES, so even an unset picker does not tweet it), the media gate and the cross-guild dedup. One attempt per guild, day and stage: the cog keeps a day key (x:week:<date> on channel 0 of music_releases_key), so a second music channel does not post again, and a failed key read skips the attempt. release_week is NOT in the crosspost text-hash dedup, because two days can share the same text and the second day’s card would be dropped (Codex on #3949). A #bot-logs audition that did not send writes no key, so the next music channel in the same slot tries again; a later slot does not, because the room card’s day key already stops the compose. Each member of a joint credit goes to tag_artists on its own (kworb.credit_members). The tweet text is release_board.first_week_x_line: the same top-row facts as the room line, in sentence case (“HABIBTI (FOMO) by Drake leads this week’s new releases board with 400.6M Spotify streams all-time.”). The top row’s artist is passed to tag_artists, so a verified account becomes an @tag. A card that only auditions in #bot-logs is never tweeted. All four cards on X (owner decision 2026-10-26). The per-album song boards, the drops board (daily and “new music friday”) and the Friday Biggest Drops card now go to X on the same lane. _sched_deliver calls ONE helper, MusicReleases._post_x, after every room post, and _X_LANES maps each card’s lane to its crosspost surface: week -> release_week, album -> release_albums, albums -> release_drops, top -> release_top. Each surface is its own /menu X picker row (“album song boards”, “new releases drops board”, “biggest drops card”), and each is in OPT_IN_SURFACES, so a merge sends no tweet until a mod ticks the row. All four use the one release_board_x experiment, with the same OFF / STAGING / PRODUCTION rules. At STAGING the three new cards also audition their tweet in #bot-logs, because the picker gates only the X post. The one-attempt key is x:<identity> on channel 0 for every lane. The identities start with different words (week:, album:, albums:, top:), so the keys cannot collide. An album board’s identity holds the date and the album, so each of the two boards on a day tweets once. A drops board’s and a Biggest Drops card’s identity holds the roster. The album board’s tweet is release_board.album_board_x_line: the same facts as album_board_line under the same sum rule, in X sentence case (“PRESS PLAY by LISA, Spotify streams on Oct 25: “Tarantino” tops the 5 tracked songs at 4.1M; 1 song not yet tracked.”). Both lines come from one private builder, so they cannot drift apart. The drops board’s and the Biggest Drops card’s tweet is the self-gated take (composed.line); the crosspost path strips links and markdown. Each card tags its top row’s act (kworb.credit_members). None of the four is in the crosspost text-hash dedup or the same-song log (_SONG_SURFACES, #4650): each card has no link, and each is an album or roster post, not a one-song post. The FIRST-WEEK TOTAL and the per-album boards (#3935, owner decisions 2026-10-07). (1) Each row’s sub gains a third figure, day 5 +8.8M · wk 41.2M: the streams since the release day. No bullet sits between the day and its daily, so each label sits beside its own figure, the same as wk (owner steer 2026-10-08). The value and the sort stay all-time. release_board.songstats_album_figures computes it on the SAME Songstats date as the daily: per track, the total on that date minus the total on the last point dated on or before release−1. The release date is Spotify’s own (pick_spotify_release), because Apple and iTunes can list one release a day apart. A track with no point that early has a baseline of 0 only when its first point is 0 (Songstats starts a new track at 0; measured on the 2026-10-06 histories). A first point above 0 gives the album no wk figure, and the row stays on the card with its other two figures (prefer absent over invented). Known bias, not corrected: a big track can repeat one value for one or two days, so a stale baseline can count one or two days of pre-release plays. (2) The fill now reads Songstats for the drops kworb resolves too (Drake, Miranda Lambert), so they get a wk figure; kworb keeps their daily and all-time total (_with_songstats). The daily budget went from 150 to 250 (all surfaces used 149 calls on 2026-10-07; the plan allows 600). The fill’s day row stores week, on and tracks. Each pass emits album_total phase=week (reason no_baseline on a miss). (3) Per-album song boards, gated on the release_album_boards experiment (OFF by default since the owner kept only the All-Time Streams card on 2026-10-07; the board’s stage is the stricter of it and the music_releases stage). Each of the top 2 rows of the room’s first-week card gets a board on its FIRST DAY WITH DATA (the first ET day the board can be built, album_board_ready) and again on day 7: the tracked songs ranked by their daily Spotify streams on the Songstats date, one number per row, the date on the stamp (STREAMS ON). At most one board per album per day and two per week; when day 7 is the first day with data, one board; no data by day 7, none (owner decision 2026-10-07). A fixed day-1 board rarely posted, because Songstats lags one to two days. The first board’s mark is one kv_cache row per channel, stage and album (music_releases_album_first, 10 days), because the post-history keys live only 96 h; a failed read counts as posted (fail-closed). The titles are Spotify’s own (spotify.album_tracks, which now caches {id, name}); kworb’s songs page is not used, because kworb lists at most 500 songs per artist (Drake’s HABIBTI (FOMO) showed 8 of 15 tracks there). The line above it names the Songstats date and the top track; it states the tracklist’s sum only when every track has a clean figure (owner decision). The line and the tag never say “day 7”: Songstats reports a day one to two days late, so the figures are usually an earlier day’s. A track’s board figure must be CLEAN (release_board._clean_daily): Songstats often repeats a track’s total for a day and then catches up (Drake’s “I’m Spent” read 47,023,223 on 10-04 and 10-05, then 47,436,966 on 10-06), so the step into a day can be a stale 0 or two days of plays. A figure counts only when the total rose into the date AND rose into the day before (or that day was 0, a new track). A song without one is LEFT OFF the board (owner decision 2026-10-07), and the card’s tag says so: streams by song · 2 songs not yet tracked. The tag carries it because the renderer draws no caption but the cut-off note (chart_cards.card_caption). The cut note counts tracked songs only. With a gap the line states no sum and does not say the top song leads the tracklist (an untracked song could be bigger); it says how many songs are not yet tracked. A board needs at least half its songs tracked, else the album waits. The card’s Songstats daily uses the same rule (AlbumFigures.album_daily): it is the sum of the clean track figures only when EVERY track has one, else the row shows no daily and keeps its all-time total (day 5 · wk 10.4M). A sum of only the clean tracks would undercount the album. On the dry run the raw sum read Dexter and The Moonrocks at +2.1M, where the clean tracks add up to about 0.8M. Each pass emits album_total phase=daily (reason not_clean). A drop Spotify dates outside the window is left off the card (_inside_window, release_board.outside_first_week): the fill keeps Spotify’s own date per drop, including an edition-exact release up to 30 days off the roster’s date (spotify_release_date_near), in the day row. The Kalshi forecast roster dated “The Life of a Showgirl: The Encore” in the week of 2026-10-02; Spotify dates it 2026-09-25, and its 5.7B all-time total led the card. A drop more than 7 days old or in the future by Spotify’s date emits album_total phase=first_week reason=outside_window; an unknown Spotify date keeps the roster’s date. The renderer draws at most 11 rows, so a longer tracklist carries the cut note. _compose_first_week hands the ranked card to _compose_album_boards in the same slot. Each board dedups on album:<date>:<album>; the day key albums:<date> is set once no board is left to post or wait for, so later slots skip the kworb reads. A due album whose tracks are not complete yet emits album_card surface=music_releases reason=not_ready and is tried again at the next slot. House genres (#3276, owner steer 2026-09-14: rap, R&B and pop only). Every card on this surface reads the house genres: _fetch_releases and _first_week_apple drop each Apple row whose genre ids are not rap (18), R&B (15) or pop (14) (release_board.house_rows), so the board, the top-drops card, the first-week card and the per-album boards all lose it. The Kalshi forecast albums carry no genre, so _house_forecasts reads the genre off the album’s Apple row (folded title, one artist inside the other). With no Apple row there is no genre data, and the album stays only when the act is on the kworb watch list: prefer absent over invented, so a kworb outage cuts it. Measured before the fix: the 2026-10-23 albums board listed Victor Mendivil (Latin), and the per-album boards of 2026-10-11 to 2026-10-23 rendered Wage War (Metal), Greta Van Fleet (Rock), Sheryl Crow (Americana) and Fontaines D.C. (Alternative).
_REISSUE_RE (anniversary / remaster / super deluxe / expanded edition) is the cheap yes. A title it passes that carries Deluxe / Edition / Version / Live / Expanded goes to the cached claude.release_is_reissue (REISSUE / NEW; the input carries the listing date). MusicReleases._reissue_keys is the one place that decides it, for the board’s first-night stat, the first-week card (_without_reissues) and the Songstats fill. A reissue’s songs are years old, so their sum is a catalog total, not a first-week number: “1989 (Deluxe Edition)” and “Ctrl (Deluxe)” passed the regex. A model error or no verdict omits the stat (prefer absent over invented); with no model client the regex alone decides. It is a separate question from release_version_kind, which calls a re-recording and a brand-new deluxe “edition” too, and those carry new plays. The first-week roster also reads the release-version verdicts now (_first_week_apple, #4230 item 3), so it keeps the same rows the board keeps.The announce paths ask Haiku whether a title is a re-cut (#3441). release_type.is_side_version (the regex) still screens every bulk row: the presence feeds, the desk down-rank, the alert lanes. Two ANNOUNCE paths also ask the cached claude.release_version_kind (labels new / recut / edition, 14-day cache per title): the release board (_version_verdicts over the first 20 in-window rows, then release_board.fresh_releases(verdicts=...)), and the new_entry debut lane (music_desk._resolve_new_entry: a regex-flagged title is asked before it drops; a regex-passed title only when the lane is about to keep it). release_type.judged_side_version maps the label: recut drops, new and edition keep (a deluxe edition or a re-recording is a real release), no verdict leaves the regex in charge. The dry run on 14 days of real candidates: the regex had dropped the Abbey Road 2019 reissue as a remix (an edition), and passed a remix album (“Pulse: FINAL FANTASY XIV Remix Album Vol. 3”). The album-milestone count asks claude.album_variant_relation about EVERY title pair one album title starts (CB-17: related_pairs -> releases_past(relations=...), up to 3 rounds because a split pair regroups the page). album_milestones._combine joins the verdict with the word list’s read (_relation): the model decides a pair the list cannot place; a pair the list places counts only when the model agrees, else the count is withheld; a model error keeps the list’s read. Measured on 311 hand-labelled real pairs off 147 kworb album pages: the rule before (the list, then the old prompt on the ambiguous pairs) 275 right / 36 wrong; this rule 281 right / 12 wrong / 18 withheld. The model alone calls a numbered sequel (“A Love Letter To You 5”) a variant, so the list stays as the block on that. The edition keys (_EDITION_MARKERS, _REMASTER_RE) stay regex: they build persisted keys and group uploads, and a key must not depend on a model call. The watch lane’s presence feed keeps side versions apart (#4230). music_desk._presence_key keys a regex-flagged row on the artist plus apple_music.title_edition_key (so “Mercy (Live)” and “Mercy - Live” are one key), so it never seeds or reads the base song’s record (Codex #2455), and it gets its own first sighting. These keys live in their own cache record, <chart>:side, that only the watch lane writes: presence is an unlocked read-modify-write, and an overlapping alert-lane write of the shared record would otherwise erase them. A side version the check clears as new is then “established” after its 24h window, and a later NEW flag on it is not a repeat debut (#2139). Other consumers of the chart (the alert lanes, the platform boards) feed and read only the shared record. The music_alert debut lanes stay regex-only (#4230 item 4, measured 2026-10-16). 30 days of apple_debut / spotify_debut / watch_new_entry titles held 13 distinct regex-flagged titles. Haiku cleared 4 as edition: 2 were old records the date gate drops anyway (Abbey Road 2019, MTV Unplugged), and 2 were album variants the lanes would then post as debuts (“Bass Persuades (A Cappella Edition)”, “Popstar (Instrumental Edition)”; the second flipped to recut on a re-run). So the judge fixed nothing there and added 2 wrong accepts, for about $0.004 per 13 calls. A rendition is a re-cut before the model is asked (#4268). release_type.rendition_marker reads the version suffix for instrumental, a cappella (also “A Capella” and “Acapella”), karaoke, sped up, slowed or reverb. release_version_kind returns recut for such a title with no call, and judged_side_version calls it a side version whatever the verdict. Haiku had labelled all 4 real titles of that shape EDITION, because the suffix also says “Edition”. Karaoke is only in this set, not in _SIDE_MARKERS, so the bulk rows are unchanged. A live, acoustic or edit cut is a re-cut before the model is asked too (#4570). When a remaster, edition or anniversary tag sits beside one, the model reads the tag and answers EDITION or NEW (“Purple Rain (Live In Utrecht - 2020 Remaster)”, “Photograph (Acoustic) [2020 Remaster]”, “Brothers In Arms (Edit) [2022 Remaster]”), so the board and the new_entry lane kept it as a real release. rendition_marker now also reads a version-suffix live / acoustic / edit (_RECUT_RE), so such a title is recut with no call. A live recording is a side version by the owner steer of 2026-08-21, and the bulk regex already flags all three. Two shapes stay with the model: a DELUXE or EXPANDED tag beside the word (_DELUXE_RE; “eternal sunshine (slightly deluxe and also live)” carries new songs, and a deluxe edition is a real release), and a REMIX with a remaster tag (“Abbey Road (Super Deluxe Edition) [2019 Remix & Remaster]” is an album edition). Measured on real iTunes and Deezer catalog titles of the 30-day chart artists, 2 runs: live with a remaster or edition tag and no deluxe tag, not RECUT on 71 of 80 before and 0 after. With no tag, the model already called all 94 acoustic or edit titles RECUT, and 326 of 336 live ones; the other 10 are live recordings or live albums it called NEW or EDITION (“SO BE IT (Live)”, “Long Story Short: Willie Nelson 90 (Live At The Hollywood Bowl)”), which the rule now drops. A group that only NAMES something is skipped: “Long Live …”, “… Night Live”, “live” before a subtitle word (“Live Forever”, “Live Wire”), and a “(From …)” source group (_SOURCE_RE). It is a list of exceptions, not of live shapes: in 715 distinct real “live” suffix groups, a list of performance words missed 91, nearly all real live recordings (“Live 1975”, “BBC Radio 1 Live Lounge”), while the exceptions skip 4: two “Long Live” names (new songs, which the model keeps) and two “(From …)” live sessions (which the model still calls RECUT on 2 of 2 runs). “Live” in title text (“Live Wire”, “Long Live”) is not read. The new_entry lane logs such a drop as judge: rule, not model (MusicDesk._side_version_judge, #4708), because no model call happened.
cogs/x_mentions.py + utils/x_mentions.py, #x-mentions) answers an @tootsiesbar MENTION that names a music artist (or song) by POSTING that artist’s numbers — the first EVENT-DRIVEN music surface where the trigger is an inbound tweet, not a schedule. The wire in: a standing twitterapi.io filter rule (@tootsiesbar, catching mentions + replies) delivers to /x/webhook, which now routes BY rule_tag — xgame_* → the game, xmentions → this cog’s handle_webhook_tweets (utils/healthcheck.py). The rule is RECONCILED on a 15-min loop against the x_mentions experiment: created when the master guild has the surface on, DELETED when OFF, so a dark surface spends no twitterapi.io read credits (unlike the game’s per-round rules, this one is standing). Detection is two-stage, the pattern every entity consumer uses: a deterministic scan of the tweet text against the top-artist watchlist (x_mentions.scan_watched, on names.name_in_text — whole-word, decoration-robust) UNION a Haiku extractor (claude.scan_music_mention) that resolves a stage-name alias (“Hov” → JAY-Z) and pulls the SONG title. Recognition is BROADENED beyond the top-100 watchlist (owner steer): a watchlist hit runs every lane, but a LITERALLY-NAMED off-list act the classifier reads is accepted too — the boards lane confirms it by chart presence, so an act like Ellie Goulding (outside the top 100) still gets answered, and a name that charts nowhere is a silent skip rather than a bad post. Two lanes, both REUSING the desk/alert primitives (no re-roll): the BOARDS lane posts the artist’s PER-CHART cards, across the Billboard Hot 100 / Billboard 200 / Global 200 (first-party utils.billboard, wrapped to the platform duck-type by XMentions._BillboardWatchRow so the ONE tested builder serves both), the live Spotify + Apple Music charts (kworb) and the US iTunes STORE albums chart (Apple’s own RSS through bot.itunes_charts, #3469; XMentions._chart_reader resolves which client serves a method, the desk’s rule). The store chart joined on 2026-09-20: a reply under a Miley Cyrus post asked how her ~36.6K pure-sales forecast squared with “#1 and #2 on iTunes albums”, the reply lane’s facts block held only the Apple Music albums rank (#11, a streaming chart), and the hand-posted draft answered the iTunes question with it (“#11 on Apple Music US right now, not #1”) while the store feed had her at #1 and #3. The desk had read that chart since #3469; this lane had not (the class: a new chart source wired into one consumer). Measured after the wiring, 3/3 drafts named iTunes and the ~51.7K units read instead. The reply lane’s facts carry two chart blobs, and each chart FAMILY the question NAMES reserves one slot for its strongest unit first (x_mentions.charts_named / order_units_for_question: “itunes” is the store’s albums + songs charts, never Apple Music; “apple music” the two streaming charts; “spotify”; the Billboard family; “albums” / “song” wording NEAREST a cue narrows that family to that half, each cue reading its own neighbour, so “her song on Spotify and her album on iTunes” narrows both correctly, and a song question about iTunes reads the store’s SONGS chart, kworb.itunes_songs, which joined the lane’s platform list for it; the compose’s feed note is keyed by CHART, since that songs chart is a kworb page and only the albums chart is Apple’s own feed) so an act ranking higher elsewhere does not lose the one row that answers to the cap, and two Spotify rows cannot crowd out the iTunes row of a “#1 on Spotify, but where on iTunes?” (Codex on #3543, three rounds). The 16:34 UTC reply of 2026-09-20 (“your 56k overall prediction could never be accurate then… cause she is only gonna pull in 12k from streaming”) exposed two facts-block faults (#3545, #3546): readings_for_question FILTERED the market lines to the metric the question named, so “12k from streaming” handed the draft the Streams ladder alone and it argued “17.7M streams, not 12k” against a units figure (3/3 drafts); the named ladder now LEADS and the release’s other ladders follow, and when a streams ladder and a units or sales ladder ride together the block adds one line saying they are different measures (x_mentions._MEASURES_NOTE, no conversion – Luminate’s paid / ad-supported split makes a flat rate an invented number). Measured after: 4/4 drafts quote the units read and two say streams do not map to units. And the pure-sales ladder the desk had posted was refused by the lookup’s confidence gate (music_markets.is_projectable, the Markets section), so a “pure sales” question could not quote ~36.6K; it can now. The same review moved the artist-chart composers’ credit examples onto the board’s own platform (chart_boards.platform_credit_examples, the #2753 rule) and rewrote every example artist-led, credit_rule’s own contract: an iTunes card had been shown “apple music has him at #8” as the phrasing to imitate, and the older Spotify / YouTube pairs opened with the source name the rule bans as subject. The same day’s other three drafts checked out against the live ladders (~51.7K units, ~36.6K pure sales, ~17.7M streams). The kworb rows carry no release date, so a catalog re-entry marks NEW exactly like a fresh drop — the #2045 class — and a mention about a just-passed or catalog-surging act would have drawn “NEW”/”debut”. XMentions._mark_platform_reentries DATES the mentioned act’s NEW-flagged rows (the shared release_age_days, fail-open per row) and wraps an older title in _ReentryRow so _entry_marker/watch_artist_board draw “RE”; Billboard rows already carry is_reentry, so this is the platform half (#2597, the port of the desk lane’s #2595 fix; the shared boundary is chart_boards.NEW_RELEASE_MAX_AGE_DAYS). A chart with 2+ of the act’s titles is a chart_boards.watch_artist_board (all their titles on it), composed by the source-aware chart_boards.artist_chart_board_compose_inputs and rendered by build_board_card; the board’s right-hand column carries a NUMBER wherever one exists, and the move marker only where none does (owner ask “add streams column”, then 2026-08-28 on a shipped Bad Bunny Billboard 200 card: “this should be streams not move on the right”). Three tiers, in order: (1) the chart’s OWN play count for its period, which only Spotify’s daily/weekly kworb rows publish (the leader’s plays ride the headline as “N plays that day”); (2) the title’s LIFETIME Spotify total, which the CALLER resolves and passes as totals — the Billboard boards use this, since a printed Billboard row publishes no streams at all (utils.stream_totals.board_stream_totals, keyed by song_key; the leader’s total rides the headline named as lifetime); (3) the move marker, on the rank-only Apple/iTunes boards and on any Billboard board whose totals did not resolve. A row the caller could not resolve draws a BLANK cell, never its move — one column carries one meaning, the same cell the desk’s individual-artist board leaves for a title with no published figure. Every number is abbrev‘d with photo-finish precision like platform_streams, and no board ever shows an invented one. The lifetime column also changes the COMPOSE inputs, because the same blob is the self-gate’s ground truth: artist_chart_board_compose_inputs brackets the figure, names it as lifetime, bounds the take to a row that HAS one, and says the rows are CONTEXT. Without that last line the Haiku judge read every row’s total as a figure the take had to report and failed correct posts for “omitting” the others — measured 2026-08-28, n=8 per case, real charts + real compose + the real gate: the caption alone passed 6/16, naming the bracket 12/16, naming it plus the context line 15/16, against 16/16 for the same boards drawing the move column; a chart with exactly ONE title is a single-entry big-number card (chart_boards.ArtistChartEntry + artist_chart_card_inputs + build_number_card, owner steer “single row turns to card”), so a chart is never dropped for being thin. XMentions._unit_for_chart builds whichever fits, and both ship through one path (_ship_unit), which resolves the card’s COVER ART at ship time via the same shared ladder the desk uses (XMentions._art_bytes → music_markets.resolve_art_url → catalog cover / artist portrait, with entity_image.resolve_desk_image as the never-blank fallback) — a board resolves by ARTIST, a single card by title+artist; a miss renders on the branded floor, never blank. Up to 3 boards per mention (owner steer “a few — cap at 3”), STRONGEST CHART FIRST (ranked by the act’s best position), so a prolific act never fans out into a card storm; a board needs 2+ of the act’s titles on a chart, so a thin act yields fewer boards or none (prefer absent). The BOARDS lane runs for ANY charting act; the PREDICTION lane (the artist/song’s live Kalshi read, music_markets.reading_for_subject → number_card_hero – a live first-week market heroes the amber forecast, a MEASURED read (a partial-resolve annual streams market, e.g. Bruno Mars 2026 with the 12.5B bracket finalized) heroes the green Luminate count so far and drops the pace line from its blob, the desk’s rule; the lane used to hero the forecast unconditionally, which shipped a “~17.7B market projection” card under a “cleared 12.5B so far” take, 2026-09-09) is WATCHED-ONLY, since a music market exists only for the big releases. Each composes with compose_market_drop, self-gates at the shared 0.6 floor (music_desk_score), delivers, and crossposts (maybe_crosspost, surface x_mentions). The REPLY lane + parent context (owner steer 2026-09-08, the mention-reply check). Both lanes above post STANDALONE tweets, so the person who asked never saw the answer in-thread: three real questions under one Miley Cyrus Kalshi post (“What about week 1 for Miley?”, “Is CRJ gaining ground since your previous post?”, “what about the debut?”) got one standalone Party In The U.S.A. card and two no_artist scans. Two changes. (1) When the mention replies to HER OWN post (x_mentions.reply_parent_id – only her post, never a third party’s thread), the cog fetches the parent once (FixTweet, keyless, fail-open) and, when the tweet alone names no act AND asks a question (the scan’s own asks_question flag first, #3419 – the same Haiku read names the act and says whether the tweet asks or requests something, read through x_mentions.mention_asks, which bounds a true to a text with at least one word so an emoji burst never costs a fetch, bounds a false to a text with no literal “?” so a missed ask never costs the answer (measured 2026-09-20: “will it be enough for #1 ? how is olivia and ella looking this week ?” came back false, no parent read, no draft, while the two “?”-free questions beside it under the same Miley post were drafted), and falls back to the wording regex x_mentions.asks_question when the reply carried no flag: a “?”, a leading question word, or a request-SHAPED phrase such as “update on the debut” / “give me the week one numbers” – never a bare noun, so “sales are insane” and “Temazo!!” alike stay no_artist; the regex missed “so hows she doing” and read a rhetorical “is it just me or this slaps” as a question; the parent is fetched only at that point, so a reaction costs no FixTweet round), _resolve_subject re-runs on the parent’s text under the SAME watched/literal rules; that second paid Haiku read claims a second scan allowance, so X_MENTIONS_SCAN_DAILY_CAP bounds model calls, not tweets. The scan hit folds via=parent, and a parent-derived act feeds ONLY the reply lane below – the automated boards/prediction lanes run solely for an act the mention itself named, so the first reaction under a Miley post can never publish three standalone Miley cards (the PR #3081 Codex review’s P1). (2) A REPLY lane (XMentions._reply_lane) drafts the answer she would post UNDER the mention and queues it in #bot-logs for the OWNER to post by hand – the reply is manual for now, the standalone posts stay automated. The x_replies experiment gates this lane (owner decision 2026-10-08, default OFF). It is the one gate for every X reply draft (see x_reply_draft below). OFF drafts nothing, skips the parent-post read (it feeds only this lane), and emits kind=reply reason=experiment_off. The lane drafts only for a mention that ASKS something (kind=reply reason=not_asked otherwise, #3567). The lane checks her PARENT post’s qualifiers (epic #4412, #4467). The compose quotes the facts block “as it is written there”, and the parent post is its first fact, so a wrong “for the first time ever” in her own post would come back as the answer. _reply_lane runs utils.qualifier_check.check_source_qualifier (surface=x_mentions, research_purpose=x_mentions_qualifier, subject = the resolved act) on the parent before the facts block. A cut puts the cut claim in the block and appends the note after it (“Your post says only: …”); the block gives up the note’s length so the compose’s 1400-character slice never cuts the note (_REPLY_FACTS_CHARS). No clean cut drops the draft (kind=reply reason=qualifier_no_clean_cut), and a draft that states a cut word again is dropped (reason=cut_qualifier). The mention text is the question, not a fact, so it is not checked; the restated-word check still catches a cut word that comes back through it. Dry run 2026-10-18: the two real parents this week with a qualifier were kept (the Ella Langley “longest-running No. 1 in Hot 100 history” record is true); her deleted Tate McRae “first time ever” post was cut 6 of 6 times. With the check off, 0 of 24 drafts restated that false qualifier, because the compose preferred the live chart rows or declined; the check stays because the prompt tells the compose to quote the parent as written. Two subject guards run before any lane (2026-10-15): a reply under her post whose whole text is one act name (x_mentions.is_bare_name: “Queen” under her Karol G post) is a reaction, not the band, so scan reason=bare_name and nothing runs (#3567); and when the mention names a title but no act, the parent’s act is kept only when that title is the parent’s own song, appears in the parent post, or sits on the act’s live chart rows (x_mentions.title_in_text), else scan reason=parent_mismatch and nothing runs (“Why is Day and Night missing?” under her Miley post was drafted from Miley’s facts, #3568). The draft prompt’s only fallback is EMPTY: “say the one closest thing you do have” was cut (#3568). STAGING and PRODUCTION both draft to #bot-logs. The boards and prediction lanes stay graduated and ignore the key. The facts block (x_mentions.format_mention_facts) is her parent post, then the live Kalshi reads, then the act’s strongest chart blobs (the per-chart units are read ONCE in _maybe_act and shared with the boards lane). Stored history in the facts (#3562, E): after the live rows, in the room they leave, up to _REPLY_HISTORY_LINES of the act’s stored Billboard runs (_reply_history: the same db.chart_history_lookup read the @Toots chart_history tool makes, narrowed to the release when the scan named one, weeks lines only) labelled HISTORY (stored Billboard weeks), so “since your last post” and “week 1” have the earlier weeks to answer from; a history line never rides cut (one that does not fit the facts budget is dropped whole, like a market line); the read skips the four sections it does not use; fail-open (no line), emits tool_call (tool=chart_history, source=x_mentions, matched = rows read as on the tool, count = lines). The market match is readings_for_subject(max_lead_days=None): the pre-release window LIFTED (the desk already priced that week in public and a human posts the answer; the AUTOMATED prediction lane keeps the 2-day window), the metric read off the question (luminate.metric_from_text, “pure sales” -> that ladder) but applied ONLY when a live ladder for the release carries it (“first week” parses as the First-Week Units label and “sales” as a bare Sales label, which no ladder is titled – a hard filter on those blanked the market fact for the commonest ask), and with no usable metric EVERY live ladder for the release rides, each named by its metric – where the automated unique-hit read would return nothing for a release carrying both a Pure Sales and an Album-Equivalent Units ladder. readings_for_subject returns ONE release’s ladders: two releases at the same tier (an artist-only lookup while two of the act’s albums are live) is [] rather than both mixed into one block, and the chosen release’s SIBLING ladders join on release_pair_key (the event-ticker code), not on subject text – Kalshi titles the two sales series inconsistently, so a sibling can sit at another subject tier. The parent post is reply context only when it is ABOUT the resolved act (the act came off it, or it names the act): a Drake question under her Miley post carries no Miley number into the facts block. The market lines come BEFORE the chart rows and are never cut (a format_reading line ends with its forecast, so a sliced line is a malformed fact – a line over budget is dropped whole); the chart rows take only the room left. No facts -> no draft. The compose is its own prompt, claude.draft_mention_reply (NOT draft_x_reply: that one is ADD-don’t-echo under a third party’s tweet with the numbers fenced off; here the number IS the answer, quoted as written from the facts block, EMPTY otherwise). Delivery is the reply-draft queue’s two-message shape (x_reply_draft.format_draft_header + the mention’s fixup link, then the bare draft, no ping), the draft text cached under x_reply_text for the nightly owner-edit join. bot_logs.post now RETURNS whether it sent (every older caller ignores it): #bot-logs is this lane’s only destination and the tweet is already claimed, so a silently dropped post (no channel, a Discord 403) is send_failed, never shipped; the bare draft is sent only after the header landed (a draft with no target above it could be pasted under the wrong tweet); and the x_reply_text entry is written only after both messages sent – a draft the owner never saw must not be scored by the nightly owner-edit join as the source of a reply they wrote themselves. X allows the account to reply to a post that mentions it (the #1732 403 is for posts where she is not mentioned) and x_poster.post_tweet already takes reply_to_tweet_id, so the automated in-thread reply is one wiring step away when the owner wants it. Emits x_mentions kind=reply. Gates: SINGLETON (one @tootsiesbar) → master guild only; the x_mentions experiment (OFF default — ships DARK like x_crosspost/x_game, since it posts NEW content to the public timeline; STAGING auditions the cards in #bot-logs, PRODUCTION posts to the music channels — both lanes → music_market_channels — + crossposts); master kill switch; a durable per-tweet dedup; the BOARDS lane’s WEEKLY cooldown on the unit’s dedup_id (owner steer “per chart / per artist”, keyed on the ISO week) — a board keys per artist:chart (a later mention that week can surface a DIFFERENT chart but never repeats one), a single card keys per ROW (row:chart:song_key) so a collaboration is not double-posted under two different mentioners’ names; the prediction lane’s per-(artist,lane) cooldown; and cross-surface topic dedup (it writes/reads post_dedup_history under x_mentions, in the music-desk set). X crossposting is OPT-IN (x_crosspost.OPT_IN_SURFACES), so an unset picker never tweets. Fully fail-open: any lane miss drops that lane, never the webhook ack. Instrumented via the x_mentions event (see docs/OBSERVABILITY.md). The MANUAL companion is the artist-sweep skill (.claude/skills/artist-sweep/, scripts/artist_sweep.py): a one-off that renders EVERY per-chart board for one artist (across the whole Billboard family + the platform charts) for the owner to post themselves — the retroactive tool for a mention the live lane never fired on (the Ellie Goulding case). It never posts. Since #3416 the judge states threshold_passed + count itself and the regex pair is the fallback and the bound. Which charts a mention names is the scan classifier’s read since #3549, not the regex’s: scan_music_mention returns charts ({platform, region, kind} on closed vocabularies, claude_client.MENTION_CHART_*), x_mentions.charts_from_scan maps it to chart families (the scan’s extracted song narrows an “any” kind to the song charts), and order_units_for_question takes those families ahead of the regex, which runs only when the reply carried no field (an outage, an older reply). Codex found a new phrasing the regex misread on each of six review rounds of #3543 (“Billboard says she’s #1 on Spotify”, “Spotify Global 200”, “where is Flowers on iTunes and Apple Music”); measured live 2026-09-20 the classifier read all fourteen fixture phrasings right at the platform level (scripts/eval_classifiers.py scan_music_mention_charts, four goldens). The same prompt edit made a PUSHBACK on her own number (“your 56k could never be accurate, she’s only doing 12k from streaming”) an ask (#3544): the 16:34 UTC reply of that day got no draft because both the classifier and the regex read it as a statement. The same review moved the artist-chart composers’ credit examples onto the board’s own platform (chart_boards.platform_credit_examples, the #2753 rule) and rewrote every example artist-led, credit_rule’s own contract: an iTunes card had been shown “apple music has him at #8” as the phrasing to imitate, and the older Spotify / YouTube pairs opened with the source name the rule bans as subject. The same day’s other three drafts checked out against the live ladders (~51.7K units, ~36.6K pure sales, ~17.7M streams). The kworb rows carry no release date, so a catalog re-entry marks NEW exactly like a fresh drop — the #2045 class — and a mention about a just-passed or catalog-surging act would have drawn “NEW”/”debut”. XMentions._mark_platform_reentries DATES the mentioned act’s NEW-flagged rows (the shared release_age_days, fail-open per row) and wraps an older title in _ReentryRow so _entry_marker/watch_artist_board draw “RE”; Billboard rows already carry is_reentry, so this is the platform half (#2597, the port of the desk lane’s #2595 fix; the shared boundary is chart_boards.NEW_RELEASE_MAX_AGE_DAYS). A chart with 2+ of the act’s titles is a chart_boards.watch_artist_board (all their titles on it), composed by the source-aware chart_boards.artist_chart_board_compose_inputs and rendered by build_board_card; the board’s right-hand column carries a NUMBER wherever one exists, and the move marker only where none does (owner ask “add streams column”, then 2026-08-28 on a shipped Bad Bunny Billboard 200 card: “this should be streams not move on the right”). Three tiers, in order: (1) the chart’s OWN play count for its period, which only Spotify’s daily/weekly kworb rows publish (the leader’s plays ride the headline as “N plays that day”); (2) the title’s LIFETIME Spotify total, which the CALLER resolves and passes as totals — the Billboard boards use this, since a printed Billboard row publishes no streams at all (utils.stream_totals.board_stream_totals, keyed by song_key; the leader’s total rides the headline named as lifetime); (3) the move marker, on the rank-only Apple/iTunes boards and on any Billboard board whose totals did not resolve. A row the caller could not resolve draws a BLANK cell, never its move — one column carries one meaning, the same cell the desk’s individual-artist board leaves for a title with no published figure. Every number is abbrev‘d with photo-finish precision like platform_streams, and no board ever shows an invented one. The lifetime column also changes the COMPOSE inputs, because the same blob is the self-gate’s ground truth: artist_chart_board_compose_inputs brackets the figure, names it as lifetime, bounds the take to a row that HAS one, and says the rows are CONTEXT. Without that last line the Haiku judge read every row’s total as a figure the take had to report and failed correct posts for “omitting” the others — measured 2026-08-28, n=8 per case, real charts + real compose + the real gate: the caption alone passed 6/16, naming the bracket 12/16, naming it plus the context line 15/16, against 16/16 for the same boards drawing the move column; a chart with exactly ONE title is a single-entry big-number card (chart_boards.ArtistChartEntry + artist_chart_card_inputs + build_number_card, owner steer “single row turns to card”), so a chart is never dropped for being thin. XMentions._unit_for_chart builds whichever fits, and both ship through one path (_ship_unit), which resolves the card’s COVER ART at ship time via the same shared ladder the desk uses (XMentions._art_bytes → music_markets.resolve_art_url → catalog cover / artist portrait, with entity_image.resolve_desk_image as the never-blank fallback) — a board resolves by ARTIST, a single card by title+artist; a miss renders on the branded floor, never blank. Up to 3 boards per mention (owner steer “a few — cap at 3”), STRONGEST CHART FIRST (ranked by the act’s best position), so a prolific act never fans out into a card storm; a board needs 2+ of the act’s titles on a chart, so a thin act yields fewer boards or none (prefer absent). The BOARDS lane runs for ANY charting act; the PREDICTION lane (the artist/song’s live Kalshi read, music_markets.reading_for_subject → number_card_hero – a live first-week market heroes the amber forecast, a MEASURED read (a partial-resolve annual streams market, e.g. Bruno Mars 2026 with the 12.5B bracket finalized) heroes the green Luminate count so far and drops the pace line from its blob, the desk’s rule; the lane used to hero the forecast unconditionally, which shipped a “~17.7B market projection” card under a “cleared 12.5B so far” take, 2026-09-09) is WATCHED-ONLY, since a music market exists only for the big releases. Each composes with compose_market_drop, self-gates at the shared 0.6 floor (music_desk_score), delivers, and crossposts (maybe_crosspost, surface x_mentions). The REPLY lane + parent context (owner steer 2026-09-08, the mention-reply check). Both lanes above post STANDALONE tweets, so the person who asked never saw the answer in-thread: three real questions under one Miley Cyrus Kalshi post (“What about week 1 for Miley?”, “Is CRJ gaining ground since your previous post?”, “what about the debut?”) got one standalone Party In The U.S.A. card and two no_artist scans. Two changes. (1) When the mention replies to HER OWN post (x_mentions.reply_parent_id – only her post, never a third party’s thread), the cog fetches the parent once (FixTweet, keyless, fail-open) and, when the tweet alone names no act AND asks a question (the scan’s own asks_question flag first, #3419 – the same Haiku read names the act and says whether the tweet asks or requests something, read through x_mentions.mention_asks, which bounds a true to a text with at least one word so an emoji burst never costs a fetch, bounds a false to a text with no literal “?” so a missed ask never costs the answer (measured 2026-09-20: “will it be enough for #1 ? how is olivia and ella looking this week ?” came back false, no parent read, no draft, while the two “?”-free questions beside it under the same Miley post were drafted), and falls back to the wording regex x_mentions.asks_question when the reply carried no flag: a “?”, a leading question word, or a request-SHAPED phrase such as “update on the debut” / “give me the week one numbers” – never a bare noun, so “sales are insane” and “Temazo!!” alike stay no_artist; the regex missed “so hows she doing” and read a rhetorical “is it just me or this slaps” as a question; the parent is fetched only at that point, so a reaction costs no FixTweet round), _resolve_subject re-runs on the parent’s text under the SAME watched/literal rules; that second paid Haiku read claims a second scan allowance, so X_MENTIONS_SCAN_DAILY_CAP bounds model calls, not tweets. The scan hit folds via=parent, and a parent-derived act feeds ONLY the reply lane below – the automated boards/prediction lanes run solely for an act the mention itself named, so the first reaction under a Miley post can never publish three standalone Miley cards (the PR #3081 Codex review’s P1). (2) A REPLY lane (XMentions._reply_lane) drafts the answer she would post UNDER the mention and queues it in #bot-logs for the OWNER to post by hand – the reply is manual for now, the standalone posts stay automated. The facts block (x_mentions.format_mention_facts) is her parent post, then the live Kalshi reads, then the act’s strongest chart blobs (the per-chart units are read ONCE in _maybe_act and shared with the boards lane). The market match is readings_for_subject(max_lead_days=None): the pre-release window LIFTED (the desk already priced that week in public and a human posts the answer; the AUTOMATED prediction lane keeps the 2-day window), the metric read off the question (luminate.metric_from_text, “pure sales” -> that ladder) but applied ONLY when a live ladder for the release carries it (“first week” parses as the First-Week Units label and “sales” as a bare Sales label, which no ladder is titled – a hard filter on those blanked the market fact for the commonest ask), and with no usable metric EVERY live ladder for the release rides, each named by its metric – where the automated unique-hit read would return nothing for a release carrying both a Pure Sales and an Album-Equivalent Units ladder. readings_for_subject returns ONE release’s ladders: two releases at the same tier (an artist-only lookup while two of the act’s albums are live) is [] rather than both mixed into one block, and the chosen release’s SIBLING ladders join on release_pair_key (the event-ticker code), not on subject text – Kalshi titles the two sales series inconsistently, so a sibling can sit at another subject tier. The parent post is reply context only when it is ABOUT the resolved act (the act came off it, or it names the act): a Drake question under her Miley post carries no Miley number into the facts block. The market lines come BEFORE the chart rows and are never cut (a format_reading line ends with its forecast, so a sliced line is a malformed fact – a line over budget is dropped whole); the chart rows take only the room left. No facts -> no draft. The compose is its own prompt, claude.draft_mention_reply (NOT draft_x_reply: that one is ADD-don’t-echo under a third party’s tweet with the numbers fenced off; here the number IS the answer, quoted as written from the facts block, EMPTY otherwise). Delivery is the reply-draft queue’s two-message shape (x_reply_draft.format_draft_header + the mention’s fixup link, then the bare draft, no ping), the draft text cached under x_reply_text for the nightly owner-edit join. bot_logs.post now RETURNS whether it sent (every older caller ignores it): #bot-logs is this lane’s only destination and the tweet is already claimed, so a silently dropped post (no channel, a Discord 403) is send_failed, never shipped; the bare draft is sent only after the header landed (a draft with no target above it could be pasted under the wrong tweet); and the x_reply_text entry is written only after both messages sent – a draft the owner never saw must not be scored by the nightly owner-edit join as the source of a reply they wrote themselves. X allows the account to reply to a post that mentions it (the #1732 403 is for posts where she is not mentioned) and x_poster.post_tweet already takes reply_to_tweet_id, so the automated in-thread reply is one wiring step away when the owner wants it. Emits x_mentions kind=reply. Gates: SINGLETON (one @tootsiesbar) → master guild only; the x_mentions experiment (OFF default — ships DARK like x_crosspost/x_game, since it posts NEW content to the public timeline; STAGING auditions the cards in #bot-logs, PRODUCTION posts to the music channels — both lanes → music_market_channels — + crossposts); master kill switch; a durable per-tweet dedup; the BOARDS lane’s WEEKLY cooldown on the unit’s dedup_id (owner steer “per chart / per artist”, keyed on the ISO week) — a board keys per artist:chart (a later mention that week can surface a DIFFERENT chart but never repeats one), a single card keys per ROW (row:chart:song_key) so a collaboration is not double-posted under two different mentioners’ names; the prediction lane’s per-(artist,lane) cooldown; and cross-surface topic dedup (it writes/reads post_dedup_history under x_mentions, in the music-desk set). X crossposting is OPT-IN (x_crosspost.OPT_IN_SURFACES), so an unset picker never tweets. Fully fail-open: any lane miss drops that lane, never the webhook ack. Instrumented via the x_mentions event (see docs/OBSERVABILITY.md). The MANUAL companion is the artist-sweep skill (.claude/skills/artist-sweep/, scripts/artist_sweep.py): a one-off that renders EVERY per-chart board for one artist (across the whole Billboard family + the platform charts) for the owner to post themselves — the retroactive tool for a mention the live lane never fired on (the Ellie Goulding case). It never posts. Since #3416 the judge states threshold_passed + count itself and the regex pair is the fallback and the bound.cogs/x_reply_draft.py + utils/x_reply_draft.py) drafts the reply Toots would leave under a tweet, and the owner posts it on X by hand. Three triggers share produce_draft: the LISTENER (her own posts that link a tweet), the REPLY lane (kick_reply_lane: each wire-desk slot drafts under up to 3 ranked wire tweets the desk did not post) and the BANGER lane (an X search for the hottest post on the slot’s moment). The reply lane checks the source tweet’s qualifiers (epic #4412, #4464). No desk check reads a tweet the desk did not post, so a false “for the first time ever” in a wire tweet reached the draft as its premise. produce_draft runs utils.qualifier_check.check_source_qualifier (surface=x_reply_draft, research_purpose=x_reply_draft_qualifier) for trigger="reply" after the tweet fetch and before the context beat, with the wire story’s subject. A quoted post’s text is checked the same way, because it reaches the compose too (Codex review on #4473). A cut replaces the checked text everywhere after it: the beat subject, the beat scope and the compose’s tweet text, which also carries SourceCheck.note. A wrong qualifier with no clean cut drops the draft as x_reply_draft reason=qualifier_no_clean_cut, and a draft that states a cut word again (qualifier_trim.restates_cut) drops as reason=cut_qualifier. Both mark the tweet seen, so a later slot does not pay for the same check. No research or no verdict keeps the tweet as written (qualifier_check reason=unchecked). The LISTENER does not run the check: it drafts under a post that a checked lane already shipped. The BANGER lane runs the same check (#4467). It drafts under any account’s post, not a wire account’s, so no lane read it. Its subject is the first of the slot’s search terms that the post names (banger_subject), else the fetched post’s author, else the post’s own words (beat_subject). The author fallback is banger-only: a reply-lane author is a wire account, never the story’s subject. Dry run 2026-10-18 on 6 real banger-shaped posts (real judge): the false Tate McRae “first time in her career” was cut, a false BTS “first … in history” was cut, two posts with no clean cut were dropped (BTS “10 Billion streams in a Single Year”, Olivia Rodrigo “36 weeks at #1”), two were kept, and no shipped draft restated a cut word. The X mentions reply lane checks her parent post (see the x_mentions entry above). Dry run 2026-10-18 on 6 real wire tweets: the two false Tate McRae “first time” claims were cut, four true qualifiers were kept, and no draft restated a cut word (PR for #4464). The manual music-drop script (scripts/music_drop_manual.py) runs the live drop’s own Music._qualifier_gate on each ranked sample before it prints (#4464).catalog_art.py, the ONE home for “the real cover art for this catalog title” — a song/album → its Apple Music cover, a film/show → its TMDB poster. resolve_music_row(title, artist) searches iTunes and _pick_artist_row decides which row answers; a different INSTALLMENT of a numbered series is a positive mismatch (apple_music.sequence_mismatch, #slime-language-3): a release card for the hours-old “Slime Language 3” shipped the “Slime Season 2” cover and linked “Slime Language 2”, because the installment number is one character in a long shared string and both fuzzy tests swamp it (the sequel scores 0.938, “Slime Season 2” lands exactly on the 0.6 floor, and “Slime Language” is a clean substring). The album was hours old and not yet in iTunes’ search index, so the right answer was NO row; the number is now compared on its own before either fuzzy test runs, and the card falls to the artist photo instead of a sibling’s cover. utils.deezer.album_title_matches reuses the SAME rule, and since #4116 it also applies the release-name rule (release_tag_mismatch) – its 60% token overlap read “Slime Language 3” vs “Slime Language” as a match and dated an hours-old record 2018-08-17; resolve_catalog_art takes that row’s cover bytes and cogs.music_news._artwork_and_link takes its cover AND its “listen” link off the SAME row, so the card’s picture and its link can never disagree. A DEEZER album cover is the SECOND cover source (#album-cover-fallback): a fresh release often lands on Deezer before Apple’s search API indexes it (Channel Tres’ “Enigma POV” had a Deezer cover while iTunes returned nothing, so the release card shipped a bare panel), so when the iTunes row misses resolve_catalog_art falls to deezer.album_cover(title, artist) — artist-verified inside album_cover (album_title_matches + _artist_overlaps), so a same-title album by another act is refused, never a wrong cover. The Deezer rung is what SAVES a card the iTunes index has not reached yet (#2683) — it is the whole reason the rung exists, and on the Slime Language 3 card it fired and still returned nothing, so the card had no cover at all. Two causes, both fixed: album_cover searched ONE term (title artist), and appending the artist ranked the real album out of Deezer’s results, so the bare title now runs as a second term; and a COLLECTIVE bills its record under the collective’s name (“Young Stoner Life”), so when the one top-level credit misses, the album detail’s contributors[] — the release’s own billed acts — decides. Net effect on the reported card: the RIGHT cover, and still no listen link, because the link rides the iTunes row and iTunes has no Slime Language 3 row to give. A row from a DIFFERENT ERA is refused on a RELEASE story (#3171). _rank_rows prefers the clean ORIGINAL over a live / remix edition when the requested title does not name one, which is right for a cert or chart story and wrong for a drop: Linkin Park’s “Faint [Live in São Paulo]” came out 2026-09-11 off the UNSHATTER film soundtrack, the wire named only “Faint”, and the card baked the 2003 Meteora cover under a NEW RELEASE kicker (owner report, 2026-09-11). The same shape shipped the 2006 “IRREPLACEABLE” cover on five B’Day 20th-anniversary-edition cards, the 2009 “Fear” cover on “Fear of Missing Out”, and the 2025 “NEVER ENOUGH” cover on “NEVER ENOUGH: VERSIONS”. music_news._row_is_a_different_record refuses a row older than _RELEASE_ROW_MAX_AGE_DAYS (365 days) and the card takes the act’s PHOTO instead — prefer absent over invented, the same call the tour lane makes. Four smaller fixes were dry-run against the live catalog and all four fail, which is why the guard sits here and not upstream: a classify-prompt rule has nothing to keep (the Perplexity source says only “a live performance of ‘Faint’”; the classifier keeps a qualifier 5/5 when the input HAS one); falling through to _fallback_art(subject) returns the SAME Meteora cover because the unified resolver kinds “Faint - Linkin Park” as a music_release and runs its own catalog search; a claim-grounded retry is worse (“Faint UNSHATTER” returns the 2025 From Zero track); and preferring the newest row cannot work because an iTunes song search for “Linkin Park Faint” returns only 2003 rows — the right row is not in the candidate set at all. So the portrait resolves off the ARTIST CREDIT ALONE, the same reason the LIVE lane passes the credit by itself. Three carve-outs keep it narrow: only a NAMED “Title - Artist” release is tested (a bare artist-level subject asks for a representative cover on purpose — Randy Travis announcing his first album in 18 years correctly took a 1987 cover); a claim stating an explicit backward interval keeps the row, because a THROWBACK’s old cover IS correct (“Music Box released 33 years ago today” wants the 1993 sleeve — retrospective.states_past_interval, which is unanchored unlike is_retrospective because here the two mistakes cost the same one degraded card); and an anniversary EDITION states no such interval, so it is correctly caught. The whole catalog path is skipped, Deezer rung included, because deezer.album_cover searches the same bare title and answers with the same old album. Measured over 30 days before shipping: 19 of 71 catalog-matched release cards fire it, about 13 of them plainly wrong art; release_art_stale makes the rate AND its cost queryable (a source == 'none' row shipped a bare card). _artwork_and_link runs the same Deezer rung directly on its already-split (title, artist) — but ONLY for a real “Title - Artist” release, since the unified fallback re-kinds the terse subject and returns [] for it. The listen link stays iTunes-only (Deezer answers bytes, not a link). The music_news RELEASE card also drops the figure outright (_build_card): a release is an announcement, so the renderer heroes the TITLE, but the classifier emits an unreliable grab-bag as the figure (a noun “album”, a date, a year, an ordinal) that would hero over the release name — no digit-shape test separates a real count from a date cleanly, so a release drops its figure and the album/tour NAME is the headline (the count a tour states rides the claim caption). A RECORD worded as a superlative heroes the count the RESEARCH confirms, never the word (#lame-most). The newsroom carded Cardi B’s Diamond-singles record on 2026-08-30 and heroed the word “most” over the caption “most RIAA diamond-certified songs by a female rapper in history”. Owner report: “most thats lame use the actual number”. The wire post (@mymixtapez, 00:48Z) stated NO count — “Cardi B surpasses Nicki Minaj to become the female rapper with the most RIAA diamond-certified songs in history” — and the classifier may only quote the post, so no prompt on it can find a number that is not there (reproduced 3/3 on the real post text: the old prompt answers most every time). The RESEARCH had the count: the verify judge’s own note that night read “with 4 diamond singles”. So verify_music_claim now returns a fourth value, figure — the short number the research states behind the claim — and the desk heroes it. The hero carries its UNIT (owner steer: “should say 4 Diamond certified… or something”): a bare count reads as nothing in the big slot, so a COUNT takes the NOUN for the thing it counts (“4 Diamond singles”, “402 Hot 100 entries”, “87 weeks at No. 1”) while a figure whose own format already says what it is stays as it is (“8B”, “#1”, “$141.8M”, “7x Platinum”). The unit is a NOUN, never a description: the first pass produced “4 Diamond certified”, which the owner read back as “sounds wierd” — “certified” is a past participle, so the phrase has no head noun and the 4 counts nothing. Both prompts now say the noun rule outright and name the bad form. _parse_verify_claim caps it at 28 characters, trimmed on a WORD boundary (_trim_words) so a long answer never loses half the unit it exists to show, and the renderer auto-shrinks and then ellipsizes on top of that. The researched figure must also CARRY A DIGIT: figure_is_rank_word rejects only a pure ranking phrase, so without the digit test a wordy judge answer (“Diamond certified singles”) would trade one wordless hero for another. That test applies to the research fallback ONLY — a named state the WIRE stated (“Diamond”) is a real figure and still heroes. The final hero is settled ONCE, in card_figure, and both the story meta and the music_news_verified event read it, so a live reading that outranks the wire (a “top 10” claim settled at “#8”) cannot leave the event reporting a hero the card never drew. Two kinds take NO figure at all and skip the fallback entirely: a no_chart story says a release MISSED a chart, so there is no charting number by definition and its research is full of digits that are not one (the chart’s own name, a year); and a release is an announcement whose NAME heroes, so clearing it here keeps the event honest about what the card draws (the _build_card clear stays as the renderer-side backstop). No extra API call: the judge already reads the research, so the count rides the verdict it already returns, grounded in the same research the take is grounded in. Three rules keep it honest. The wire’s own figure always WINS when it is a COUNT (the research figure only fills a gap, so a normal card is unchanged); the one exception is a THRESHOLD the wire states (“surpasses 100 career entries”), which is a rung the count passed and yields to the verified research’s larger count for the same unit (figure_is_passed_threshold + count_past_threshold, the Rod Wave card of 2026-09-09, described with the other hero guards above). It is used only on a VERIFIED verdict, and the judge returns '' on a false one, so an unconfirmed count can never hero. And with neither, the figure goes EMPTY and the card falls to headline mode (the artist title heroes over the art) — prefer absent over invented. classify_music_news also now states that a figure is ALWAYS a number and never a ranking word, and music_news.figure_is_rank_word is the deterministic backstop under both. The guard is ONE rule: a ranking word sinks the figure unless a NUMBER follows it immediately. That separates every case — “top 10” and “top 5 debut” keep their rank word because it introduces a real number; “most”, “all-time best”, “most RIAA Diamond songs” and “most Hot 100 entries” do not. Two earlier passes each got one case wrong, both caught in review: the first demanded that EVERY word rank (so “most RIAA Diamond songs” slipped through), the second took any DIGIT as proof of a real figure (so “most Hot 100 entries” slipped through on the CHART’s own number). A flat “reject digits in a chart name” rule would have killed the real “402 Hot 100 entries” with it; reading the word AFTER the rank word is what tells the two apart. What survives is a figure with no stranded ranking word: a count with its unit, a self-describing format (“8B”, “#1”), or a named state (“Diamond”, “OUT”). The cost is a named state that CONTAINS a ranking word — a Grammy “Record of the Year” hero would be dropped — and that is the right way to be wrong: a card heroing nothing degrades one post, a card heroing “most” is the bug. It DROPS the figure instead of digging a count out of the claim prose: a claim names other numbers (“did not enter the Billboard Hot 100”), so a scan would hero a chart name as a count, and a wrong number is worse than none. Live dry run of the whole path (2026-08-30, real Perplexity research): the real Cardi B wire post runs classifier -> research -> judge -> hero 4 Diamond singles over the caption “most RIAA Diamond-certified songs by a female rapper in history”; “Drake, the most Billboard Hot 100 entries in history” heroes 402 Hot 100 entries; “Taylor Swift, the most weeks at No. 1 by a woman” heroes 87 weeks at No. 1; and “Rihanna, the most RIAA diamond-certified singles” comes back unverified (the research reports 7, and “most by a female artist”, not “any artist”), so the card heroes nothing. A release that announces a tour gets its OWN card copy (_tour_card_copy + _tour_kicker, owner report on the “Long Way Home Tour - Stella Lefty” card): a tour is not a record drop, so the “MUSIC · RELEASE” kicker relabels to tour · announcement (or tour · update when the claim adds/extends/reschedules dates), the TOUR NAME heroes on its own instead of the run-on “Tour - Artist”, the ARTIST rides the card’s BYLINE over that headline, and a purple tag tells a tour apart from an album at a glance (via render_number_card’s tag_accent override, which never moves the figure/stamp off brand green). Every story KIND now names itself in that tag colour, not just the tour (_tag_accent + _KIND_ACCENT_KEYS, #2798): the tag is the card’s one colour anchor, and with only the tour keyed, a week of cards read as one green wall with different words on it. release green / cert gold / chart blue / milestone cyan / first_week rose / touring peach / novelty mint, each LOOKED UP from chart_cards.ACCENTS rather than copied – those values were measured for contrast against real cover art, _equal_tag draws near-black on the block, and keying by the palette’s own name means a later re-measure reaches these tags for free. It is the same rule the BOARD cards already state: “the genre family gets its own colour each, so two genre boards in a day are obviously about different charts”. Two rules constrain the map. AMBER IS RESERVED: the renderer paints a projected=True figure amber to warn that a number is HYPOTHETICAL and lets that amber outrank any tag_accent, so a SETTLED figure must never wear a near-amber tag beside it – touring takes the peach (the coral until #2295 lifted it), NOT the rap amber it was first given, and market (always projected here) maps to None and keeps the amber it already has. And no_chart STAYS NEUTRAL (a near-white block): it is the one lane that states a negative about a real act, reported straight (“report facts, it doesn’t have to be negative or hate”), so a hue would editorialise the miss. An unmapped kind keeps the default green rather than borrowing another lane’s meaning. EVERY figureless announcement now carries that BYLINE, not only a tour (_announcement_card_copy, owner report “the tour name - artist is pretty bland or album name - artist is pretty bland”): the subject arrives as one “Title - Artist” string and shipped whole it read as one long name with a dash in it, at one weight, with the dash wrapping onto its own line (“Bass Persuades - Miley Cyrus”, “Hidden in Pieces Live at the Royal Albert Hall - Yeah Yeah Yeahs”). The act splits OFF the headline and render_number_card’s new byline= sets it on its OWN LINE UNDER the title, so an announcement reads TAG -> WORK -> ACT -> caption – the hierarchy a number gives a stat card. The work then gets the whole wrap, so it sets LARGER (measured: the Yeah Yeah Yeahs title goes from 72px to 96px). The placement and the styling are BOTH owner calls, and the first pass got both wrong: it shipped the act ABOVE the headline as a tracked uppercase ACCENT line, and the owner read the result back as “those are ugly too, also put artist name below”. So the byline moved below the headline, dropped the tracking and the uppercasing, and took the TITLE’s own colour at 40px. Three lessons hold it there: an accent byline goes muddy over a pale photo (the tour purple against a near-white field is about 1.5:1); a third coloured element under the accent tag and beside the accent stamp reads busy; and 40px against a 96-128px headline separates the two by SIZE, which needs no colour at all. It is still drawn into the SAME blurred dark plate the headline uses over art – at 40px the hard 3px shadow alone left it muddy on a busy or pale frame. It applies in HEADLINE MODE only – a stat card’s title is a small credit line under the number with no room for a second one, so byline is a no-op there. The headline names the act ONCE (music_news.strip_artist_prefix, owner report 2026-09-02): a concert film is billed under the act’s own name, and the classifier appends the act again as the subject’s artist part, so the Katy Perry card read “Katy Perry: The Lifetimes Tour – Live From Paris” over a byline that said “Katy Perry”. _announcement_card_copy now drops a leading “resolve_catalog_art(kind=movie|tv) used to take cinema_numbers.resolve_lead’s poster on trust, and LeadInfo carries no title. TMDB answers any query with its top hit, so the classifier’s “Looney Tunes” for the film “Coyote vs. Acme” shipped the 2003 “Looney Tunes: Back in Action” poster on a 2026 card (#2742), under the gate-trusted catalog label. The rung now calls search_movie / search_tv itself, and screen_title_verified requires TMDB’s title to EQUAL the requested one after case, accents, &/and, punctuation and a leading article are folded. Containment is not enough: reference._title_names_subject passes “Looney Tunes” inside “Looney Tunes: Back in Action”. A show request may end in “Season N”; the season is dropped before the search and the compare. A mismatch returns None, so the card drops to its next rung. Measured 2026-10-15 over 37 real film/show names: 35 verified, and the two refusals were the wrong film and a different name (“Mission Impossible 8” -> “Mission: Impossible - The Final Reckoning”). “Stranger Things Season 5” found no show before (TMDB has no match for the season string) and now resolves. The rung reads the poster off the search row and no longer calls movie_detail. Event tmdb_poster_verify. Still open: the market path passes the CLASSIFIER’s name here, not the market’s own subject (utils/market_subject_image.py), so a misname that happens to be a real, equal title still passes. A mismatch is no longer a refusal on its own (#4174). Equality refused the same film under another name (“Mortal Kombat 2” / “Mortal Kombat II”, “Mission Impossible 8”), and a refusal drops the card to a LESS trusted image, against the owner’s steer “don’t be too strict, especially if the image already comes from a source that we trust”. So screen_title_check returns verified / year_mismatch / mismatch, and a mismatch asks claude.same_screen_work (Haiku, skip_persona, both titles and both years), cached 30 days in the screen_same_work DurableCache (bound lazily to bot.db, so bot.py is unchanged). The judge fails CLOSED: no client, an error or an unreadable answer refuses, as equality did, and is not cached. A bracketed year on the request (“Superman (2025)”) is cut from the search text and passed to TMDB’s year filter, because TMDB matched nothing for the literal string; it must then equal TMDB’s year within one, so “The Running Man (2025)” can never take the 1987 poster. Still open: a request with NO year cannot tell a same-title remake apart (“Fantastic Four” answers the 2005 film).apple_music._title_matches (ratio 0.6 or containment) and deezer.album_title_matches (60% token overlap) stay the prefilter, and the installment rule (sequence_mismatch, Slime Language) and the non-Latin rule (Cinderella) still refuse first. A pass that is not equal is the band (title_match_band; #3440 cut it at ratio 0.85, and CB-07 dropped the cut because the passes over it were mostly another song, “Hell At Night” for “Hell of a Night”; album_match_band adds “a new core word”, so “Love Songs” for “Love” is band while “1989 (Taylor’s Version)” for “1989” is not). confirm_band_rows drops the band rows with no call when an equal row is in the same results; otherwise one cached closed-set pick (claude.match_catalog_title, Haiku) keeps the picked title or none. A model error drops the band rows: no link or date beats a wrong one. The judge needs no bot.py line: the existing boot hook apple_music.set_clip_durable_db binds its cache and turns on _get_title_judge, which builds one ClaudeClient on first use. Before boot (scripts, tests) there is no judge and the old rule stands; set_title_judge attaches one by hand. The call sites filter by ACT first, so the band weighs only the requested act’s rows. Wired in resolve_apple_music_url, _resolve_via_artist, song_release_date, and deezer album_release_date, track_release_date, album_genres, album_top_track. Event catalog_title_confirm.apple_music._credit_similar accepts a credit by containment (_credit_exact, a fact about the strings) or by a difflib ratio of 0.8 (a guess). Deezer’s _artist_is_billed accepts by 60% token overlap. In 30 days of chart lookups every iTunes ratio-only pass was another act (14 Adele rows for “ADÉLA”, “Masego & Don Toliver” for “Drake & Don Toliver”), and 18 of 36 Deezer overlap-only passes were (“Slim Drake Derulo” for Drake, “Taylor Swift Piano Covers”). So a row that passes ONLY that way (credit_ratio_only, deezer._billed_overlap_only) goes to apple_music.judge_credits: one cached ClaudeClient.classify label (purpose catalog_credit_match, SAME / DIFFERENT, 30 days per request + credit). DIFFERENT or no verdict drops the row (prefer absent). In resolve_apple_music_url a dropped row still counts as the act being present, so the homonym guard ships no link rather than the look-alike’s title. Wired in resolve_apple_music_url, _resolve_via_artist (also the artist-search guard), song_release_date, and deezer track_release_date, album_genres, album_top_track (confirm_billed_rows). The judge turns on with the #3440 boot signal; with no judge the old rule stands. The other sync callers of _artist_matches (catalog_art, music_markets, versuz_catalog, x_game, chart_credits) keep the old rule. Measured by replaying 30 days of chart lookups, 2 runs each: 424 iTunes lookups changed 0 links (3 credit calls, 0 flips); 685 Deezer lookups changed 4 outputs (22 credit calls, 0 flips): two tribute-act values became absent (a CCR date, an ABBA top track), and “Miley” lost the right “Miley Cyrus” rows (Haiku called it DIFFERENT on 1 of 2 label runs). Event catalog_title_confirm (source itunes_credit / deezer_credit). The compilation preference (_is_compilation) stays a regex: a Haiku OWN / COMPILATION label made 1 pick better and 2 worse of the 8 picks it decides (an act’s own greatest-hits album beat its studio album)._BAD_VERSION_RE, _ALT_VERSION_RE or release_type.is_side_version flags to the cached release_version_kind. Over 1,212 real flagged titles (2 runs) that label was worse: it calls a tribute or karaoke cut NEW and a live cut with a remaster tag EDITION, which would clear titles the guess pools and the debut lanes must drop. So the bulk rows keep the regex; the announce paths keep the model (#3441). tests/test_version_tags_cb10.py holds the real titles as goldens. Follow-ups: a PLAIN / OTHER label for the clean-cut preference (#4571), and live + remaster read as EDITION on the announce paths (#4570, fixed: a suffix live / acoustic / edit is now a rendition marker)._pick_artist_row compares normalized titles, and _normalize_title drops every bracket, so “WEEZER (GOLD ALBUM)” matched the Green Album’s tracks and the 2024 Remaster: the card took the wrong cover and the release-age path the wrong date. _name_matches now also asks release_name_check(raw request, candidate). “match” (no bracket name on the request, or the candidate carries it) and “mismatch” (the candidate carries a DIFFERENT bracket name, “(Green Album)”) are exact and cost no call. “unsure” (the candidate carries no bracket name: “Weezer (2024 Remaster)”, “Hit ‘Em Up” for “Hit ‘Em Up (Dirty)”) goes to Haiku: _judge_release_names asks ClaudeClient.classify (purpose catalog_release_name, SAME / DIFFERENT, cached 30 days) once per distinct candidate name of the requested act, at most 3 per search page, inside resolve_music_row. No verdict (no judge before boot, a model error) keeps the old rule and matches, so the model can only REFUSE a name the fuzzy match took; it never adds a match. The judge turns on with the same boot signal as the #3440 band judge, so no bot.py line. Event catalog_title_confirm with source itunes_release_name. The _normalize_title keys (song_pool, chart_boards) are unchanged. Siblings with the same shape: #4349.The kworb standing matchers ask Haiku on a split credit (#4170). The strict credit match (_artist_credits) splits an UNRANKED multi-act credit into its parts, so a part can equal a different ranked act: “Zezé Di Camargo & Luciano” took the German rapper Luciano’s streams rank. The known ranked set (#2615) keeps a RANKED duo whole but cannot know an unranked one. So in KworbClient.artist_standing, youtube_artist_standing, standings and resolve_chart_row, a row that matched only through a part of the request (fragment_match) goes to confirm_act: one cached Haiku label (haiku_act_pick over claude.classify, purpose=kworb_act_check) says whether the credit is ONE act or a COLLAB of separate artists. COLLAB keeps the best-ranked part, as before; ONE or a model error gives no row. In standings, one label covers all four charts, so every chart reads the same act. A single-act request, a ranked credit, or a row carrying the whole credit makes no call. A credit whose every part is a ranked act on its own (all_parts_ranked, #4277) is a COLLAB with no call: Haiku called “George Michael & Wham!” ONE on 1 of 2 runs, and all 168 live credits of that shape were real collabs. The pure matchers are unchanged. The judge needs no bot.py line: KworbClient.bind_db, which the music desk calls at boot with the DB, turns on _get_act_judge, which builds one ClaudeClient on first use. Scripts and tests get no judge, so the old rule stands; set_act_judge attaches one by hand. standings and resolve_chart_row now pass ranked_act_keys() as known. Event kworb_act_check.
Five guards decide the row, and each one closes a shipped bug. (1) The row must CREDIT the artist (_artist_matches), else an unreleased album fuzzy-matches a stranger’s record — Gracie Abrams’ “Daughter From Hell” once returned a metal demon cover. (2) A music lookup with NO artist is refused outright, because the title-only top hit is how a Dua Lipa take shipped Jack Harlow’s album cover (his SONG is called “Dua Lipa”). (3) The row must match the TITLE, by its own name or by its ALBUM, so a track recovers a heavily-styled album iTunes cannot rank (“NUEVAYoL” carries “DeBÍ TiRAR MáS FOToS”). (4) _rank_rows then orders the survivors, because crediting the artist and matching the title is still not enough — the art a row carries is its COLLECTION’s cover, not the artist’s, and a remix is not the recording the story is about. (5) On the ARTIST-ONLY path the artist must LEAD the release whose cover the row carries (_cover_credits) — see the guest-spot bug below.
The bug that added guard 5 (#not-drake). An ARTIST-LEVEL story names no release (music_news._artwork_and_link passes title == artist for “Drake”, a career or market number), so guard 3 does not apply and there is no title left to verify. That left “credits the artist” as the only test, and a GUEST SPOT passes it while carrying somebody else’s artwork. The iTunes song search for “Drake” returns “Ahí” by “KAROL G & Drake” FIRST; its collectionArtistName is “KAROL G”, so the cover is her album. @tootsiesbar shipped a Drake prediction-market card with her album cover baked in and a “listen” link into her album (owner report: “this isn’t drake”). _cover_credits asks the question guard 4 already answers for the ROW’s artist, but about the REQUESTED one, in two reads. WHICH credit owns the cover: the collection credit, falling back to the row’s own credit when iTunes left it empty. Then WHERE the artist sits in it: they must be the LEAD act (apple_music._lead_credit splits a joint billing on its first separator), not a second-billed one — owner steer: “keep going till you find the art of the artist as a lead artist”. Crediting alone is too weak twice over: “KAROL G & Drake” credits Drake, and so does “DJ Snake & Future” for a Future card, which the live search returned before the lead rule landed. So the walk keeps going to a row he headlines (“In My Feelings” / “Scorpion”), and no such row means None — the caller then resolves a real PHOTO of him instead.
A SELF-TITLED record shares that branch (2026-10-04). “blink-182” by blink-182 passes title == artist, so the picker took the first owned row: “All the Small Things” on “Enema of the State”. The media gate saw the wrong album and dropped the cover, and @tootsiesbar shipped a bare 2B card. Now an artist-level row that NAMES the act wins (_names_the_act): its track title or RELEASE (_release_title: a song row’s album, else an album row’s title) equals the name (apple_music.title_verified), it carries only self-titled packaging tags (_SELF_TITLE_TAG_RE, an allow-list: deluxe / bonus / remaster / “
Splitting a band’s own name is harmless, and that is not luck — it is why the split result goes through _artist_matches, which accepts the row’s credit CONTAINED in the requested one. “Earth, Wind & Fire” splits to earth, which is contained in the requested earth wind fire, so the band still matches itself; the same holds for “Simon & Garfunkel”, “Tyler, The Creator”, “Florence + the Machine” and “Above & Beyond”. What the split rejects is a requested artist billed SECOND, where the lead matches nothing. Live dry run over 40 artists (the separator-named bands included): 39 resolve, each now led by the requested act, and the one miss (“Hall & Oates”, which iTunes bills “Daryl Hall & John Oates”) already missed before the guard. The guard is a strict tightening of one branch — it can only drop a row whose cover provably belongs to another act, so a joint release the artist LEADS (“Drake & Future”) and every title-specific lookup are unchanged.
The other direction is NOT a substring test, and that is the Miguel Newberry cover (2026-09-20). A requested act contained in the row’s credit used to pass on containment alone, and a mononym is a substring of every longer name that starts with it: the Billboard 200 re-entry card for Miguel’s “All I Want Is You” baked the same-title 2026 single cover of Miguel Newberry (a painting of a woman in a headwrap), because the Apple album search returned his row first and 'miguel' in 'miguel newberry' read as a credit. The rung was catalog_cover, which is gate-exempt, so the vision judge’s correct “not Miguel” flag was overruled (media_coherence reason=verified_source) and the card shipped to X. apple_music._credit_similar now requires the requested act to name WHOLE billed acts of the row’s raw credit (_billed_acts splits it on _BILLING_SEPARATOR_RE; _names_whole_acts checks that every act is wholly inside or wholly outside the requested words, and that the inside acts cover them all, with a leading “the” ignored on both sides). ‘drake’ names one whole act of “Drake & Future” and still passes; ‘miguel’ names a PIECE of “Miguel Newberry” and is refused, and the ladder walks on to Miguel’s own discography, which carries the 2010 album. The same rule now also runs the music_markets pickers (pick_album_art_url / pick_artist_album_art_url through _credits_artist), which carried their OWN substring copy and were the path this card took — one credit rule, read from apple_music, so the two cannot drift again. Live dry run after the fix: Miguel resolves to his own cover, and Drake / Earth, Wind & Fire / Steve Lacy Featuring SZA / The Weeknd / Tyler, The Creator / Tyla / Shakira & Burna Boy / Kelly Price all resolve exactly as before. The residual is the mirrored shape — a lesser-known “Miguel Newberry” credit on the requested side against superstar Miguel’s row — which stays accepted because that direction is what lets a _lead_credit-split band match itself.
The SECOND layer (owner steer, same day: “make sure this never happens again”): the gate exemption is EARNED per row, never assumed by the rung name. The first layer fixed one hole in the matcher; the next hole (the difflib spelling ratio at 0.8 is the one left, and it accepts ‘drako’ for ‘drake’) would have shipped the same way, because catalog_cover was stamped by the code path and the gate trusted the label. So apple_music.credit_verified(requested, credit, track=) splits the matcher’s DETERMINISTIC half (_credit_exact: equality, whole billed acts, the credit inside the requested billing as a run of whole words (#4123; a bare substring test let the act “I” verify against “GENER8ION & Yung Lean”), a literal featured credit; credit_verified then also requires the request to name whole acts of the row, or the row whole acts of the request, on the RAW request, whose separators mark where one act ends (#4123)) from its spelling-tolerant half, and catalog_art.cover_rung(row, artist) reads the picked row’s own credit through it: exact -> catalog_cover (gate-exempt), ratio-only or NO credit -> catalog_art.FUZZY_COVER_RUNG (catalog_cover_fuzzy), which is absent from x_crosspost._IDENTITY_VERIFIED_ART so the vision judge keeps its say. Every music cover site stamps through it: resolve_art_url (the pickers now return the ROW — pick_album_art_row / pick_artist_album_art_row, with the URL functions as wrappers — and the no-credit pick_any_art_url last resort is fuzzy by construction), catalog_art.resolve_catalog_cover (the rung-returning music half of resolve_catalog_art, read by entity_image._deterministic_art and by market_subject_image’s catalog hint, where a fuzzy cover no longer wears the exempt catalog label; the Deezer fallback reports deezer_cover), and both music_news._artwork_and_link cover returns. The lookup matcher _artist_matches is UNCHANGED in what it accepts: a listen link or a track row should still survive “Rick Ro$$” against “Rick Ross”; only the exemption stops trusting that match. The THIRD layer, from the 30-day judge log (owner: “how can we resolve this once and for all”, 2026-09-20). Reading every media_coherence reason=verified_source row for 30 days split the exemption’s record cleanly in two. On deezer_artist_exact PORTRAITS the judge was wrong five times out of five (Lady Gaga, Katy Perry, Harry Styles, Juanes, JENNIE: each Deezer photo IS that artist, in a look the judge did not recognize), so that exemption stays. On catalog_cover the judge was RIGHT in about 17 of 19 flags, and the two it got wrong were real covers that do not show the artist (Charli xcx’s “Music, Fashion, Film” is three men in a kitchen). Every one of the 17 was a wrong CATALOG PICK that the pickers’ search-grade matching let through: a joint or remix single ranked ahead of the original (“Iris” by “The Goo Goo Dolls & Steve Aoki”, three times; the Cedric Gervais, Young Saab and Dada Life remixes), or a title the fuzzy matcher accepted (“Heaven Is Empty” for “Heaven”, “Slime Season 2” for “Slime Language 2”, “Caile” for “BAILE INoLVIDABLE”). So the fix is deterministic and upstream, and the judge stays overruled only where it is unreliable: (1) apple_music.credit_verified refuses a billing with an act the request never named (an extra act is a different record), and a featured credit in a track title is a match, never a verification; (2) apple_music.title_verified is the exemption’s title half, core-title EQUALITY (core_title: format suffix and parentheticals off, then the shared normalizer) plus the installment marker (_sequence_marker, which since #4141 also reads a spelled last word one..twenty as the installment number), plus the release-name rule (release_tag_mismatch, #4116: a requested bracket name such as “(Gold Album)” must match the candidate’s own), with an edition tag on the candidate the request does not carry (_EDITION_TAG_RE: remix, live, acoustic, sped up, …; packaging tags like Deluxe and remasters set aside) read as a different recording – _title_matches is unchanged for SEARCH; (3) catalog_art.cover_rung(row, artist, title=, subject_kind=) requires both, on the row’s track title for a song subject, its album for an album subject, either for the newsroom’s untyped row; (4) music_markets._rank_art_rows orders the candidates (verified-exact first, then the clean original, then the row’s own collection) and resolve_art_url POOLS the album-entity and song-entity searches before the pick, so the artist’s own original beats a joint single the album search ranks first. Live dry run: “Iris” resolves “Dizzy Up the Girl”, “Summertime Sadness” and “Sweater Weather” their originals, “Heaven” and “Slime Language 2” the right records, all on catalog_cover; the ten-credit regression sample is unchanged. Follow-ups landed the same day: deezer.album_cover_match reports (url, exact) and prefers a row whose billing names only the requested acts, so a superset billing answers only as the fallback and resolve_catalog_cover reports it as catalog_cover_fuzzy rather than the exempt deezer_cover; chart_credits._artist_matches (the Genius credits path, #3522) reads artist_watch._names_a_credit, the whole-segment rule, instead of fold containment; and the ops monitor grew cover_fuzzy_high and cover_overrule_high (docs/OBSERVABILITY.md) with a cover_exemption panel and an Axiom monitor on the overrule count. What stays open: the Haiku subject classifier (image_subjects) was not the fault in any of the 30 days’ cases (293 calls, every one ok; it named Lady Gaga correctly on the card whose portrait the judge misread). The same sweep closed the Deezer cover rung (deezer.album_cover): its top-level credit test was a token SUBSET (want <= cand), and ‘miguel’ is a subset of “Miguel Newberry”’s tokens with the core title equal, so the same single would have carded there too. deezer._names_whole_acts is the whole-act rule on that module’s own tokens, and it runs on the top-level credit and on each contributor in _credit_matches_contributors (each act of a split band name still names a whole act of the contributor “Earth, Wind & Fire”; “Lil Baby” across [“Lil Durk”, “Baby Keem”] is still refused). The FOURTH layer (#4203, 2026-10-16): only a ONE-act Deezer credit is exempt. deezer.album_cover_match reports exact=True only when the request is one act and the row’s own credit (or the act’s own discography) names exactly that act. deezer._one_act decides “one act”: the request carries no billing connector by the repo’s one party split (artist_watch.is_solo_credit, so “/”, “vs” and “x” count as connectors), or it equals the row’s (or the discography rung’s resolved) Deezer artist ENTITY as a whole name. A Deezer album row carries one entity, usually the lead act on a joint single (“QLONA” lists only “KAROL G”), so “Earth, Wind & Fire”, “Tyler, The Creator” and “Mumford & Sons” stay exempt. Deezer keeps a few joint entities (“Lady Gaga & Bradley Cooper”), and a request equal to one is exempt too, since it is that release’s exact billed entity (both rules from the Codex review on #4221). A billing of two or more acts, and any credit proven only through the release’s contributor list (an initialism such as “YSL”, or a split billing), still finds the cover but reports exact=False, so the callers stamp catalog_cover_fuzzy and the vision judge can drop it. The cause was #4140: “BTS & Jungkook” verified against the contributors [“Behind The Scenes”, “Jungkook”], because “Jungkook” matched directly and that counted as proof for the unrelated initialism. The judge already runs on every image, so this adds no judge call; it only lets a drop verdict on those covers stand (plus the second confirm vote on a flag). Measured over 30 days: 9 deezer_cover checks, 0 overruled flags. The real-model dry run judged 5 live joint covers (QLONA, Face Card, Slime Language 3, Die With A Smile, Iris) and passed all 5. The Apple side’s joint credits (catalog_cover through apple_music.credit_verified) are outside this change. The FIFTH layer (#4222, 2026-10-16): the collection must be the act’s own. A song row’s artwork is its COLLECTION’s cover, so cover_rung also requires _collection_verified(row): one credit must name whole billed acts of the other, in either direction (apple_music._names_whole_acts, no spelling ratio, so “Drako” never owns a “Drake” cover and “Miguel” never owns a “Miguel Newberry” row; Codex review on #4270). The request tag WORDS must also match, so “Espresso (Live)” never verifies “Espresso (Remix)”. _own_collection stays the RANKING rule. iTunes fills collectionArtistName only when the collection carries a different credit, so a filled credit that does not name the row’s act reports catalog_cover_fuzzy. The cause: “Last Thing You Need” credits Morgan Wallen exactly, but its row sits on “Grand Theft Auto VI: The Album” (“Various Artists”), and that cover shipped exempt over a correct judge flag 6 times in 30 days (5 on catalog_cover, 1 on market_subject_image’s catalog label, which reads the same rung). Replay over the live iTunes catalog: the 6 GTA VI flags now meet the judge; of 93 judge-passed exempt rows, 2 move to the judge (the GTA VI row and a TV-soundtrack cut); 91 keep the rung. A song on its own parent album (“Sparks” on “Parachutes”, “Iris” on “Dizzy Up the Girl”) keeps the exemption on purpose: that IS the song’s release cover, and the text-evidence check already measured the judge wrong there. The same PR closes the other direction (#4263): a tagged REQUEST. title_verified refuses a tagged candidate for a plain request, but “Espresso (Remix)”, “Lose Control (Acoustic)”, “Hit Me Hard and Soft (Live)” and “1989 (Taylor’s Version)” each verified the plain release. cover_rung now requires the request’s recording tag (apple_music._edition_tagged) on the candidate too, else catalog_cover_fuzzy. Measured on the same replay: 1 of 93 exempt requests carried a tag (“Patient Zero (Piano Version)”), and it resolves to its own tagged single, so it stays exempt.
The THIRD layer (2026-09-20, the close-out of the same steer): a verified rung yields to PRINTED TEXT, and to nothing else. The 30-day judge log, read by rung, said where the judge is reliable. On deezer_artist_exact portraits it was wrong 5 of 5 (each photo WAS the artist, in a look it did not know), so the overrule there is right. On catalog_cover it was right 17 of 19, and every one of the 17 was readable from text printed on the artwork: “STEVE AOKI” on the Iris remix, “Colliysha Newberry” on the cover shipped for Miguel, “vs. CEDRIC GERVAIS” on the Summertime Sadness remix. So faces are the judge’s unreliable lever and printed text is its reliable one. The first judge cannot be told to read only text (its job on the unverified rungs is a wrong face), and a counter-rule stacked on it is the Opus-accretion failure docs/PROMPT_OPTIMIZATION.md names. So _media_gate asks a SEPARATE question, claude_client.crosspost_media_text_evidence(line, uri), only after a subject flag on a rung in _IDENTITY_VERIFIED_ART: does the text printed on the artwork name a different ACT than the post? Only a DIFFERENT answer drops (enforce mode; log mode ships and records), with the quoted text appended to the note as ` | text: …, and it emits the ordinary reason=mismatch, so the ops monitor's drop accounting is unchanged. An unrelated flag never asks it (an ad names nobody the post is about), and an errored read keeps the art. THE ACT, NOT THE TITLE: the first wording also counted "another song or album title" and the dry run caught it dropping the real "Iris" 5 of 5, because a song's cover is its parent album's cover and prints "Dizzy Up the Girl". The title half is already deterministic (apple_music.title_verified inside cover_rung; a title that does not verify never reaches the exemption), so the question asks only for a name the catalog credit did not carry. Measured before shipping (scripts/dryrun_media_gate_text_evidence.py, Sonnet, n=5 per image, live catalogs): the three wrong-act covers 15/15 dropped; the real Iris, the real Slime Language 2, Miguel's own cover, Charli xcx's faceless cover and the five misjudged portraits 0/45 dropped. It costs one extra Sonnet vision call on the roughly one card a day the exemption used to overrule, and nothing on the rest.
**An ARTIST-level cover never picks a SOUNDTRACK while the act has another album (2026-10-04).** pick_artist_album_art_row takes the act's newest full album. A soundtrack credit can name the act ("Shakira & Michael Giacchino" on Zootopia 2), so the credit check passes it, and it won as the newest album. The Shakira Global 200 board from the X mentions lane shipped Disney art, and the media gate dropped it to a text-only post. _is_soundtrack (catalog genre, or the RELEASE name: an album row's title, a song row's album`; “soundtrack”, “motion picture”, a standalone “OST”, “original score”, “music from and inspired by”, or “music inspired by” followed by a media word such as film or series) now removes those rows from the pool unless they are all the act has. The last two names came from the Rihanna “9 songs past 2B” catalog card (2026-10-19): “Black Panther: Wakanda Forever - Music From and Inspired By” (credit “Rihanna & Tems”, genre “Alte”) matched none of the first three, won as her newest full album, and the media gate dropped its mask cover to a bare number card. The order is deliberate: the act’s own single beats a soundtrack full album, because the soundtrack sleeve shows the film, not the act. The music desk boards use the same picker, so the fix covers them too.
_rank_rows sorts on two keys, version first, then collection. The CLEAN ORIGINAL outranks a remix / sped-up / live edition (apple_music._is_clean_original, the same preference apple_music._track_url applies to its own hits), unless the requested title names an alt version itself (apple_music.targets_alt_version — then the story IS about that edition). The artist’s OWN collection then outranks one credited to somebody else (_own_collection): iTunes leaves collectionArtistName empty on an artist’s own album or single and fills it only when the collection carries a different credit, so a filled credit that does not name the artist is an exact signal that the cover belongs to a third party. Both are PREFERENCES, not filters — the same rule apple_music._is_compilation states, so a track that only lives on a compilation keeps its cover.
The bug that added the ranking (#wrong-missing-images). @tootsiesbar posted a Charli XCX certification card carrying a cartoon bear. “Vroom Vroom (DJ Fingerblast Remix) [Mixed]” is a real Charli XCX track, so guards 1 and 3 both passed it, but the track sits on Alex Chapman’s compilation “Songs To Have Gay Sex To (DJ Mix)” — the card baked THAT cover and the listen link pointed into the mix. It loses on both ranking keys to her own “Vroom Vroom - EP”. Ordering the version key FIRST is load-bearing: “Speed Drive” exists only on the various-artists “Barbie The Album”, which is foreign-collection but IS the song’s real release, and it must still win over a remix single of the same title. A pure collection filter got that case wrong in a live dry run.
subject_image.py, the open-web photo finder (find_subject_image) — Grok + Perplexity candidates, interleaved, downloaded, then gated by a TWO-STAGE vision gate (fail-CLOSED at every step): the Haiku group pick RANKS (all candidates, one cheap call), then a SONNET single-image confirm_subject_image on the winner DECIDES; a confirm-rejected winner is dropped and the pick re-runs on the rest (a real photo may have lost the group rank to a flashy graphic), two rejects → no image. The two stages exist because they fail differently, measured separately on a saved corpus of real X candidates (#find-subject-image): shown N candidates and told to pick the best, Haiku picks the least-bad even when EVERY candidate is junk — that group dynamic is most of how the Grok leg’s revival shipped 42% junk (screenshots, a FanDuel betslip, stat graphics, a meme carrying a homophobic slur) and was REVERTED within the hour (#1894 → #1897). And even single-image, Haiku accepted a stylized fan graphic, a photo of a trading card in a slab, and a real photo the subject wasn’t in — 3/3 votes each — while Sonnet rejects all three (8/9) and keeps every real photo (12/12). The MODEL was the lever, not the prompt: the reject list already named those classes and Haiku accepted them anyway. The two stages share ONE prompt builder (_subject_pick_prompt) so they can’t drift; the reject list includes the merchandise/collectible class (a trading card in a slab, a vinyl being held, a shirt without the person — a photo of an OBJECT depicting the subject is not a photo of the subject), added measured: it took the card class from 1/3-flaky to 3/3 reject with all controls intact, and the source-photo eval stays 9/9 with its golden. The GROK LEG IS ALIVE ONLY BECAUSE OF THE CONFIRM — its query asks for POSTS ("recent posts with pictures of X"; the literal "a photo of X" reads to Grok as an image-generation request, it answers “I can’t display images” and never searches, returning zero 100% of the time — and plain “posts about X” surfaces photo-less posts, so the “with pictures” clause is load-bearing). If the confirm is ever removed, the leg goes back to dead (_grok_candidates’ docstring says so). Acceptance was judged on SHIPPED IMAGES, never a success counter (a counter cannot tell a photo of Ohtani from a slur meme, which is exactly how the #1894 regression shipped): 18/20 builds shipped with ZERO hard junk on the junk-prone slate, and the confirm loop’s cost is one/two Sonnet vision calls per build. The Perplexity leg is separately flaky UPSTREAM: return_images answers a valid code-built payload with HTTP 400 “invalid request” ~52% of the time in prod (115/223 calls, 108 of them 400) while every PROSE purpose on the same key fails ZERO times across 6,000+ calls. Not a malformed request, not the account, not the model (sonar vs sonar-pro measured identical), not the recency filter, and not our own burst (~16 calls/day). It IS partly time-sensitive: back-to-back 5/8 fail, 15s-spaced 2/8. The fix is ONE SLOW retry (_IMAGE_RETRY_DELAY_SECONDS=10) — measured, a retry at ~10s recovers 4/10 real 400s (end-to-end 4/14 → 8/14) while 0.5-3.0s recovers 0/2 and a further +30s attempt recovers 0/10, so there is deliberately no backoff ladder. The pick’s PARSE is fail-safe and its token budget must cover a narrating judge (#1935, the Skubal wrong-photo): the Dodgers-acquire-Skubal wire post shipped with a photo of a different person because of a PARSER accident, not a model judgment — Haiku narrated instead of answering (“Let me analyze each photo: Photo 1: A headshot…”), the old 60-token budget truncated it before any {"pick": N} existed, and _parse_gif_pick’s fallback scraped the first digit out of the prose, turning “Photo 1” into pick=1 (the Sonnet confirm then trusted the poisoned caption — Seattle U’s own site serves that stranger’s photo as Skubal’s roster headshot, so no vision gate could save it; 4 of the 5 candidates were genuinely him and an intact pick had every chance). Two invariants hold from that: the bare-integer fallback parses ONLY a reply that IS a bare integer (prose/truncation → None, fail-safe — regression-tested on the incident’s literal reply), and every {"pick": N} judge runs _PICK_MAX_TOKENS (300, matching the curator picks; 60 was sized for the bare JSON and starved any reply that narrated first). The ops-monitor’s scorer_truncated finding watches the *_pick/*_confirm purposes too, since a truncating pick judge now loses picks instead of mis-picking. The source-photo rung records whether we CHOSE (2026-09-13, the “Buddy” drop). gated_source_photo returns source_photo when the wire post carried exactly ONE fetchable photo and source_photo_pick when it carried several and the gate picked one; is_source_photo is the test every caller uses, since both are still the wire’s own picture. The music newsroom’s first-party FEED image takes its own feed_photo rung: it is downloaded with no vision check, so it must not share a label whose exemption was earned by the checked path. The split exists for the DELIVERY coherence gate, which trusts the unpicked rung and keeps full authority over the pick (x_crosspost._IDENTITY_VERIFIED_ART) — the same unique-vs-pick shape as deezer_artist_exact/deezer_artist and youtube_chart/youtube_chart_pick. It separates all three of the rung’s confirmed drops in 30 days (211 checks): the two single-photo drops were FALSE (a costumed orange unicorn — the film’s own title character — dropped as “not the dog from ‘Buddy’”, and Mastantuono celebrating in the Fiorentina kit dropped as “he plays for Real Madrid”, which API-Sports contradicts for 2026), and the two-photo drop was a TRUE catch (the wire attached Billy Barratt AND a 2002-film still of Christian Coulson, and pick_subject_image took the reference). With one photo there is no selection step to get wrong; with several the mispick is ours, which is exactly what the gate should still police. Note what the Barratt case also says about the PICKER: the correct photo was on the same post and the picker passed it over, so the drop cost that post its art too. The OPEN-WEB tail now closes on TWO tests, not identity alone (#riaa-card-headline). A music-news card reading “Sweet Boy - Malcolm Todd is now Platinum, per RIAA” shipped a screenshot of a trendrod.com article PAGE whose headline announced a Manila tour date. The failure class is not the wrong person: it is a real photo of the RIGHT person wrapped in someone else’s layout — a tour admat, a single-show poster, a page screenshot — whose burned-in text states a story the post does not tell. The cause was one sentence. The tail used to end “only pick a number when you can clearly SEE the actual subject in it”, which is a STRICT SUBSET of the reject list above it, so it read as the decision rule and cancelled the list; the picture passes an identity test, so it shipped. The source-photo tail already carried the matching rejects-still-stand clause, ablated and load-bearing there, and the open-web tail did not. It now names the layouts, names the PUBLICATION FURNITURE that identifies a page (masthead, nav bar, headline set in type, byline, date line, comment count), and names the one layout that is NOT a reject — a RELEASE COVER, which is the work itself and which this rung legitimately returns. Ablated on 30 REAL images, three arms, n=3 on the live Sonnet confirm: 20 photos and covers from the live search, 7 designed layouts (3 tour admats/posters plus 4 rendered article pages), 3 controls. The old wording accepted 20/21 designed-layout builds; the shipped wording accepts 0/21 and still keeps 60/60 of the photos and covers. The page-furniture sentence is the load-bearing half: an intermediate draft that named the layouts but not the furniture cleared the admats and still took the article pages 12/21, so naming the class alone was not enough. The release-cover exception stays NARROW, and that was measured. Six subject kinds reach this rung — music_release, musician, film, tv, video_game, and place/thing/org all fall through _resolve_primary on a deterministic miss — so a review asked the exception to name series and game art as well (Codex, #3295). A second ablation on 9 real TMDB posters and Steam store artworks says no, twice over: the narrow wording already keeps all nine 3/3, so the ask fixes nothing, and the wide wording (“the work’s own artwork … a game’s box or store art”) BROKE the merchandise reject — measured at n=5 with the candidate’s real source context, a vinyl-box shot that rejects 0/5 under the narrow wording was taken 3/5 under the wide one, because a boxed record reads as the work’s own artwork. The merchandise class is an earlier fix in this same list, so the narrow list stands and the poster kinds are covered by measurement rather than by naming. scripts/dryrun_designed_graphic_gate.py re-runs the SHIPPED arm over that whole corpus plus the artworks and the controls; it does NOT reproduce the arm comparison, which was a one-off harness. The tour lane loses nothing measurable — over 30 days desk_art records ZERO music_news tour builds resolved by this rung, the catalog and Deezer rungs answer every one. A MUSICIAN’s gate is told the subject is a person or a band (#3297). The open-web search for the act Bonobo returned a National Geographic photo of a bonobo ape, and the confirm accepted it: the gate asked “does this show Bonobo”, and the photo does. _resolve_primary now passes the subject kind on its musician fall-through, and _subject_pick_prompt adds one note for a musician on the open-web posture only: the photo must show the person or the people in the act, and an animal, object or place with the same name is a different subject. Measured on the live Sonnet confirm, n=5 per image: the incident photo was accepted 10/10 before and rejected 25/25 after (23/25 with the kind and no second sentence), and five real artist photos (Bonobo, Muse, Tame Impala, Deftones, girl in red) stayed accepted 5/5 in every arm. The Haiku pick moved the same way. confirm_subject_image forwards only musician (_CONFIRM_SUBJECT_KINDS); the athlete loosening was measured on the Haiku pick and does not reach the stricter confirm. The wire’s own photo keeps its “take it” posture with no note, because a musician story’s own image can be a cover.market_art.py, Polymarket’s own curated leg portraits, as the LAST-RESORT image source for cards — rung 4b of the market_subject_image ladder, under the vision search and above the ungated native icon. What it’s actually for is DETERMINISM, not coverage — and that distinction is the whole finding (#poly-leg-art): the pitch was “it covers niche named people the catalogs miss”, and a first eval measuring the ladder ONCE per subject demolished that (0 of 14 sampled subjects would have hit the branded floor — the existing rungs placed all 14), while as a HIGH rung it displaced a working found result. But re-running the same ladder 3× per subject showed the rung above it is a coin flip: find_subject_image is non-deterministic, and for subjects that do have usable leg art it whiffed on 9 of 18 builds (50%) — the same subject resolving ['none','none','found']. So the card was going out bare half the time, and the leg portrait (deterministic, exact-name-matched, size-gated) fills exactly those. The n=1 eval hid it: this is the docs/PROMPT_OPTIMIZATION.md “n≈5 not n=2, report the spread” lesson applying to a resolver rather than a prompt. Demoting it is what makes it safe — down there it can only ADD art to a card headed for the floor, never take one away, and the Gamma search only spends on that same failure path instead of on every build past the catalogs. Polymarket, NOT Kalshi: Kalshi exposes no art at all (verified against 200 open events pulled with nested markets — zero image URLs, no image field on the event/market/series shape or in its undocumented v1 API; the thumbnails on kalshi.com are frontend-only), so this borrows Polymarket’s art for ANY market card including a Kalshi-sourced one. Three guards, each closing a paid-for failure: (1) distinct from the event icon (distinct_leg_images) — every leg has an image but on most markets it’s just the EVENT icon repeated (one League of Legends logo across all 33 legs, one crude-oil icon per price bucket; measured on the top 100 open events by volume, only 4 carried genuinely distinct per-leg art, all 4 named-entity fields), so without this the “backup” would mostly re-serve the generic native-image rung; (2) exact name match (find_leg_image, accent/punctuation/case folded) — a loose match is how the wrong person’s face reaches a card, the axis this resolver documents as its recurring bug class, so a near-miss falls through instead; (3) minimum resolution (big_enough, MIN_COVER_PX=400 on the SHORT edge) — leg art runs 183×275 to 3500×1741 in practice and the portrait cover is a full-bleed 1800×2400, so a 200px source renders visibly soft, trading a clean branded card for a blurry one; live-verified that a 400×400 source still renders sharp while the 199×254 Ethiopia thumbnails are correctly refused. The size gate is also WHY the rung fires rarely, and the tension is structural: a subject niche enough that TMDB/Deezer miss it tends to have TINY leg art (the Ethiopian field runs 183-200px) which the gate then refuses, while the legs carrying big art are the famous politicians TMDB already has — so the win is the flaky-vision backstop above, not raw coverage. One keyless Gamma search per lookup, wrapped in the shared retry_http — Gamma throttles under a burst and a throttled call is indistinguishable from a genuine no-match (both empty), which silently turned a 429 into “no art” until the retry landed. Pure matching + the size test unit-tested; live-verified against the real Brazil/Ethiopia/2028 fields.market_subject_image.py, the card BACKGROUND-image resolver for every market/betting/desk card (resolve_market_image → (png, source)), and the home of the standing image rule: a DETERMINISTIC source per kind FIRST, an open-web vision photo search LAST (owner steer, applies to every desk + genre from now on — a wrong/misleading image is worse than a plain one). The ladder, best-first: (1) structured catalog_hint {title, kind, artist} → utils.catalog_art.resolve_catalog_art (music→iTunes/Deezer cover by ARTIST, movie/tv→TMDB poster); (1b) a YOUTUBE-CHART market’s leg → that video’s own THUMBNAIL, via utils.kworb.find_video_entry + video_thumbnail_urls (see the paragraph below); (2) structured game_teams {home, away, sport} → _resolve_matchup_image → API-Sports team LOGOS composited by utils.market_chart.render_matchup_card into a broadcast half/half card (each half a soft blurred wash of that team’s OWN logo, real colors, no guessed hue; logos via utils.api_sports.team_logo); (3) the unified classifier — with no structured hint, hand image_subject the combined extraction text (question + leading answer + event/ticker context) and route its result through entity_image._deterministic_art (person→TMDB, musician→Deezer, music_release→catalog, film/tv→TMDB+OMDb, team→logo); (4) vision (utils.subject_image.find_subject_image, Perplexity+Grok, vision-gated by pick_subject_image which rejects a map/infographic/boxscore/odds-graphic/wrong-artist) — on the CLEAN name; (4b) a PREDICTION MARKET’s own curated LEG portrait (utils.market_art, see below — the LAST-resort deterministic backstop under the non-deterministic vision search); (5) native image; else the branded floor. The two failure axes to check on any card (the audit lessons): WRONG SOURCE (a sports game hitting vision instead of API-Sports — the Spanish GradaMas58 boxscore fix) and WRONG SUBJECT (a clean source fed a polluted query — the Charli “Music, Fashion, Film” Kalshi-category-as-album, stripped in utils.luminate._subject; the Big Brother contestant-as-show — the image_subjects classifier led with the SHOW (“Big Brother”) for a “who is evicted” market, so rung 3 resolved the show’s TMDB poster, which is host Julie Chen, not the leading houseguest; the X media gate caught it and dropped the card to a bare number. FIXED by the classifier rule that a who-wins/eliminated/evicted competition market leads with the CONTESTANT named in the “Leading answer” line, not the show/award/league — the show poster there is the host or a logo, so it goes second and never wins rung 3; cache namespace bumped to v10 to flush the host posters). The awarding-body-as-subject variant (v11): the SAME classifier led with the awarding ORG (“Emmy Awards”) for “Emmy Winner: Outstanding Drama Series” (leader “The Pitt”) — Haiku does not know the 2025 show, so it kinded the whole thing as the ceremony, rung 3 got no org logo, and the vision search on “Emmy Awards” cached a red-carpet PERSON photo (the media gate then dropped the card to a bare number). FIXED in the SAME rule, SCOPED to the category: a WORK category (Best Picture/Documentary, Outstanding Drama/Comedy Series, Album/Song/Record of the Year) names a WINNING WORK, so the classifier leads with that title kinded film/tv (EVEN when it does not recognize it — a poster needs only the name) or music_release (only when the text names the artist, else the item is skipped and the market floors — absent over invented); a PERSON category (Best/Lead/Supporting Actor, Actress, Director) still leads with the PERSON, so a lesser-known nominee’s name is not mis-read as a title (Codex review on #2604). The awarding body (the Emmy Awards, the Academy, the Recording Academy) goes second or is dropped. Dry-run n=4 (11 cases): [org: Emmy Awards]→[tv: The Pitt], work controls held (Oscar→film, Grammy→music_release, artist-less album→floor), and person controls held even for obscure nominees (Coralie Fargeat, Katherine LaNasa→person); cache namespace bumped to v11. The featured-leg collapse (v12): the v11 classifier fix was correct but never fired for the DTF St. Louis Emmy card, because the classifier never got the nominee. A leaderboard/binary snapshot can carry its ranked legs ONLY in chart_specs (the per-leg {label, pct} the card draws) while its own outcomes collapses to a single {event_title: pct} entry with no usable yes_label — so _featured_label picked the event title, and _extraction_text handed image_subject only the bare category “Emmy Winner: Outstanding Limited or Anthology Series”. The classifier then correctly returned [] (an award category names no imageable work), the card floored, and the DTF St. Louis cover shipped art-less (confirmed in Axiom: image_subject → [], market_subject source=none, and the media gate said the number card was fine — the art was never resolved, not dropped). FIXED in _featured_label: when its own pick is empty OR merely restates the market title, it reads the leading NAMED leg off chart_specs (else outcomes), skipping a generic label (“Tie”) or one that just repeats the title. Dry-run on the live Kalshi board: the collapsed snapshot went source=none → source=tmdb_poster (the real DTF St. Louis poster), and a healthy leaderboard is unchanged (still Beef); cache namespace bumped to v12. The THIRD failure axis, and the one the two above hide: NO RESOLVER CALL AT ALL (2026-08-31). The axes above both assume the ladder ran. A lane that never calls resolve_market_image produces the same screenshot — a bare number card on the branded floor — with a different cause and a different fix, and the tell is in Axiom: the post emits NO market_subject and NO market_image event, where a genuine ladder exhaustion emits market_subject source=none. Absent = the code path skips art; source=none = the ladder ran and found nothing. Two lanes shipped this way and BOTH were caught by the owner, not by monitoring, because card_art grades the attempts it sees and an un-attempted card is simply missing from the rate (see docs/OBSERVABILITY.md → card art): the RT Final card (market_alert._post_reconciliation, #final-numbers) and the X-mentions market-projection card (x_mentions._prediction_lane). Each had a SIBLING in its own file that already resolved art — the decided-ladder outcome lane (_post_outcome, wired at #2655) and the chart lane’s _mk_card — which is what marks these as sweep misses rather than decisions: the owner steer was “every single path has art”, and #2655 wired one path without asking which others built a card. Both now resolve. The RT lane stamps a DETERMINISTIC catalog_hint {film, movie} rather than letting the classifier re-derive the subject from the market question (we know it is a film, and an award/category question is exactly where that classifier has floored before — see the v11/v12 cases above); it resolves on rung 1, the TMDB poster, under surface="market_reconcile". The X-mentions lane partitions the market subject on the ' -- ' release/artist separator and uses the cog’s shared _art_bytes music ladder, so a bare-artist album ladder resolves a portrait and a release resolves an artist-verified cover. Both are fail-open to the branded floor. The standing check when adding ANY card-shipping lane: does it call an art resolver? A card builder that takes artwork_png and is never passed one is the whole bug. The betting surfaces (betting_board/betting_alert/betting_value) all stamp game_teams so a game card gets logos first; cinema_desk/music.py use catalog/TMDB with clean titles. The drop/alert PREDICTIONS lanes carry the same Kalshi games but never stamped game_teams, so a sports card shipped art-less (owner report “sports markets aren’t shipping with art”, a WNBA “Minnesota vs Las Vegas: Spread” number card on the branded floor). resolve_market_image now DERIVES game_teams for a Kalshi game when no caller stamped it (utils.sportsdata.names.kalshi_game_teams): the event TICKER names the league (KXWNBASPREAD → WNBA, the only signal that tells the Minnesota Lynx from the Timberwolves — both are “Minnesota” in the bare-city title), then each side of the A vs B title resolves to a FULL team name inside that league’s roster. Both sides must resolve UNIQUELY or it returns None and the card keeps the floor — a city that is two teams in its league (“Los Angeles” = Lakers + Clippers) is never guessed (prefer-absent). A derived game is is_game too, so it follows the logos-or-the-branded-floor discipline (no open-web/native fallback). Only US team leagues have rosters (NBA/WNBA/MLB/NHL/NFL). Soccer (2026-10-19): a Kalshi series in _KALSHI_SOCCER_SERIES (EPL / La Liga / Serie A / Bundesliga / Ligue 1 / MLS, matched as an event-ticker prefix) returns the title’s two CLUB names unchanged, tagged football. A soccer title names clubs, not cities, so there is nothing to resolve; the safety check is the logo lookup, which must match BOTH clubs inside ONE ESPN league (espn.team_logos). Before this a soccer game had no game_teams, so it went to the open-web photo search: on 2026-10-18 that put Bournemouth’s pier on “Bournemouth vs Sunderland: Total Goals” and a Udinese team photo on a Lecce card, and “Monza vs Cagliari” got no art. Kalshi’s own club spellings (“Bilbao”, “Santander”, “Stade Brest 29”, “M´gladbach”, “New York RB”, “Los Angeles F/G”, “Saint Louis”, “Parma Calcio”, …) are aliases in _SOCCER_CLUBS; measured 2026-10-19, 390 of 390 recent Kalshi soccer matchups resolve two crests. Every other league (NCAAF, KBO, KBL, CBA, NBL) is unchanged. The derived rung reports source=sports_api like a stamped one (identity-verified, so the X media gate trusts it). A market with NO subject skips rung 4 entirely (#question-card): when the classifier names NOTHING — its documented verdict for “an abstract trend/topic naming no imageable entity”, the honest read of “Will the #1 Movie on Netflix have at least 21 million views?” (no film is named) — rung 4 would search the open web on subj, which with no classified subject is the RAW MARKET QUESTION. The web’s best literal match for a market question is the MARKET: the exchange’s own OG preview card (kalshi.com/api-app/preview/...), which renders that exact question as text over a price line, and it shipped as the background cover-cropped into an unreadable slab of the question. The gate is not the thing to fix (measured n=5 on the real card): judged against a CLEAN entity it rejects that card 5/5 even as the lead candidate beside a real photo, and 5/5 alone — it only takes it when the SUBJECT IS THE QUESTION, where the card is the one literal match. So the QUERY is the bug and a counter-rule in pick_subject_image would be scar tissue for a case that stops arising. No subject → no photo hunt → rung 3b, the MEDIA-PLATFORM logo (detect_media_brand + the org→Wikidata path), then the branded floor. Rung 3b (owner steer “art on every path — netflix and youtube, never the exchange”): a subject-less market often still NAMES a platform — “the #1 Movie on Netflix”, “the top Music Video on YouTube”, “#2 on the Billboard 200” — and the platform IS the imageable subject there, so a deterministic word-boundary scan (_MEDIA_BRANDS: Netflix / YouTube / Spotify / Apple Music / Billboard / HBO Max / Disney+ / Hulu / Prime Video / Paramount+ / TikTok / Twitch / Rotten Tomatoes / IMDb / Instagram, each verified to resolve a clean P154 logo) hands the canonical name to the SAME entity_image._deterministic_art({"kind":"org",...}) rung a classified org would take, yielding source=wiki_logo (contain-fit, light-flipped for a dark mark like the Netflix red). GATED on no_subject, so a market that DOES name a person/title/team keeps that better, more specific art — the platform logo only ever RESCUES a card that was otherwise heading for the floor, never displaces a real subject. This is the fill for the empty number/percent cards (the “32.5M Netflix views” card the owner flagged); it is NOT the exchange’s own Kalshi/Polymarket mark (an earlier take, rejected). When 3b also misses (an abstract macro question naming no platform), the floor still holds. A classifier BLIP lands here too (the client swallows its own errors, so an empty result is indistinguishable from a real empty verdict) and that’s the safe direction: a miss is never cached, so the next build re-resolves. Verified end-to-end on the live markets — the reported card resolves to the floor, its #1 Show sibling to the Netflix wiki_logo, and the film/person/musician/team controls keep their art (deterministic art wins for person/musician; a team still reaches the search on the clean name). The durable image cache is keyed by market AND SUBJECT (_cache_key = _market_key + _subject_key), and the subject half is load-bearing (#wrong-leg-cover): a multi-outcome BOARD — “which album is #1 on the Billboard 200 the week of Aug 8?”, a Netflix top-title bracket, a Big Brother eviction field — is ONE market whose cover is art for whichever LEG currently leads (market_alert._race_snapshot folds N legs into one aggregate and stamps the LEADING leg’s catalog_hint). Keyed by the event alone, the first leg carded pinned its art for the whole 4-day TTL and every later post about a DIFFERENT leg served it: a board led by Olivia Rodrigo’s “you seem pretty sad for a girl so in love” cached her cover under kxtopalbum-26aug08, and the next day’s alert — “Morgan Wallen’s ‘I’m The Problem’ is tracking toward another week at #1” — shipped her album art on his card, to Discord and to X (owner report, verified straight out of the live kv_cache row). Note the resolver itself was never wrong: the drop 64 minutes earlier resolved the correct Wallen cover on a cache MISS, so this was invisible to every gate — the art was right, the key was too coarse, which is the failure mode a “does the cover match the artist” check can’t see. _subject_key reads the same three inputs the ladder resolves from (catalog_hint artist+release, a caller’s subject_hint, the featured leg), contain-deduped, and ends in a DIGEST rather than a bare truncation when long — two long-titled legs of one board share a prefix, and a truncated key rebuilds the same collision. A single-subject market (yes/no, threshold, plain artist) yields an empty subject half and keys exactly as before, so the cache stays as warm as it was. The namespace is also VERSIONED (market_subject_img_v12) — bump it on any resolution-logic change so cached garbage re-resolves on deploy (v6 flushed the preview cards the raw-question search had already cached, which the Netflix-views alerts kept re-serving; v9 flushed every wrong-leg cover the event-only key had pinned; v12 flushed the award boards that had cached a none floor when the featured leg collapsed to the event title). Emits market_subject (source rung + cache_hit). A TEAM subject takes its crest before the open-web photo (2026-10-23, the #sports audit): the crest rung now runs ahead of find_subject_image, and _crest_names also tries the title’s own spelling of the side (“Monza” for the classifier’s “AC Monza”). On an “A vs B” title the team is the side the featured leg names (_prefer_featured_side): the classifier lists the teams in title order and the resolver kept the first, so a result for the away side used the home side’s art (the Brisbane crest on a Tasmania win). The cache moved to market_subject_img_v17.
A YOUTUBE-CHART market gets its leg’s VIDEO THUMBNAIL (rung 1b, #youtube-chart-art). Kalshi runs four daily music-video series — top and runner-up, USA and Global — and they were the one market family the whole ladder missed. The card shipped with the branded floor to Discord and to X (owner report, “still needed art”: “Runner-Up Daily Music Video USA on YouTube: Aug 5, 2026”, leg “ICONIC BY MISTAKE”). The cause is not a broken rung. A leg is a VIDEO TITLE and the payload names no artist anywhere: Kalshi writes no :: subtitle here, so there is no catalog_hint, and image_subject then returns NOTHING — correctly, because its prompt requires a music release’s artist to be NAMED IN THE TEXT and forbids guessing one (the guard that stopped the wrong-artist ships). That empty verdict sets no_subject, which switches off the photo search, and the card lands on the floor. Reproduced live before the fix (classified: None → source=none). The missing input is the chart the market settles on. kworb mirrors YouTube’s most-viewed music videos, and each row links to the YouTube VIDEO ID — so a leg resolves to one specific video and to that video’s own thumbnail, which is the picture the chart itself shows. find_video_entry takes the MOST-VIEWED charting video whose title contains the leg label as a WHOLE PHRASE. Both halves are load-bearing. The phrase test covers both leg shapes — the raw video title (“Rod Wave - Hustle (Official Music Video)”) and the bare song name (“petal” inside “Ariana Grande - petal”) — while _fold_phrase pads each side so word boundaries still hold (‘peta’ does not match ‘petal’). Views beat an exact title match, and that was measured, not assumed. An exact-first rule looked right and was wrong on the reported card: the chart carries the official “ICONIC BY MISTAKE” MV at 1.26M views, a dance practice at 622K, and an auto-generated ART TRACK titled exactly “ICONIC BY MISTAKE” at 452K — so exact-first shipped the art track’s pillarboxed album cover while the real music video sat higher on the same chart. The label is the SONG’s name there, so the exact match is a coincidence. The tie-break is what the market IS: a daily chart market exists for a video because that video is near the top today, so among candidates it means the one charting highest. Today’s view count is the right comparator — one metric, one day, and unlike pos it stays meaningful across the two merged pages. video_thumbnail_urls asks for maxresdefault (1280×720) then sddefault (640×480) and deliberately omits hqdefault (480×360): every id serves that size, so including it would turn a genuine miss into a blurry card. Measured over all four live events, 56 of 60 legs resolve, and the rule picks the OFFICIAL video every time it differs from exact-first (26 of 60 legs). The 4 misses are videos outside the fetched slice, and each falls through to the rungs below exactly as before. Gated on the market TITLE (is_youtube_video_market: it says both “music video” and “youtube”), not on a ticker prefix, so a new series in the family works with no code change — and no other market pays the chart fetch. market_subject reports source=youtube_chart; the eval art_resolution grades the rung against the live chart and carries a golden for the whole-phrase test. A TRAILING QUALIFIER on the leg is dropped, but only for a UNIQUE match (#2286). Kalshi labels a leg with the CHART’s song name while kworb carries the RAW UPLOADED YouTube title, and the two disagree about the parenthetical: the Aug 10 runner-up leg read “KALYANI (Remix)” where the charting video is titled “KALYANI (with Shreya Ghoshal) OFFICIAL MUSIC VIDEO | ARJN | KDS | …”, so the whole phrase was absent and the card shipped art-less to Discord AND to X. A label ending in a parenthetical now retries WITHOUT it — and that retry demands exactly ONE distinct charting video, where the full-label pass takes the most-viewed of several. The asymmetry IS the safety: dropping “(Remix)” WIDENS what the label means, so with an original and a remix both charting, most-viewed would hand back the ORIGINAL’s thumbnail for a card about the remix — the wrong-art class this rung exists to avoid. One video means the chart holds one reading of that name and the leg can only be it; more than one stays a miss. The strip is END-anchored, so a real leg whose parenthetical sits mid-string (“Tera Mera Rishta Continues (Film Ballad)| Awarapan 2| …”) keeps every word after it. A Kalshi VIDEO-ID SUFFIX matches on the id alone (#4127). When two charting videos share a title, Kalshi labels each leg “Choosin’ Texas [YouTube video 2: nUsrYVxrDwI]”. kworb.label_video_id reads the id (the index N is not stable, so it is ignored), and find_video_match returns the row with that video_id, unique on a complete read. When no row carries the id, the result is None: every other row with the title is a different video. Measured over all 166 distinct YouTube daily-chart leg labels of the 30 days to 2026-10-17 against the live chart: 8 KALYANI legs now resolve to their exact video (before: none), and 4 “Choosin’ Texas” legs whose id was off the chart no longer resolve to the OTHER upload (before: the wrong video, reported unique). The suffix is never DISPLAY text (#4342). kworb.display_leg (the stripped half of label_video_id) is applied wherever ONE leg label becomes text a reader or a model sees: the card heroes (subject_hero), the alert and drop number figures, the single-leg compose blobs, the winner card and compose, and the /ask grounding’s yes-side tag. Where one text names SEVERAL legs (card rows, the drop board blob, a lead change, the leader judge’s list, the grounding lines, the /ask outcome list), kworb.display_legs is used instead: Kalshi adds the suffix exactly when two legs share a title, so a leg whose clean name collides with another leg in the same text keeps its raw label. That prevents “Choosin’ Texas overtook Choosin’ Texas”. A label that is only the suffix stays raw. Matching, the fabrication regexes and the stored claim key (_answer_key, market_alert_answers) keep the RAW label, so no stored key changed. Measured on all 282 events of the four series (about 70 days to 2026-10-17): 1057 of 48356 rendered texts lost the suffix, 0 changed in any other way, and board texts where both uploads of one song appear are unchanged. Whether the two names meant the same video was CONFIRMED before the widening, not assumed: the live event KXYTTOPVIDEOG2D-26AUG10 carries ONE KALYANI leg and kworb’s chart ONE KALYANI video, and Kalshi builds its legs from that chart. The same no-artist gap bites the TAKE, not just the art (#youtube-take-fabrication). The leg names a video and no artist, so the drop compose has to name the act from nothing — and Opus invented a whole different song: it shipped "After You" by Stray Kids is locked as the No. 1 on KXYTDAILYTOPVIDEOG-26AUG23, whose #1 leg was “Dai Dai” (Shakira & Burna Boy). The art rung had resolved the CORRECT “Dai Dai” thumbnail; the delivery media gate then read the take (Stray Kids) against the image (Shakira), called a mismatch, and dropped the correct art — so the post shipped as a bare number card (the reported card). Two fixes in cogs/market_drop._compose, both scoped to is_youtube_video_market. (1) Ground the take. _youtube_grounding_block reads the same kworb chart the art rung uses and appends each leading leg’s real upload title (“Shakira, Burna Boy - Dai Dai (Official Video)”) to the market blob, as DATA next to the numbers, not a new rule. A real-Opus dry run (n=4) went from 3/4 (one “KATSEYE’s Gnarly” fabrication, one wrong-artist “BLACKPINK’s Dai Dai”) to 4/4 correctly crediting Shakira & Burna Boy. (2) Fail-closed gate. market_subject_image.take_references_leg checked the take names the leading leg (since CB-02 #4566 this is youtube_grounding.take_names_a_leg: the strict leg_phrase_in_take, else the Haiku leg judge; #4579 deleted take_references_leg); a take that names a DIFFERENT song (the “After You” vs “Dai Dai” case) is dropped via emit_decline(reason="leader_fabrication") rather than shipped over its own correct art. The market ALERT runs the same two guards (#2527). Its compose had neither, so a YouTube lead_change or swing take could name an act the market never showed. Both pieces now live in utils/youtube_grounding.py (youtube_grounding_block, leading_legs, take_names_a_leg, fetch_youtube_rows), and both cogs import them. In market_alert._fire the blob gets the kworb upload for each leg it names (a lead change grounds the new leader AND the leader it passed), and a take on a daily-chart board that does not name its leg is dropped before the judge, as market_filtered reason=leader_fabrication. Since #4239 the alert’s LEADER takes use the drop’s #3442 check. A lead change, a confirmed ending-soon leader or a swing on the leading leg, on any chart board (the YouTube daily charts plus is_chart_slot_market), ships on take_names_leader_only (it names the leader and no rival leg) with no model call. A leg with no race snapshot (a two-leg event) has no rival list, so it always goes to the judge (Codex, #4272). The rest asks the Haiku market_take_names_leader, and False or an error drops it (leader_fabrication / leader_check_unavailable, fail closed). The reason: an alert take on a lead change names BOTH legs (“X overtakes Y”), so the token check passed a take that crowned the overtaken leg; in the #4239 dry run it shipped 14 of 15 such inverted takes and the judge shipped 0 to 1. Other takes on a YouTube daily-chart board (a conviction break, a settled-out report) are not a claim on the slot and keep take_names_a_leg. Known limit: the judge sees only the bare leg title, so a take that names the ACT and not the song (“Shakira and Burna Boy” for “Dai Dai”) still drops; no real alert take had that shape in 30 days. The forecast lanes quote a number, not a video, so they skip both. A SHORT, clean song title (≤ 3 distinctive tokens) must appear as a WHOLE PHRASE, so a fabrication that merely shares one ordinary word with the leader (“Love Is a Game” vs a “Love Me Again” #1) is still caught — a longer raw upload title (artist + song + credits) keeps the softer any-token test, since a real take names the song succinctly and would fail a whole-phrase match against the full label (Codex review, #2525). The fold keeps Unicode word characters of any script, so a non-Latin #1 is checked instead of folding to nothing and fail-opening. A possessive is dropped before folding (“Rod Wave’s” → “rod wave”), so a grounded take is not rejected over the stray “‘s”, and the check runs against EVERY leg tied at the top price, so a take naming a co-leader is not dropped either (both Codex review, #2525). A short label’s required phrase is the CLEANED identity (“Golden Official Lyric Video” → “golden”), not the raw label, so un-parenthesized upload noise is not put back into the text a take must repeat. Deterministic, so it costs no model call and cannot misfire on a take that does name the leader; the grounding handles the wrong-ARTIST case the token gate cannot see. The gate (a SUPPRESSION path) is scoped by is_youtube_daily_chart_market — the STRICTER predicate that requires the daily-chart framing — not the broad fail-open is_youtube_video_market the art rung and grounding use, so a plain “release a music video on YouTube” binary (whose leg label is the whole question) never reaches it. Both the grounding and the gate key on the REAL leg label via _leading_legs, which reads meta['yes_label'] FIRST: the single-leg binary shape (_kalshi_binary) rebuilds outcomes as {event_title: price} and keeps the song only in yes_label, so reading outcomes alone handed back the event title (“Top Daily Music Video Global on YouTube”) — the grounding then looked that up and found nothing, and the gate validated the title, which a fabricated take echoes (“…global YouTube music video…”). That was the actual incident shape, so keying on yes_label is what makes the fix fire in production, not only on the multi-outcome board. The SAME fabrication hit the NETFLIX winner series (#netflix-take-fabrication). The market_drop compose shipped “My Brilliant Career: Season 1” — a 1% leg — as the “top global Netflix show this week”, while the 99% leader was “Outer Banks: Season 5” (event kxnetflixrankshowglobal-26aug24, verified from live Kalshi candlesticks: Outer Banks bid 0.98/ask 1.00, My Brilliant Career 0.00/0.01). The card carried the CORRECT Outer Banks art (rung 1’s tmdb_poster); the delivery media gate read the take (My Brilliant Career) against the image (Outer Banks), called a mismatch, and dropped the art — so the post shipped as a bare 99% number card (the reported card). The deterministic gate now also fires for is_netflix_rank_market (the Kalshi “top Netflix show/movie this week” winner series, matched off the title), but ONLY at a CLEAR-leader angle (report for a ≥ 95% lock, or frontrunner), where the take must state the leader as a fact; a contested Netflix leaderboard keeps the drop_score self-gate, so the suppression path stays narrow. A Netflix leg is a full media TITLE, and a deterministic token check could not tell which title a take DECLARES the winner. Three rounds of token matchers each drew a new edge case (Codex review, #2538): a one-shared-word leak (“My Brilliant Career” shares “my” with a “My Life with the Walter Boys” leader), then a board-aware overlap rule that still leaked a wrong franchise EDITION (“Love Is Blind: France” vs a “Love Is Blind: UK” leader) and SUPPRESSED a correct comparative take (“Outer Banks stays #1 over My Brilliant Career”) for naming the runner-up. Token overlap cannot read which winner a sentence crowns, so the Netflix family asks a cheap Haiku judge instead — claude.market_take_names_leader(take, leaders), a market_take_check call that returns true (names a leader as the winner), false (declares a different title — a fabrication to drop), or None (could not judge). It FAILS OPEN: the gate drops only on an explicit false, so a Haiku outage never suppresses a real post, and the delivery media gate stays the backstop for wrong ART. The YouTube family keeps its per-leg check (youtube_grounding.take_names_a_leg, the strict leg_phrase_in_take first) that its clean song-name legs suit. Cinema and sports winner locks have the same shape and are the open sweep (a follow-up issue). Other markets keep the drop_score self-gate as their only check. The winner card heroes the #1 CALL, not the 97% odds (owner steer: “post forecast not probability”). A “Top Daily Music Video” market arrives as a single-leg BINARY lock, so _report_number_figure takes the subject_hero path — which read a rank only from an explicit “#N” or “runner-up”, so the unranked “Top Daily …” title heroed the market’s PROBABILITY (the 97% Dai Dai card). market_cards.named_rank now reads a TITLE-LEADING “Top Daily <thing>” as the #1 slot, the twin of its “Runner-Up Daily <thing>” → #2 rule, so the card heroes “#1” with the winner as the subject and the percent demoted to the caption. Anchored to the title start, so a market that merely uses “top daily” as a metric (“top daily streams reach 10M”) keeps its own threshold hero. The X media gate TRUSTS this rung (2026-09-09, the “Dai Dai” drop). x_crosspost._IDENTITY_VERIFIED_ART now lists youtube_chart: the thumbnail is fetched by the video id the chart itself carries, so the picture IS the video the market ranks, and the vision judge cannot check it anyway — a thumbnail is a frame with no title to read, so the judge’s only lever is recognizing a famous face, which fires on exactly the biggest artists (“That’s Shakira … not ‘Dai Dai’” dropped the correct Dai Dai thumbnail off a settled market_outcome winner line). 30 days: 0 catches, 2 flags, both that. The trust is for a UNIQUE match only (Codex review on #3109): kworb.find_video_match reports whether one charting video carried the phrase on a COMPLETE read of both kworb pages (youtube_videos_read; a half read never reports unique) for a CURRENT chart day (youtube_chart_is_current: within two days of the date the title names, since the settle sweep can render a dated market late and today’s list may have moved on), and a most-viewed PICK among several is named youtube_chart_pick and stays gated. A unique PHRASE match is only a nominee (CB-21, 2026-10-18). The phrase test says one charting video CONTAINS the leg’s words, not that it IS the leg: the real leg “Zid” sat inside “Jab Zid Pe Aa Gayi Parvati”, and the qualifier drop mapped “Patient Zero (Official Lyric Video)” onto the Official Music Video. So kworb.confirm_unique_video asks Haiku SAME / DIFFERENT (classify, purpose kworb_video_same, cached 14 days per label + video id) before the rung reads youtube_chart. An exact folded title equality and a Kalshi video-id label ask nothing. DIFFERENT or no verdict keeps the thumbnail as youtube_chart_pick: the art still ships and the gate decides. A wrong TAKE on this family stays gated upstream (take_references_leg, winner_line_score). Detail in docs/OBSERVABILITY.md under media_coherence.
The chart TAKE fabrication gate covers EVERY chart family (#3442). cogs/market_drop.py drops a composed take that crowns a different title than the board’s leader. It runs on the four YouTube daily-chart series (any board) and on every single-slot chart board with a clear leader (is_chart_slot_market: Netflix rank boards, plus music-chart boards whose title LEADS with “#N”, “Top
github.py, GitHubClient for filing issues/PRs via the GitHub APIrailway.py, Railway API for /undo rollbackshealthcheck.py, aiohttp server at /health (Railway healthcheck). Also optionally exposes two token-gated debug routes, both disabled unless DEBUG_QUERY_TOKEN is set: POST /debug/query (#652), a read-only single SELECT inside a READ ONLY transaction with a statement timeout — so it physically can’t mutate data — the sanctioned way to inspect prod DB state from a Claude-on-the-web session, whose sandbox proxies all egress over 443 and so can’t open a raw Postgres socket on Railway’s public proxy port; and GET /debug/integrations, the generic outbound-integration smoke test (utils.integration_probes) — probes every (or a ?names= subset of) upstream from the bot’s own datacenter IP and reports provisioned/reachable/ok per source, the only way to tell a real upstream outage from a Claude session’s own egress block; and GET /debug/usage (#727), the metered-API usage/quota read — pulls SGO’s entity cap (/account/usage, quota-exempt), API-Sports’ daily request cap (/status), the Odds API credit budget, and Highlightly’s daily cap (passive headers) off the bot’s own clients and returns spend-vs-cap per source plus a low rollup, so “what’s our usage / are we near a cap” is one authoritative read instead of inferring from 429s. Also serves the Tootsies Arcade routes (#3687: /arcade/, the whitelisted client files, /arcade/api/config, /arcade/api/token, /arcade/ws), wired by utils/arcade/web.add_routes whenever the bot is passed in; the API + socket reach the Arcade cog’s hub and answer 503 / close 1013 while it is not loaded. stop() closes the game sockets first so an open WebSocket cannot hold the runner’s cleanup.usage.py, the cross-integration usage/quota core (#727, extended across every metered integration in #753): pure per-source parsers (parse_sgo_usage over /account/usage’s requests+entities rate-limit tree, parse_api_sports_status over /status’s daily requests, odds_usage_report over the captured credit headers, highlightly_usage_report over the passively-captured daily headers, parse_elevenlabs_usage over /v1/user/subscription’s character_count/character_limit monthly cap (the voice-budget SGO-entities analog), parse_github_rate_limit over /rate_limit’s REST-core used/limit hourly budget, **giphy_usage_report over the passively-captured X-RateLimit-*-Day daily search cap, openai_usage_report over the passively-captured per-minute x-ratelimit-*-requests RPM) → a normalized UsageReport/UsageMetric (used/limit/remaining/pct, is_low at LOW_PCT=0.85), plus the fail-open async collect_usage that reads every provisioned source by its cheapest authoritative path. Consumed by the health-watch quota poll, the /debug/usage endpoint, and (the pure parsers) unit tests. The single home for “how much of each metered budget is spent” — the answer to the SGO-entity-cap blind spot that took /bet dark. Coverage (#753 epic): the keyed metered sources with a readable budget are polled (SGO, API-Sports, Odds API, ElevenLabs, GitHub, twitterapi.io — its prepaid credit balance via parse_twitterio_usage over /oapi/my/info, #1255; a prepaid wallet, so the metric carries the remaining balance with limit=None and the low signal is a balance floor flagged by the ops-monitor, not a pct) or captured passively (Highlightly daily, Giphy daily, OpenAI per-minute RPM); Giphy’s search endpoint returns NO X-RateLimit-*-Day headers in practice (verified live 2026-07-10, on cache HIT + origin MISS), so collect_usage SKIPS Giphy when the passive capture is empty rather than emitting a recurring ok=false error=no_headers quota event every tick (#1118) — self-healing if Giphy ever starts sending them; OpenAI’s per-minute window is informational only (period="minute" — the ops-monitor renders it but never flags quota_low, since a rolling rate can’t fill ahead of time; its hard signal is the 429 in integration-health). Sources with no machine-readable budget (Perplexity prepaid balance + Genius, both dashboard-/limit-less; OpenAI $ spend, which needs an org Admin key the owner declined) are covered reactively by the existing integration-health rates + auth probes, not polled.integration_probes.py, the /debug/integrations registry: one Probe per outbound integration (Genius, Apple Music, OpenAI, ElevenLabs, Perplexity, Giphy, API-Sports, SGO, twitterapi.io, The Odds API, Polymarket, Kalshi, Highlightly, MusicBrainz, Wikipedia, Wikidata, GitHub, Railway), each the cheapest real read that proves host reachability + (where keyed) auth. Returns ProbeResult(provisioned, reachable, ok, status, duration_ms, detail) — reachable isolates the network axis (DNS/egress/timeout) from ok (status<400, the auth/quota axis). PROBES is the extension point; reach_only=True marks a POST-only host where reachability is the honest ceiling. Fail-open, bounded by a short per-probe timeout, runs all probes concurrently, and emits no events (diagnostic-only, must not skew ops-monitor health rates).polls.py, the pure validate/normalize/format core behind the polls cog (#888): build_poll_spec (clamps a raw request to Discord’s 2-10 options ≤55 chars / question ≤300 / duration 1-768h, recording what it trimmed in notes), normalize_options (strip/dedupe/cap), clamp_duration + parse_duration_hours (“3 days” → 72), to_discord_poll (the one discord touch — building a discord.Poll value object, no network), and format_poll/format_poll_list (render live results + the recent-poll list). Side-effect-free so it’s unit-tested with no live client; the discord I/O lives in cogs/polls.py (mirrors the utils/starboard.py split).m!, case-insensitive (M!Play), also @Jockie Music Premium play … (a user mention, <@id>), a ROLE mention (<@&id> play tyla), m!p, and slash /play. Up to four instances answer the same prefix: “Jockie Music (2)” replied “You are not in a voice channel” to a command the Premium instance was already serving, so the listener parses by embed shape, never by author name. WHERE Jockie answers is the whole quirk: “Started playing …” and “There are no more tracks” go to the VOICE channel’s chat; the “Added Track” embed (posted when something is already playing: a Track field **[Title by Artist](url)**, a footer Requested by <username>, the queue position) and the “Title by Artist has been skipped by <@id> (requester privilege)” line go to the channel the COMMAND was typed in, which on the first hosted night was #casino. So _watch reads Jockie in every channel of the server: an Added Track puts {title, requester} on MatchState.queued (the username maps to a player through MatchState.handles, every name Discord has for the two players), a skipped / empty / left line ends the current song (only from a bot whose Started-playing lines the voice chat has shown, MatchState.music_bot_ids, never from Toots’ own embeds), and when a queued title starts in the voice chat it is credited to its requester by title (_title_key, loose), ahead of the play-command window, which stays the rule for a track that starts at once. Other lines: “Skipping, 1/2 (2 votes required)” is a vote, not a skip (a non-requester’s m!skip needs a majority; the requester skips at once); “You are missing the required session permission leave” is m!stop by a non-owner; “Track has been wound to 02:08/03:25” is m!seek/m!forward; “No one has been listening for the past 3 minutes, leaving” and “No tracks have been playing for the past 3 minutes, leaving” are idle exits (read as left); “I have been summoned” is m!join. A free-text m!play kelly dilema resolves on Deezer (the Deezer icon, a deezer.com link), a Spotify/Apple link keeps its own link and title (a Spotify title carries “(feat. X)” and “- 2014 Remaster”); the same query typed twice queues the same track twice.versuz_catalog.py, the Versuz song-credit lookup (#2874): Deezer contributors (their role: Featured acts first, then the title’s credit, then the co-leads last-billed first, #2885), then iTunes billing, then Genius’s featured_artists when both leave the cast blank (a posse cut both catalogs credit to the lead alone, #2885 item 2; gated on GENIUS_ACCESS_TOKEN, the catalog’s lead and link stay), all behind the shared title/artist matchers; song_from_link reads a pasted Spotify / Apple Music / Deezer / YouTube link into a title and artist for the card’s Fix forms; base_title drops edition noise (“- 2014 Remaster”, “(Radio Edit)”) but keeps a remix or a live take; one cache per song (hits 30 d, misses 1 h), L1 in process over the durable kv_cache tier bound at boot (versuz_catalog.bind_db in bot.py, #2885 item 4: a redeploy mid-night does not re-read the catalogs for the songs already on the board). tests/test_versuz_catalog.py.scripts/music_drop_manual.py + the music-drop skill (.claude/skills/music-drop, owner ask 2026-09-08): the HAND-FED music drop. The owner pastes a song link (Apple / Spotify / Deezer, plus an optional YouTube video); the script resolves the track (versuz_catalog.song_from_link), pulls a Perplexity read on it, composes N posts on the guild’s /menu music-model label (read from the settings table via /debug/query – owner steer: use the DB entry for the model) in one of two STYLES: a song link alone gets Toots’ TAKE (the SAME music_post prompt the live drop uses + discourse_score(surface="music"), floor 0.35); a song + VIDEO link gets a REPORT (the newsroom’s release lane, compose_music_record(kind="release") + music_desk_score(kind="release"), floor 0.6 – owner steer: “I wanted a report style for the video drop”), grounded in a claim built from real reads only (the YouTube title’s kind + channel, the iTunes release date; press rides as context, and the claim LEADS with the video, since a single-first claim made the gate score a ten-day-old release “out now” at 0.2), and prints the pick in the two shapes Music._sched_deliver ships (room: take, Apple link, video last; X: one line, video last) plus the cover art. A dry run; it never posts. Pure parts (split_links, default_style, room_blob, video_kind, report_claim, pick_best, render_shapes) are unit-tested in tests/test_music_drop_manual.py.scripts/versuz_card_manual.py + the versuz-card skill (.claude/skills/versuz-card, owner ask 2026-09-03): the RETROACTIVE card for a night Toots did not host. Reads the host’s polls out of the channel (or a JSON dump), infers each round’s sides from the running score in the poll titles, matches the options to the music bot’s Started-playing lines by title (CB-20 measured a model read and kept the title similarity: on the 44 real options of the two hand-made nights it is 44/44, Haiku 41-43/44 and Sonnet 42/44; with the true song taken out of the list, the similarity still matches a wrong song 16 times of 44, so a person reads the PNG before posting), resolves credits through versuz_catalog, renders the same card. A dry run; it never posts. tests/test_versuz_card_manual.py. **--sql (owner ask 2026-09-10, the 2026-09-02 night on the archive and the leaderboard): the run also writes the night as ONE SQL statement (archive_sql) the owner runs in the Postgres service shell, the rows a hosted finish writes (versuz_matches ended/archive, a versuz_rounds row per poll with the poll’s message id, a game_scores row per player with the finish’s detail shape, the winner’s game_champions best streak; the current reign is not touched); it inserts nothing when a match of the two players already started at that second. A match the owner wants OFF the archive (a demo night, a false start) gets status = 'hidden' by hand (2026-09-10: matches 2, 7 and 15 of the master guild): the Archive picker, the Reopen offer and the merge offer all read status and skip it, and its rows stay for the record. The channel walk starts at the window’s end (a snowflake carries its time) and waits out a 429, since paging from today to a week back hit the rate limit.scripts/versuz_recap.py + the versuz-recap skill (.claude/skills/versuz-recap, owner ask 2026-09-10): the X CAPTION over a Versuz card, generated (utils.versuz.recap_caption / recap_caption_for) in the one shape the shipped posts use (<Theme>, <A> vs <B>. <N> rounds, <V> votes, and <winner> takes the night <w>-<l>. + the invite line; a tie is said, a dead-even night says so). A hosted night is read by --match-id through the read-only /debug/query (the round rows give the round count and the vote total: the 2026-09-08 post said 19 rounds over an 18-round card, a retyped number); a night with no row takes the fields by hand, and the manual card script prints it with --caption. Never posts.versuz_card.py, the drawn Versuz scoreboard (#2877): reuses the desk card’s chrome (chart_cards’ floor, bloom, wordmark, tag, fonts) for a two-column round list, up to 24 rows, winner lit, each title over a small credit line (versuz_card.credit_line: <artist> ft <first feature>, either half alone when the other is missing; owner ask 2026-09-05 – three of the eight rounds that night were carried by an artist who was neither player’s, and the older feature-only line hid it). Deterministic; tests/test_versuz_card.py renders the real night.versuz.py, the pure Versuz match core (#2874): parse_jockie (the music bot’s Started-playing / skipped / no-more-tracks / left lines), MatchState (the slot rules, round readiness, poll title + options under the 55-char cap, the tally, ties, first-to, void, re-vote, the resume payload) and the text renderers the card uses. No discord, no network; tests/test_versuz.py replays the real 2026-09-02 voice log through it and requires the same 18 pairings and Old 10-8.arcade/ (#3687), the Tootsies Arcade’s pure core + socket hub: ring_kings.py (the fight sim, pure, seeded, 60 steps/s), deathmatch.py (the FPS sim, pure, seeded, 60 steps/s: nine stages with water, low cover and props, movement, server hitscan with a 200 ms rewind, bots), room.py (the game picker, lobby slots per game, host, ready/start rules, grace-time rules, the session tally, the wire messages), token.py (the signed join token), discord_auth.py (the Activity sign-in: the OAuth code swap + the two Discord checks, each an arcade_auth event), hub.py (rooms, the WebSocket handler, the match loop, game_match), web.py (the /arcade routes on the health server), and the LIVE FIGHT CARD core (owner ask 2026-09-25: the rest of the channel watches a fight without opening the game): live_card.py (pure, render_live_card(LiveCardState | DmCardState) -> PNG, a Ring Kings layout and a Deathmatch layout, 1000x400, the arcade palette + the client’s Press Start 2P font with the card bold font as the fallback, avatars reduced to 48 px and scaled up 3x for the pixel look, ~25 ms warm against a 60 ms budget) and live_feed.py (pure: LiveFeed folds the hub snapshots + events into a LiveCardState and picks the moment line, and DmLiveFeed does the same for a Deathmatch round (DmCardState, the kill feed line); EditPacer, one per channel, allows one edit per 1.2 s and at most 4 in any 5 s (Discord allows 5 edits per 5 s per channel, owner steer 2026-09-25), uses a 5 s gap for 30 s after a 429 (throttle), sends a knockdown, a power punch or counter that lands, a round change or the match end at the first allowed time, holds other changes 1.2 s so a burst is one edit, and keeps at most one pending edit; the snapshot’s “over” phase is ignored until the hub’s over message reaches finish, because a winner-less “over” card would show a DRAW). The hub feeds them through three ArcadeHooks (on_match_start, match_frame at 4 Hz, on_match_void) and the cog draws the card on the room’s message (see cogs/arcade.py above, “Live fight card”). See cogs/arcade.py above for the design. Stall handling (2026-09-28): the match loop shares the bot’s event loop, so a stall elsewhere freezes the match. After a stall over MAX_CATCHUP_MS (250 ms) the loop restarts its schedule from now and does NOT replay the missed steps: replaying ran the whole stall as one burst, with the bots acting while the players’ inputs were stuck, then 60 snapshots at once. utils/loop_watch.py (loop_stall) names the code behind each 1s+ stall. Own thread (2026-09-28, owner ask “can we add some isolation”): game_loop.py runs a second event loop on its own thread; the web server (the arcade page, its sign-in API, the game socket and /health + /debug/* + /x/webhook) and the ArcadeHub run there. The hub’s hooks into the Arcade cog run on the BOT loop (to_main / run_main when the hub waits, call_main in order when it does not); the cog’s calls into the hub (create room, mint, room lookup, cities, sweep, shutdown) run on the GAME loop (call_game / on_game). The non-game routes read their body on the game thread, then run on the bot loop (they use the DB pool and bot state). Measured with a real match over a real socket while the bot loop was blocked 3 s: the longest gap between snapshots fell from 3015 ms to 53 ms. Not covered: one long C call that keeps Python’s lock (a huge json.loads, one slow regex). Kill switch ARCADE_THREAD_ENABLED=0 puts everything back on one loop; every helper then calls directly, which is also how the unit tests run.discord_info.py, the pure core behind the read-only discord_lookup cog: the row shapes (MemberInfo/ServerInfo/RoleRow/ChannelRow/ReactorRow/PinRow), the format_* renderers (member/server/roles/role-members/channels/emojis/reactors/pins), and humanize_age (compact “3y 2mo” duration math). ASPECTS is the dispatch set; the MAX_* constants the list caps. Side-effect-free + unit-tested; the discord I/O (reading guild/member/channel objects, name resolution, reaction/pin fetches) lives in cogs/discord_info.py.scheduled_events.py, the pure core behind the scheduled-events cog (#888): build_event_spec (validates name/future-start, defaults end to +1h and location to TBD, clamps to Discord’s limits with notes), build_event_edit (a sparse {field: value} of only the provided edits for ScheduledEvent.edit), parse_event_time (ISO 8601 / unix / naive → aware UTC), and format_event/format_event_list. Side-effect-free + unit-tested; the discord I/O (create/edit/cancel/fetch, all needing Manage Events) lives in cogs/scheduled_events.py.message_search.py, the pure core behind the message-search cog: the SearchQuery/MessageFields shapes (the latter carrying the RICH per-hit detail — body, flags, reaction tallies, attachment (name,kind,url) rows, embed titles), the pure message_matches(fields, query) predicate (AND across filter categories incl. from_type + role/everyone mentions, AND within keywords), the model-arg parsers (normalize_keywords, parse_has with friendly aliases, parse_author_type, parse_order, parse_date for ISO/YYYY-MM-DD/epoch, clamp_limit), the is_empty() constraint guard, sort_results (newest/oldest), and format_results (a rich multi-line block per hit + a by-author/by-channel breakdown header). HAS_KINDS/AUTHOR_TYPES are the supported filter vocab; CONTENT_CHARS + the scan bounds (GUILD_SCAN_*, SINGLE_CHANNEL_SCAN) the caps. Side-effect-free + unit-tested; the discord I/O (name→id resolution, channel/thread/forum walks, has: kind + reaction + attachment extraction off a discord.Message) lives in cogs/message_search.py.social_search.py, the unified cross-platform social DISCOVERY core (#1272): one normalized SocialResult shape (platform/title/link/author/stat/published/engagement) + pure per-platform mappers (tiktok_search_result over /v1/tiktok/search/top items[] + the /v1/tiktok/get-trending-feed aweme_list[] — id/desc/statistics/create_time with _tiktok_date normalizing the unix-int-on-trending vs ISO-on-search split, canonical tiktok.com/@handle/video/id link fallback; youtube_search_result over /v1/youtube/search videos[] + /v1/youtube/shorts/trending shorts[] — id/title/channel/viewCountInt/lengthText-or-durationFormatted; instagram_reels_result over /v2/instagram/reels/search reels[] — id/caption/owner.username/like_count/comment_count) + three async orchestrators: search_socials (keyword search, TikTok + YouTube + Instagram) + search_hashtag (the #-tag sibling, sharing a _keyword_fan_out helper) + discover_trending (no-keyword trending, TikTok region-scoped feed + YouTube shorts), each fanning out via the ScrapeCreators client, MERGING + engagement-ranking, rendered by format_social_results (title · @author ✓ · stat · duration + link). Each SocialResult also carries a duration (TikTok video.duration ms / IG video_duration s / YouTube lengthSeconds, rendered once as m:ss) + a verified author badge. _SEARCH_ENDPOINTS/_HASHTAG_ENDPOINTS/_TRENDING_ENDPOINTS are the extension points (add a platform’s path/param/key/mapper). Fail-open per platform (a source miss contributes nothing), emits social_search. Mappers + formatter + orchestrators are pure/unit-tested (tests/test_social_search.py); the ScrapeCreators fetch is guarded in utils/scrapecreators.py. Wired as the /ask search_socials + discover_trending tools (handlers cogs/ask.py:_search_socials/_discover_trending) so the model finds fresh social content uniformly — the “search all social platforms uniformly” ask.playercard.py, the PURE scoring core behind the /card cog (#570), no I/O: takes a guild SNAPSHOT (every active member’s raw PlayerStats) and renders one member’s Card (OVR + tier + three percentile-ranked RATINGS + badges + archetype + chase line). _STATS is the single source of truth for the named face-stats (key/label/emoji/blurb/weight/terms) — the OVR weights, the rubric (rubric_lines), and the card labels (stat_labels) all derive from it. Only the 3 RATING_KEYS (clout/humor/social) are percentile-ranked + feed the OVR (face_stats/overall_rating/rubric_lines default to them); the other dims (presence/taste/degen) are NOT ratings — archetype_for reads the wider ALL_STAT_KEYS so a one-lane regular still gets their label (a degen cards “The Whale”), and their raw numbers surface in the tape/badges. pct_rank (ties-split percentile) makes every number relative to the room. CLOUT = the three per-post RATES (reactions_per_msg + replies_pulled_per_msg + mentions_per_msg) blended with the three received TOTALS (reactions_received + replies_received + mentions_received) — six inbound signals balancing rate + volume; HUMOR = laugh_share + bangers (the #570 rework — magnitude vs flavor; the three ratings are weighted EQUALLY, 33/33/33). Also exposes the PURE detail_pages(tape) (→ CardPage/StatLine): the four scrollable detail pages that cover EVERY counted stat, formatting a RESOLVED tape dict (skip-zero, name/dict/date/duration helpers) — the cog supplies the Discord render + name resolution. Plus the PURE about_member_text(summary, names) for the memory hype-up — keeps only the sentences of a memory note that NAME the member (word-boundary, NFC, case-insensitive over every alias), so a whole-room note collapses to its about-them signal — and spread_evenly(pieces, budget, max_items=), which picks a budget-fitting subset of the time-ordered notes spread EVENLY across the range (anchored on the first), so the hype-up reflects the member’s whole arc from their first days to now, not just the recent stretch. Fully unit-tested in tests/test_playercard.py; the data-gathering + Discord render live in cogs/playercard.py.engagement.py, the shared engagement-scan engine (#570) — the per-message tally /stats (cogs.stats, the full-history scan) runs to build the seed /card reads, so the counting logic lives in ONE place. The cog walks each channel oldest-first and calls ScanState.tally_message(msg, channel_id=, start=, end=); because the walk is ordered + intra-channel, a reply’s target is already seen, so replies + reply-latency + the conversation-flow stats (closer/reviver/burst) resolve INLINE. ScanState.finalize then folds embed-fixer webhook reposts onto the real user BY USERNAME (the repost’s avatar is the webhook’s, not the user’s — the proven utils.starboard name-fold; a genuine bot like MEE6/Toots is skipped) and emits the per-user stat BLOB the seed stores (a JSONB dict, so a new stat is just a new key — no migration). The blob is comprehensive: volume + cadence (messages/chars/longest-message/days/longest+current streak/longest-absence “ghost”/first+last/hour+weekday+monthly histograms/busiest-month/busiest-day/late), reactions (split laughs→HUMOR vs hype→CLOUT, by-emoji, bangers, top post; bot reactions are excluded — Toots’ own via r.me (she reacts via chime-in) AND MEE6’s music-link auto-reaction via fingerprint (mee6_autoreaction_counts mirrors MEE6’s owner-confirmed, live-verified automation: it reacts 🔥 + 🆒 + ✌️ — all unicode, the automation UI’s “:cool:” is the unicode 🆒 (U+1F192), NOT a custom emoji — on any message carrying a music domain, so a trigger-domain message carrying all three has one of each subtracted) — a bot’s reaction must not inflate a member’s reactions_received/laughs/bangers; both are free (no per-reactor users() fetch, prohibitive over a ~300k-msg rebuild) and SAFE (requiring all three emoji means a music post lacking the full fingerprint is never touched, so it under-corrects rather than ever stripping a real human reaction; a dry run over 200 real music posts confirmed 80/93 fire)), social (replies sent/received, ride-or-die outbound + top-fan inbound + best-duo, mentions made/received + distinct-mentioned reach + #1-mention, most-replied post, quickdraw + avg latency), flow (closes/revives/bursts), content persona (links bucketed apple_music/spotify/youtube/x/tiktok/instagram/gif/image/video/other — gif = animated shares (tenor/giphy/direct .gif), image = a pasted still picture (imgur/direct .png/.jpg), video = a raw clip URL (streamable/direct .mp4), each distinct from an attachment which is counted separately as images_sent/videos_sent — attachments split images/videos/files + stickers sent, voice notes sent, forwards + cross-server forwards (the “mole”, a native Discord forward whose source guild differs), got-pinned, first-of-day opener, signature emoji via own-text emoji scan, total emoji-messages + emoji count, questions, caps, edited), roasts (#570: own-laugh rate per message via is_own_laugh, left-on-read = posts with no reaction AND no reply), and per-channel (channel_messages + home channel). The per-message trackers behind most-replied / left-on-read are bounded (only posts that GOT a reply or reaction are held, not every message). IDs of other people (ride_or_die/top_fan/best_duo/#1-mention) are stored so the reader resolves the current name. Pure, unit-tested in tests/test_engagement.py. Incremental catch-up (#570): every acc field is additively mergeable (it’s how the fixer fold works), so the seed stores a round-trippable durable merge state beside the display blob and a CATCH-UP scan folds a delta into it via the same EngagementAcc.merge. The public surface: finalize_accs() (the folded {uid: acc} + token→uid resolver, the raw material), acc_to_state/state_to_acc (the JSON-safe round-trip — reply_targets/repliers resolved token→uid, names dropped), merge_into_state(stored, delta_acc, resolve) (the fold: stored state + delta → one merged uid-keyed acc), and seed_row(uid, acc, resolve?) (→ {"display", "state"}; resolve defaults to identity for an already-merged catch-up acc). ScanState(opened_through_ordinal=) is the catch-up’s first_of_day guard. The member_engagement seed is the canonical per-user engagement store going forward — new per-user engagement features read it, and the message-engagement counting lives HERE in one engine (don’t re-implement a tally elsewhere). The starboard reactions + bangers boards (in the /leaderboard stats hub) now read this seed directly (reactions_received / bangers, no scan; #957, #949 closed) — the only #wall-of-fame SCAN left is /star + the /card’s real-MEE6 “wall bangers” flex (lifetime_totals), which need the actual wall the seed can’t capture.engagement_labels.py, the Jev labeling pass for three engagement stats (#4318, epic #4309). The seed counts questions (ends with ?), laugh_messages (is_own_laugh) and caps_messages (mostly capitals) with shape rules; this pass asks TypeSafe’s Jev model three Nouls per message instead (“does messages.m7 ask a real question”, “is the author of messages.m7 laughing”, “is messages.m7 excited hype”). Shape: ScanState.tally_message returns the author token; cogs/stats.py:_walk feeds (token, text) to a LabelPass; the pass cleans the text (links, mentions and custom-emoji ids replaced, 500-char cap), sends it through the shared client (utils.typesafe_client, #4310, purpose=engagement_labels) and credits the verdict back with ScanState.credit_labels. The scan drains the pass before the fold, so the counts merge like every other stat (an embed-fixer repost folds onto the user). Stored keys (beside the shape-rule keys, which are unchanged): labeled_messages (messages the pass saw), labeled_by_model (of those, Jev answered), questions_labeled, laugh_messages_labeled, hype_messages_labeled. Reader contract: read a *_labeled key only when labeled_messages == messages; otherwise read the shape-rule key. A catch-up without the pass (the key unset, or before #4318 shipped), or a rebuild resumed without it, breaks the equality, so a reader falls back by construction. The reader (CB-14): utils.engagement.labeled_stat(stats, key) is the one reader. The /card content page reads “questions asked” and “times you laughed” through it (LABELED_STATS). “ALL-CAPS posts” stays on the shape rule: the stat names a shape, and Jev’s hype label answers a different question, so moving the card to hype is an owner call. Awards read none of the three. Until one full labeled rebuild (/stats rebuild:True) runs, no member has the equality (2026-10-18: 1,445 labeled of 709,777 messages, 0 of 279 members), so the card shows the shape counts and this reader changes nothing members see. /stats period: backfill and the snapshot repair do not label. An additive backfill therefore breaks the equality, and the card falls back to the shape count until the next rebuild. A window delta reads the same rule on the deltas: a snapshot older than the rebuild has labeled_messages == 0, so the delta is unequal and reads the shape key. Rules (owner): counts only, never text in the seed, a log or an event; the request carries message text only (no author, id or channel), so it labels the message act, not the person; the scan stays pure; fail open per message to the shape rules (no answer, an open breaker, a missing key); /card’s live fallback is untouched. Live, no experiment (owner, 2026-10-17: “remove experiment and have it ship to prod”): every /stats rebuild and catch-up, including the scheduled daily one, labels whenever TYPESAFE_API_KEY is set, so the equality stays true. With the key unset, no request is sent and the shape rules count alone. The off switch is the key: unset TYPESAFE_API_KEY on Railway to stop sending text. #4388 shipped it behind an engagement_labels experiment (OFF); that gate was removed in the follow-up PR. 20 messages per request under named keys, 2 in flight (measured 2026-10-17): with the messages in a list (messages[i]), labels moved between neighbours as the batch grew (at 20 per request hype accuracy fell to 0.80; Jev does not count list positions reliably). Under named keys (messages.m0, messages.m1, …) 20 per request scored 0.98 or better on every label, the same as 1 per request. One per request is not usable: the shared client’s loop guard allows 600 requests a minute for the whole bot, so 300k requests would take about 8 hours, and each request emits one typesafe_api event. At 20 per request a 300k-message rebuild is about 15k requests, about $3.60 and about 25 minutes at the limiter, beside the Discord walk; a pilot’s Jev call during a labeled rebuild can wait a fraction of a second for a limiter slot. Memory is bounded to (2 + 1) x 20 cleaned texts. Pure parts unit-tested in tests/test_engagement_labels.py; live accuracy in scripts/eval_engagement_labels.py (docs/EVALS.md).engagement_window.py, the pure windowing core for weekly/monthly engagement stats (#1017). The seed is all-time cumulative, so a windowed view = the seed MINUS a daily SNAPSHOT from N days ago (db.engagement_snapshots, frozen by the /stats daily catch-up). Period (all_time/weekly/monthly, .days = None/7/30, .label); window_display (per-member numeric counter deltas, clamped at 0 — bangers/reactions/the OVR rating inputs); window_state (the reply_targets/mention_targets graph deltas for windowed Besties); extract_display/extract_state (unwrap the {display, state} snapshot blobs); and reconstruct_snapshot (the INVERSE, for the /stats period: backfill — window_display/window_state over (current, scanned_delta) to build a past-dated snapshot whose forward diff reproduces the window). No I/O — the cog reads the live seed + get_engagement_snapshot_near and hands the blobs here; the SAME board builders rank over the windowed blob exactly as over the all-time seed. One daily snapshot serves BOTH windows by subtraction (no extra scan jobs); forward-looking (no snapshot yet → empty window, never all-time mislabeled). Unit-tested in tests/test_engagement_window.py.awards.py, the pure award-selection core behind the weekly/monthly awards cog (#1017). Side-effect-free: the cog builds the windowed boards (utils.engagement_window) + the windowed OVR ranking (utils.playercard) + closest-pairs (utils.social_graph) and hands the already-ranked inputs here; compute_awards(keys, period, …) picks each award’s winner under two dedup layers: cross-category (_pick_single’s taken set — a person is never crowned two single-winner awards in ONE post, so nobody sweeps; keys order is priority, so the headline takes the top contender and later awards fall to the next distinct one; an award with no untaken candidate is omitted) + rolling anti-repeat PER CATEGORY (recent_winners[key], the set the COG feeds — the last few winners OF THAT AWARD, so a person can’t repeat the SAME award for a few periods but CAN win a different one — skipped for a fresh runner-up, falling back past it only if every untaken candidate is recent). AWARDS is the registry (key/emoji/title/blurb/metric/blurb_off_top; AwardSpec.title(period, month_name) fills a {month}-templated title, and AwardSpec.measure(period, rank) returns what the award measures — the superlative blurb only when the winner is FIRST on the board, else the blurb_off_top wording, because the anti-repeat below deliberately crowns runners-up and AwardResult.rank carries their real place so no post claims a place they did not take). rank is COMPETITION ranking — 1 + how many scored strictly higher — not the row index: _rank_by_metric breaks ties by uid, so when the dedup skips the first of two level members the second would otherwise read as “second” and be recorded as an off-top crown though nobody outscored them (the week of 2026-08-30 had two members level on 934, 414+520 against 514+420): WEEKLY_KEYS = (mvp,) (the mvp award titled “Player of the Week” weekly) vs MONTHLY_KEYS = (mvp, funniest, besties) (the mvp titled “{month} MVP” monthly, e.g. “June MVP”) — both the SAME engagement metric, NO OVR/”Player” award. A metric award ranks a windowed display counter, or a +-joined SUM of them (reactions_received+replies_received → MVP = most engagement, the detail breaking the sum down “4,931 reactions · 812 replies”; laugh_reactions → Funniest); the one rating-style special is besties (windowed closest pair — a relationship axis, so it doesn’t claim taken and softly prefers a pair bringing a fresh face). bangers (wall-clearing posts) stays defined + tested but is off the live key sets; the player/OVR + breakout AwardSpecs were removed (no OVR/Player award — OVR is the /card rating; breakout never live). Unit-tested in tests/test_awards.py.chart_history.py + cogs/chart_history.py, the durable chart-history store (owner steer 2026-09-20: “start storing all of these values in Postgres … go back maybe two years … update each week”). Every chart lane read its source on the fly and threw the reading away, so a forecast of our own had no history to fit on. Four tables in db.py, one join — the Billboard CHART WEEK (a Saturday) and the folded artist_key (names.fold_for_search, the same fold for every source; the title key drops Billboard’s “(EP)”): chart_history_weeks (billboard.com dated pages, one row per chart/week/rank), chart_history_projections (HITS midweek + building documents back to 2025-01, and Nora Music’s X posts + the posts citing them, one row per projected release per chart week), chart_history_markets (one row per Kalshi music-numbers EVENT: latest implied figure, Luminate’s settled count, the #1-debut binaries’ result) and chart_history_market_snapshots (the implied figure per event per day — the path a settlement erases). The cog is a 10-minute tasks.loop doing ONE bounded unit per source per tick, each walk a ResumableScan under guild 0 (resumable_scan:chart_history:<source>) so a redeploy resumes: Billboard re-reads the current week until the page flips then walks back CHART_HISTORY_WEEKS_BACK dated pages (a page is stored only when its printed week IS the week asked for — billboard.com answers an unpublished date with the current chart; a date that fails three reads on three different DAYS is skipped and named in meta — the breaker’s cooldown and the dated-page cache answer several ticks with one read, so a tick is not an attempt, and a week already counted today is skipped before it spends a page (#3600), so one dead date cannot hold the walk for the day; the dated reads go through chart(..., quiet=True): the store’s own breaker, never the live lanes’ block state; and a DATED page that prints no week is a miss (undated) for every caller and is never cached, since the requested date is not evidence of what the page is — review on #3548); the story walk reads one story per tick for the newest unfilled week, a week still inside its publish window does not hold the older ones, and a candidate page that parses but verifies against no stored row falls through to the next candidate; HITS pages the dataset newest-first via HitsClient.projection_docs (a document with no structured chart_entries, which is every one before 2026-04, is parsed from its chart_data TSV twin by hits.parse_chart_data; without that fallback fifteen months of history parsed to nothing, found by the round-3 backtest on 2026-09-21) and re-reads each tick everything dated on or after the newest date held (inclusive, so a document HITS edits in place under the same date is re-stored — a document still at the _updatedAt last stored is skipped, so the newest document is not rewritten every tick; the end of history is judged on the raw page size, never on how many documents parsed); Kalshi walks each series’ settled ladders one page per tick (KalshiClient.get_series_markets_raw), once a day end to end (a page that misses three ticks in a row is dropped and the walk moves to the next series, so a cursor Kalshi no longer accepts cannot stall it), over the baseline KALSHI_SERIES unioned with the families the desk discovers live (music_markets.music_series, frozen per walk), where a settled page only names the events to read, since it holds rungs, not events, and only the rungs that resolved: each event is fetched whole through its own endpoint and a ladder with rungs still trading (an annual streams market with one threshold crossed) is stored open, without a count; an annual window gets no weekly chart date; plus a daily EXHAUSTIVE snapshot of every open event’s implied figure (every series, paged), stamped only when nothing was missed — what was missed (a series whose event list failed, an event whose markets failed) is kept in the checkpoint, as (ticker, metric) pairs, and the next tick re-reads ONLY that off the checkpoint, never off a fresh discovery, so an incomplete day never re-runs the whole sweep and never certifies a partial one; the event read follows its page cursor, so an event is always read whole; the weekly streams families state their window without a year (“July 10 - July 16”, 71 settled KXALBUMSTREAMSU events live on 2026-09-21), so market_window takes the year from the end date or from the event ticker’s date code (...26JUL16, read by the ONE music_markets.ticker_date_code the debut walk also uses), never guessed when both are absent or when the period and the code disagree; an artist-streams event (“Will 2Pac have 73000000 Streams”) is keyed as the ARTIST, since it names no release; Nora walks months through XProvider.search_raw (own posts + "via @Noramusicinc" citations, parsed by parse_nora_text — the Sunday RADAR list, the prose “62K units” call, the bare “#4” rank call; a rank in a HEDGED sentence — “challenging for a #1 debut”, “could miss #1” — is a contest, not a call, so the units count and the rank stays NULL; a page of posts with no projection line is a zero-row success, not a miss), re-reading the current month daily and ending the walk after three empty months (their searchable history starts 2026-08) – the end marker is scoped, so the month that was current when the walk ended is still walked in full once the calendar moves on, and a search miss (search_raw returns None) leaves the cursor untouched; all four of those came from the Codex review on #3548. The chart-week rules: HITS’ tracking-week Thursday + 9 days, where the Thursday is hits.tracking_thursday of the document date (HITS dated documents by their PUBLISH day until 2026-01-08, the midweek on the Tuesday inside the week and the building chart on the Friday after it, so a plain date + 9 mis-keyed a year of history; found by the first backtest, 2026-09-20); Kalshi’s window end + 9 days (a KXALBUMDEBUT reads its date off the ticker); a Nora post maps its Eastern date to the Friday..Thursday week it speaks about, a FRIDAY post being the wrap of the week that just closed (nora_chart_date). Fail direction per row: absent over invented — an unparseable Kalshi title, a Nora line with no number and no rank, an undated HITS document all yield nothing. Pacing is small by construction (the whole Billboard walk is ~208 pages at two a tick; the Kalshi client’s own limiter and breaker guard the rest; Nora is metered at one page per tick per query). Telemetry: chart_history (per unit + a daily coverage row), chart_history:<source> ops-monitor health, three dashboard panels. Read the store over /debug/query (SELECT ... FROM chart_history_projections WHERE artist_key = 'jheneaiko' ORDER BY chart_date), or in the bot through the ONE read path chart_history.lookup_history -> db.chart_history_lookup -> render_history (#3562, E: the @Toots chart_history tool and the X mention reply’s history lines; the portal is the next consumer). The projections key carries source_ref (the document or tweet id) and chart, so two Nora posts about one release in one week are two readings, not one overwrite, and a Hot 100 rank call beside a Billboard 200 one is two rows; a HITS document re-read after an in-place edit is replaced whole (replace_chart_history_projections, one transaction), so an entry HITS dropped is dropped here. The printed units are also the DESK’s authority, not only the store’s (#3627, 2026-09-22). Kalshi settles an album-equivalent-units event on Luminate’s RAW consumption count, and the Billboard 200 figure Billboard publishes is filtered DOWN from that same count – the two are different numbers for one release and one tracking week, and every desk lane was stating Kalshi’s while naming the Billboard 200. Measured over the six settled debuts the store holds both numbers for: Kalshi ran +7.9% on Rod Wave’s Don't Look Down (106,778 against 99,000), +6.6% on Westside Whimsy (78,919 against 74,000), +5.4% on KATSEYE’s WILD (179,122 against 170,000), then +1.3%, +0.8% and -0.2%. The post that found it went to X on 2026-09-22 – “Jhene Aiko’s ‘Westside Whimsy’ closed its debut week with 78.9K album-equivalent units, her first No. 1 Billboard 200 album” – a day after @chartdata printed 74,000; the blob the model composed from read ... | debut / first week | final 78.9K, so the model reported exactly what it was handed and the figure was wrong before any prompt saw it. BOTH numeric axes carry a Billboard print, and both are filtered (#3630, 2026-09-22). Album-equivalent units ran +7.9% to -0.2% against the print over six debuts; STREAMS ran +11.7% on Westside Whimsy (79,696,421 against 71.33M) and +14.2% on Rod Wave’s Don't Look Down (108,575,899 against 95.07M) – the same chart rules biting harder, because streams are where most of the filtering happens. Billboard states the raw on-demand count inside the SEA parenthetical (“SEA units comprise 70,000 (equaling 71.33 million on-demand official streams…)”), parsed by billboard_stories._sea_streams onto StoryRow.sea_streams – 12 of 12 stories in the corpus, three authors’ phrasings. That figure is the LEADER’s alone and always will be: Billboard prints no per-rank stream count, so unlike the units axis the streams one can never reach past No. 1, whether or not the chart page has flipped. The restatement takes the ALBUM stream family and no other (music_markets.is_printable_streams_reading, an EXACT match on the KXALBUMSTREAMS series ticker), because the metric label says “Streams” for families that count different things and a matching title cannot separate them: KXSONGSTREAMS counts one SONG and a title track carries the album’s own name, KXALBUMSTREAMSU settles on WORLDWIDE streams by its own rule text where Billboard prints the U.S. count, and KXARTISTSTREAMS/KXARTISTSTREAMSY count an artist’s whole catalog, which a self-titled album matches. Measured before the gate (Codex on #3650): a song week (18,500,000), a worldwide week (142,000,000) and an artist catalog week (522,500,000) each took the album’s printed 71,330,000. The match is exact rather than a prefix because the sweep DISCOVERS families live, so a family nobody has measured keeps its own number. The units axis needs no such gate today: KXTOTALALBEQUIV is the one album-equivalent family that is not one album, and it is annual, so the date window already refuses it. The window guard is on the DATES, not the metric or the stage, because neither separates the families: KXALBUMEQUIVY is an annual units market and an annual ARTIST-streams market reads release_stage key weekly_artist, not annual. Measured while adding the axis: a 5,000,000,000-stream YEAR was restated onto a 71,330,000 week, a 70x error, because the guard keyed on a taxonomy instead of the data. Pure sales is still untouched – no settled market has been available to measure it against Billboard’s printed album_sales. Billboard’s print is the FINAL everywhere, and Kalshi’s settlement is kept beside it, never instead of it (owner steer 2026-09-22: “avoid reporting on the luminate and use the billboard number, the filtered number – that’s the actual truth … we should still record the final luminate Kalshi outcome, just to have it for posterity”). The scorecard already said this – called_shot.ScorecardRow.final_units has graded on Billboard since 2026-09-17, “Billboard is the actual final score”, naming the filter as Billboard’s chart rules (no bundle sales, no physical-with-download as a digital sale, a price floor, its own stream ratio) – but the number arrived by a hand-fed JSON and the REPORTING surfaces never got it. THE SPLIT, one rule: the field a surface REPORTS carries Billboard’s figure (LuminateReading.value_units, CallScore.settled_units), and Kalshi’s own value moves to a field beside it (settled_raw_units), so no surface can state the raw count by forgetting to prefer the other – the miss-risk runs the safe way. The STORE splits them back apart into their own columns (chart_history_markets.settled_units + printed_units, called_shots.settled_units + billboard_units), so a column keeps the meaning it always had, rows written before and after stay comparable, and the PAIR survives for the projection correction #3629 has to be fitted on. What a reader or the model SEES is Billboard’s alone: the card pill and the compose blob credit Billboard on a restated figure, the chart_history tool renders “final N (Billboard)” and renders Kalshi’s only when no print exists (then naming it “(Kalshi/Luminate)”), and the reconciliation blob heads “BILLBOARD published UNITS”. The raw count is a stored fact, not an answer. music_markets.apply_printed_units does the restatement at the shared sweep (beside contradicted_pure_sales, for the same reason: it is the one read the desk, the alert, the newsroom, the mentions lane and @Toots all share, so they state ONE number), joined on the TRACKING WEEK – the reading’s period end is nine days before the chart date (hits.projected_chart_date), which is the week the story states. It does NOT wait for the store: the story publishes the Sunday BEFORE the Tuesday page flip (2026-09-20 15:00, two hours before Kalshi settled the same market), while the store’s story walk is chained behind the flip because verify_rows needs the page rows – so the store gets the figure on Tuesday and the desk needs it on Sunday. The desk therefore reads the story itself, cached per chart date for the process’ life (one read per week). How much is restated depends on the DAY, because the two Billboard sources land two days apart. From the Tuesday page flip the chart page gives (rank, title, artist), so verify_rows pins the release to a rank and the whole printed top 10 restates (billboard_stories.row_for_release). Before the flip only the No. 1 can be named at all, off the story HEADLINE, because the leader’s own paragraph states the figure without naming the title (“The set earned 74,000 equivalent album units…”) – which is also the one rank verify_rows cannot confirm from the page, so the page’s own row stands in for it there. No rank may be matched by NAME against the prose: in the 2026-09-26 story “Westside Whimsy” appears inside the No. 2 segment (“…the only debuting title in the region… with Ella Langley’s Dandelion falling to No. 2 with a little more than 60,000”), so a name-in-segment match hands the No. 1 the No. 2’s figure – the exact error the lookup exists to remove, caught by its own test before it shipped. A settled Kalshi market lands in the pre-flip half (Kalshi settled Westside Whimsy two hours after the story went up and four days before the page flipped), so the headline road is the one that carries a debut week. A release outside the printed top 10 keeps its Kalshi figure: Billboard prints units for the top 10 only, so there is no truer number to move to. Fail-open at every step. Telemetry: market_corrected reason=printed_units + the printed_units dashboard panel, whose alarm direction is EMPTY (the sources nearly never agree, so no rows while settled units readings post means the lookup went dark). The printed units (research round 2, 2026-09-20, billboard_stories.py): a chart page carries rank, peak and weeks, never units, and Kalshi settles on Luminate’s count only since July 2026, so the first backtest had 18 settled counts to grade a forecast on. Billboard’s own weekly chart story states the units for the whole top 10 (the No. 1 in full with its SEA/sales/TEA split and the week ending; the rest in prose, “falling to No. 2 with a little more than 60,000 equivalent album units (down 11%)”), back for years. The cog’s fifth walk (story) takes the newest stored Billboard 200 week with ANY unfilled row in the printed top 10 (#3626: it used to ask only whether the No. 1 carried units, so a week whose story named only the leader left the list with up to nine rows empty for good – measured 2026-09-22, 41 of 59 filled weeks were partial and 78 top-10 rows were stranded; the live dry run then showed how many of those actually come back, 9 of 38 across twelve sampled weeks and all nine from one leader-only week, because the rest are figures no candidate story states), finds its story through billboard.com’s WordPress post API (BillboardClient.story_posts, the Chart Beat category over the Sunday..Thursday before the chart date, story_post_candidates ordering the titles that name the Billboard 200: the ones naming its leader first, newest first, then any other that is not a preview question, because some weeks have no leader-titled story at all and the numbers sit under a top-10 story such as “Madonna & Michael Jackson Are in the Billboard 200’s Top 10 Together” (2026-07-18); a no-break space inside “Billboard 200” is folded first, since WordPress keeps them and one hid the 2026-07-04 story), reads each candidate’s page in turn (story_page) while ranks remain empty, taking from each only the ranks still unfilled so the best candidate stays authoritative and a later one adds just what it missed, parses each one (parse_story: a figure counts only inside the segment after a RANK ANCHOR such as No. 4 or climbs 10-9 in the same paragraph, never a rank on another chart or in dated history; a story whose own dated <month day> is another week is rejected whole) and VERIFIES each figure against the stored page rows (verify_rows: the printed title or artist at that rank must appear in the segment the figure was read from) before update_chart_history_units writes the units / album_sales / sea / tea / units_change_pct / units_approx / story_ref columns. One story per tick; a week whose window holds no story, verifies to nothing, or comes back PARTIAL (figures stored, ranks still empty) counts an attempt only once its window has closed and is skipped after three (named in meta). Neither story read caches, so a pass costs one discovery read plus up to three page reads, live, and a week that stays incomplete spends at most three passes. A dead candidate is recorded and the walk reads the NEXT one, so a dead middle link cannot hide a later story carrying the ranks the first missed; once figures have landed the pass counts as partial rather than as the uncounted unavailable, or a permanently dead link would be retried every tick. A week whose publish window is still OPEN counts no attempt, because a later story may complete it, so it is stamped in read_on and read at most ONCE A DAY: partial repeats every tick and the story reads are uncached, so an open window would otherwise cost hundreds of live reads over its three or four days (Codex review on #3635). That is the same shape as the page walk’s attempted_on. The story walk’s checkpoint unit carries a VERSION (_STORY_UNIT_VERSION), because is_done is read before the work list: the old done set held 54 of the 60 weeks still incomplete on 2026-09-22, so without a new key this change would have reopened six of them. Measured on the twelve-story corpus in tests/fixtures/billboard_stories.json (2024-09 to 2026-09): 102 of 110 top-10 rows, the eight misses being figures stated in a following paragraph with no rank anchor – absent, not guessed. The leader sentence has five shapes, “in the week ending Sept. 17”, “in its first week, ending Sept. 3”, “in its first week (ending Aug. 27)”, “in its first week (June 12-18)” and one that names no week at all (“spends a second week at No. 1 … earning 180,000 equivalent album units”, read off the first opening paragraph that names the No. 1 and a figure, with the week left unknown); the first regex knew one and the local 99-week harvest found the rest (2026-09-21). Telemetry: billboard_story (the two reads, its own breaker, quiet like the artist-history read), chart_history source story. Dry run: python -m scripts.dryrun_chart_history (live reads, parsed to rows, no DB; --story adds the current week’s story read + verification). Research round 4 (2026-09-21, the day-one capture): a sixth walk, daily, stores the signals nothing upstream archives, into two more tables. chart_history_daily holds the five platform charts once a day: the Spotify US daily chart off kworb (us_daily, with streams, keyed on the page’s OWN chart date, which kworb publishes one or two days late, so the page is read every tick on the client’s conditional GET and stored the tick its date moves) and the four rank-only charts (Apple Music US songs + albums and iTunes US songs off kworb, iTunes US albums off Apple’s RSS), keyed on the read’s UTC date since the pages print none. chart_history_track_days holds per-track per-day Spotify streams, US and Global: today’s US row off the chart, and the last ~30 days off the track page’s daily grid (TrackStreams.days, CHART_HISTORY_GRID_PAGES_PER_TICK pages a tick) the first time a track is seen, then never again – the day-one streams of a release’s charting tracks, which the audit found recoverable for one month and then gone. Every read rides the lanes’ own kworb + iTunes clients and their caches, so the walk adds no page reads beyond the grids. A Spotify page that names no date stores nothing (reason=undated); a chart under its row floor (DAILY_CHART_FLOORS, well under the published sizes) is a miss (short), never the day’s snapshot, since a snapshot is written once and not re-read; a dead grid page, or one whose rows yield no reading (empty_grid), counts one attempt per day, is skipped after three days and named in grid_skipped, and never holds the queue (a track already missed today is skipped before it spends a page, #3600); a grid is checkpointed only when it yields at least one record; a track charting in the US but never on the Global chart parses (Global total 0) so its grid is read. Research round B (2026-09-22, the holdover curve, utils/chart_priors.py): the store now grades ITSELF. fit_curve reads every consecutive-week pair where BOTH weeks printed units (db.chart_history_unit_pairs) and yields the retention curve by week on chart: 0.417 at week 2, 0.703 at week 3, 0.804 at week 4, 0.910 across weeks 5 to 8, 0.973 from week 9 (fit 2026-09-22 on 245 pairs over 44 weeks). ONLY week 2 is banded by the previous week’s sales share, and that is measured, not assumed: banding week 2 cut the leave-one-out median error from 20.6% to 13.1% and the p90 from 82.0% to 30.1% (a sales-heavy opening falls hardest – two Stray Kids EPs opened 97% sales and kept 22%, Rod Wave opened 9% sales and kept 42%), while banding the other buckets moved the pooled error by nothing, because 172 of the 245 pairs are week-9+ holdovers that hold about 0.97 whatever their mix. The curve’s own error, same leave-one-out: 5.3% median, 22.2% p90 overall; by cell 11 to 21% on the banded week-2 cells, 29% on the pooled week-2 cell, and 4 to 8% from week 3 on. The curve lives in the module as DATA with its fit date and pair count; python -m scripts.fit_chart_decay re-measures it against the live store (through /debug/query, resolving the domain and token off the Railway API) and prints a paste-ready block, so a refit is a reviewable diff and never a silent drift. The store’s units are Billboard 200 TOP 10 only, and that CONDITIONS the curve: a pair exists only where the release printed units in both weeks, so each cell measures how a release held GIVEN that it stayed in the top 10, and a release at the edge carries no odds of falling out of the printed figures. That missing half is measured from the same rows read the other way (db.chart_history_unit_exits -> chart_priors.EXIT_RATES, fit on 476 weeks): of the weeks that printed a figure, how many were followed by a week ranked BELOW No. 10, or off the Billboard 200 entirely, by rank band and by whether the week was an OPENING. The exit reads the following week’s RANK, never its units, and that correction matters (Codex review on #3614): reading units counted 53 of the 476 pairs as exits although the release was still inside the top 10 with no figure stored yet, while an inner join dropped the 8 releases that left the chart outright, so the holdover cells read 20% where they measure 3 to 7%. It dominates a fresh debut – an opening at #4-6 fell out 23 times in 26 (88%), and at #7-10 23 times in 25, against 3% for a holdover at #1-3, 7% at #4-6 and 43% at #7-10 – so the outlook line states its own cell’s rate BEFORE the basis, and the head plus the rank say the rest. Research round D (2026-09-22, the store’s own forecast BOARD): chart_boards.holdover_forecast_board renders the round-B arithmetic as a postable Board, over db.chart_history_current_top10 (the newest stored week’s printed top 10). It carries projected=True, so the card dates itself PROJECTED FOR rather than wearing a printed chart’s date line, and source="Tootsies", because the figures are OUR arithmetic on Billboard’s printed units and nothing from HITS or Kalshi rides it (the one-source rule). Rows sort by the PROJECTED figure while their lead marker keeps the rank the chart PRINTED, which is the board’s whole angle: on the live top 10 of 2026-09-19 it puts PRIMA, printed at #4 off a 56,000 opening, LAST at about 23,000 for its second week. The store forecasts units and models no rank, so the headline claims units only (#3625). The lead is the row’s OWN order, the printed rank rides the credit line in plain words (“Ella Langley · was #1”), and the headline names its COMPARISON SET and the leader’s WEEK COUNT – because the first card the owner saw got all of it wrong (2026-09-22). Sorted by the projection the printed ranks run 1, 2, 3, 5, 6, 9, 10, 7, 4 with #8 absent, and every other board in that module leads with the row’s own place in the list – so a column of THOSE reads as a sorting fault, and the card can say no better: chart_cards.render_board_portrait draws no column header (the board’s own value_header="proj. units" reaches nothing) and draws a caption only when it carries a cut-off note, so no legend is available. The marker was “LW #4” for one afternoon; the owner’s answer was “I don’t like the LW, what is that?”, which is this file’s own rule about defining a technical name or using the plain words. The lead became the row’s place in the list, as on every sibling board, and “was #4” moved to the credit line, where there is room to spell it out. That move cost a FABRICATION until the week count went in: “was #1” beside a figure for next week is a week-over-week story with no week in it, and Opus filled the gap – measured n=4 on the live top 10, the takes said “in its second week” twice about a record in its 22nd, where the old row shape said it 0 times. holdover_outlook already returns the week the projection is FOR, so the headline states it and the rate went to 0 of 6, with the takes reading better for it (“23 weeks deep is the story here”). A second guess of the same shape needed the FENCE, not more data: the rows say where each release sat LAST week, and a model narrating a chart story reads that as the start of a streak and counts it. Measured on the live top 10 in the ship dry run, n=6: one take wrote “her third straight week atop the board” about a record that had been No. 1 for ONE week (it printed #3 the week before, #2 before that). The board cannot supply the run – it is a pure function of one printed week – so the existing career fence NAMES the chart-run shape and the limit (“where each release sat LAST week, never how long it has sat there”), which is the conditional-ban precedent rather than a new rule. After, n=12: 0 crisp false run claims, 0 chart-lead claims, and one soft ungrounded phrasing (“second week showing that kind of pull”) that happened to be TRUE – it printed 67,000 in both of the last two weeks – and is tracked rather than tuned further, because stacking rules on Opus compounds. The COMPOSE block carries the same two facts, because the model reads the block and not the card: the board joined _IMPLICIT_RANK_KINDS (it sorts on one column and leads with another, the shape proj_pure_sales is in that set for, and it shipped after the set was written), so its rows are numbered in our projected order, and the caption – which the card draws only the cut note of – defines both columns. The headline read “leading the Billboard 200 for the week of …”, which is a No. 1 call the board does not model, and the model wrote what it was handed: measured on the real model, 4 of 4 takes off that headline said “tracking to lead the Billboard 200” and 4 of 4 off the units-only one scoped the claim to “the current top 10”. It forecasts the PRINTED TOP 10 alone, so a release outside it can debut above every row – which is what happened: Jhene Aiko’s “Westside Whimsy” took the week of Sep. 26 with 74,000 and Dandelion printed second at a little over 60,000. The headline was not the whole cause: the board’s label read “Billboard 200 units, our own projection”, which the compose header renders as “TOOTSIES PROJECTION for the parse_nora_text drops a rank from a sentence holding a hedge word (_HEDGE_RE: “challenging for a #1 debut” is a contest, not a call). Since 2026-10-18 Haiku reads EVERY rank sentence, not only the hedged ones, and the regex decides only when Haiku gives no verdict: the old fast path stored “the #1 is too close to call” and “has reached #1 on US iTunes” as Billboard 200 #1 calls (row A16 in docs/CLASSIFIER_AUDIT.md: better 6, worse 0 on 57 labelled real sentences). Before that, a hedged rank sentence was asked once of Haiku (nora_records_checked -> claude.classify, purpose=nora_rank_check, labels CALL / CONTEST). Only CALL keeps the rank; CONTEST, no verdict, an error or no client drop it as before. Measured on 189 real posts (2026-07 to 2026-10): 6 hedged rank sentences, one firm call restored (“ENHYPEN is LOCKED to score their first #1 album, challenging for 110K first week”), identical in 3 runs. The Billboard story discovery’s _LEADER_TITLE_RE stays a regex: it only ORDERS the candidate posts, and the caller tries each and keeps the first whose page parses.The release WATCHLIST and the ARTIST PRIOR (#3562 A + B, 2026-10-05). Two additions, both on the store above.
ChartHistory._watch, inside the daily walk). It reads kworb’s artist songs and albums pages for each act with a release in or near its tracking week. These pages are the only free per-song daily figure for an album with no track on the US top 200 (the Westside Whimsy case: No. 1 at 74,000 units with zero tracks on that chart). The list comes from the store alone, so it costs no fetch (db.chart_history_watch_candidates, priority in this order): a Kalshi album event whose tracking window starts within 3 days or ended within 7; a HITS debut for the newest projected chart week; a new entry in the top 25 of the Apple Music or iTunes album chart in the last 3 days. chart_history.watchlist dedupes by folded act and caps the list at 20. Each act is read once per UTC day, 4 acts a tick (CHART_HISTORY_WATCH_ACTS_PER_TICK). An act is done for the day when both pages were read and stored, when it has no kworb id, or after 3 incomplete reads. An incomplete read (either page empty, or with no refresh date) stores what did arrive and tries again next tick, so one failed page does not lose the day (Codex on #3866). The Kalshi source takes every KXALBUM* family except the #1-debut binary, with a window of 8 days or less, so a family Kalshi adds is included and a year-to-date family is not. The table key includes artist_id, because a collaboration sits on both acts’ pages under one item id. The act’s id comes from the store’s own Spotify chart rows first, then from kworb.resolve_artist_id (an exact leaderboard match) on the whole credit and then on each member of a joint credit. That lookup misses smaller acts: 7 of 19 on the 2026-10-05 dry run (Victoria Monét, Johnny Marr, Nessa Barrett, Ms. Rachel, &TEAM, a Hamilton cast album, Clarent). A miss is a zero-row success (reason=no_artist_id), not a failed read, so the daily ok rate keeps measuring the reads. The rows go to chart_history_artist_days, keyed on (kind, item_id, as_of). as_of is the page’s own refresh date (kworb.refreshed_at, its last-modified header), not the stream day. Measured 2026-10-05: the page’s “daily” is the growth of Spotify’s public play counter between refreshes (Blinding Lights read 1,555,942 on the page against 1,491,688 on the 10-03 chart grid), so the row also stores total, and the difference of two stored totals is the exact growth. A page with no refresh date is not stored (undated). A page holds about 320 songs for a big act, so chart_history.watch_rows keeps only the fresh songs (lifetime no more than 45 days of the current daily), the act’s top 10, the fresh albums, the watched title and the top 3 albums: about 40 rows an act, about 220 KB a day. A failure in the watch phase is caught on its own, so it cannot stop the grid backfill after it.ChartHistory._freshness). Once per 6-hour slot it emits chart_history phase=freshness with each daily chart’s lag in days, ok=False reason=stale past the limits in chart_history.FRESHNESS_LIMITS (3 days for the Spotify page, which kworb dates a day or two late; 1 day for the rank charts). The ops monitor files chart_capture_stale on the newest row, and also when the job emitted no row at all in a window of normal traffic. The per-source ok rate cannot see a capture that stopped, because a walk that does not run emits nothing, and a missed day of these charts is lost for good.utils/artist_priors.py, read by read_prior over db.chart_history_prior_rows). For one act it states the last opening (rank, and units with their split when Billboard printed them), the sales share of the newest printed opening, the VISIBLE share of streams (the act’s US Spotify top-200 streams in that tracking week over SEA x 1,250), and the week-2 decay (the act’s own printed ratios, stated as a median only from 2 pairs up, beside the banded chart_priors curve cell). The point is “this opening = the last printed opening”, with the measured ratio range: fit 2026-10-05 on 24 same-act pairs, ratio p25 0.877 / median 0.998 / p75 1.529, absolute error 30.5% median and 75.2% p90 (the round-3 harvest read 32.8% on 22 pairs). That is a Friday starting point for the day-one signals to move, never a forecast alone. Every input is absent when the store cannot state it: an opening outside the top 10 has a rank and no figure, and the visible share needs an SEA figure and the US daily chart stored for EVERY day of the tracking week. A day the capture did not cover would read as zero streams, so an uncovered week is absent, never 0%. Where stated, the share is an upper bound on the album’s own share (it counts every charting song of the act, and divides by the paid rate). The table’s index on as_of serves the freshness read, and its row budget is at most 800 rows a day (30 songs + 10 albums x 20 acts). The match is the strict credit match the history read uses. No surface reads the prior yet: the forecast board (D) is its first consumer.utils/forecast_backtest.py + scripts/backtest_forecast.py (2026-10-05). It grades three forecasts of an OPENING week on the store’s printed debuts: HITS’ Tuesday midweek (midweek-20), HITS’ building chart (hits-top-50, published after the week closes, so a reference and not a forecast), and the same-act prior (artist_priors), computed for each label from the act’s EARLIER printed openings only. A HITS row joins a label on the same chart week, the same folded title and a shared act (artist_priors.same_act); when HITS published more than one document of a kind for a week, the earliest is the forecast. Each cell is the median absolute error, the nearest-rank p90 (the maximum under 10 labels) and the median signed bias, by genre; a cell under 5 labels is marked. The prior reads only openings billed to the SAME set of acts (artist_priors.party_set, each party through the alias table): a joint album (“Future & Metro Boomin”) is its own act, so one party’s solo opening is not its prior. Measured on the 2026-10-05 pull, the rule drops one of 27 priors and moves the overall prior error from 28.3% to 29.1%. Matching one RELEASE across sources (HITS against Billboard) keeps the wider shared-act rule (same_act), which that join needs. The genre is Apple’s primaryGenreName, looked up by the script through apple_music.search_catalog and matched with apple_music.title_verified plus a shared act. Four searches in turn: artist + title, the title alone, the act’s albums, then the album’s own tracks, because the album search does not list every album (Lady Gaga’s “MAYHEM” is missing from it at limit 200). An album Apple does not list is unknown, never a guess: 22 of 168 on 2026-10-05. The script reads the live store read-only over /debug/query and calls no model. The table it printed is on #3562.billboard_stories.verify_rows dropped the No. 1 whenever the story’s week paragraph named neither title nor artist (“The new set launches with 228,000 …”): 6 of the 9 stored weeks with no No. 1 figure, among them ATEEZ’s 2026-07-11 debut (228,000) and Westside Whimsy (74,000). Rank 1 now also verifies off the story’s OPENING (StoryRow.lead_context: the headline and the paragraphs before the week paragraph), and only rank 1, since the opening names other acts too. _STORY_UNIT_VERSION moved to v3, so the walk re-reads the 71 incomplete weeks once, one a tick. (2) Billboard printed the 2026-10-03 No. 1 under “Miley”. The fix is one line in the shared alias table (artist_watch._ALIASES, "Miley": "Miley Cyrus"), and artist_priors.act_keys reads that table in both directions, so no second matcher exists. It is a CREDIT-ONLY alias (_CREDIT_ONLY): credit_names and credit_matches include it, performing_names does not, so no catalog is SEARCHED under “Miley”. That matters for RIAA, whose award search is a substring match and whose catalog read serves nothing when any of the act’s own names reads short. The visible effect elsewhere: a chart board that attributes per act now credits a row printed “Miley” to Miley Cyrus.resumable_scan.py, the shared resumable-long-scan spine (#570) — the foundation for a backfill that walks a lot of history UNIT BY UNIT and must survive an interruption (a redeploy SIGTERM, a Railway restart, an OOM, a transient snag). A scan that holds everything in memory and writes once at the end can run far longer than the gap between redeploys, so it may NEVER finish — each attempt is a gamble against the next deploy (exactly what stranded the /stats rebuild over the ~300k-message server). It wraps a scan into UNITS (a channel for /stats; a day for /remember, the planned PR-B adoption) processed one at a time and CHECKPOINTED as done, so an interruption loses only the in-progress unit, a resume skips the done ones, and memory stays bounded. ScanProgress (a done-unit set[str] + a small meta carry, e.g. the high-water snowflake) is the pure shape; parse_progress/encode_progress are the jsonb-safe round-trip; ResumableScan(db, guild_id, kind) is the per-(guild, kind) checkpoint over the settings KV (load/mark_done/update_meta/clear, plus mark_done_local → the encoded value for the atomic path where the caller persists the checkpoint inside its OWN transaction, alongside the unit’s data, so a fold + its checkpoint commit together and an interruption can’t double-count). One tiny per-(guild, kind) settings row, no new table. Pure + unit-tested (tests/test_resumable_scan.py); the feature supplies the per-unit scan+persist and calls mark_done after each unit lands durably. (#570’s /stats is the first adopter; memory’s /remember backfill is the planned second, hence “shared”.)/stickers, #4812)/stickers link:<tiktok url> size:<chat|sticker|emoji> finds every sticker in a TikTok
video’s comments and gives them back as GIFs. Anyone in a server can run it (owner
call 2026-10-27: no experiment, no role limit).
utils/tiktok_stickers.py is the core: canonical_video_url (link
forms), scan_video (comment paging on the shared ScrapeCreators client),
download (TikTok CDN), to_gif (Pillow, WebP to GIF), build_zip, and the
in-memory SCAN_STORE. cogs/stickers.py is the Discord shell.
utils/sticker_web.py is the download page on the health server.share_comment_id / comment_id / comment in its
query asks for ONE comment’s sticker. The app hides that id behind a short link, so
resolve_link sends one HEAD (redirects not followed, no credit) and reads the
redirect target. The scan then stops at the page that holds the comment
(comment_found). There is no single-comment endpoint, and the id in the link does
not move the comment to the front (live probe 2026-10-27). A comment deeper than the
page cap, or a reply outside the inline reply preview, reads as not found.
Replies (measured 2026-10-27; read since 2026-10-28, see “Background loading
and the reply walk” below). The comments endpoint returned no
inline replies on 100 real comments. The replies endpoint returns about 3 replies
per credit and needs the PARENT comment id, which a reply’s share link does not
carry (only share_comment_id = the reply and comment_author_id). Searching
every older thread for one real reply link cost 81 credits and 2.5 min and still
missed it. The API also lists only part of a video’s comments: on one video it
listed 40 top-level comments and 31 replies of a stated 187, and two real shared
comments (a top-level one and a reply) were in none of them after every thread
was read. So when a comment link’s scan reads everything listed (complete)
without the comment, the reply says how much TikTok showed and that replies are
not read. It does not guess which case it is. The
resolve runs after the defer, because Discord allows 3 s for the first reply.cid. A stop inside a batch spends at
most 4 extra pages. Live: 15 pages in 15 s against 99 s one by one. TikTok’s own
list for one 5,740-comment video ended at offset 416, so a deep scan can stop
complete well short of total.sticker_scan_max_pages
(default 25, about 1.5 min and 425 comments) bounds one scan; sticker_scans_per_day (default 10) bounds a server,
counted in server_rate_limits per UTC day (the /order counter).to_gif returns None and the sticker is left out (prefer absent over a
file Discord rejects). The shrink order is fewer colours, then fewer frames, then a
smaller picture./stickers/<scan_id> shows
one sticker at a time with ◀ ▶ (and the arrow keys), a size switch, a download
for the one shown, a ZIP, and below them the grid of every sticker found so far
(owner steer 2026-10-27: the page browses like the Discord reply). ▶ on the last
sticker POSTs /stickers/<scan_id>/more, which digs the SAME stored scan the
Discord buttons dig (tiktok_stickers.dig), so a dig on either side shows on
both. That is the page’s one credit-spending route: POST so a link preview cannot
fire it, and bounded by the scan’s page cap. StoredScan holds the dig state
(page_cap, seen, lock); the lock lets one dig run at a time, and a dig that
waited returns the other one’s stickers instead of reading more. The unguessable
scan id is the only key and there is no index. Scans live in memory for 24 h, so
a redeploy clears them and old links 404.tiktok_stickers.start_loading
reads the rest of a video scan in the background: the remaining comment pages,
DIG_PAGES at a time under the page cap, then the reply thread of every comment
with reply_comment_total > 0 (read_replies, 4 threads at a time). The page cap
bounds the reply pages too (at most REPLY_PAGES_MAX = 60), so a scan costs at
most twice the cap. A walk where every thread fails sets ok=False, so a dead
replies endpoint shows in the ops-monitor rate. The loader stops once the store
drops the scan. ▶ never stops short: while the loader
runs (StoredScan.loading), more_coming keeps ▶ on, and ▶ at the last sticker
waits up to 15 s for it. A comment link that is not among the comments searches
the reply threads too, so a link to a reply under a visible comment works.
Measured 2026-10-28 on a 1,127-comment video: the list ended at 212 comments; the
replies added 47 more and 1 sticker (8 in all against 7). The reply walk takes
about 30 s, paced by the client’s 60-a-minute limit. Cost: about 1 credit per
comment with replies, on top of the pages. What it cannot reach: comments TikTok
hides from the logged-out view (most of a big video’s comments), see #4817.tiktok_stickers.check_comment, ENSEMBLEDATA_TOKEN)
about that comment. EnsembleData reads the app’s comment list, which holds far more
comments than the logged-out list: measured on a 1,127-comment video, 44 sticker
comments in its first 89 against 6 in ScrapeCreators’ 214, and the top comment
first. It gives NO sticker image (no cmt_sticker_struct, only content_type
16/17 and a “[Sticker]” text prefix), so it cannot replace the search. It reads at
most ED_CHECK_PAGES (5) pages of 30, 1 unit each, and the result decides the
reply:
sticker_hidden);tiktok_stickers.browse_caption and
end_line write the words both sides show. While ▶ can read more, the caption
has no “of N” (“sticker 4 · used 1 time”), and the last sticker adds “▶ reads
more comments.” Once ▶ can read no more, the last sticker says which end it is:
the list ran out (“no more comments: tiktok showed me 358 of 5,743. 27 stickers
in all.”, and only then “of N”), or the page cap stopped it with comments left.
A scan keeps the cap it started with, so the cap line says to raise it and run
/stickers again.
Measured 2026-10-27: on a 5,743-comment video the source’s list ends after 21
pages (358 comments), so the 25-page default cap almost never binds; the short
list is the source (#4817), not the cap._HEDGE_AFTER) is requested once
more and the first good answer wins; measured, the repeat answered in under 1 s
while the first took 20.9 s. This costs one extra credit per stalled page
(hedge_requests on sticker_scan). Second, at the time limit the scan cancels
the pages still running and keeps the ones before them in order; the next dig
starts at the cancelled page. The first page of a scan is waited for, because
without it there is nothing to show.gif_for runs at most CONVERT_SLOTS (4)
downloads + conversions at once, for the cog and the page together. Cached sources
and GIFs across all scans stay under CACHE_BUDGET_BYTES (128 MB); past it the
oldest scans lose their cache and re-convert on demand. The page ZIP holds the 100
most-used stickers. The page cap tops out at 100 pages, so the reply lands inside
Discord’s 15-minute interaction token. The daily cap re-checks the count that the
atomic increment returns, so two runs at the same moment cannot both pass it.
A sticker with no numeric id gets a hashed id, because its URI has “/” in it./menu/menu (cogs/settings.py, a MenuView of pages) is the one mod-config surface. Discord caps a view at 5 action rows, so each page budgets 4 selects + a nav row. Pick the home by the knob’s TYPE:
Tunable to utils/tunables.py:TUNABLES (with group/mood/field if it should merge into a multi-field row; give it a one-line help when the name and unit alone do not say what the number does – the tune card shows it as small text under the value, in the same component, so it never costs a component against the V2 cap; the versuz knobs carry one, #3146) + a live resolver beside the others. The tune editor (cogs/tune.py) renders TUNABLES automatically as modal-rows — no UI code, no settings.py change. This is the default and lightest path. When a knob’s surface is RETIRED, add its group (or short) to cogs/tune.py:_RETIRED_GROUPS and drop it from _PAGES: tune stops showing it, and the Tunable stays registered so a stored value still resolves (discourse wire accounts, #4175).discord.ui.ChannelSelect/RoleSelect on a MenuView page (the _DiscourseSelect/_WatchedSportsSelect pattern).discord.ui.Select. It can’t be a tune modal-row (Discord modals are text-input-only, no dropdown), so it’s a select either on a MenuView page OR on the tune view’s reserved top row (the _AskModelTuneSelect precedent — keeps it grouped with the other tunables).Wiring a new MenuView select is a 4-touchpoint checklist (miss one and the UI silently lies — saves but reverts on re-render, or doesn’t persist):
discord.ui.Select whose callback sets self.parent_view.selected["<key>"] = ... and await self.parent_view.autosave(interaction, "<key>").add_item(...) it on a page’s _render_page branch (mind the row budget; the nav row is always the last)._load_initial_state into state["<key>"].elif key == "<key>" branch in autosave’s persist step (most write db.set_setting)._refresh_select_defaults re-applying the picked value’s default/default_values on re-render (selects revert to construction-time defaults otherwise). (A select on the tune view instead persists in its own callback + refresh(), not these MenuView hooks — see _AskModelTuneSelect.)Channel select defaults (#2733). Build a channel select’s defaults with self._channel_defaults(...) / self._single_channel_default(...), never a bare guild.get_channel_or_thread. discord.py evicts an ARCHIVED thread from the cache, and a ChannelSelect saves only what it showed, so a cache-only default deleted the thread from config on the next save. /menu resolves every configured id once when it opens (_resolve_configured_channels: cache, then fetch_channel). An id Discord answers NotFound for is dropped on the next save. An id whose fetch failed for any other reason is added back on save (_keep_unresolved_channels), because the select could not show it.
Nav: the menu chain is vibe → channels1 → channels2 → prediction → experiments → models → calendar → tune, walked by more ▸/◂ back. Tune sits LAST (it’s the raw caps/cooldowns/cadence NUMBERS editor, the most advanced page, so it’s placed past the friendlier ones — owner steer). Every config page’s forward nav reaches the experiments hub via experiments ▸ (on the prediction page, whose next page IS experiments, that single button replaces more ▸). The hop experiments→models is its own models ▸ on the experiments page (a _ModelsNavButton — it swaps in the separate Models View via open_models, not a flip_to page change), and the models page’s back is ◂ back (it steps back to experiments — open_models’s return_page = 4). The hop models→calendar is a [calendar ▸] button on the Models page that swaps in the separate calendar view (cogs.calendar_view.build_calendar_view — the scheduled-post day-planner, chill/yaps pages over a 30-minute working-hours grid), whose ◂ back rebuilds the Models page. The final hop calendar→tune is a [tune ▸] button on the calendar that swaps in the separate tune editor (cogs.tune.build_tune_view); tune is terminal (no onward button), and its ◂ back rebuilds the calendar. The page layout + nav contract is documented in the cogs/settings.py module docstring (keep it in sync).
Opening the /order view lazy-reconciles in-flight orders against GitHub on every invocation (the check_fixes /ask tool reconciles too), and a background reconcile_sweep loop (@tasks.loop, ~5 min, started in cog_load) sweeps every configured guild’s in-flight orders so a shipped fix flips to SERVED + pings the reporter promptly after the deploy instead of waiting for someone to poke the order system. The sweep is per-guild gated on the master kill switch (an OFF guild is skipped, leaving its orders in-flight + served-ping pending) and fully fail-open per guild. For any non-terminal row with an issue_number, reconcile discovers its same-repository PR from GitHub’s cross-reference timeline and mirrors draft, review-ready, and merged work as ON THE STOVE, NEEDS A TASTE TEST, and PLATING. A new order is always checked once, including during the process’s first hour. Successful PR reads are then throttled for an hour to protect the GitHub quota; a failed fetch or database write is not cached and retries on the next sweep. A closed issue flips the row to SERVED. The close-on-deploy.yml workflow closes the issue as soon as the fix merges to main (deploy assumed to follow), so a closed issue is the served signal. It closes only an issue with the order label: other PRs also write “Part of #N” to name one part of a tracking issue or an epic, and before this rule the workflow closed 11 of those (epics #4412 and #2874 among them, with sub-issues still open). An unreadable label skips the close with a warning, because the job must never fail while Railway waits for every check suite on main. Reconciliation is read-only on GitHub: it never approves, merges, or closes work. It deliberately does not poll Railway for deploy success: Railway is configured to “wait for CI”, so a CI job that blocks on the deploy reaching SUCCESS deadlocks (Railway waits for the check-suite, the check-suite waits for the deploy — this is exactly what stranded #125/#127/#126). Deploy failures are surfaced via deploy_event / Railway logs, not by holding the order open. This replaces the previous out-of-band scripts/update_order_status.py flow, which broke whenever the GitHub Actions runner couldn’t reach Railway’s Postgres host.
Completing the circle (reporter ping on SERVED): every order stashes the channel it was filed/reported in (orders.reporter_channel_id, captured in _file_order, carried through a retry). When the reconcile flips a row to SERVED, _ping_reporter posts back in that channel tagging the original reporter (<@requester_id>) so the person who flagged the bug hears the fix shipped — the human-facing close to the file_fix → issue → PR → deploy loop. Fires once per order (guarded by orders.served_pinged_at, marked after a successful send OR a terminal can’t-reach so a courtesy ping never retries; an OFF bot leaves it pending). Fully fail-open and honors the master kill switch; emits order_served_ping. Applies to mod /orders and autonomous file_fix orders alike (both share _file_order).
Arcade: generated 3D fighters, the server side (#4012, part of #4005). Every member gets one rigged 3D fighter built from their Discord avatar (owner decision 2026-10-14), and Ring Kings and Deathmatch both show it in place of the procedural body. The pipeline is utils/arcade/fighter_models.py:
likeness_refusal checks every name in constitution.GENERATED_LIKENESS_CONDITIONS (#4013/#4018) through LIKENESS_CHECKS before anything is paid for: opt_out, own_pfp_only (the avatar URL belongs to that member), chart_artists, game_only, arcade_only, never_described, deletable. No tuple, or a name with no check, means nothing runs.SheetClient). Grok fallback (owner, 2026-10-14: “whatever fails with openai, just retry with grok at highest level possible”): when that one OpenAI call fails for any reason and GROK_API_KEY is set, the same call (same avatar, same prompt) is made on Grok grok-imagine-image-2.0 at medium quality and 2k, and the Grok sheet goes through the same likeness gate. Only a missing avatar is never sent. A Grok failure names xai in its reason (safety_reject_xai is final). A row that failed at the sheet step on OpenAI before the fallback existed gets ONE more run once it is set up (fm.grok_retry_error, mirrored in the SQL of db.begin_fighter_model and db.fighter_backfill_candidates). Before 2026-10-14 there was no failover, on the view that the member agreed to the OpenAI pipeline only; the owner changed that. Likeness is the top requirement (owner, 2026-10-14): the prompt keeps the face, hair, skin tone, glasses, facial hair, accessories, colors and style, and a non-human avatar stays in its own style. The likeness gate (best_sheet) has a vision judge (ClaudeClient.fighter_likeness_score, a 0-1 number only) score each sheet against the avatar. Under 0.7 it draws again, up to 3 times, and keeps the best. When none reaches 0.7 the best is still used and the row is flagged needs_review for the owner (the compose self-gate rule: never drop the member). The pose gate (owner, 2026-10-16): some sheets copied the avatar’s pose (a hand on the face, a third hand on the face, a salute, a hand at the mouth) or drew all-dark eyes, and Tripo builds and rigs exactly that shape, so no client fix can remove it. A second vision judge (ClaudeClient.fighter_pose_score, Sonnet, two 0-1 numbers only, the sheet alone) returns limbs (two arms and two hands, both hands clear of the face, head and body, a full front body) and eyes (open eyes with visible whites). Only limbs is a gate. eyes is a preference among new sheets that pass both gates, and it never blocks a sheet or starts a rebuild: the coordinator’s sweep of all 152 stored sheets (2026-10-16) found 5 clean A-poses that failed only on eyes because the avatar’s own style hides them (a covered face, a blindfold, a face turned up, a glowing outline, eyes closed in a smile), and rebuilding those would cost about 525 credits and could lose the likeness. A sheet must pass likeness AND limbs (fm.SheetPick.passes); drawing stops at the first such sheet whose eyes also reach 0.7, else the one with the best eyes is kept (so an avatar with no visible eyes can cost up to 4 draws); SHEET_TRIES went from 3 to 4 because two gates pass less often on the first draw, and one more draw (one image, two judge calls) costs far less than a 105-credit Tripo build of a bad sheet. When no draw passes both, the one passing more gates is kept (then the higher likeness) and the row is flagged. The prompt now says the A-pose holds “whatever the pose in the picture”, with arms 30 to 45 degrees out, a clear gap between each hand and the thighs, and two open empty hands “clear of the face and head”, and asks for open eyes with visible whites. The kept sheet’s score is stored in arcade_fighter_models.pose.TripoProvider, the v3 API at openapi.tripo3d.ai). ModelProvider is the interface for a second provider (Meshy).prepare_glb checks the shared contract: the Mixamo bones exist, at most 30k triangles, at most 6 MB (4 MB until the 2048 px textures), Y-up and 0.3-3 m tall. Rig pose check (2026-10-16, fm.pose_fault): a hand joint within 0.3 of the skeleton height (Head joint over the lower foot joint) of the Head joint, or more than 0.02 of it above its Arm joint, is bad_pose. Measured on the stored models: the two bad ones were at 0.18 and 0.24 (one also 0.06 above its Arm joint), every other at 0.3 or more with both hands below the shoulders. A third hand is not a joint, so only the sheet judge sees that one. A member’s bad_pose retry draws a new sheet (the cog never resumes it: the same rig would fail again), and a second bad_pose for the same avatar is final (run_pipeline(bad_pose_final=True)), so a false positive costs at most two builds; for an artist import the first is final. The coordinator measured the margins on all 185 stored hi GLBs: the check flags only the two hand-at-head models (0.18, 0.24), the closest good model is at 0.405, and every good hand is at least 0.21 of the height below its Arm joint. Pose audit, capped at 30 rebuilds ever (20 until the eyes rebuilds) (Arcade._pose_audit_tick, in the backfill tick): every ready member model with no pose score (built before the gate) is judged once, 4 a tick, the listed members first (fm.judge_order): its stored sheet by the pose judge and its stored lo GLB by the rig check (fm.pose_verdict). The four models the owner named are always rebuilt; they are listed as one-way hashes (fm.POSE_REBUILD_ALWAYS, fm.subject_tag), so no member id is in the repository. Eyes rebuild test (owner, 2026-10-16: “Test 2 first”). A close-up face render of every served model, scored by a vision judge, showed about a quarter with dark or smeared eyes. The owner chose to rebuild two first: fm.EYES_REBUILD (hashes again) names one member whose stored sheet has clear eye whites but whose model lost them, and one whose sheet itself is dark. Each joins the audit although its pose is already scored, until it owns a rebuild slot (so it is rebuilt at most once, under the same cap), and ranks with the listed members. Its rebuild fails closed on the eye score too (run_pipeline(require_eyes=True), sheet: eyes_gate): no new sheet with eye whites, no Tripo spend. The owner then asked to fix all the bad ones: the list now names 18 members (the human-style faces with dark sockets, smears, or closed or blank eyes; normal small eyes and the cartoon or animal styles with no eye whites by design are left out), and fm.POSE_REBUILD_MAX went from 20 to 30 (about 1,900 more credits). First run (2026-10-16): 10 rebuilt, 3 stopped at the eyes gate with no Tripo spend, and 5 waited. #4260 blamed the bot’s member cache, and that was wrong: the slots show the 5 runs started at about 07:05Z and a deploy at 07:12Z cut them off (one had reached the Tripo upload), so each slot read cut and the old rule treated it as a spent rebuild. A cut-off rebuild now starts again (below). The cache fix stays, because a member missing from the cache is a real case: a rebuild reads a member missing from the cache from Discord (_current_member, one REST read per server); a miss counts as an avatar failure, so a member who left is looked up at most _POSE_AVATAR_TRIES times. Each restart also judged the waiting members again (2-4 calls each), because the verdicts lived only in memory: a rebuild verdict is now stored in kv_cache (fighter_pose_verdict, 30 days, <model content hash>|<reason>|<score>) and read before a judge call; it counts only for the model it judged. The REST read happens at most once per _PFP_CHECK_S per member (a found avatar is reused, so a rebuild held back by a cost limit does not read every tick), and its misses have their own counter. Only when every unscored model has a verdict does it rebuild, in fm.rebuild_order (listed, then rig faults, then sheet-only failures by ascending score), so the cap cannot run out before the surest cases. A no_score verdict is asked again after an hour. A failing model is rebuilt through the normal run path (start_model_run(rebuild_pose=...): the cost limits, the run slots, the likeness conditions) with db.begin_fighter_rebuild instead of begin_fighter_model and fm.KeepServedStore, so the row stays ready and keeps serving the old model and sheet until the new model is stored; a failed rebuild writes only its spend and is not retried. A rebuild FAILS CLOSED on the pose gate (run_pipeline(require_pose=True)): when no new sheet passes it, or the judge never answers, the run stops before Tripo (sheet: pose_gate, no credits) and the old model stays; the slot stays used, so the member is not drawn again. An avatar read failure in a rebuild gives the slot back and leaves the row alone (a failed row would start normal runs outside the cap); the verdict goes back into the audit’s memory, so the next try needs no second judge call, and after 3 avatar failures the rebuild is given up (refused reason=pose_no_avatar). A rebuild paused at its start also keeps its verdict. The slot is marked started before the pose score is written, so a crash between the two leaves a slot the next boot reports. Each slot moves <uid> -> <uid>:started (after the start checks and the avatar read) -> <uid>:done; a slot still started at the next boot was cut off by a restart, so it is reported once (pose_audit reason=cut_off, count = cuts so far) and marked cut<n>. That rebuild starts again on the SAME slot (<uid>:retry<n> -> <uid>:started<n>) until it was cut _POSE_CUT_TRIES = 6 times (3 until 2026-10-17); the old model stays served meanwhile. A retry claim that does not start goes back to cut<n>, so the slot is never freed for another member and the cap holds; a slot still retry<n> at boot (a restart between the claim and the start) goes back to cut<n> too, with no cut counted. The audit waits _POSE_BOOT_WAIT_S = 120 s from the first tick that sees a server (the gateway is up) before it reads the slots, so a rebuild the old container is still finishing during a deploy is not read as cut off and paid for twice. A slot value the code does not know reads as a spent rebuild. Before 2026-10-16 a cut rebuild was never retried, and a deploy on a busy day cut 5 eyes rebuilds that way. The boot check records the members with a retryable cut slot (_pose_cut), and they join the audit like the eyes members (one judge call each, then the rebuild). Quality rebuild (owner, 2026-10-16: “top 25 most recently active members”). The owner found the models low in quality. Two causes, measured: P1 is Tripo’s low-poly series, and the hi texture was 1024 px for the whole body. A v3.1 build from one member’s stored sheet gave a clean face with eye whites at the same face count and the same 60 credits, so new builds use v3.1 and a 2048 px hi texture. fm.QUALITY_REBUILD (hashes) names the 25 members with a served model whose last message (member_engagement last_ts) was newest on 2026-10-16. Each joins the audit like an eyes member, gets the verdict quality from the judge’s score, and is rebuilt once on its own slot set (fighter_quality_rebuild, fm.QUALITY_REBUILD_MAX = 25, 35 since 2026-10-17 when the owner asked for 10 more (the next 10 by last message still on P1), then 36 for one member the owner named, then 41 for ranks 36-40; the rest waits on #4372, apart from the pose slots, so a member the pose audit already rebuilt on P1 gets it too, and it neither holds nor uses the pose reserve). The rebuild builds from the STORED sheet when the judge’s limbs score of it passes POSE_FLOOR (run_pipeline(reuse_sheet=...), generate decision=reuse_sheet): the likeness stays and no sheet is drawn. Otherwise it draws new sheets under the pose gate. No eyes gate on this rebuild. The stored sheet is NOT reused when it is known bad (a listed pose, a rig fault, or an eyes-listed member: the verdict’s score is then 0) or when it was drawn from another picture than the one the run stores (the row’s pfp_hash), so the picture check still sees a changed picture (review on #4297). About 105 credits each, about 2,625 for the 25. First run (2026-10-16/17): 20 rebuilt (14 from the stored sheet, 6 from new sheets), 5 given up. Other PRs deployed 8 times in the hour after the merge, so 21 rebuilds were cut off and the 5 reached 3 cuts. Each restart also paid Tripo again, about 1,000 credits wasted: a rebuild kept no state of its own (its task ids went onto the served row, mixed with the old model’s, so they could not be resumed). A rebuild now resumes after a restart: KeepServedStore(on_state=...) reports each saved Tripo task id and the kept sheet, and the cog keeps them in kv_cache (fighter_rebuild_run, 2 days: pfp, tasks, reuse or sheet, likeness, pose; a drawn sheet in base64 under its own key <uid>:sheet, written once). A new task id for a step drops the saved ids of every later step (fm.TRIPO_STEPS), so a remade model is never paired with the old rig. A reuse state builds from the row’s sheet only while the row is still for the same picture. A resumed sheet skips the eyes gate (it passed it when it was drawn). The writes are best effort, and an opt-out deletes the state (review on #4324). A cut-off rebuild of the same picture resumes its finished tasks (fm.Resume, generate reason=resumed), or, when no model task was made yet, builds from the kept sheet with no new draw. The state is deleted when the rebuild ends. _POSE_CUT_TRIES went from 3 to 6, so the 5 given up start again. At most fm.POSE_REBUILD_MAX = 30 rebuilds ever (20 at first, the coordinator’s bound; 30 since the owner asked for the eyes rebuilds; one durable kv_cache slot each, namespace fighter_pose_rebuild), each member at most once, so the spend is at most about 30 x 105 = 3,150 Tripo credits plus up to 4 sheets each. The audit stops on its own once every served model has a score. It shrinks a texture over 2048 px (1024 px until 2026-10-16; the client’s FMODEL.LOD.hi.tex and MAX_BYTES moved with it, and its file cache BUF_MAX went from 8 to 12 MB so two hi files still fit). Ring Kings loads “hi” on a phone too, so there FMODEL.load(..., {maxTex: 1024}) draws each texture into a 1024 px canvas before the GPU upload (shrinkTex): four 2048 px PBR maps are about 64 MB of GPU memory per fighter, and a low-memory phone can lose the WebGL context (review on #4297). It also turns the model to face +Z: the first live Tripo GLB faced +X, so the turn is baked into the vertices, the node transforms and the inverse bind matrices. A GLB that fails a check is not stored.arcade_fighter_models holds one row per subject: kind member (a Discord user id) or artist (a chart artist, #4016, built later on this interface). The row holds the GLB, the sheet, the Tripo task ids (a re-download without a new paid run), the credits and the avatar key it was made from. A join re-reads it (Arcade._refresh_model): when the guild’s arcade gate is open and a GLB exists, the cog puts the URL in Room.models, and both start messages add "model": "fighter/<id>.glb?v=<hash>" (relative: inside the Activity /<path> maps to /arcade/<path>) to that human fighter. The AI and the bots never get one. GET /arcade/fighter/{id}.glb?v=<hash> serves the GLB (model/gltf-binary). It answers only the exact v hash, and only for a member of a server where the feature is on (the gate is cached 60 s); anything else is a 404, so a Discord id alone fetches nothing. v is checked with a hash-only read before any GLB bytes are loaded. Cache-Control: private, immutable. The cog keeps the last 8 in memory, and an opt-out during a read is never cached. The client appends &lod=lo for the small level of detail; no lod (or any other value) serves “hi”. A server whose arcade gate closes has model cleared from its open rooms on the next backfill tick. The route runs on the arcade thread and reads through to_main.
Runs start from the backfill (Arcade.backfill_once, every minute: the most recently active members first, by each member’s last message member_engagement.stats.state.last_ts (owner, 2026-10-15: “order by last active”; updated_at is the seed time, not activity; and only members whose last message is at most _BACKFILL_ACTIVE_DAYS = 90 days old: the owner paused the older members on 2026-10-15, #4060, and a member of any age still gets a run the first time they join a room), in a server with the feature on, who have no row yet), from a join with no model, and from a new picture at an open. The picture check at an open (owner, 2026-10-15: “detect pfp change on open and fire regeneration of avatar async”). The open IS the join: the socket’s first hello (the Activity and the web link alike) places the member in a room, and _serve calls on_join -> Arcade.joined -> _refresh_model as a background task, so the check never delays the hello or the welcome. There is no earlier moment that knows the member: the Activity’s /arcade/api/token exchange returns a token and names no user. _refresh_model gives the room the stored model URL first, then, at most once per _PFP_CHECK_S (10 min) per member, reads the member from Discord (_current_member: one fetch_member REST read, the gateway cache when the read fails), and starts a run with trigger=pfp_open when the picture key differs from the row’s pfp_hash. The old model shows until _model_ready stores the new one. The fighter is built from fighter_avatar(member): the server avatar when the member set one, else their own avatar, never the default avatar. The lobby and the cards already show display_avatar (server avatar first); until 2026-10-15 the fighter read member.avatar only, so a member with a server avatar got a model of a picture the server never showed, and a new server avatar never started a run (30 days of Axiom: zero join or pfp_change events). fm.avatar_owner reads both URL shapes (/avatars/<id>/ and /guilds/<g>/users/<id>/avatars/), so own_pfp_only holds for a server avatar: the member set it, so it is their own picture. A member in two servers with two server avatars can alternate the key; the 24 h member cooldown limits that to one run a day. A picture change inside the cooldown is refused (refused reason=cooldown trigger=pfp_open) and is checked again at the member’s next open after it. The 90-day backfill pause does not apply: a member who opens the arcade is active. Several runs go at once (owner, 2026-10-15: “all in parallel if needed”). fm.PARALLEL_RUNS (5, a constant: the owner asked for no /menu knob) sets how many: Tripo’s P series runs 5 tasks at once per account, and a full pool’s 429 (code 2000) is waited out with Retry-After, so other jobs on the key queue instead of failing. Arcade._model_runs is a _RunSlots, a semaphore whose size can change without stopping a running run. The cost checks count every run already in flight: the credit cap adds _RUN_CREDITS_EST (105, measured) for each of them plus the new one, and the balance floor takes the same amount off the cached balance, so several starts in one tick cannot all pass on one read. An artist import takes its slot before it begins. Each tick starts runs until that many are queued or running, and skips a member already in flight. Each run builds on its own TripoProvider.fork(): the fork uses the same HTTP sessions, key, breaker and rate limiter, and has its own credits, task ids and last output. Before 2026-10-15 one run went at a time every 3 minutes, and the runner was idle about half the time (a run takes about 3.5 minutes). Before a run is queued AND again when it starts, _global_refusal checks the clients’ breakers, the balance read (a failed read pauses, it never skips the check) and the cost limits; a pause defers the row with no try counted. A failed run is retried after 1 h, 6 h, 24 h, 24 h, then turns final (flagged needs_review); a content refusal (banned, safety_reject) or not_biped is final at once. The retry count and final reset ONLY on a new avatar (or an opt-back-in), so a failure before the first Tripo task cannot retry for ever (#4019 review F; tests/test_arcade_fighter_models_pg.py checks the SQL on a real Postgres). Each Tripo task id is saved when the task is CREATED and its credits when it finishes, so a failed run counts its spend and a retry with the same avatar resumes from those tasks and the stored sheet instead of paying again; a short outage on the re-read of a saved task is a retryable failure, never a second payment, while a saved task that can never be used (expired, refused by Tripo, unfinished after 5 minutes) is paid for again and a saved banned / not-biped task is final at once. A run cancelled mid-task, or a saved task the run drops, still adds that task’s credits when it ends (credits only, never its task id). A join also retries a failed avatar change that kept the old model. A deploy defers the in-flight run for an immediate retry. The backfill skips a member whose refusal is personal (a cooldown, a retry wait) and stops only on a global one. Cost limits are the 3d fighters Tunables, read from the MASTER guild only because one Tripo account serves every server: models a day (1,000) and credits a day (100,000), because the owner set no budget limit (with 5 runs at once the backfill can make about 2,000 a day, so a full day can reach the 1,000 cap; the start check of each run reads both caps again), and a balance floor (100, a pause, not a failure, low so that Tripo’s auto top-up fires first). A member waits 24 h between runs. The usage poll reports source tripo (the balance, and the day’s spend against the cap). The opt-out (owner steer 2026-10-14: no new command; moved INTO THE GAME 2026-10-15, owner: “get rid of my 3d fighter button”, and “add a confirm button” against a delete by mistake): My fighter in the game (web/arcade, #mePanel). It shows the player’s own status and offers Opt out of 3D fighters or Opt back in. Since 2026-10-17 (owner: “put my fighter in fighters when your name pops up”, “so you can opt out, but keep confirmation”) the panel opens from the Fighters page, not from a lobby button: an Opt out button (#rOut) shows in the top left corner of the 3D view when the player’s own tile is picked (under the name until a later 2026-10-17 change; there it made the 3D view about 95 px shorter), and it opens the confirm screen straight away (openMeConfirm, no status request, and its Cancel closes the panel). A My fighter button (#rMe) shows in the page header only when the members came back with no tile of the player’s (no ready model: opted out, no picture, still building), so Opt back in stays reachable. A finished opt-out or opt-in loads the Fighters page again (rosterReload), so a deleted fighter leaves the strip. Opt out opens a confirm screen (“This deletes your 3D fighter and the picture it was made from. …”) with Cancel (the focus, never disabled) and a red Delete my fighter that takes no tap for its first 800 ms. The client talks over the websocket with {"t":"me","op":"status"|"out"|"in"} (ArcadeHub._me): the message carries no member id, so the hub always acts for the socket’s own member; an out without confirm: true is refused (err why=confirm); a per-socket bucket (ME_PER_S 0.2/s, burst ME_BURST 4) answers err why=slow past it. The hook Arcade.fighter_me runs the SAME _opt_out_member / _opt_in_member as the old panel, so the deletion is unchanged: it cancels the member’s queued or running run, deletes the models and the sheet, drops the cached GLB (_drop_glb) and the open rooms’ model (_clear_models), and keeps an opted_out row so the backfill skips the member. The opt-out works even where the server has the feature off (state off). Opt back in turns that row back into a due retry (it keeps the spend history, so the daily caps still count it) and, from the lobby, starts the build at once through _refresh_model (every gate and cost limit applies). The reply is {"t":"me","state":...,"done":...} with state ready / building / none / no_pfp / failed / final / out / off / error (_me_state); the status read runs after the change in its own try, so a failed read still reports a change that happened as done (Codex review on #4154). A welcome while the panel is open asks for the status again, so a reply lost with a dropped socket cannot leave the panel busy. A match start and Escape close the panel. The hub marks its own refusals me: true (and reports a failing hook with emit_error), so only those end the panel’s wait. An in from a member who is not opted out changes nothing and answers without done; a real opt-in clears the member’s picture-check time so the build request runs now (the cost limits and the gap between runs still apply). The old panel’s _status_line reads _me_state, so both surfaces share one state order. The /fight card no longer shows My 3D fighter: lobby_view has no fighters argument, and a card edit drops the button from an old card. FighterButton (custom id arcade:fighter) stays registered, so a button still on an old card opens the private panel (fighter_panel), which now also says where the setting lives and gives its Opt out the same confirm step (_FighterConfirm: Cancel / red Delete my fighter). ring_kings_models is graduated always-on (experiments.GRADUATED_ALWAYS_ON, owner 2026-10-14: “ship on”, “dont need experiment here”) after two live dry runs, so the arcade gate and the master kill switch are its only per-server switches.
Arcade: chart artists as AI fighters, and member models on the AI (#4016, #4030). The owner chose real top rappers and pop stars as generated fighters (#4014, option 3). GPT Image refused to draw real people (5 of 6 calls, public-figure and other categories), so the owner approved Grok (grok-imagine-image-2.0) with each artist’s official photo (the bot’s Deezer art, or its Wikidata portrait where the Deezer photo is an art shot) for fully clothed, arcade-only sheets. The sheets and the Tripo builds were made once, by hand, with scripts/arcade_artist_pilot.py; no image or model of a real person is in the repo. In a bulk pilot run, an error ends only that artist and is recorded in its result; model.json is written as soon as Tripo returns each paid task id, so a crashed run leaves the ids to resume; the task poll and the file download retry a dropped connection (retry_http), and the paid create POSTs do not retry (#4409). The pilot builds on fighter_models.TRIPO_MODEL (v3.1 with the detailed geometry and the v3.5 texture model since 2026-10-17; P1 before that), so a pilot run and a member run use the same settings. artist_roster.PHOTO_SOURCE picks each artist’s reference photo: Deezer by default, Wikidata (WIKIDATA_IDS) where the Deezer photo is an art shot. One artist changed to Wikidata on 2026-10-17 (#4405), because the Deezer photo had the eyes closed. utils/arcade/artist_roster.py holds the roster (the bot’s own chart data), a fixed subject id per artist (one block from 4,016,001 up, far below any Discord snowflake, so is_artist_id and the route tell them apart), and TRIPO_TASKS: each artist’s finished Tripo task ids. fighter_models.import_artist stores an artist by RESUMING those tasks (TripoProvider.build(resume=...), no credits), after the likeness conditions (artist_request: subject kind artist, on the roster). The import is resume-only (build(create=...)): a saved task that expired fails the import with not_reusable instead of paying for a new task, the row turns final, and the artist stays absent. The saved “lo” converts were made at TRIPO_LO_FACES (4,000 faces since #4035); when that differs from fighter_models.LODS, the import drops convert_lo from the resume and converts again (about 10 credits), so a LODS change never serves a stale file. Each artist row is stored under artist_model_key (artist:<slug>:lo<faces>:<TRIPO_TASKS_MODEL>, plus :r<n> when artist_roster.TRIPO_TASKS_REV names that artist). TRIPO_TASKS_MODEL is the Tripo model the saved task set was built on, so a regenerated set changes every key and every artist imports again. TRIPO_TASKS_REV is a per-artist revision, so one rebuilt artist imports again and the others keep their rows. An import only resumes saved tasks, so it costs no credits. A stored row with an older key stays served and is imported again, and a retry resumes a convert an earlier try already paid for (the row’s provider_tasks). Arcade._artist_tick (each 1-minute tick, BEFORE the member backfill and in its own try, so an artist failure never skips the backfill; at most one import per tick, and only when a run slot is free: it takes that slot while member runs go) reads the stored artists into _artist_urls, imports a missing one, and deletes (db.delete_fighter_model) an artist whose newest week in the bot’s chart history (db.chart_history_newest_week_by_credit, one grouped read per credit string, then chart_history._credited, the strict credit match) is more than 12 months old (fighter_models.in_chart_window). A failed read neither imports nor deletes (refused reason=chart_unread; a stored model stays served), a read with no week only skips the import, the newest week is grouped per credit string, so a prolific act’s rows never push a newer featured credit past a limit, a stored artist row whose id is no longer on the roster is deleted (_sweep_departed_artists, reason=off_roster, and is_artist_id stops the route serving it), no Tripo key means no import (reason=unprovisioned), an open Tripo breaker waits (reason=breaker_open) and a Tripo outage during an import is deferred without counting a try, and an opt-out during the AI’s face reads drops that member’s model (_glb_gen). Stored artists load without a Tripo key; only the import needs one. Each artist’s step is isolated (one failure leaves the rest). A full pass holds for 24 h (_ARTIST_RESCAN_H); then the chart window is checked again, so an artist who leaves it while the bot stays up is still deleted. The artist route keeps the member route’s rules: the exact v hash first, the first guild with the feature on is enough, and a delete during the blob read (_glb_gen) is never served or cached.
Arcade: the 32-artist roster (#4016, owner approval 2026-10-15). The roster grew from the 16 approved first to 33: the 16 rappers and the 16 pop/R&B singers with the most 12-month chart points in the bot’s own history (Hot 100 (101 - rank) plus Billboard 200 (201 - rank) per week, 2025-10-18 to 2026-10-17, by the strict credit match; ChartArtist.points), plus Lil Wayne. Lil Wayne is outside that top 16 (707 points), but he was live, and leaving the roster makes _sweep_departed_artists delete his model, which cannot be undone; the coordinator kept him. The 17 new artists take ids 4,016,017 to 4,016,033; every shipped id is unchanged. The build was the same as the first 16: a Grok sheet from the official photo, a sheet check, a person’s eye check of each sheet and model (front, side, back, a posed rig test), then Tripo at LODS, with the task ids in TRIPO_TASKS. Two style lines were made explicit after the eye check, because the first sheets showed a bare midriff and an open shirt (Tate McRae, Harry Styles). The pilot now reads the reference photo by the PINNED id (DEEZER_IDS / WIKIDATA_IDS), the same id the portrait route reads, never by a name search, and run builds 2 artists at a time by default, because the member backfill shares the Tripo account (5 tasks at once). Wikidata is the reference for the 7 new artists whose Deezer photo is an art shot, a group photo or too dark (PHOTO_SOURCE). EsDeeKid has no Wikidata photo, and his public photos are masked, so his fighter is masked too. All 17 were inside the 12-month window on 2026-10-15 (oldest: EsDeeKid, 2026-04-11), so in_chart_window admits them; an artist who leaves the window later is deleted as before.
The AI cast (Arcade.pick_ai_fighters, both games): where the guild’s arcade gate is open (ring_kings_models is graduated always-on since #4035, so the arcade gate and the master switch are the only switches), about ARTIST_SHARE (0.5) of the AI fighters are artists with a stored model (artist_slots: the Ring Kings AI is an artist half the time; about half the Deathmatch bots), shown by their real name and their official photo (see the next paragraph) and no lines; the rest wear members’ faces as before. An opted-out member is never a bot face (the face is swapped for another member, or Toots), and a member whose row cannot be read is skipped (fail closed). A member’s face brings that member’s stored model (#4030, owner decision): the cog adds it to room.models only when the member has a stored model, and Room._with_model gives an AI fighter its artist model (room.artist_models) or that member entry. Artist fighters stay in the arcade: no X post and no clip shows one (arcade_only); the lobby and result cards in the server’s own arcade channel name them like any fighter. The Deathmatch client draws models only after #4017.
Arcade: the chart artists’ portraits (#4016 follow-up). An artist fighter first showed only a letter tile where a member shows a Discord avatar (the lobby, the HUD, the scoreboard, the Ring Kings corners, the Deathmatch result card); the owner asked why. utils/arcade/artist_portrait.py now gives each roster artist the official photo each fighter sheet was drawn from: artist_roster.PHOTO_SOURCE first (Wikidata for 9 artists whose Deezer photo is not a usable portrait), then Deezer. The lookup uses PINNED ids (artist_roster.DEEZER_IDS, WIKIDATA_IDS, checked live on 2026-10-15), never a name search, because “Drake” also matches a 174-fan Deezer act. deezer.artist_picture_by_id reads one artist id, and the returned name must still equal the roster name; wikidata.portrait_by_qid reads one item, which must pass is_music_act. A wrong id fails closed. The download reads only the Deezer CDN and Wikimedia hosts (the final host after Commons’ redirect is checked too; thumb.wikimedia.org joined the list on 2026-10-15, because Commons redirects a resized photo there, and without it three Wikidata-first artists silently fell back to their Deezer art shot), caps the size, and to_square_jpeg cuts a 256 px square JPEG; bytes that do not decode are dropped. Arcade._portrait_tick (the 1-minute backfill tick, after _artist_tick, in its own try) fetches the photos of the artists in _artist_urls, at most 4 a tick, and holds them in memory. artist_portrait.resolve also keeps each FOUND portrait in kv_cache (namespace arcade_portrait, 7-day TTL, the JPEG as base64), so a restart reuses it with no outbound read (#4756). Before that, a restart fetched every photo again: on 2026-10-24 the deploy queue booted the bot 12 times in 12 minutes, it read the same 23 Deezer ids 97 times in 30 min, and Deezer then answered 403 to the bot’s IP. A miss is not stored, and the cog waits 30 min (RETRY_AFTER_S) before the next try. pick_ai_fighters puts artist/<id>.jpg?v=<hash> in the artist’s Profile.avatar. It is RELATIVE and same-origin, like model_url, because a Discord Activity blocks a third-party CDN URL that has no URL mapping. GET /arcade/artist/{id}.jpg?v=<hash> serves it (image/jpeg, public, immutable); a non-roster id, a photo not held, or a wrong v is a 404. The client needs no new path: person() / ensureImg in app.js and deathmatch.js load the URL like any avatar. The Discord result card (_load_avatars) takes the held bytes directly, because _fetch_avatar reads only the Discord CDN. The fail direction is absent over invented: with no photo the avatar is “” and the letter tile stays. Constitution: GENERATED_LIKENESS_CONDITIONS covers GENERATED bodies; this is a real, official photo, the same kind of image the bot already posts on its music cards, so the conditions do not apply to it. It stays arcade-only all the same: only the arcade route and the arcade’s own Discord cards show it, and no X post or public clip uses it. constitution.py is unchanged. Telemetry: one fighter_model action=portrait event per source tried (ok, duration_ms, provider deezer|wikidata, reason no_photo|download|bad_image|fetch, bytes), or one cached=True event (in detail) for a portrait read from kv_cache.
_turn accepts float noise in a bone’s scale (rtol 1e-3): Tripo wrote 0.99998688 on one axis of a live model, and the default tolerance refused it. This also applies to member models.
Arcade: the Fighters page (#4191, owner ask 2026-10-15: “add it to game menu a page you can see all characters”). A Fighters button in the lobby (#bRoster, next to End game; the My fighter panel opens from inside the page since 2026-10-17) opens a full-screen panel (#rosterPanel, the same pattern as #mePanel) where a player browses every 3D fighter, like a fighting game’s character select. It lives ONLY in the arcade page (game_only / arcade_only): nothing posts to Discord or X, and no public URL lists it.
ROSTER.pick). A strip at the bottom (#rStrip) holds every fighter in ONE row: the player’s own (marked YOU), then this server’s members, then the artists, with small vertical labels between the groups. Tap or swipe the strip, use the arrows beside the model, or the arrow keys (rosterKey); an arrow is off where a step goes nowhere. The name shows under the model. A “You” label sits before the player’s own tile and “Members” before the other members. A model loads only for a fighter that stays picked for RS_SHOW_MS (200 ms): the name, the selection and the strip follow every step at once, but a held arrow key (about 30 steps a second) loads only the fighter it stops on, not every GLB it passes (review on #4205). The first fighter of an open loads at once. Drag the model to turn it. Artists: every roster artist with a stored model (Arcade._artist_urls), by roster name, in every server where the feature is on. Members: the members of THIS server only. Why it changed: the first version opened on an Artists grid with a small Members tab in the header and asked for members only when that tab was tapped. The owner opened it 4 times on 2026-10-16 (Axiom: 4 roster scope=artists, 3 roster_view scope=artists, zero scope=members) and never saw a member: the request path worked (reproduced end to end on the real hub), the design hid them. The open now asks for BOTH groups at once.GENERATED_LIKENESS_CONDITIONS). Database.fighter_model_ready_hashes returns only rows with status = 'ready' and a stored model, so an opted_out row, a pending run and a failed rebuild are never listed. utils/arcade/roster.member_rows keeps only ids the ROOM guild’s get_member finds, so a member of another server, or one who left, is not listed; bots are dropped. A player no longer in the server gets an empty Members page. Names are the current server display name. Your own fighter comes first and is marked YOU.{"t":"roster","op":"list","tab":"artists"|"members","at":<offset>} over the game socket. ArcadeHub._roster always passes the socket’s room and member to the hook Arcade.fighter_roster; a frame that carries a guild or guild_id other than the room’s is refused (err why=bad_value, roster: true). A per-socket bucket (ROSTER_PER_S 1/s, burst ROSTER_BURST 8) answers err why=slow past it. Pages hold roster.PAGE_SIZE (48) tiles; the client asks for the next page when the grid scrolls near its end, with after = the id of the last row it holds. The server starts the page right after that row when it is still listed, so a fighter removed or renamed above it between two requests does not shift the offsets and skip a row (Codex review); at is the fallback. The client only appends (skipping a row it already holds).artist/<id>.jpg?v=) or the member’s avatar (the same 128 px PNG the lobby shows; inside the Activity the discord-cdn mapped path is the fallback), with loading="lazy", and the first letter of the name when there is no picture. No GLB loads until a tile is tapped.web/arcade/roster.js, ROSTER.viewer). The fighter on show: FMODEL.load (the lo model on a phone, hi elsewhere), FMODEL.place, and loco.js’s idle clip (LOCO.rig / pose / lift / apply, the same path as Deathmatch) while the model turns slowly. Under reduced motion it neither turns nor plays the clip, and the viewer draws a frame only when something changes (a load, a drag, a resize), not in a loop. A failed load stops the frame loop. A rig the clip cannot drive stands in its bind pose and reports roster: no idle rig. The page sends at most ONE roster report (rosterReport, like avatarFailed), so failing models cannot use up the 3 client_error reports a page has for real script errors; the ops monitor’s arcade_client rate leaves roster reports out. Only one model is held: a new tap frees the old one first, and a load that arrives after a switch is freed at once. A switch frees the model on show first; closing the panel frees it and drops the WebGL context (dispose), and the next open builds the viewer on a fresh canvas.show), and a match start (case 'start') all close it. A welcome after a reconnect opens it again (both groups asked for again). Each group loads its next page when the strip is scrolled near the end of what that group has drawn (rosterMore), with the after cursor; a step past the last loaded member while members have more pages loads them instead of jumping to the artists (ROSTER.step). New members go in before the artists, and the strip scrolls by the added width when the artists are left of the view, so the tiles on screen do not move. A rate-limited list request (err why=slow, which names its tab and op) is tried again once 1.5 s later; a refused view is ignored (only a lost count).Events. arcade_room action=roster (one per group per open: scope members |
artists, count, reason ok |
off | error; every open now sends one of each) and action=roster_view (scope = the group of the fighter on show, members or artists; no fighter named; at most one a second per page, so holding an arrow key does not run into the rate limit). Panel arcade_roster; no monitor (docs/OBSERVABILITY.md). |
Arcade: memory cities, and a city per Ring Kings round (owner asks 2026-09-26). Deathmatch adds up to 16 cities (MAX_EXTRA; 8 at first) from the server’s memory (utils/arcade/memory_cities.py). /fight reads the server’s last 400 DAILY memory notes (CITY_NOTES; 90 at first) (Arcade._refresh_cities, at most once per 6 h per server, waits at most 1 s, then the read finishes in the background) and keeps the result in ArcadeHub.guild_cities; each new room copies it to Room.extra_cities. The rules: only daily notes (room-level summaries behind the memory fence; user_facts is never read, so no city is tied to one member); only cities in GAZETTEER (big, well-known cities; names that are also common first names, such as Austin, Charlotte, Brooklyn or Phoenix, are left out so a member’s name never becomes a city); a case-sensitive whole-word match NOMINATES each city, so nothing outside the list is invented; then Haiku CONFIRMS each nomination (CB-32, memory_cities.confirmed_cities, classify purpose=memory_city_check, PLACE / OTHER, cached 30 days per hash of city + sentences): it reads only the sentences of one note that name the city, and OTHER (the name is inside a person’s name or a song, album, film or business title: Memphis Bleek, “Ran to Atlanta”) removes that note from the city’s count; no verdict, no client or a failed step keeps the regex hit (a wrong arena is harmless). Haiku only, never Jev: the input is memory text. Only cities with 2+ regex notes are asked about; at least 2 confirmed notes per city; the 16 built-in cities are skipped. A memory city has the key x-<slug>, the arena “Downtown”, a layout picked from its key, and a skyline painted by paintGen in the client over the look of a plain built-in city (GEN_BASE). The lobby message carries extra ([{k, name}]) and start carries cname. The first read on the live data found Madrid, Atlanta, Liverpool, Chicago, Philadelphia, Barcelona, Milan and Paris for the main server. Ring Kings now fights each round in a different city: Room.start samples one city per round from the same list (built-in plus memory) into Room.rk_cities, the start message carries cities, and the client redraws the backdrop when the round changes (the city’s sky and landmark above the crowd, the city name under TOOTSIES on the banner). Telemetry: arcade_room action=cities (count, duration_ms, and since CB-32 detail.asked = notes the model was asked about and detail.dropped = notes it called OTHER; ok=false + error on a failed read), in the arcade_client panel; the model calls are claude_api purpose=memory_city_check.
Owner asks, same day: the Sound and ? buttons are now small icons in the bottom-left corner (.ib), because the Discord bar covers the top of the page. Sound is a speaker drawn in SVG, with waves when on and a cross when off. The Ring Kings city view is bigger: the city fills the top 94 rows at 2/3 scale, and only 2 faded crowd rows (alpha 0.5) sit in front of it. With no cities in start, the old 4-row crowd and dark arena come back.
Arcade: a harder Hard (owner ask 2026-09-26). Live, a member won Ring Kings on Hard by TKO in round 1. The cause: a player’s jab winds up in 5 frames, and the AI reacted 2 to 8 frames into a wind-up, so most jabs landed first. A scripted player who only pressed jab (and power when full) won 200 of 200 simulated fights against Normal and against the old Hard. Hard now has its own Ring Kings brain (FighterSpec.hard, the HARD_* constants in ring_kings.py): it reacts 1 to 3 frames into every wind-up, slips 62% and blocks a jab 28% more, is ready again 3 frames after a clean slip (a player needs 25), punches only into an opening (the player’s recovery or stun), and counters more. Simulated over 200 fights: the jab spammer wins 0; a scripted player with a 200 ms reaction and 85% correct dodges wins 25%. Normal is slightly harder too (owner ask, same day): on 55% of the player’s punches it reacts inside the wind-up (NORMAL_READ), it is ready 7 frames after a clean slip, it punches into an opening at 22% per frame, and it holds 85% of its own punches while the player winds up. Over 200 fights the jab spammer now wins 173 (was 200) and the 200 ms player 177. Deathmatch Hard: BOT_OVR_HARD 95 -> 110, and a Hard bot turns at 6 rad/s (normal 4), waits 0.55 to 1.0 s between shots (normal 0.8 to 1.5), hits at most 80% (normal 72%), and counts a player as 1.6 times closer when it picks a target. A player who stands still against 7 bots now lasts a median 30 s (old Hard 44 s, Normal 68 s).
The help box is short and per game (owner ask 2026-09-26): openHint shows only the game in the lobby or in play (Ring Kings, or DM.hintHtml for Deathmatch), only the controls for this device (COARSE: phone or keyboard), and 2 to 4 rule lines. The first help opens once per game (rk_hint_online for Ring Kings, rk_hint_dm for Deathmatch) when that game is in the lobby.
Memory cities look different from each other (owner ask 2026-09-26: “some cities too similar”). Before, every memory city had the same skyline and one tall tower. Now paintGen draws a landmark, a sky and a ground for each city. MEM_LOOK in web/arcade/deathmatch.js gives EVERY gazetteer city (166) its own landmark or scene (owner ask, same day: “all cities have a landmark or scene”; the client test fails when a gazetteer city has no entry) (for example Paris the Eiffel Tower, Seattle the Space Needle, St. Louis the Gateway Arch, Memphis the pyramid, Barcelona the Sagrada Familia spires, Las Vegas neon and the Strat), one of 6 skies (SKIES) and one of 7 grounds (flat, river, lake, hills, mountains, beach, desert). The 33 landmark and scene shapes are in MARKS (the last 6: palms, stadium, volcano, atomium, fountain, minar). A key with no entry (only possible if the list grows without one) gets a sky and a ground picked from a hash of its key, and no mark (since 2026-10-15; before, one of 8 plain shapes, which invented a landmark). The walls and floor come from a built-in city that suits the ground (GROUND_BASE). test_every_memory_city_look_is_a_real_city_with_a_painter checks that each mapped key is a gazetteer city and each shape has a painter.
Memory-city marks are at their true size (owner, 2026-10-15: “things like the pyramids or the towers should be true to life”; PR “Arcade: every memory-city landmark at its true size”). Before, each mark was drawn at its painter’s own size, about 60 to 112 strip pixels, whatever the real landmark: the Puerta de Alcalá (19.5 m) stood as tall as the Petronas Towers (452 m).
MEM_LM in web/arcade/deathmatch.js names, for each of the 165 memory cities, the real landmark its mark stands for: n, its height H in meters and src (W English Wikipedia, S the CTBUH Skyscraper Center, T travel guides, with a:1 on an approximate or disputed figure). hill is the ground it stands on above the viewpoint (the Acropolis, Namsan, Fourvière). t is the painter’s main color where the real one differs (the green Tyne Bridge). A sky mark is the city’s skyline cluster, its tallest tower at the named building’s height.v and D: a named public viewing place (a square, a park, a promenade, a bridge) and its great-circle distance in meters to the landmark. Both points are OpenStreetMap coordinates (Nominatim), stored with their query and OSM display name in scripts/data/arcade_landmark_viewpoints.json; scripts/arcade_landmark_distances.py computes every D (--fetch re-queries). No D is picked to make a mark fit (coordinator review of #4148: an earlier D for Shanghai had been).null means the city has no recognisable skyline landmark, and its strip shows only the skyline. There are 14: Cancún, Malibu, Ocho Rios, Negril, Punta Cana, Kingston, San Juan, Santo Domingo, Lima, Beirut, Manila, Dhaka, Compton and Santorini. Absent, not invented.giza (the three pyramids from the plateau edge, 1.5 km) and Tulum’s castillo (from the beach, 300 m) are self:1 rows, drawn unscaled, px tall. MEM_TONE (#4147) still colors Cairo and Memphis. Memphis’s Pyramid is an ordinary row (98 m from Beale Street, 1.8 km: 17 px).PW pixels round, so a landmark H m tall seen from D m is PW/(2*pi)*H/D pixels tall (lmPx; the same number as #4147’s atan(H/D)/0.1875 degrees below about 20 degrees). MARK_H holds each painter’s own height in pixels, measured from its painted pixels. paintMark scales the painter about its foot by h/MARK_H[mark], with the wrap copies off for the draw.stripH(city) gives a memory city a strip taller than 128 px when its mark needs it (a multiple of 32, 8 px over the mark’s top). The painters still draw from GY=PH up and paintStrip shifts them down, so the horizon stays the bottom row. The raycaster reads the strip’s own height (VROW counts rows above the horizon), and the 3D cylinder takes its height from the strip’s aspect. 10 cities need it (2026-10-15): Guangzhou 320 px (the Canton Tower from Haixinsha, 610 m: 44.5 degrees), Taipei 224, Shenzhen, Perth and Auckland 192, Frankfurt, Paris, Shanghai, Kuala Lumpur and Melbourne 160. A strip and its pixel copy cost 1920*h*8 bytes: 1.9 MB at 128 px, 4.7 MB at 320. Only the played arena keeps its strip. Ring Kings’ 2D backdrop (app.js) draws the whole strip at its 2/3 scale with the bottom row on y=94, so a tall mark is cut only by the canvas top; its 3D cylinder (rk3d.js) follows the strip’s aspect like Deathmatch’s.landmark_osm_name is the hand-reviewed OSM name, and the test fails when a --fetch matches another feature, or a viewpoint, a shop, a stop or a ticket office.pano() drops pixels at alpha 127 or less. Three passes lift a pixel 30% covered to 66% alpha. Measured in Chromium: every mark keeps opaque pixels; the fewest is Bali’s 6.LM_GAP) are cut to 40% of its top. These towers are invented filler; the mark is real. Ground marks (TERRAIN: Table Mountain, Vesuvius, Diamond Head, a statue on its hill) and a hill are painted behind the towers. The 3D skyline ring keeps the same 23-degree gap toward the mark (city.vista, set by genCity), as it does for a landmark model. A low mark can still sit behind the arena’s own wall from some spots, like a built-in model.obelisk, spire, tarch (a through-arch bridge), mesa, crater (a volcano without smoke).tests/test_dm_memory_landmarks.py checks that every memory city has a row or an explicit null with a reason, that H is 5 to 900 m (1500 m for a mountain), that each row’s D and v equal the script’s and D is at least 400 m, that the drawn height is the rule’s, and that it fits the city’s strip.
The result card is posted in the channel only for a human-vs-human result (owner ask 2026-09-26: “announce human vs human wins, at least 2”). announce_reason in cogs/arcade.py skips a vs-AI fight or a Deathmatch with fewer than 2 people (not_pvp) and a Deathmatch a bot won (bot_won). A PvP draw is still posted. The lobby card still shows every result, and scoring is unchanged (it already counted PvP only). Each decision emits arcade_room action=result.
The host can go back to the lobby mid-match (owner ask 2026-09-26: switching games needed a disconnect). A small house icon (#quitB, 30x30 like sound and ?; owner: “keep it small”) shows in the bottom-left row during a Ring Kings fight or a Deathmatch round, for the host only. The first tap turns it red and shows the toast “Tap again for the lobby” (3 s); the second tap sends quit. ArcadeHub.quit_match cancels the match loop, voids the match (game_match reason=void_quit, no result, no score) and sends void with why=quit, and everyone returns to the lobby with the room still open. The results screen keeps its buttons on screen on a short phone (a sticky button row), and it says “Result posted in Discord” only for a result that was posted (a human-vs-human result).
Deathmatch holds up to 10 fighters (owner ask 2026-09-26; was 8). dm.MAX_FIGHTERS is 10, so a room with Bots on fills to 10, and the lobby and card text follow it. The maps are 36x36 since #3985 (they were 24x24, with about 350 open cells), and spawns stay spread out. The client has 10 slot colors. The Discord live card draws 2 rows of 5 with 56 px faces (_DM_COLS, _DM_FACE). Measured: one server step costs about 41 us with 10 fighters against 39 us with 8 (the budget at 60 Hz is 16,667 us).Deathmatch turns slower and the maps have more cover (owner ask 2026-09-29: the aim swung left to right too fast, and the maps were too open). In web/arcade/deathmatch.js, mouse look is MOUSE_SENS = 0.0016 rad per pixel (was 0.0035) and the arrow/Q/E keys turn at a flat 1.6 rad/s (was 2.6). The flat key rate made a turn-around slow (owner, 2026-09-30), so a held turn key now ramps: it starts at KEY_TURN_MIN = 1.2 rad/s and eases up to KEY_TURN_MAX = 4.2 rad/s over KEY_RAMP_S = 0.5 s. A 0.1 s tap turns about 7 degrees; a held key turns 180 degrees in about 0.9 s. Changing direction or letting go starts the ramp again. Mouse look stays linear. Touch look keeps its old rate through TOUCH_LOOK. In utils/arcade/deathmatch.py, every stage got 2-cell wall pieces: six on beach, harbor, castle, plaza, hills and market, three on downtown and river, one on canals, placed where they cut the longest straight sight lines. Each piece stands free, with open floor on every side, and every stage stays one connected floor. The average sight distance from a floor cell dropped 3-20% per stage (plaza 6.3 to 5.2, beach 6.4 to 5.3). A 10-bot round still ends with one fighter left on every stage.
Deathmatch stages are places, not mazes (owner ask 2026-09-28: “more unique features across the stages so it’s not just a generic maze”). The prototype’s four maps were mazes of one-cell walls, shared by all 16 cities. Now there are nine stages in dm.LAYOUTS, one kind of place each: A downtown (NYC, Cleveland), R river (London, Boston), B beach (Miami, Jamaica), W canals (Amsterdam), H harbor (Sydney, Toronto), K castle (Cardiff), P plaza (Dubai; Houston until it moved to the S suburb stage on 2026-10-13), L hills (LA, San Francisco), M market (Lagos, Accra). A memory city gets one of dm.MEMORY_LAYOUTS (A R H P L M) by its name. The cells:
1 2 3 a building or wall: it stops movement, sight and shots, as before.. the ground, and , a second ground (a park, a road, a bridge, a pier): floor.~ water, o c low cover (a car, rock, planter, stall, container…), T a standing prop (a tree, palm, lamp): each stops MOVEMENT only. Sight and shots pass. The server keeps two grids for this: grid (sight, used by the aim line and line of sight) and block (movement, used by _try_move, the bots’ BFS and the reachable set). wall_at is the movement test; sight_at is the sight test.The client (web/arcade/deathmatch.js) reads the same codes (KIND_CH). Each city has a kit that dresses them: the second ground, a water color, two low-cover painters (LOWS), and a prop painter (PROPS). The raycaster passes low cover and draws each box at LOW_H (0.42) of a wall; a sprite behind a box is cut at the box’s top (lowClip). A prop is drawn PROP_H (1.5) walls high, and below row PROP_CLEAR it is only a trunk or pole. The reason: a shot passes a prop, so a prop must never hide a fighter the server lets you hit (the first cut drew a full-height tree that covered a bot completely). Measured with 10 bots, 20 seeds per city: every round ended on “last”, as before, but the median round is 13 to 22 s against 21 to 47 s on the old maps, because water and low cover give longer lines of sight. _reachable now starts at the first open cell, because (1, 1) is water on the harbor stage.
Deathmatch in 3D, with four guns and health (owner ask 2026-10-12: “match Hood Warfare or better”, “our maps and guns need upgrade”). The reference was a viral Unreal-engine FPS set in Chicago blocks: dusk light, fog, real houses, an AK with a red dot, health, magazines and reloads.
web/arcade/dm3d.js draws the SAME server map (36x36 since #3985) in real 3D with three.js. three.min.js (r149, MIT, the last release with a classic non-module build) is vendored in web/arcade/ because the Activity CSP blocks every other origin. utils/arcade/web.py whitelists both files and puts a content hash in their URLs. One map unit is one tile; map (x, y) is three (X, Z).
TALL, by stage). The facade is a canvas texture 4 tiles wide and 8 stories tall, with lit windows in an emissive twin map. Sided houses that fill a rectangle get a gable roof. Mural walls (a flag, kente, the dragon, a phone box) stay 1 story and use the old 32 px painter.tests/test_dm_water.py holds the order.deathmatch.js can name its models: props:{set:'dm-nyc',own:['car'],lm:{n:'liberty',d:88,h:34}}. The files are Tripo GLB models (P1, detailed PBR textures) made by scripts/tripo_props.py (--set dm, --set dm-<key>; the tables are DM_PROPS and one per city), in web/arcade/props/<set>/ with one manifest.json per set (file, sha, triangles, bytes, size_m, the prompt, the Tripo task ids, the lo file, and base_xz for a tree or a lamp). The shared set dm holds the kit pieces every city can use (DM_SET in dm3d.js). A city set dm-<key> holds its landmark and the kit pieces it replaces (own; New York’s yellow cab replaces the shared car). A kit name with no model (the chain-link fence, the London bus) stays procedural.
dm3d.js reads each set’s manifest once per page, turns every kit spot into a PROPS.place item (props.js, #4022), and places each piece twice: from its full file and from its lo file (fewer triangles, base color only). stepLod runs every frame, after the field-of-view update. It draws a copy only when that copy’s own bounding sphere (lodSet, made once per round) is inside the camera’s view, and its view depth is nearer than the fog’s end (R.fogD; FogExp2 fogs by view depth). While the sun casts shadows, each sphere moves half a shadow away from the sun and grows by half a shadow (the shadow is the copy’s height times the sun offset’s horizontal / vertical; about 7 tiles for a tree at dusk), so a copy out of view whose shadow lies in view is still drawn; with no shadows (phones, or after the adaptive quality turns them off) the sphere is the copy’s own. A drawn copy uses the full file within LOD_NEAR (7) tiles of the camera and the lo file past it. It writes only the drawn copies into each InstancedMesh (count), only when a copy changes state, and it allocates nothing. Before this, three r149 could not cull an InstancedMesh (props.js turns culling off), so every copy on the map drew every frame: batch D measured Amsterdam desktop +13% (#4023). Per-copy culling keeps at most two draw calls per kit piece, where splitting the copies into map chunks would add a draw call per chunk. A phone places each piece once, from its lo file only (loOnly, #4034), never fetches a full file, and is culled the same way. With no PROPS the map draws as before. The procedural piece is drawn first and is removed when a model arrives. A failed file keeps the procedural piece and props.js reports it once per page; dm3d never calls reportErr itself. A load that lands after the round ended adds nothing, and dispose() never frees props.js’s cached models.fitItems scales each model into the space of the procedural piece, from size_m. No kit places a low-cover GLB since #4155: every cover cell draws a procedural real object (SEEN, see “Cover you can see at contact” below), so the Tripo low-cover models (the shared ones and the city-owned ones from #4023: Toronto’s chairs, Lagos’ stall, Chicago’s cart…) stay in their sets but are not placed. The low branch of fitItems stays for a future GLB cover piece, and its tests run on every set. Low cover FILLS the server footprint (0.92 x 0.46, COVER_LONG/COVER_SHORT), at most COVER_MAX tall (owner, 2026-10-15, “hitting invisible walls”: the old uniform scale drew Toronto’s chairs, a market stall or a cart at half the box or less, so the body stopped at nothing). A piece at least SQRT2 times as long as it is wide stretches to the box; a more compact one is placed twice, side by side, each copy in half the box. Its height is its real height (size_m over TILE_M, at most COVER_MAX), or the old fit’s height when that was taller. LOW_FILL pieces keep their old stretch. tests/test_dm_props.py runs the real fitItems on every kit mesh and checks that each piece draws at least 90% of its box. A model longer along z turns a quarter. A standing prop is scaled to STAND_H (STAND_W widens a small crown, and its trunk, by a factor) and stands on base_xz (the mean x, z of its lowest 5%; a palm leans), so the trunk sits in the server’s PROP_HALF box; the procedural lamp’s light moves to the model’s height. tests/test_dm_props.py checks every file against the contract and the fit (below head height a tree is only a trunk), and runs the prop path in Node against the real props.js.d toward -X (the painted landmark’s direction), inside the strip cylinder and a little bigger than the painted one, so it hides it. It is unlit, tinted like the strip, ignores the fog, and is a plain mesh so it is culled when out of view. Phones get its lo file. When it fails, the painted one shows.d/h numbers above were picked by eye: the Miami lifeguard hut stood 52 m tall in the sky and the Amsterdam windmill 63 m. Now a city’s lm gives H, the landmark’s real height in meters, and D, the real distance in meters from which the arena sees it (lm:{n:'liberty',H:93,D:300}). landmark() stands the model LM_D (150) tiles out and makes it LM_D*H/D tiles tall, so it fills the same angle of view as the real one seen from D. 150 tiles keeps it inside the sky dome, and walking across the map turns the view of it by under 7 degrees. lm.y stays in tiles: it lifts a model onto a painted hill (Los Angeles, Cardiff’s motte). lm.dir (W, N, E, S; W by default, the painted one’s side) puts it on another side of the map: Sydney’s Opera House stands to the north, across the harbor. The skyline ring leaves a gap of about 23 degrees each side toward the landmark, so the landmark shows over the map’s own edge the way a real one shows down a street; before, the ring hid a landmark under about 15 degrees.
REAL_LANDMARK_H in tests/test_dm_props.py, within 5%): Liberty 93, Griffith Observatory 25, Golden Gate tower 227, Rock Hall 49, Zakim 98, Lekki pylon 90, Black Star Gate 12, Burj 828, De Gooyer 26.6, Opera House 65, Elizabeth Tower 96, Cardiff keep 12, CN Tower 553, Willis 442. Each D is between 100 m and 5 km, and the angle it fills is between 1.5 and 35 degrees. Measured in a SwiftShader render: Liberty, Big Ben, the CN Tower and the Burj show down a street; the Opera House showed only after it moved north over the harbor.g.mk=1 … g.mk=0; paintStrip(city,true) skips the marked draws (the lm* helpers return early). deathmatch.js passes that bare strip to DM3D.build as stripBare, and landmark() swaps the strip texture to it once the model is in the scene. When the model fails, the painted one stays. Los Angeles paints the Hollywood sign, not the observatory, so it marks nothing. Cardiff marks only its keep: the model is the shell keep alone, so the painted curtain walls, towers, gatehouse and flag stay around it. tests/test_dm_props.py checks that each Deathmatch set’s table in scripts/tripo_props.py names exactly its manifest’s models, so a removed model is not made again by the next --set run.fence; the procedural chain-link fence draws there again) and Cardiff’s castle wall section (barrier; Cardiff’s first low cover is now the shared rock, since a road barrier on a castle green is not true either).chase, 305 m, seen from 2 km) and Miami Brickell’s Panorama Tower (panorama, 265 m, seen across Biscayne Bay from 3.5 km), both made with Tripo. Montego Bay’s real sight is the hills behind the bay, which its painted strip already draws, so Jamaica has no model. lm.W (the real width in meters) scales the model’s width too (lmScale): Tripo made both towers about 0.4 as wide as tall, and the real ones are about 0.16. The scale uses the tower’s shaft at mid height (shaft_m in the manifest, from shaft_width in scripts/tripo_props.py), not the bounding box: the box’s widest part is the podium, so scaling by it drew the shaft too thin. Houston’s painted strip lost its orange launch tower (112 px) and its two painted towers are now 38 and 32 px: all three stood taller than the 46 px Chase Tower model, and no real building in that view is taller than the Chase Tower. deathmatch.js passes stripBare only when the city’s painter has a g.mk=1 mark; Houston’s painter has none now, so it paints the strip once.STAND_H made them 4.5 to 5 m; real palms are 12 to 25 m and pines 8 to 15 m. Scaling the models up made their trunks wider than the server’s PROP_HALF box. Now fitItems scales a palm or pine to STAND_M (12 m; Miami’s royal palm 15 m, Los Angeles’ fan palm 20 m, STAND_M_SET), kitProps cuts the model below its crown (clipY in props.js: a clipping plane on a copy of each material; the renderer has localClippingEnabled), and trunks() draws a thin trunk of real width (TRUNK_R, 0.385 m across) from the ground into the crown: straight up to TRUNK_BEND (1.2 tiles, above a fighter’s head, so it stays in the server’s box), then leaning to the crown’s center. The trunk ends at crown_tip, a third of the way up the crown, where the fronds or branches surround it on every side; a trunk that ended at the cut left its tip in the open under hanging fronds (dm-dxb’s tip stood 0.48 tiles below them). The clipping plane also cuts the shadow (clipShadows): three.js leaves it out of the shadow pass by default, so the uncut trunk cast a wide shadow. The crown’s height, center and the trunk’s top (crown_y, crown_xz, crown_tip) are in each manifest; scripts/tripo_props.py crown_of computes them (model_points now reads the int16 positions that quantize writes, which trunk_base could not), and tests/test_dm_props.py recomputes them and checks, in the full and the lo file, that the crown surrounds the trunk’s top (no gap over 90 degrees). Not done: a crown can still pass through a near facade, because the crown is wider than the server’s prop box.giza), Memphis’s Pyramid is scaled to 98 m at 1 km (MEM_PX), Tulum paints El Castillo, a small Maya temple on its sea cliff (castillo), and Edmonton (its pyramids are 24 m, not visible from downtown) paints its skyline. Cairo and Memphis paint their landmark in its own color (MEM_TONE: limestone for Giza, steel and glass for Memphis). In the front buildings’ color, the real-size pyramids read as more buildings and Menkaure was lost. The other generic marks stay as they are; their real heights are a follow-up.Fan-out batch A (Miami, Jamaica, Houston, Los Angeles). Each city set replaces two or three kit pieces and adds its landmark; every model has a lo file and each set is under 2 MB (tests/test_dm_props.py checks the cap, the sha, and metallicFactor 0 on every lo file, and fits each city’s own low cover and trees with the same rules as the shared set).
| city | own (what the city’s model shows) | landmark (lm) |
|—|—|—|
| mia | car 1950s turquoise convertible, lounger pink-and-white striped chaise, palm grey-trunk royal palm | lifeguard: the pink and turquoise South Beach lifeguard tower, d 88, h 30 |
| jam | car white route-taxi minibus with green and yellow stripes, lounger a bamboo beach bar counter (under the procedural umbrella), palm leaning coconut palm | flag: the flag of Jamaica on a pole, d 88, h 30 |
| hou | car red pickup truck, yard brick garden wall with a hedge and a mailbox | rocket: the Saturn V, d 88, h 40 |
| la | car purple lowrider, palm tall thin fan palm | observatory: the Griffith Observatory, d 88, h 26, y 7 |
Los Angeles paints the Hollywood sign, which is readable text, so its model is the Griffith Observatory in the same hills. The L stage’s skyline ring hides the bottom of a landmark at ground level (the domes looked like they floated), so lm.y lifts it onto the hill line. A first Jamaica flag came back with blue patches (the real flag has none); a wrong flag is worse than the painted one, so it was made again with flat-color wording. A first Miami lounger came back as a square armchair that filled half its cover box; the prompt now asks for a chaise three times longer than wide.
Budget: one city set is at most 2 MB (CITY_SET_BYTES). That is the cap that protects players, because a match downloads the shared dm set (about 4.3 MB) and one city set. All dm sets together are at most 40 MB (DM_TOTAL_BYTES); this only bounds the repository and the deploy image. The number comes from the rules: 17 cities x 2 MB + the shared set is about 38.3 MB. It was 24 MB until batch A took the total to 25.6 MB.
Frame time (Playwright, SwiftShader, 6x CPU throttle, 6 alternated rounds against main): phones change by -3% to +2% in all four cities. Desktop measured +10% to +19% before #4040’s per-copy culling, and Miami +14% after it. The beach maps draw about 100 kit copies. The first cut put Miami at +30% on desktop, so every own piece and landmark was converted again with a lower face limit (Prop.conv_faces in scripts/tripo_props.py, 10 credits a convert), and Miami and Jamaica got their own palms of about 750 triangles. A test with the near (full) files swapped for the lo files took Miami desktop from +12% to +4%, so the rest of the desktop cost is the full copies near the camera (LOD_NEAR); tuning that is a shared dm3d.js change, left to a follow-up measured on real devices. Desktop at 6x on SwiftShader is noisy: one build ranged 361 to 541 ms across rounds.
lo file left glTF’s default metallicFactor of 1, which drew every far copy dark; scripts/tripo_props.py now sets it to 0.dm-ams: a tulip flower cart as planter, a canal saloon boat as boat, the windmill landmark), Sydney (dm-syd: a surfboard stack as crate, a wharf bench as container, the opera landmark), Toronto (dm-tor: two Muskoka chairs as crate, a red streetcar as container, the cn_tower landmark) and Chicago (dm-chi: an alley garage as fence, a hot dog cart as car, the willis landmark; THE BLOCK’s Ring Kings set already has a sedan, a dumpster and a station column, so Chicago does not repeat them). A city prop takes the name of a kit piece the city already draws, so no code changes. Each city set is under 2 MB. Lessons: a model much longer than 2:1 (the first canal boat, 4.5:1) scales to a thin sliver in the 0.92 x 0.46 footprint; a tall model in a LOW_FILL slot (a streetcar shelter) is cut to COVER_MAX and reads as a table. Ask Tripo for a low shape about twice as long as it is wide.phoneKit:false after its props (...lm:{...}},phoneKit:false}). On touch, kitWanted in dm3d.js then builds no Tripo kit, and the procedural kit merges exactly as with no props; desktop is unchanged. The landmark is not gated: a phone still loads its lo file (one plain mesh, about 4k triangles). Use it when a city’s kit breaks the 10% frame-time rule on phones; test_phone_kit_false_keeps_the_procedural_kit_on_phones_only runs the real dm3d.js and props.js with the flag on and off.fitItems fits them into the same space and no shared code changed. The tables are DM_SF_PROPS, DM_CLE_PROPS, DM_BOS_PROPS and DM_LON_PROPS.
dm-sf: a cable car (car) and the Golden Gate Bridge (goldengate, d:88,h:46).dm-cle: a stack of rusted steel I-beams (barrier), a rusty 1970s pickup (car) and the Rock Hall (rockhall, d:88,h:34).dm-bos: an amphibious tour boat (car), a brick bed with an iron fence (planter) and the Zakim Bridge (zakim).dm-lon: a double-decker bus (bus, not in DM_SET, so only own loads it), a Victorian lamp (lamp) and Big Ben (bigben, d:88,h:36).--yaw 0, not the default -90. Their deck then lies along x, and landmark() shows them from the side, across the view, like the painted ones. With the default turn, the Golden Gate showed end-on as a single tower in front of the painted bridge.h is about 0.55 x d.A (downtown) layout hides behind the skyline ring. At h:24 the Rock Hall showed only in gaps between those buildings; at h:34 its tower and pyramid show above them.tests/test_dm_props.py checks it). The landmarks use 11k-14k faces to fit that, not 20k.dm-lag: a yellow danfo minibus as car, a wax-print fabric stall as stall, the bridge pylon), Accra (dm-acc: a tro-tro as car, a kente stall as stall, the Black Star gate), Dubai (dm-dxb: a white SUV as car, a sandstone agave planter, a light date palm, the burj) and Cardiff (dm-cdf: a castle wall as barrier, a coal cart as crate, the shell-keep castle). A city can only own the names its kit draws, so Lagos and Accra swapped the kit crate for car, Dubai swapped rock for car and Cardiff swapped rock for barrier (a LOW_FILL piece, so the wall fills the footprint). A city set is at most 2 MB (CITY_MAX_BYTES in scripts/tripo_props.py, test_a_city_set_fits_its_budget). The landmark height h comes from the painted strip: one strip pixel is 0.29 tiles at d 88. Frame time is the binding limit, not bytes: a kit piece repeats up to 92 times (Lagos and Accra have 92 c cells), so at 500 lo faces the vans alone added 46k triangles on phones (+12%). The repeated pieces now keep about 800-1,500 full faces and a lo of 100-480; Dubai owns a 729-face palm in place of the shared 1,878-face one, which it repeats 27 times; the Burj (3.4k) and the castle (5.8k) are far under the 20k landmark limit. Count a city’s c/o/T cells before choosing face counts. Tripo has a floor: below a few hundred faces a lo convert may come back unchanged (the planter lo stayed 476). Lessons: a landmark prompt must say “alone, no other buildings” (the first Burj came with a city block, the first castle with a house beside it); a wall prompt must say “nothing on top” (the first one grew a fin); a 300-face lo smeared the wall’s texture, so it uses 600; a city cannot own a tree that its trunk rule passes, because STAND_W widens every tree by 1.6, trunk included. All executors share one Tripo key, which runs five P-series tasks at a time: run one task at a time, and treat a 429 as a wait (Tripo._req retries it without a limit; --floor waits while the shared balance is low).Bucket). Shadows and antialiasing are off on touch devices, and at most 2 to 4 point lights follow the lamps nearest the camera (a fixed pool, so no shader recompiles).bakeLook) with the PFP on the face (faceCanvas) and a slot-colored belt. It holds its current gun and falls when out. The hands are gloves: never a guessed skin tone.deathmatch.js start() keeps the start message’s model on each fighter (fighter/<id>.glb?v=<hash>, the #4011 contract). dm3d.js checks it with FMODEL.modelPath, resolves it against the page URL and loads it at the lo LOD (FMODEL.load(url,'lo'), which asks for ...&lod=lo: about 6k faces and 512 px textures). The procedural fighter is built first; the model then takes the body’s place (F.proc hidden) at 1 tile tall, while the name tag, the held gun, the muzzle flash, the hit flash and the death fall stay. poseModel drives the skeleton every frame from Deathmatch’s own state with FMODEL.poser (the posing helpers both games share): the legs, hips and spine play the shared locomotion clip (the next bullet), both hands reach the gun’s rh and lh grips (2-bone solves, elbows down and out), and a hit throws the chest back. The arm on the fighter’s own right takes the grip, so mirrored rig names work. A 404, a broken file, a bad rig, the 12 s timeout or a pose that throws keeps (or brings back) the procedural body and sends one client_error dm3d model: <why> fighter <id> per URL through app.js’s report3. A model that arrives for a dropped fighter or an ended round is freed. FMODEL’s file cache holds 8 MB (8 lo files). Measured on #4017 (Playwright + SwiftShader, phone settings, 6x CPU throttle, 8 fighters on screen, six interleaved rounds): the mean frame went from 291.6 ms to 315.3 ms (+8.1%; the model runs held 308-323 ms while the procedural runs ranged 251-334 ms), draw calls from 124 to 52 (one skinned mesh per fighter in place of about 8 merged parts), triangles from 98k to 145k. A CDP profile put the page’s JS at the same 1.3 ms a frame in both modes: the whole difference is the emulated GPU drawing and skinning the models, which a real GPU does in hardware.web/arcade/loco.js, owner report 2026-10-15: “the running motion … looks awkward. Did we model the running?”). It was not modeled: the old cycle turned the two thighs by sin(walk) about the fighter’s x axis. Nothing else moved, the feet slid (the cadence was not tied to the stride), and a strafe or a backpedal played the forward cycle.
preset:idle, preset:walk and preset:run, retargeted ONCE onto the drake artist rig (scripts/arcade_loco.py --retarget, one animations/retarget task, 30 credits, animate_in_place). The retargeted GLB holds the artist’s mesh, so it stays in a scratch folder and is never committed. The Tripo walk and run each hold two gait cycles.loco.js header, the clip data (src.style.credit) and scripts/arcade_loco.py; the arcade has no credits screen to carry it, and none is added (owner, 2026-10-15: code and docs credit is enough). The clips used are the “Neutral” style’s Neutral_BW (walk back), Neutral_BR (run back), Neutral_SW (walk sideways) and Neutral_SR (run sideways). scripts/arcade_loco.py --style reads only those four BVH files out of the 1.5 GB zip (HTTP range requests) and writes six clips: walk_b, run_b, walk_r, run_r, and walk_l / run_l, which are the right steps mirrored (the takes’ real left steps slid 24-49% of the travel speed on the test rig, the mirrored right steps 11-26%).
ox, oz), the speed and the travel direction (dir, measured: 85 to 88 degrees for the side steps). Two corrections make the two sources agree. (1) The loop’s facing is taken out of every bone (mq * yaw(-psi) * q * mq^-1); a first version only changed the rotations’ frame, so each clip kept the capture room’s heading (the backward run was turned 125 degrees, and a backpedal crossed its legs on served rigs). (2) The thigh and shin rotations are turned into the served rigs’ rest (TRIPO_REST: the legs splay about 9 degrees; 100STYLE’s hang straight); without it a backpedal stood 0.4 hip heights wider than a forward run. The foot stays relative to its own rest, so a standing foot is flat. Every walk and run clip is rolled so the left foot lands at phase 0 and stores mr, where the right foot lands (each read from the first harmonic of the foot’s distance ahead of the hips along the clip’s travel), so clips from the two sources cross-fade in step.loco.js between /*CLIP*/ and /*END*/. --style adds the direction clips to the stored clip, so a new --retarget must be followed by --style.LOCO.step / LOCO.pose, tested in tests/test_arcade_loco.py). It follows Unreal’s Lyra locomotion: cardinal clips, orientation warping, stride warping.
CARD), the one nearest the travel angle. The fighter switches only once the travel is HYST (10 degrees) past the halfway angle, so a move near 45 degrees does not flip. A first version blended the two clips on either side of the angle; measured, a diagonal of a Tripo clip and a 100STYLE clip slid twice as much as either clip alone, because their feet land at different times.HYST); with a clip’s own direction offset (a side clip travels at 85-88 degrees) it reaches about 62, and WARP_MAX (66) bounds it. The old strafe was the forward clip turned by 90.AIM_ROLES), so the head stays within 10 degrees of the aim in every direction; the sway and the bob stay.FADE (0.2 s), and the pose mixes every cardinal with weight. A reversal passes through a side step and cross-fades through both switches. The travel angle itself eases at most TURN_MAX (12) radians per second (the old smoothed direction vector passed through zero and flipped the pose by 180 degrees in one frame).mr, where the right foot lands: about 0.5 for a forward or backward gait, 0.27 or 0.42 for a side step (the trailing foot closes up). During a cross-fade each clip’s time is warped so its markers meet the blended ones, so a foot planted in one clip is planted in the other.RATE_MAX (1.3) times faster than captured; past that the stride grows (LOCO.stride: each foot moves further along the travel and a 2-bone solve bends the leg to reach it, the hips dropping at most DROP_MAX so the planted foot still reaches the ground), up to STRIDE_MAX (2) times; past that the cadence rises again. A side step at 2.5 times its stride read as a straddle jump in a render, so the stride limit is 2. A first cap of 2 on the play rate left a bot’s strafe (about 3 hip heights per second) sliding 0.5-0.6 of its speed; 1.3 hands that range to the leg solve.LOCK_EASE of the stance); between lift-off and landing the foot rises on an arc of CLEAR (0.08) hip heights, so a side step’s trailing foot, which the capture keeps within 2 cm of the floor, does not drag. Both ease out while two cardinal clips cross-fade (their feet land apart).gc: the lowest its ideal-leg ankle gets, which is where the clip’s planted foot is down. The pose lowers the hips by it. LOCO.lift(rig, pose) then sets the hips’ height on a real rig so that the rig’s lowest ankle is as high above its rest height as the ideal leg’s (a foot that is down in the clip is down on the rig, and a run’s flight stays), and lifts the hips if any ankle or toe would go below its rest height. Measured on five served rigs, main dipped a foot up to 7% of a hip height into the ground and let some hover; with lift, no foot dips and every planted foot touches.main; at 1 hip height per second (walking) 0.15-0.29 against 0.18-0.42, the forward walk equal (0.19 against 0.18). main also dipped feet into the ground at every speed; this dips none. test_served_rigs_keep_both_feet_out_of_the_ground_in_every_direction holds the bound (0.35) on three of the rigs, and checks the legs do not cross.LOCO.rig(bones, hold) at attach, then each frame hold.position.y = LOCO.lift(...) and LOCO.apply after P.reset() and before the gun IK. A rig LOCO.rig refuses (no hips or legs) keeps the old thigh swing and sends one client_error dm3d model: loco: no rig fighter <id>; a page without the clip (or with a clip missing a direction) sends loco: no clip once. The procedural body has a knee (the shin and shoe are their own mesh under p.userData.knee, one more draw call per leg) and its thigh and knee follow the clip (LOCO.legs). A dead fighter gets no gait.x per corner), so there is no run. The stance gets a bounce: the hips dip 1.2 cm at about 2.2 Hz, the two corners out of step, and the planted IK legs flex under it, on both bodies. Not when down, and not with reduced motion. The punches and the gloves are unchanged.main): the mean frame changed by -2.7% (phone, procedural), -0.2% (desktop, procedural), +0.9% (phone, models) and +0.3% (desktop, models), all inside the round-to-round spread. The JS time of DM3D.frame rose by 0.5 to 1.0 ms at 6x throttle. The procedural knees add 14 draw calls for 7 fighters (118 to 132); the models add none.fpBuild, once per fighter, kept on F.fp): a clone of the placed model (F.rig.src). The copy shares the vertex attributes, materials and textures with the third-person model and has its own bones, reset to the rest pose (F.rig.rest). Each skinned mesh is rebound to the copy’s bones and gets a new INDEX only: the triangles whose three vertices have a MEAN of at least half their skin weight on the bones from each Shoulder (else Arm) bone down, and at least 0.2 at every vertex (FMODEL.armBones, FMODEL.armTris). Measured on 3 served models (coordinator’s check): a rule of 0.5 at every vertex from Arm down tore holes in sleeves that lean on the shoulder and spine weights (from the shoulder bone, 4% to 90% more sleeve triangles on six served files); a mean with no floor kept triangles with one corner almost all torso, which stretched into long spikes. The head, the torso and the legs are not in that index, so they never draw and never clip into the camera. A plain mesh stays only when it hangs under an arm.1/GUN_S tall in the gun’s units, the same size against the gun as a third-person fighter. The gun’s sway, recoil, ADS, reload, switch and the scope hiding move it exactly as they move the box arms.FMODEL.fitArms, per gun): a grid search over the body’s yaw and place. It scores each arm at 84% of its length from its grip, the shoulders nearly square, each shoulder on its own side of the gun, and the shoulders below and behind the gun. The first cut pinned the right shoulder and turned the body until the left hand reached: on the AK it turned 70 degrees, and both arms came in from the right and crossed under the gun.fpSolve, once per gun change, since the pose in the gun’s space does not change): poser().reach puts each wrist so the palm sits on its grip (rh pistol grip, lh handguard), then the hand turns from its rest frame (the fingers’ direction, the palm’s normal) to the grip’s (GRIP_R, GRIP_L, GRIP_L9 for the 9mm’s two-hand hold), and the finger joints curl. A hand without finger bones (the served lo files measured on 2026-10-15 have none) takes the forearm’s direction and a palm-down rest. The palm offset is in the gun’s units, so the gun’s world scale applies (without it, the left hand floated off the handguard).FMODEL.fpArms): no model or one still loading, a copy that throws, no arm triangles, a solve that throws, or a device still slow at the lowest adaptive level (adapt sets R.q.floor when the pixel ratio is at its floor, shadows are off and the median frame is still over 25 ms) -> the box arms. Each reason sends one client_error dm3d model: fp arms: <why> fighter <id> per model through modelReport. The copy goes with its fighter or its model (fpFree from dropFighter and dropModel).frameSlot, the #4054 rule): your own while you play or wait to respawn, the watched fighter’s once you are out of lives.FMODEL.feetFacing, both games): a rig kind gives a default facing, but three.js’ Soldier sample faces -z with Mixamo names, so the game put its LEFT hand on the pistol grip. load() now reads the facing from the feet: when the toes point against the default, the facing turns round. The six served models measured on 2026-10-15 all face the default way, so they are unchanged.tests/test_arcade_fp_arms.py runs the real fmodel.js and dm3d.js in Node on a rig built in the test (no member’s likeness). The red dot tube is open, and the Sniper shows a scope overlay. Effects: muzzle flash and light, tracers, impact puffs, shell casings.reportErr to arcade_room action=client_error, so the arcade_client health rate shows a 3D view that fails in the field.MODEL_H), so one tile is 1.75 m. Every car was squeezed into one cover cell (0.92 x 0.46 tiles, at most 0.5 high): a 1.6 m toy.
stage_of(key, stage) turns each stage’s vehicle cells (VEHICLE_CODE: c on the A, B, S, L, R stages, o on M and P) into runs of whole cells as long as the city’s real vehicle (CITY_VEHICLE, in meters; SEDAN for San Francisco, Boston and every memory city). A run parks along the street, keeps a one-cell gap to every other run, and may not cut off any floor cell that was reachable before. A vehicle cell that cannot hold a real vehicle becomes floor (at most MAX_VEHICLES 12 runs a map); one that opens no ground (a cell in a canal) turns to water. The result is cached per city and layout.VehicleKind.full is true when the vehicle is at least as tall as the eye (EYE_TILES 0.9, the same as dm3d.js EYE). A half-cover vehicle (a cab 1.5 m, a convertible, a lowrider, the sedan) blocks movement only: the aim line at eye height passes over its roof, so the view and the server agree. A full-cover one (a pickup, an SUV, a minibus, the Routemaster bus 4.38 m) also stops sight and shots at its exact box: sight_at, segment_clear and ray_wall_dist test the boxes (_ray_box), and the client’s sightAt, los and wallDist do the same (rayBox).start carries vehicles (vehicles_wire: [x0, y0, x1, y1, height in tiles, full, model]). deathmatch.js parseVehicles marks the cells solid for movement (fullAt, like water), and dm3d.js vehicles() draws each one at its real length, width and height: the city’s own model (props.own: New York’s cab, London’s bus) or the shared sedan, with each axis scaled to the real size, because Tripo’s proportions are not true (its cab was 0.48 as tall as long; a real one is 0.3). A procedural body stands in until the model loads. tests/test_dm_vehicles.py runs the client’s functions in Node against the server on random points, rays and segments.EYE rose from 0.62 tiles (1.09 m, under a real car’s roof) to 0.9 (1.58 m). A shot’s puff and hole sit at eye height (rayHit). Every street-dressing piece a fighter can stand under (an awning, a porch roof) now sits at OVERHEAD (EYE + 0.2) or higher; the old awnings, 0.86 to 1.06 high, cut across the view.EYE is 0.9, so with the body against a cover box (CONTACT, the server’s RADIUS 0.24) the lowest point on screen is EYE - CONTACT x tan(half the vertical FOV). On a 19.5:9 phone in landscape that is H_MIN, 0.805 tiles (1.41 m); a 16:9 desktop sees a little lower. A planter, a lounger or a crate (0.3 to 0.5 tiles) went off the screen about 0.7 tiles before the body stopped at it. The camera stays as it was (the owner turned down a lens shift and a wider FOV).
H_MIN and COVER_TOP (0.83, 1.45 m): dm3d.js SEEN maps each kit’s low piece to a hedge in its planter, two stacks of crates on a pallet, a market stall with its goods piled on the counter (its canopy above head height on thin poles), a group of boulders, or a chain-link fence at its real 1.8 m (see-through), with a fence on each long face of its box so the fence you see is where your body stops. They are procedural and replace the Tripo low-cover GLBs; each fills its 0.92 x 0.46 box. The old procedural low pieces (all 0.5 tiles or lower) are gone.coverGround, after streetUnder): a crate or a street tree on the road is on the street, a tree in the park stays on the lawn.COVER_TOP is under a fighter’s head (FIG_H 0.84) and the eye, so every solid piece is half cover: the server lets shots pass cover, the same as before, and a fighter behind one still shows the top of the head. No cover piece reaches the eye, so the sight rule (a piece at or above EYE_TILES stops sight and shots, as the vehicles’ sections do) has nothing new to stop; the fence, the one piece above the eye, is see-through.H_MIN except Miami’s 1.4 m convertible and LA’s 1.35 m lowrider (0.80 and 0.77 tiles). They stay at their real size; tests/test_dm_view.py names them._floor_char: , on the downtown stage A, . on most others). Which ground is grass is the city’s kit (New York’s and Cleveland’s second ground, LA’s main ground), so deathmatch.js streetUnder sets G.lawn for each vehicle cell to the city’s other ground when its own is grass. dm3d.js, the raycaster and the minimap all read G.lawn, and the grass tufts follow it.,, the same ground as its park, and , is grass in those two cities. deathmatch.py ROADS lists, per stage, the park boxes whose , cells stay ground; every other , cell is a road. layout_wire (the start message) sends road cells as ROAD_CH =. The server’s own layout keeps ,, so movement, sight, bots and spawns do not change. deathmatch.js parseLayout reads = as , plus G.road, and streetUnder draws it on the city’s street ground. ROADS covers A and every other memory-city stage (R, H, P, L, M), since a memory city borrows a kit that may have grass as its second ground. The minimap paints green exactly where the 3D ground is grass (groundGrass).tests/test_dm_view.py runs the real lowProp pieces and a real three.js camera in Node._overlap adds _voverlap) and bots (_vhits), on the server and the client (overlap adds G.vboxes). Before, it stopped at the run’s whole cells: a bus box is 1.39 tiles wide on a one-cell run, so a fighter could stand inside its full-cover box, unseen and unshootable; and a run is longer than its vehicle, so a cab had an invisible wall 0.57 tiles in front of its bumper. The run’s cells still keep the bots’ paths and spawns off the vehicle.VehicleKind.parts, from the front; Vehicle.parts()): a pickup’s hood and open bed (1.1 to 1.25 m) are half cover and its cab (1.8 to 1.9 m) is full cover; an SUV’s and a tro-tro’s hood are half cover. What you see over, you can shoot over. The wire is a dict per vehicle (box, model, front, parts); the stand-in is drawn section by section, and the model faces front.zlib.crc32 of the city and stage), so the whole map gets them; the row order had filled only the top half of the downtown stage.segment_clear walks the grid only (the boxes are tested once per segment), so London’s bots do not test every box at every step.drop3d) draws the vehicles too (vehFallback), and a memory city never borrows its base city’s landmark or vehicle model (genCity).cart (dm-chi/cart.glb) so the vehicle rule leaves it alone. A cover cell whose kit piece is a vehicle (a memory city’s borrowed kit on another stage) draws a road barrier.PROP_HALF box, so they need new models), and the raycaster fallback, which does not draw the vehicles.utils/arcade/deathmatch.py). WEAPONS holds 9mm, AK, Pump and Sniper. Every fighter has HP_MAX 100 and carries all four, starting with the AK. A shot draws its angle from the seeded rng inside the spread (ADS or hip, plus moving, plus the AK’s bloom). It hits the nearest body within HIT_HALF 0.22 tiles before the wall and the gun’s range, with the same rewind as before. The Pump fires 12 pellets with damage falloff, spaced evenly across the cone (pellet_angles, see the Pump spray note below). Health comes back at 40/s after 4 s without damage. Magazines are per gun, reserve ammo has no limit, a switch takes 0.4 s and cancels a reload, and an empty gun with the trigger held reloads.| Gun | Damage | Shot gap | Magazine | Reload | Spread hip / ADS (rad) |
|---|---|---|---|---|---|
| 9mm | 34 | 0.27 s | 12 | 1.25 s | 0.025 / 0.006 |
| AK (auto) | 25 | 0.10 s | 30 | 2.2 s | 0.03 / 0.008, bloom up to +0.05 |
| Pump | 12 x 10, falls to 30% at 9 tiles | 0.9 s | 6 | 2.5 s | 0.16 / 0.10 |
| Sniper | 100 | 1.4 s | 5 | 2.6 s | 0.09 / 0 |
pellet_angles puts each pellet at the centre of its own slice of the cone (2s / 12) and moves it by up to a quarter slice (PELLET_JITTER 0.5), from the match rng, so a seed gives the same pellets. One bullet keeps the old single draw, so the other guns do not change. Point-blank is still 120 (a one-shot kill to about 1.5 tiles). Hip fire does a steady 60 at 3 tiles (was 88 on average) and 13 to 26 at 6 (was 29, 0 to 78). ADS does 80 to 100 at 3 and 26 at 6. PUMP_BOT_NEAR went from 2.5 to 1.6, the hip cone’s HIT_HALF / hip (1.375) with the old 14% margin. The client casts one ray and draws one tracer per pellet for every fighter (pelletAngles, the same spacing). The tracer pool grew from 16 to 28 and the puff pool from 20 to 28, with one shared tracer geometry. The crosshair already opens to spreadNow, so it widens to the new cone.start carries weapons (the table above), hp and spawn_w. An in frame adds w (the wanted gun), rl (reload) and ads. fire is now the trigger state, and the server latches a press for 9 steps. A snapshot row adds hp, w, mag, flags (1 reloading, 2 switching, 4 ADS) after the first six fields, so live_feed.py is unchanged. New events: hit (by, victim, dmg, hp), reload, and w on shot and kill. The client predicts its own cooldown, magazine, reload and switch with the same rules, and the kill feed names the gun.e.buttons, including a pointermove that reports a button change, because a second button pressed while the first is held never sends a pointerdown (Pointer Events, chorded buttons; the review on #3973 caught that aim-then-fire did nothing). Phones keep the old layout of stick, look and FIRE, with no new buttons (owner ask 2026-10-12: “make sure mobile commands aren’t overwhelming”). A tap on the gun panel takes the next gun, a reload is automatic when the magazine is empty, and with the Sniper, holding FIRE aims and letting go shoots. On a phone the gun panel drops the slot row and sits beside FIRE.bot_hit). The factors hold a normal-mode bot’s time to kill a standing player at 0.96x to 1.35x of the old time to the first hit, at 3, 6 and 10 tiles. Hard is 2 to 3 times faster than normal. In 200 seeded bot rounds, every round still ended on “last”. Rounds run 15 to 20% longer.chi, stage C, “The Block”): rows of two-flats with gangways, back-yard chain-link fences, garages on the alley, parked cars and street lamps. The sky shows the Willis Tower, the Hancock Center and the L.Deathmatch maps 50% bigger, 3:00 rounds, PFP name tags (owner ask 2026-10-12, #3985: “make the maps like 50% bigger, the games end pretty quickly, very representative of the cities and the avatars, use the PFPs of everyone in the Discord properly, make sure perf is good”).
Axiom showed the problem first: in 201 rounds over 30 days, most were one member against 9 bots, and the median round lasted 19 s. A headless sim found the reason. On the 24x24 maps every fighter spawned about 6 tiles from another, often in sight, so the first kill came the moment the bots’ 2.5 s spawn hold ended.
N is 36 (was 24). All ten stages are redrawn by hand at 36x36, each with named zones for the cities that use it. For example: downtown has a 3x3 street grid, a park with a fountain, a library and a subway plaza. The river has four bridges between a Boston bank and a London bank (the Hatch Shell, the Palace of Westminster). The block has two-flats with gangways, a garage alley, a viaduct with underpasses and a basketball court. The client reads the size from the layout rows, so it needed no size change._spawns still takes the best of 40 random cells by distance, but a cell that a placed spawn can see (a clear line inside SPAWN_SIGHT, 15 tiles, the bots’ sight) loses to any cell that none can see. On the new maps 0 to 6% of spawns are in sight of another (39% on the old beach), and the nearest spawn is about 10.7 tiles away (was 6.3). test_the_36x36_stages_break_up_open_ground_and_hide_the_spawns holds it, together with “no open field wider than 7x7”.ROUND_S is 180 (was 120). Measured with one idle member against 9 bots, the member lives 15.6 s on the new maps against 13.9 s on the old ones. The bigger map helps a little; the bots still roam and find a player. A lives or respawn mode is the larger lever and is an open owner decision.PFP coverage telemetry. game_match detail adds bot_member_faces, bot_toots_faces (the server had too few members with their own avatar) and no_avatar. The client reports the first PFP that fails to load on a page as a client_error (“avatar load failed (activity |
web)”). Panel arcade_dm_faces. The ops monitor’s arcade_client rate leaves these out: one member’s dead avatar URL is not a broken client. |
R.fogD, 97% fog) is not drawn, and one past SHADOW_D (10 tiles) casts no shadow. A hidden fighter still draws its tracers (shotOther checks F.me, not visibility), so a sniper beyond the fog still shows where the shot came from. Measured in headless Chromium with 10 fighters: draw calls fell from 563 to 406 (Chicago) and from 547 to 391 (market). DM3D.info() returns the last frame’s draw calls and triangles for such checks.LIVES = 0 means unlimited, and KILL_TARGET = 15. A fighter taken out respawns after RESPAWN_F (3 s) at _pick_spot(living enemies): the same rule as the round’s spawns, a cell no living enemy can see inside SPAWN_SIGHT, else the farthest. It comes back with full health and full magazines, its old movement input cleared, under SHIELD_F (2 s) of spawn protection: it takes no damage, bots do not target it, and firing ends it. The first to the kill target wins (Result.reason == "score"); at 3:00, the most kills. A shot rewound to before a fighter’s respawn (spawn_tick) cannot hit it at the spot where it died. A round can still limit lives (Deathmatch(lives=n)): then Fighter.standing (alive, or respawning with a life left) decides the last fighter standing, as in the first version.lives; a new spawn event carries the slot, spot and lives; the snapshot row adds entry 10 (lives left, 1 when unlimited) and flag 8 (spawn protection); start adds lives (0 = unlimited), kill_target and respawn_s; a standings row adds lives, and its alive means still standing at the end. live_feed keeps a respawning fighter standing, and has a “KILL TARGET” result; the result card has score lines.meUp puts you at the server’s spot with full health and magazines. Another fighter’s respawn jumps to the new spot rather than sliding across the map. The 3D body undoes its death darkening and blinks while protected. The HUD reads “Kills K · Leader L/15”. A start with no lives (an older server) means one life and no respawn.LIVES = 1. The lives and free-for-all tests pass lives= to the round.| Map | Draw calls before | After |
|---|---|---|
| Houston (lamps) | 324 | 123 |
| Chicago | 404 | 137 |
| Sydney harbor | 570 | 179 |
heldGun, GUN3; geometry.userData.shared keeps a fighter from freeing it, and dispose clears the cache with the round). A fighter is now about 8 draw calls.THREE.Points. A Points size is not a sprite’s world size: frame() sets it to .7 / tan(vfov/2) so a halo keeps the old sprite’s size on any screen and with the aim zoom. All chain-link fence panes are one mesh.adapt in dm3d.js). Every 2 s window is judged by its MEDIAN frame, so one hitch does not decide it, and the round’s first window (shader compiles, face textures) is skipped. A median over 25 ms lowers the pixel ratio by .25, down to .6, and at .6 turns the sun’s shadows off (every material recompiles once then: one hitch, on a device that is already slow). Five windows in a row under 20 ms (a 60 Hz or 120 Hz screen) raise it by .125, never above the start value; a level it had to leave twice puts the ceiling one raise step (.125) below it. Simulated on three device profiles, it settles on the highest level that runs fast after at most two swings. (The first version compared against the shortest frame ever seen, so one fast frame kept a fast machine stepped down: the review on #3990 caught it.) A frame over 1 s, or a hidden page, is a pause and does not count. (A first version skipped frames over 250 ms, so a device at 4 fps never adapted: the dry run caught it.)clientWidth, a forced layout each frame. It now writes only on a change (setT / setO, a cache cleared at each round start) and uses the width from resize(). The minimap draws the map once per round into an offscreen canvas and blits it, not 1,296 cells a frame.perf report per round (arcade_room action=perf): mean fps, the share of frames over 33 ms, 3D or raycaster, touch, and where the pixel ratio and shadows settled. Panel arcade_dm_perf. DM3D.info(true) lists the scene’s visible objects by type for a draw-call check.T: a tree, palm, pine or lamp) and low cover (o, c) blocked movement at their WHOLE cell, but the client draws a thin trunk or pole (0.09 to 0.14 tiles wide) or a car about half a tile wide. So a fighter stopped about 0.4 tiles before a lamp pole, at nothing visible.
boxes_of gives each prop and cover cell a footprint, and _try_move adds _hits: a test of the fighter’s circle (radius 0.24) against the footprints in nearby cells. Its old edge probes sit 0.29 from the center, past the body, so they test only walls and water (_full_at); a probe that tested a box stopped a fighter up to 0.07 short of a corner (Codex review). A prop’s footprint is a square PROP_HALF (0.12) to each side of its center. A cover footprint is COVER_LONG by COVER_SHORT (0.46 by 0.23, half sizes), with its long side along the open street: along x when the cell has at least as many open floor cells beside it on x as on y. That is the rule dm3d.js already used to turn each cover piece. Walls and water still fill their cell. wall_at answers for the footprint, not the cell. block (whole cells) still drives bot paths and spawns, and a BOT still moves by the whole-cell test (_cell_at): it never enters a prop or cover cell. Review found three ways a bot that stood inside such a cell got stuck, because a whole-cell path could not always get it out past the boxes (a first step through the trunk; a box next to the bot blocking the nearest exit; a corner of boxes blocking every exit, Miami (23, 17)). Patching the exit search kept missing cases, so bots keep the old rule, and the footprints serve the human, whose view they are for. _bfs may end at a human who stands in a prop or cover cell, and then stops on the floor cell next to it.parseLayout builds the same boxes (G.box), and wallAt / hits / tryMove are the same tests, so prediction does not fight the server. Measured: 400 random walks of 400 steps on all 10 stages gave bit-identical end points in node and Python. dm3d.js turns each cover piece from G.box. Every piece stays inside its box below head height, MEASURED: a node script runs lowProp and standProp with three.js and takes each model’s farthest vertex under 0.84 high (Codex review found car wheels, crates, the boat rim and the fence cap outside by up to 0.03). The car is 0.88 by 0.42 with its wheels inside the body’s width, the bus 0.9, the container 0.92 by 0.46, the crates jitter less across, the rocks became three boulders in a row, the planter leaves and stall fruit turn with their piece, and the chain-link fence got a concrete footing that fills its box. The minimap draws each cover box. The 2D raycaster fallback still draws cover as a whole-cell box; all 8 perf reports in 30 days came from the 3D view.Deathmatch.sky is “day” for DAY_SHARE (75%) of rounds, else “city” (the city’s own dusk, night or fog). It has its own generator (seed ^ 0x5C7), so the sky never changes spawns, bots or shots. The start message carries it. A “day” round over a city whose sky is not already day (dayLike) draws a clear blue sky, thinner fog (fd at least 24), puffier white clouds, and no lamp light pools, cones or halos (in sun they read as pale triangles).dress in dm3d.js). On each ground-floor wall face that looks onto open ground: a concrete sidewalk with joints and, on asphalt, a curb; by facade style, porches and hedges on houses, stoops, window AC units and fire escapes on brick and stone, striped shop awnings on glass, metal and stucco; downspouts; and on low streets, utility poles with wires to the next pole. The rule that keeps it honest: a fighter’s center always stays RADIUS (0.24) from a wall, so below head height every piece stays within DRESS_D (0.2) of the face, and deeper pieces (porch roofs, awnings, fire escapes, crossarms) sit above head height. A node script measures this: of about 219,000 pieces across every style, height and seed, the deepest below head height is the porch deck at 0.2.muralTheme now makes each one a real building for its city, at the stage’s normal heights, and moves the picture onto something real:
The flags and cloth awnings use the picture as a texture, one bucket per picture (one draw call more each, plus its shadow). They follow the dressing rule: below head height within 0.2 of the wall, else overhead. A client test fails if any city’s wall painter has neither a facade style nor a theme. Cost (headless, day, 10 fighters, main -> part 2): Lagos 135 -> 146 draw calls and 85k -> 120k triangles; Cardiff 116 -> 125 and 59k -> 125k; Jamaica 154 -> 158 and 69k -> 112k; LA 115 -> 118 and 61k -> 118k. Real buildings have more faces than one-story slabs.
S (owner ask 2026-10-13, after a Hood Warfare 2 clip: “neighborhoods that people know”). Streets two cells wide (three across, three down) around blocks of houses on their own lots. Each lot has a front lawn, a 3x2 house (mostly siding, so dm3d.js gives it a gable roof), a driveway with a car now and then, and a side yard. Behind the houses, back yards are split by a line of yard fences with gaps. One block is a park with trees and a court. A script generated the layout (seeded) and it is checked in as literal rows like every stage. Every one of its 865 floor cells is reachable.
yard. That is a clipped hedge behind white pickets, built to fill the 0.92 x 0.46 footprint (measured), so it reads as the low wall it is.tests/_dm_arenas.py, the old 24x24 stages; a missing cell is a wall). A test that checks the real maps asks for the real_maps fixture. Redrawing a city map no longer breaks a gun test.Deathmatch bots hunt (owner, 2026-09-26: “does the AI seek you? a lot of ties or time run out”). Before, a bot with nothing in sight walked to a random cell, so the last few fighters could miss each other until the clock ran out. Live, 12 of 67 finished rounds (18%) ended on time or in a draw. Now, after HUNT_AFTER_S (40 s) or once HUNT_ALIVE (3) or fewer are alive, a bot with nothing in sight walks to the nearest fighter (_hunt_goal), counting a player as HUNT_PREFER (2) times closer. Simulated with a player who hides, 120 rounds each: with 4 fighters, time or draw went from 50 to 0 and the median round from 92 s to 28 s; with 8 fighters, from 60 to 0 and from 117 s to 35 s.
/fight takes one optional opponent (owner ask 2026-09-26, “challenge mode”; it shipped with 4 in #3716 and was cut to 1 the next day, because everyone else uses Join game). challenge_targets drops empty options, bots, the caller and repeats. The card carries the ping (“@Bo @Cy, Ana is calling you out. Tap Join game to accept.”) with allowed_mentions limited to those users. Join game is the accept, and it stays open to everyone. When a room is already open in the channel, the caller joins it and the ping goes out as a reply to its card, so there is still one room per channel. A card bump reposts the card without the ping, so nobody is pinged twice. Telemetry: arcade_room action=challenge (count; ok=false + error when the reply failed).
Game sound starts OFF (owner, 2026-09-27: the game paused their music). The setting moved to a new localStorage key, rk_sound, which is ‘1’ only after the player taps the speaker; the old rk_mute key was written as ‘0’ on every page load, so it could not tell who had chosen sound. With sound off the page creates no AudioContext, so it never takes the phone’s audio. Discord’s own app can still pause music (for example in a voice call); the page cannot control that.