← BACK TO SAM Uncle Sam hat
Sports Analysis Machine · Version Notes

Every update, tracked in the open.

Newest first — every version bump below reflects an actual change to how SAM predicts, logs, or displays picks.

SAM XAI 2.1

SECURITY + ACCURACY PASS (version held at SAM 1.5.8 per Drew's call — label unchanged). (1) TWO OPEN ADMIN ENDPOINTS CLOSED: /admin/trigger-autograde and /admin/clear-pending-mlb (plus its /api/clear-pending-mlb alias) were sitting ABOVE the session/auth checks in the fetch handler and had no auth at all — anyone who knew the path could hit them with a plain GET, and clear-pending-mlb mutates data (wipes pending MLB bets). Both now require admin auth before doing anything. Behavior change: those two URLs now need ?key= or an X-Admin-Key header, same as the other /admin routes. (2) ADMIN AUTH HARDENED: new isAdminAuthed() helper replaces the three inline providedKey !== env.SESSION_SECRET checks (set-sam-password, feedback, backfill-calibration). It prefers a separate ADMIN_SECRET and only falls back to SESSION_SECRET if unset — so the session-cookie signing key no longer has to double as the admin key; accepts the key via X-Admin-Key header so it doesn't have to ride in a URL where CDN/proxy logs capture it; still accepts ?key= so nothing existing breaks; constant-time compare. Non-breaking until Drew opts in by setting ADMIN_SECRET. (3) TIE TRIALS SPLIT 50/50 (non-soccer): runTeamSportSimulation was folding every integer-rounded tie trial into side B via winPctB = 100 - winPctA, a small systematic tilt toward whichever side was B. Non-soccer branch now recomputes winPctA from (winsA + ties/2) before shrinkage. Soccer untouched — draws stay a real modeled outcome there. (4) RECENT FORM FINALLY WIRED (NFL/NBA/WNBA/CFB/CBB): calculateLambdaMultiplier's recentFormDiff input had been dead since it was written (always 0, formFactor always 1.0). getRestDaysForSides already fetches a 16-day scoreboard for both teams for the rest calc, so it now ALSO computes each team's last-5 scoring margin from that same already-fetched data — zero new ESPN calls, no added subrequest pressure. In the sim core, recentFormDiff = FORM_DAMPEN * (recent last-5 margin − season margin), i.e. how far a team is over/under-performing ITS OWN baseline lately (deviation, not raw win-streak momentum — deliberately sidesteps the hot-hand fallacy), bounded by the existing ±8% clamp, no-ops under 3 completed games, and printed in the tool trace for transparency. FORM_DAMPEN = 0.5 is a deliberately conservative starting value exposed as a top-of-file constant for tuning. Only the 5 leagues that already do the rest fetch get it; MLB/NHL/Soccer would each cost a new subrequest so they were left for a deliberate future call. (5) PER-LEAGUE CORRELATION MADE TUNABLE (no values invented): sampleBivariatePoisson's rho is now a per-league SCORING_MODEL field instead of a hardcoded 0.28, but MLB/NHL/Soccer were all left at 0.28 on purpose — no per-sport value was fabricated, since there's no fitted data to back one yet.

SAM XAI 2.1

SAM 1.5.8 NOW RUNS gemini-3.7-flash (was gemini-3.6-flash). Same paid intro rate as 3.6 ($0.75/$3.75 per 1M through end of 2026) — not a cheaper swap, a better Flash for the tool loop (schedule → sim → [PICK]). Thinking stays MEDIUM. The 25k Monte Carlo is still Worker code (run_matchup_simulation), not the model. Cached lookups and auto-grade still skip Gemini entirely. Label stays SAM 1.5.8.

SAM XAI 2.1

$10 / 10-PACK CHECKOUT NOW CREDITS THE ACCOUNT. Stripe Checkout for /buy?tier=10 was already creating a live $10 session, but paying it never wrote credits_remaining: the success URL dropped the session id, STRIPE_WEBHOOK_SECRET is not set on the Worker, and no sam_bettors row has ever received pack credits. Return from Checkout now retrieves the paid session from Stripe and applies the same unlock as the webhook (10 credits for the $10 pack, or 30-day premium for $25), with a KV lock so webhook + return cannot double-credit.

SAM XAI 2.1

NETWORK HAMBURGER MATCHES GRIMALDI.TV. SAM's slide-out menu now uses the same starred order as the rest of the network: HOME, ⭐ LIVE BROADCAST, ⭐ LAUNCH SPORTS ANALYZER, Football, Baseball, Basketball, UFC, WNBA, Tennis, Lib News, Trivia, then SAM-only links (Stats, Workflow, About, Version Notes).

SAM XAI 2.1

TENNIS AUTO-GRADE CATCH-UP. Older WTA/ATP rows were sitting Pending after the match was final because (1) ESPN tennis scoreboards return the whole tournament, (2) Event Date was often the tournament start or an earlier-round date, and (3) names like Lili/Lilli and Lin Zhu/Zhu Lin missed the exact-string match. Grader now range-fetches tennis ~16 days past Event Date, matches last names and reversed given/family order, and processes oldest Pending first. get_game_schedule prefers a still-scheduled match over a completed earlier-round result so the logged Event Date is the actual match.

SAM XAI 2.1

TENNIS IS HOLD SERVE FIRST (Drew, college tennis at Eastern Illinois). If they cannot break you they cannot beat you — the sim now uses real service-game hold % from TennisMyLife match tapes (service games minus breaks against), not a rank-guessed hold. Surface-specific hold (clay/grass/hard) wins when there are enough games on that court. Weather actually moves the hold number: wind and rain make holding harder, heat speeds the bounce, humidity fluffs the ball. Indoor still plays faster. Rank/W/L are fallback only when hold sample is thin. Writeups must cite both players' hold %. MLB unchanged.

SAM XAI 2.1

TENNIS THIN-DATA 50/50 (MLB LEFT ALONE). Drew: leave MLB for a few more days, it is hitting; the leak is weaker tennis where rank/W/L never come back and SAM still crowns a winner off a default #80 hold. Tennis/WTA/ITF only: if either player is missing rank or season W/L, or the hold/break sim is 50-51, lock the logged pick to 50/50, say why, still write the Airtable row, still serve the cache with Line movement. Do not auto-grade Win/Loss on those rows (Push once the match is final). MLB and every other league still pick a side.

SAM XAI 2.1

BASKETBALL IN THE SAM MENU. The hamburger sports list now has Basketball under MLB, linking to https://basketball.grimaldi.tv (NBA + NCAAB). Same network link as football, WNBA, tennis, and UFC.

SAM XAI 2.1

UFC ACCURACY PASS (post-release patch, version held per Drew's call). (1) 5-ROUND FIGHTS WERE BEING SIMULATED AS 3-ROUND FIGHTS: runUFCMultiStatSimulation had the round loop hardcoded to 3, with no exception for title fights or scheduled 5-rounders \u2014 a real bug affecting every main event and championship bout, since decision math, finish-rate odds, and cardio all play out differently across 5 rounds. Added fetchUFCFightRounds(), which looks up the matched fight on ESPN's UFC scoreboard and reads the real round count off the competition's own description field (\u201c5 Rnd (5-5-5-5-5)\u201d) plus its title-fight flag, run in parallel with the existing power-ratings fetch so it costs no added latency. Falls back to 3 rounds if the fight can't be matched on the board (same non-blocking spirit as the rest of the UFC pipeline). (2) REACH/STANCE EDGES WERE FLAT ACROSS ALL WEIGHT CLASSES: a 4-inch reach edge was scored identically for a heavyweight and a flyweight, which isn't physically right \u2014 reach differential matters proportionally more relative to a smaller frame. Added ufcWeightClassScale(), a lookup table (1.6x at strawweight down to 0.65x at heavyweight) now applied to the reach-edge calculation, keyed off the real weightClass field ESPN's athlete endpoint already returns (added to fetchUFCFighterStats's extracted fields \u2014 was being pulled by ESPN this whole time but not captured). (3) NOT FIXED, BY DESIGN: opponent-strength normalization (raw SLpM/TD stats aren't adjusted for the quality of who they were earned against) and ring-rust/layoff time are both real remaining gaps, but neither has a reliable current data source \u2014 ESPN doesn't expose opponent quality metrics, and pulling last-fight date would mean two more sequential ESPN calls per fighter on top of the already-tuned rate-limit-safe chain (the one that took a dedicated bugfix round to get right, see below). Faking either with a heuristic would make picks look more rigorous than they are, so left alone until there's a real data source worth wiring in.

SAM XAI 2.1

MLB 64% DEFAULT LOCKED OUT. After five straight 8/19 losses, Airtable showed the Monte Carlo at 50-58% while Confidence % and the writeup were almost always 64% (40 of ~108 recent MLB picks). Grok was rounding coin flips up because the prompt said not to hedge and that favorites clear 60-65%. Fix is in code: logged confidence and the visible split are overwritten from the actual run_matchup_simulation numbers (picked side's sim %, both sides sum to 100). Band calibration may still pull a number DOWN, never above the sim. Dummy/tiny-sample starters (Stock 12.1 IP / 3 GS) no longer sneak through the sample-weight backdoor — under 15 IP and 4 GS is unused, matching the Friday rule. Starter lock restored: 0% weight more than 6 hours before first pitch, ramp 6h→3h, full inside 3h. Prompt no longer permits inflating a 52% sim to 64%.

SAM XAI 2.1

CONSERVATISM SHRINKAGE SET BACK TO 0.50 (0.55 → 0.50). Per Drew's call after MLB looked off. A raw 70% edge reports 60% instead of ~61%, a raw 80% edge 65% instead of ~67%. Same constant as the original conservatism pass; applies to every sport including MLB.

SAM XAI 2.1

POST-MORTEM TAGS ARE ACTUALLY ACCURATE. Grade-time tags were leaking starter names into LOSS PATTERNS (comma-split turned "actual starter(s): Pintaro" into fake tags) and faded_market was still looking for "SAM and the market disagree" — a phrase the Market Line never writes. Parser now keeps a whitelist (wrong_probable, dummy_era, injured_qb_used, preseason_stats, thin_data_signal, high_confidence_miss, favorite_miss, coinflip_miss, faded_market). faded_market fires from the real line: SAM vs implied % (>=5) or "**+X% edge** over the market" / "**X% below** the market", never "roughly aligned". Historical losses are re-read the same way when TRACK RECORD loads, so old cards count. Autopsy line is "POSTMORTEM: tags — Winner 62% — logged Stock, Pintaro started". If faded_market or thin_data_signal repeats enough in a league, the next sim gets a hard tool-result warning (do not widen a SAM-vs-market gap; sparse/zero stats are a red flag) instead of rewriting the whole engine after one bad night.

SAM XAI 2.1

WNBA IN THE SAM MENU. The hamburger sports list now has WNBA under UFC/MMA, linking to https://wnba.grimaldi.tv. Same network link as football, tennis, and UFC.

SAM XAI 2.1

STRIPE PAYMENT ACTUALLY UNLOCKS SAM. Paying the $25 VIP Payment Link or /buy never flipped sam_bettors: the live Stripe webhook omitted checkout.session.completed, the Worker only handled that one event, Payment Links have no client_reference_id, and STRIPE_WEBHOOK_SECRET was never set so every POST 400'd. Webhook now accepts checkout.session.completed and invoice.paid, matches the payer by session id OR email (creates a sam_bettors row on that email if needed), sets premium_until from the Stripe period end (or +30 days / +10 credits for /buy), and verifies events via the signing secret or by retrieving the event from Stripe when the secret is missing.

SAM XAI 2.1

GOOGLE SHEET STAYS CURRENT WITH THE LIVE REPLICA. Airtable is still the official write. Sport sites already read https://ai.grimaldi.tv/api/bets-replica.json (not Airtable). After every replica save, SAM pings GOOGLE_SHEET_SYNC_URL if set so the Drive sheet Ai Grimaldi Bets replica refreshes immediately. A one-minute Apps Script fallback also pulls the same JSON.

SAM XAI 2.1

ALL GROK KEYS DEAD → SAM 1.5.8. After every Grok key (primary + backups) fails or is skipped, a new sim goes to Gemini / SAM 1.5.8. Already-run games still serve the stored writeup. The reply notes that SAM XAI 2.1 was unavailable. Next request skips the dead Grok keys for 15 minutes instead of waiting on them again.

SAM XAI 2.1

GOOGLE SHEET / BETS REPLICA. Airtable stays the official log. Reads (cache, slate leftover check, track record, /stats, sport-site pick APIs) now use a Cloudflare KV copy first so they stop burning the Free-plan 1,000 Web API calls. New picks and grades still write Airtable, then update the replica. Public feed: GET /api/bets-replica.json and GET /api/bets-replica.csv (newlines stripped so Google IMPORTDATA can parse Notes). drewgrimaldi can force a one-time Airtable dump at GET /api/bets-replica/refresh.

SAM XAI 2.1

ODDS API BACKUP KEY. env.ODDS_API_KEY_BACKUP is tried when the primary Odds API key returns 401/402/429. Only after both keys fail do we skip The Odds API for 15 minutes and fall through to ESPN moneylines. Secret is set via wrangler, never hardcoded.

SAM XAI 2.1

ESPN MONEYLINE FALLBACK WHEN THE ODDS API IS OUT. The Odds API returned 401 on cache hits (monthly credits gone — they reset on the 1st). Line movement went silent. fetchMarketOddsData now tries The Odds API first; on 401/402/429 or a missing key it reads moneylines off the ESPN scoreboard we already fetch (ESPN BET / DraftKings / FanDuel when present). No new paid key. A 15-minute skip avoids hammering a dead Odds API key. Failed odds still do not print a warning on the user-facing pick.

SAM XAI 2.1

CACHED REPLY ALWAYS CHECKS LIVE ODDS FOR LAST-MINUTE LINE MOVEMENT. Person B asking "Tigers vs Nationals" after Person A already ran it still gets the identical stored pick/writeup (no second sim). SAM now always fetches the current market line on that cache hit and appends a movement note: same / toward / away from the pick, opening price → now. The previous 800ms skip dropped this too often. If the first log had no Market Line, Person B still sees the current line labeled as checked just now. Opening price is parsed from the stored Market Line and also saved as a ---ODDSLINE--- snapshot on new writes so later lookups do not depend on the regex.

SAM XAI 2.1

AIRTABLE CACHE SHORTCUT ACTUALLY FIRES FOR ALREADY-RUN GAMES. The whole point of logging picks is to serve the stored writeup instead of re-running verify/stats/25k sim. That path existed but missed most real questions after a slate run: (1) only the first 100 Pending rows were searched, no pagination; (2) two Pending rows for the same MLB series (yesterday + today) counted as "ambiguous" and skipped the cache entirely; (3) Placed Date coming back as a datetime failed the exact === todayET() check so every hit was treated as stale and re-simulated; (4) lookup required the user to type "vs" or "at"; (5) a cache hit still waited on The Odds API (tennis can fan out many 4s calls) plus a non-peek credits check before answering. Fix: paginate today's + pending records, prefer today's unique matchup, match nicknames even without vs/at, compare dates by YYYY-MM-DD, and return the stored reply immediately (line movement has 800ms to append, otherwise skip).

SAM XAI 2.1

SCRAPINGANT ESPN FALLBACK WHEN ZENROWS IS OUT. Drew has a ScrapingAnt API key to use while ZenRows is unpaid/capped (AUTH004). Added fetchEspnViaScrapingAnt() against https://api.scrapingant.com/v2/general (x-api-key header). tryEspnBypassFallbacks now tries ZenRows first, then ScrapingAnt, then the unused Browser binding. If ZenRows returns 402/AUTH004, a sticky flag skips it for 15 minutes so we don't keep burning failed ZenRows calls. fetchEspnCoreWithRetry (UFC/tennis athlete lookups) now uses the same fallback chain instead of ZenRows-only. Secret: SCRAPINGANT_API_KEY. No-ops until that secret is set.

SAM XAI 2.1

SAM LOGIN IS sam_bettors MEMBERSHIP. Every row in Grimaldi.tv's sam_bettors table can sign in with that username or email. Password hashes on that table were never the same as Grimaldi.tv's screen-name unlock, so requiring a SAM hash rejected real members (credits and premium included). Lookup is username OR email; if the row exists, SAM starts a session on that bettor id so free/daily, credits_remaining, and premium_until apply as stored. Not on the list = incorrect username. Supabase errors surface as such. drewgrimaldi is still the only admin for Run today.

SAM XAI 2.1

ADMIN SLATE RUNNER — RUN TODAY'S BOARD INTO AIRTABLE. drewgrimaldi-only. GET /api/slate pulls today's ESPN scoreboards (MLB, NFL, NCAAF, NBA, NCAAB, NHL, WNBA, UFC, Tennis, WTA, EPL), expands UFC/tennis cards into individual matchups, skips completed games and anything already Pending in Airtable, and returns the leftover list. The signed-in admin UI has a Run today button that walks that list one game at a time through the existing /api/chat path (same sim, same [PICK], same Airtable write as typing the matchup). slate:true on /api/chat is rejected unless the session username is drewgrimaldi; when it is, the prediction-credit meter is not touched. One game per Worker request so ZenRows/subrequest limits stay the same as a normal typed prediction.

SAM XAI 2.1

NFL / NCAAF / NBA / CBB ACCURACY PASS. These are the volume sports. (1) NBA preseason is ignored the same way NFL August is — season_type pre and October games before the 20th do not count as this year's offense/defense. Under 8 real NBA/CBB games (6 WNBA), last year is blended in. (2) Rest was in the lambda math but never set. ESPN last-game lookback now sets restDays for NFL/CFB/NBA/WNBA/CBB. NBA/WNBA/CBB on a 1-day turnaround is treated as a back-to-back and scales offense down. (3) Basketball key-player skips OUT/IR so a hurt star's PPG is not the factor. Tool result prints rest and data vintage.

SAM XAI 2.1

POST-MORTEM TAGS NOW CHANGE THE SIM, NOT JUST THE PROMPT. On auto-grade of a Loss, SAM compares the logged starter/QB to the actual ESPN box-score starter and writes structured tags (wrong_probable, dummy_era, injured_qb_used, preseason_stats, favorite_miss, plus the old thin_data / high_confidence / coinflip / faded_market). Last 20 losses per league are rolled up when TRACK RECORD loads. If a tag repeats enough: wrong_probable x3 in 6+ MLB losses halves starter-lock weight; dummy_era x2 in 4+ losses raises the IP/GS bar; high_confidence_miss x4 in 8+ losses pulls that league's win% toward 50. Friday-style Stock/Pintaro would tag wrong_probable and then actually tighten the next MLB card. One bad night does not rewrite the engine.

SAM XAI 2.1

FOOTBALL WEEK-1 HARDENING (NFL + CFB). Season is close; do not let August preseason or a 0-game 2026 slate pretend to be this year. (1) NFL BALLDONTLIE games tagged preseason, week<=0, or dated August/late July are dropped. If fewer than 4 real regular-season games exist, last year is blended in (or used outright if this year is empty). (2) ESPN NFL/CFB standings do the same: under 4 games played, blend last year instead of treating a Week 0/1 sample or empty 2026 table as a full season. (3) QB key-player: injured QBs are skipped so a backup is the input; Preseason splits are never used for QB rating. Tool result now prints the data vintage so a Week 1 pick says "2025 regular season / preseason ignored" instead of looking like live 2026 form.

SAM XAI 2.1

MLB BULLPEN + DON'T LOCK A STALE PROBABLE. Follow-up to Friday's card. (1) BULLPEN: ESPN team pitching stats are pulled for both clubs. A real relief/bullpen ERA is preferred; if ESPN only exposes overall staff ERA it is used as a weaker staff proxy. That factor is blended into expected runs so the 8th/9th inning is not just "whoever started." (2) STARTER LOCK: listed probable ERA is not applied more than 6 hours before first pitch (0% of the starter weight), only partially from 6h to 3h, and fully inside 3 hours or after first pitch. A Stock-style 10.13 probable the morning of cannot manufacture a 62% pick anymore. Tool result states STARTER LOCKED / NOT LOCKED and the hours to first pitch. Re-run inside 3 hours to lock the actual starter.

SAM XAI 2.1

MLB PITCHER INPUTS HARDENED AFTER A BAD FRIDAY CARD. Friday's graded MLB slate went 2-5 (likely 2-6): dummy 0.00 ERAs (Jobe), a listed probable who did not start (Stock 10.13 / Pintaro actually threw), and ERA-only weight turning 62% favorites out of mid-rotation matchups. Fix is in the sim inputs, not the prompt. Probable-pitcher lookup now pulls IP/GS/G with ERA. A 0.00, ERA over 12, under 15 IP and 4 GS, or a reliever/opener profile (starts are a small share of appearances) is NOT used to scale expected runs, and the tool result says so. Usable ERAs are sample-shrunk toward 4.20 and capped 0.86-1.16 so one ugly or tiny-sample number cannot manufacture a 62% pick. Star-bat IL weight raised (OF/DH/SS) so a Judge/Stanton/Bellinger-style pile-up actually moves the Yankees-type number instead of getting ignored as three 1.5% OF dings.

SAM XAI 2.1

UFC TALE OF THE TAPE IS NOW PART OF THE SIMULATION OUTPUT. The fight sim returns the actual tape it used (age, height, weight, reach, stance, SLpM, strike acc, TD/15, TD acc, sub/15, last-5) plus the 25,000-trial win% split by KO/sub/decision and a why line computed from those same inputs (which tape edges moved the number, most common finish path). Not extra summary color — those rows are the simulation's inputs and results.

SAM XAI 2.1

PREDICTION ENGINES: POSITION-WEIGHTED INJURIES + UFC MULTI-STAT + TENNIS HOLD/BREAK. Three code-level upgrades so winner accuracy comes from better inputs and better sims, not more prompt text. (1) TEAM INJURIES: ESPN reports are now scored by position and status (QB/ace/star OUT haircuts harder than a depth-chart Questionable) and applied automatically to each side's lambda, cap 18%. Simulation writeup names the notable absences. (2) UFC: fights no longer mash tale-of-the-tape into one power number plus N(0,15). New per-round model uses SLpM, strike accuracy, takedown avg/accuracy, submissions, reach, stance, and last-5 form from fightingdata.com (KO/sub finish paths + decision). (3) TENNIS/WTA/ITF: matches no longer use the same mashed fight Gaussian. New hold/break set sim (best-of-3, best-of-5 for ATP slams) with hold probabilities from rank, season W/L, TennisMyLife surface record, indoor/outdoor, recent form, and H2H. Conservatism shrinkage still applied to the raw win% on all three paths.

SAM XAI 2.1

CONSERVATISM SHRINKAGE SET TO 0.55 (0.50 → 0.55). Per Drew's call after the fresh deploy landed back on 0.50. Same small nudge as the earlier 0.50 → 0.55 pass: a raw 70% edge reports ~61% instead of 60%, a raw 80% edge ~67% instead of 65%. Holding here.

SAM XAI 2.1

GROK MODEL BUMPED TO 4.6, REASONING EFFORT LOWERED TO MEDIUM. Swapped GROK_MODEL from "grok-4.5" to "grok-4.6" (Drew is getting a fresh API key for it) and dropped reasoning_effort from "high" to "medium" per Drew's request. Also discovered while fixing the base-consolidation bug above that Grok's calibration/track record has zero real history to date — every prior write silently 404'd against a base that never existed — so this reasoning-effort change starts from a clean slate anyway.

SAM 1.5.8 / XAI 2.1

REAL ROOT CAUSE FOUND VIA WRANGLER TAIL: ZENROWS CONCURRENCY LIMIT, NOT ESPN BLOCKING. Deployed the ZenRows-first change, ran wrangler tail live against a real WNBA prediction per Drew's request, and got a definitive answer instead of another round of static-code guessing: 8 athlete-overview calls fired, 5 succeeded, 3 came back AUTH006 (Too many concurrent requests) directly from ZenRows' own API -- not ESPN, not a subrequest-ceiling error, a distinct problem from everything chased earlier this session, and one the Cloudflare Workers Paid upgrade does nothing for since it's a ZenRows-side limit, not a Cloudflare-side one. Traced the exact mechanism: getKeyPlayerFactor's candidate-stats loop used Promise.all across 4-6 players, AND the outer call site ran both teams' getKeyPlayerFactor through Promise.all too -- a nested fan-out that, for a WNBA matchup, meant up to 8 simultaneous ZenRows sessions competing for the same concurrency slot the instant today's earlier change (routing every ESPN call through ZenRows first, no direct attempt) went live. That earlier change fixed the original latency problem but directly caused this one -- more calls landing on ZenRows at once, more collisions. Fixed both nesting levels to sequential (await one candidate/team fully before starting the next) rather than parallel. Also converted tennis's two-player Promise.all to sequential pre-emptively, same mechanism at smaller scale (2 concurrent vs. up to 8) -- Drew confirmed tennis is also still broken and this is a cheap, low-risk fix for the same class of bug rather than waiting for a second tail session to prove it independently. NOT YET touched: getInjuryReport's both-teams Promise.all (2 concurrent) -- not implicated in the log, left alone for now; worth revisiting if injury-report-specific errors show up in a future tail session.

SAM 1.5.8 / XAI 2.1

ALL ESPN CALLS NOW GO STRAIGHT TO ZENROWS, EVERY SPORT, NO DIRECT ATTEMPT FIRST. Drew confirmed the error was \u201cSAM's having trouble connecting right now\u201d \u2014 the generic message shown when the whole model call fails, not an ESPN-specific error surfacing cleanly. That pointed at latency/timeout rather than a clean 403: every blocked direct fetch was paying a full connect-attempt-then-fail cost before ever falling back to ZenRows, and with WNBA/tennis routing several ESPN calls per prediction, that failed-attempt tax could plausibly stack up into the whole request timing out \u2014 which would explain a generic connection failure instead of a traceable ESPN error. Drew's ask: skip the 403 entirely, wire it all up with ZenRows first. Removed the conditional gating in both fetchEspnWithRetry() and fetchEspnCoreWithRetry() (isTightMarginEspnUrl, isEspnDirectLikelyBlocked's sticky flag, and the forceZenRows flag added earlier today for tennis) \u2014 now both functions try ZenRows immediately whenever env.ZENROWS_API_KEY exists, for every sport, every endpoint, with the direct fetch demoted to a last-resort fallback only if ZenRows itself comes back empty or erroring. isTightMarginEspnUrl() and isEspnDirectLikelyBlocked() are now unused (left in place rather than risk over-editing; harmless dead code, not wired into anything). Cleaned up the now-meaningless forceZenRows argument from all four tennis call sites since every call gets that behavior unconditionally now. This trades a real increase in ZenRows usage (every ESPN call, not just the previously-flagged tight-margin sports) for the fastest possible path per call \u2014 Drew is already on the 45k/month paid tier anticipating this kind of volume.

SAM 1.5.8 / XAI 2.1

TENNIS ZENROWS COVERAGE GAP FOUND AND FIXED; WNBA CONFIRMED ALREADY FULLY COVERED. Drew reported still getting errors on both. Pulled the actual live deployed worker directly from Cloudflare first (not just the local file) to rule out a stale-deploy mismatch \u2014 it wasn't one, the live code already has every WNBA/tight-margin fix from this session. Traced every WNBA data path (team resolve, roster, standings, athlete search, athlete stats, schedule, injuries, grading) and confirmed all of them go through fetchEspnWithRetry(), which does have the guaranteed-ZenRows check (isTightMarginEspnUrl) \u2014 no gap found there. Tennis is a different story: its entire pipeline (fetchTennisPlayerStats \u2014 athlete lookup, rank list, rank detail, statistics, four separate calls per player) runs exclusively through fetchEspnCoreWithRetry(), which never got the WNBA-style guaranteed-first-try fix at all \u2014 it only fell back to ZenRows reactively, after a 403 had already been hit once this invocation (the sticky flag), or inline on that same call after a failed direct attempt. Since three of the four tennis calls use ESPN-returned $ref URLs rather than predictable literal paths, pattern-matching them the way isTightMarginEspnUrl does for WNBA would be fragile, so added an explicit forceZenRows parameter to fetchEspnCoreWithRetry() instead and set it true at all four tennis call sites \u2014 tennis has zero BALLDONTLIE coverage and its whole stat pipeline sits on this one function, so it needs the same guarantee WNBA already has, not URL-guessing. NOT YET CONFIRMED: whether this was the actual cause of the WNBA errors specifically, since that path checked out clean \u2014 need the real error text/message from Drew to pin that one down rather than guessing further.

SAM 1.5.8 / XAI 2.1

CHAT BACKGROUND SWAPPED TO THE NEW UNCLE SAM POSTER. Replaced BACKGROUND_BASE64 with the newly uploaded promotional artwork. The original PNG upload was 2.5MB \u2014 too large to embed directly without pushing the worker script size up significantly, so it was resized to roughly match the previous background's dimensions (1100px wide, same aspect ratio) and re-encoded as JPEG at quality 78, landing at about the same final size as what was already in place (~316KB vs. the previous ~280KB) rather than ballooning the deployed script. Verified the new base64 decodes back into a valid JPEG before shipping.

SAM 1.5.8 / XAI 2.1

NCAA.COM ADDED AS A GENERAL-PURPOSE STAT SOURCE FOR CFB, WIRED IN AS A QB-RATING FALLBACK. Drew asked for \u201call of the stats\u201d from NCAA.com's individual leaderboards, used as a fallback only when ESPN's college football data is thin. Checked the page directly first: it's a plain, simple server-rendered HTML table (no JS, no bot-check) with a consistent structure across every category, and found all 41 category IDs from the site's own dropdown (Rank/Name/Team/Cl/Position/G plus category-specific columns) \u2014 confirmed category 453 in Drew's link is specifically Passing Yards, not Passing Efficiency (the real QB Rating equivalent, which is category 8, \u201cPass Eff\u201d column). Built this as reusable infrastructure rather than a one-off scrape: parseNcaaStatsTable() generically parses any category's table by reading the header row and mapping columns dynamically, so adding coverage for a different stat later is a one-line addition to NCAA_FOOTBALL_STAT_CATEGORIES, not a new parser. fetchNcaaFootballStatCategory() fetches one category by name; fetchNcaaFootballPlayerStat() searches a category's full leaderboard for a specific player by name, disambiguating by team when multiple players share a name. Wired the Passing Efficiency category specifically into getKeyPlayerFactor() for CFB: when ESPN's per-QB stat fetch comes back empty for every candidate on the roster (common for less-marquee college programs ESPN doesn't track closely), falls back to looking up the top-of-roster QB by name and team on NCAA.com's real Passing Efficiency leaderboard, using \u201cPass Eff\u201d as the QBRating-equivalent value and \u201cPass Att\u201d as the usage stat \u2014 same downstream math, just a real number instead of a missing one. Verified the full path (table parsing, name lookup, team disambiguation, and a real not-found case) against live NCAA.com data before shipping.

SAM 1.5.8 / XAI 2.1

TWO NEW UFC FALLBACK DATA SOURCES ADDED, BOTH VERIFIED SCRAPABLE WITHOUT ZENROWS. Drew's original ask was whether ufcstats.com could be added now that ZenRows exists \u2014 checked it directly first: it's not a simple IP block like ESPN, it's a client-side JS proof-of-work challenge (computed SHA-256 in-browser), a fundamentally harder bot-check that a plain fetch() can never solve regardless of where it runs from. Left that one alone pending real verification against ZenRows' antibot+js_render mode. Drew then suggested two alternatives instead: ultimatefightingstats.com and fightingdata.com. Confirmed ultimatefightingstats.com's fighter stats are on its free tier (not paywalled) before touching anything \u2014 it's a Next.js app whose fighter pages are server-rendered but the actual data sits inside a React Server Component payload with inconsistent backslash-escaping depth, which made SLpM/Str. Acc./TD Avg. reliably extractable via a backslash-tolerant regex but left reach/age/takedown-accuracy/submission-avg in a more deeply nested structure not worth force-parsing. fightingdata.com turned out to be the stronger source: plain server-rendered HTML with a genuinely simple table structure (/ pairs,

for bio fields), full field parity with ESPN (age, reach, stance, striking, takedown, submission) and easier to parse reliably than the RSC-payload site. Added fetchFightingDataFighter() and fetchUltimateFightingStatsFighter() plus a shared fetchUFCFallbackFighter() chain (fightingdata.com tried first since it's more complete, ultimatefightingstats.com second) and wired it into fetchUFCFighterStats() at all three points where it previously gave up: fighter not found on ESPN at all, ESPN's athlete lookup failing outright, and ESPN finding the fighter but returning a thin/empty stat block (common for newer roster additions). Both slug-based URL patterns and every extraction regex were verified against real saved fighter pages (Islam Makhachev) before shipping, not assumed to work.

SAM 1.5.8 / XAI 2.1

SWITCHED TO NCAAF/NCAAB AS THE CANONICAL COLLEGE LABELS, PER DREW. Found the real control point while making this change: extractPick() already force-normalizes any CFB/NCAAF wording the model writes into one fixed string before it's ever logged to Airtable \u2014 so the model's own phrasing barely matters, this hardcoded rewrite is what actually decides the League field. It was writing \u201cNCAA Mens Football\u201d/\u201cNCAA Mens Basketball\u201d, not the \u201cCollege Football\u201d label added to the [PICK] enum in the previous entry \u2014 those would never have matched each other regardless of what the enum said. Updated both the strict-parse and lenient-fallback versions of this rewrite to output \u201cNCAAF\u201d/\u201cNCAAB\u201d instead. Also found a real functional gap while doing this: normalizeLeague() recognized \u201cNCAAM\u201d as a college-basketball alias but never \u201cNCAAB\u201d at all \u2014 fixed. Updated the [PICK] tag enum and the two tool-parameter descriptions that said \u201cNCAAM\u201d to say \u201cNCAAB\u201d for consistency. Net effect: since nothing has actually been logged for CFB yet (confirmed with Drew, season starts end of August), this lands clean \u2014 no legacy data to reconcile, every CFB/CBB pick from here forward will consistently land under NCAAF/NCAAB in Airtable, TRACK RECORD, and calibration.

SAM 1.5.8 / XAI 2.1

GETTING AHEAD OF COLLEGE FOOTBALL BEFORE THE SEASON STARTS. Drew clarified CFB hasn't actually been used yet (no graded picks exist), season starts end of August \u2014 wanted to be proactively ready rather than wait for a live failure the way WNBA's problems had to get diagnosed one at a time. Checked what CFB actually shares with WNBA's risk profile: BALLDONTLIE_SPORT_PATHS only covers NFL/NBA/MLB/NHL \u2014 CFB gets zero BALLDONTLIE coverage, same as WNBA, so its standings always fall through to ESPN. The good news: CFB's key-player loop is inherently lighter than WNBA's \u2014 it filters to QB-only candidates (2-4 per roster), not the 6-player scan NBA/WNBA/CBB use, so it was never going to hit the same subrequest wall from that angle. But the missing-BALLDONTLIE trait on standings is identical, so extended the guaranteed-ZenRows routing proactively: renamed isWnbaEspnUrl() to isTightMarginEspnUrl() and added a check for \u201c/football/college-football\u201d alongside WNBA's \u201c/basketball/wnba\u201d. Verified against real CFB vs. NFL vs. NBA URLs before shipping (CFB/WNBA both match, NFL/NBA correctly don't). Also confirmed against ESPN directly that the season's real schedule is already live and pulling correctly \u2014 99 games found on the default scoreboard starting 2026-08-29, so get_game_schedule's verification gate has real data to check against starting day one, not an empty board. CBB shares the exact same no-BALLDONTLIE / 6-player-loop profile as WNBA too but wasn't in scope for what was asked here \u2014 worth the same treatment if it starts showing the same symptoms.

SAM 1.5.8 / XAI 2.1

COLLEGE FOOTBALL: CHECKED WHETHER IT'S WIRED UP LIKE WNBA. Short answer: the simulation/prediction pipeline was already fully wired (ESPN_LEAGUE_PATHS, TEAM_ROSTER_LEAGUES, and KEY_PLAYER_SPORT_PATH all already include CFB, and the odds-mapping aliases for \u201ccollege football\u201d/\u201cncaaf\u201d/\u201cncaa mens football\u201d were already present) \u2014 CFB never had WNBA's missing-odds or missing-ZenRows problems. But checking it directly surfaced a real, separate bug: the [PICK] tag's league enum \u2014 the literal instruction telling the model what to write in the League field \u2014 never included College Football or College Basketball at all, only listing NBA/NFL/MLB/etc. and a generic \u201cOther\u201d catch-all. Confirmed this is a real, active problem by checking the live Airtable League field's schema directly: it has BOTH \u201cCollege Football\u201d and \u201cNCAA Mens Football\u201d as separate select options (same for basketball) \u2014 meaning the model has been writing whichever phrasing it felt like each time, splitting the same sport's graded history across two different League buckets in the TRACK RECORD/byLeague breakdown the model itself reads back on every request. Added \u201cCollege Football\u201d and \u201cCollege Basketball\u201d explicitly to the [PICK] tag enum so future picks converge on one label each. Also added \u201ccfb\u201d/\u201ccbb\u201d as defensive aliases in the odds-mapping table (fetchMarketOddsData's leagueToSport), since pick.league is written to Airtable and used for the odds lookup completely unnormalized \u2014 raw model text, not run through normalizeLeague() the way the internal simulation code paths are. NOT fixed: the existing historic split between \u201cCollege Football\u201d and \u201cNCAA Mens Football\u201d records already in Airtable \u2014 that's a data cleanup Drew would need to do deliberately (merging the two labels), not something to silently rewrite.

SAM 1.5.8 / XAI 2.1

TWO REAL FIXES: WNBA MARKET LINE, AND BOTH-TEAM WIN PERCENTAGES. (1) WNBA odds were simply missing \u2014 checked fetchMarketOddsData's leagueToSport mapping (used to translate a league into The Odds API's sport_key) and \u201cwnba\u201d was never in it, so every WNBA lookup hit the null-sportKey guard and silently returned nothing, every single time, regardless of ZenRows/caching/anything else touched today. Added \u201cwnba\u201d \u2192 \u201cbasketball_wnba\u201d (The Odds API's real sport key). (2) Drew wanted both teams' win percentages shown, not just the winner's \u2014 the system prompt's rule 3 example only demonstrated stating one side (\u201cgiving the Blue Jays a 55% win probability\u201d), which is likely why the model usually only stated the winning side. Updated to explicitly require both (\u201cgiving the Blue Jays a 55% win probability to the Royals' 45%\u201d).

SAM 1.5.8 / XAI 2.1

WNBA ESPN CALLS NOW GO STRAIGHT TO ZENROWS, ALWAYS \u2014 DETERMINISTIC INSTEAD OF PROBABILISTIC. Drew's ask after the sticky-block flag: don't leave it to a heuristic, just always route WNBA through ZenRows. Fair call \u2014 the sticky-block optimization from the previous entry only helps probabilistically (depends on Cloudflare reusing a warm isolate across requests), which still leaves room for the exact \u201cworked once, then didn't\u201d flakiness Drew was seeing. Added isWnbaEspnUrl() (checks for \u201c/basketball/wnba\u201d in the request URL \u2014 every WNBA ESPN call goes through fetchEspnWithRetry with that path, so this catches all of them: scoreboard, roster, injuries, standings, and key-player stats alike) and wired it into fetchEspnWithRetry so a WNBA call skips the direct-fetch attempt unconditionally and goes straight to ZenRows every single time \u2014 guaranteed 1 subrequest per call instead of a possible 1-or-2. Deliberately scoped to WNBA only, not all ESPN traffic: WNBA is the specific, confirmed tight-margin sport (no BALLDONTLIE coverage the way NBA/MLB/NFL/NHL get), and unconditionally routing every sport through ZenRows would multiply usage against the new 45k/month plan for leagues that aren't actually having a problem. Verified the URL match against real WNBA vs. NBA vs. core-API (UFC/tennis) URLs before shipping \u2014 no false positives. The sticky-block flag from the previous entry stays in place for every other sport.

SAM 1.5.8 / XAI 2.1

ESPN \u201cSTICKY BLOCK\u201d DETECTION \u2014 CUTS BLOCKED-CALL COST ROUGHLY IN HALF FOR THE REST OF A REQUEST. Drew's WNBA symptom (\u201cworked once, then didn't, then worked again\u201d) is the signature of a request sitting right at the edge of Cloudflare's subrequest ceiling \u2014 not a hard bug, a budget that's genuinely borderline depending on which specific ESPN calls happen to get blocked that particular request. Every ESPN call that gets 403'd was costing 2 subrequests (the failed direct attempt, then the ZenRows retry), for every single call, even though the block is IP-based and effectively all-or-nothing \u2014 once one call gets blocked, the rest almost certainly will too. Added a lightweight, fail-safe optimization: a module-level espnDirectBlockedUntil timestamp. The first ESPN call in an invocation still attempts a direct fetch as normal (so a genuinely-unblocked window isn't wasted); if that gets a 403/520/429, every subsequent ESPN call in that same request (and likely nearby ones too, since Cloudflare commonly reuses warm isolates across close-together requests) skips the doomed direct attempt entirely and goes straight to ZenRows for the next 4 minutes \u2014 1 subrequest instead of 2. For a WNBA prediction making 15-20+ ESPN calls, if even half hit this path that's roughly 7-10 fewer subrequests, real headroom back under the ceiling. This is a soft, probabilistic optimization, not a guarantee \u2014 isolate reuse across requests isn't something Cloudflare promises, so the flag may not always carry over between separate invocations, but it can only help within a single request and never makes anything worse (the very next direct attempt after the sticky window expires re-establishes ground truth normally).

SAM 1.5.8 / XAI 2.1

WNBA FRESH PREDICTIONS FAILING OUTRIGHT \u2014 A REAL REGRESSION FROM TODAY'S OWN ZENROWS EXTENSION, NOW MITIGATED. Drew reported the same \u201chaving trouble connecting\u201d error but this time for a brand-new (non-cached) WNBA prediction, not a lookup. Root cause is a real tradeoff introduced earlier today: extending ZenRows to rosters, injuries, standings, and player stats fixed those endpoints returning null on a 403, but it also means every blocked call now costs 2 subrequests (the failed direct attempt plus the ZenRows retry) instead of 1. WNBA was already documented as the tightest-margin sport for Cloudflare's per-invocation subrequest ceiling (no BALLDONTLIE coverage the way NBA/MLB/NFL/NHL get, and an earlier session had already cut its key-player candidate fetch from 15 to 6 for exactly this reason) \u2014 today's fix traded silently-degraded data for a request that fails outright once the doubled cost tips it over. Two real fixes, not guesses: (1) found that both teams' roster lookups, and separately both teams' standings lookups, were being fetched in Promise.all \u2014 fully parallel \u2014 even though the underlying team-list and standings calls are the exact same URL for both teams in a given league. Parallel execution meant neither could benefit from the other's cache write (both check cache, both miss, both fetch independently). Switched both to sequential awaits so the second team's lookup now hits the cache the first one just populated, cutting a full duplicate ESPN call (and its possible ZenRows retry) out of every single team-sport prediction, not just WNBA. (2) Cut WNBA's key-player candidate cap specifically from 6 to 4 (NBA/CBB keep 6, they have more budget headroom) since that stat-fetch loop was already flagged as the single biggest subrequest sink before today, and today's change quietly doubled it. Not able to confirm this fully resolves it without a live invocation to test against \u2014 flagged to Drew as the next thing to watch.

SAM 1.5.8 / XAI 2.1

FOUND THE ACTUAL REASON WNBA KEPT GETTING STUCK: A THIRD, SEPARATE \u201cat\u201d BLIND SPOT, THIS TIME IN GRADING ITSELF. Drew reported WNBA \u201cstill struggling\u201d a day after the fast-path and dupe-check \u201cat\u201d fixes. Checked live Airtable data directly: all 4 of the previous night's WNBA games were confirmed STATUS_FINAL on ESPN, but only 2 of 4 Pending records had been graded \u2014 the exact 2 that stayed Pending were both logged with full team names and \u201cat\u201d (\u201cPhoenix Mercury at Atlanta Dream\u201d, \u201cDallas Wings at Washington Mystics\u201d), while the \u201cvs\u201d-phrased ones graded fine. Root cause: splitMatchupLabel(), the function gradePendingBets() uses to break a Bet Label into two team names before calling findFinalResult(), only split on \u201cvs\u201d/\u201cv\u201d/\u201cv.\u201d \u2014 same blind spot as the earlier two fixes, but in a completely different function nobody had checked yet. When it returned null for an \u201cat\u201d-phrased label, gradePendingBets() just did \u201cif not sides, continue\u201d \u2014 silently skipped that record, forever, every single cron cycle, with no error or log line to notice by. Fixed the same way as the other two: widened the separator regex to also accept \u201cat\u201d. Verified against the real stuck labels before shipping. Manually graded the two records stuck from this bug directly in Airtable so Drew didn't have to wait for the next cron cycle: Phoenix Mercury at Atlanta Dream \u2192 Win (Atlanta Dream 96-82), Dallas Wings at Washington Mystics \u2192 Loss (Mystics won 96-92). LESSON FOR NEXT TIME: there are now three independent places that had to each separately learn to recognize \u201cat\u201d as a matchup separator (fast-path lookup, write-time dupe check, and this grading parser) because each was written as its own regex rather than calling one shared parser. If a fourth one turns up, the real fix is consolidating all matchup-label parsing into a single shared function instead of patching each site individually as it's discovered.

SAM 1.5.8 / XAI 2.1

REMOVED REMAINING USER-FACING GEMINI/GROK BRANDING. Drew asked whether provider names had already been scrubbed from the model switcher; checked the live deployed code directly and found four spots that still said it outright: the model-picker dropdown subtitles (\u201cGemini-powered \u2014 the original model\u201d / \u201cSecond opinion \u2014 powered by Grok\u201d), the cross-model failover notice shown to a user when their requested provider is down (\u201cGemini was unavailable, so Grok answered this one instead\u201d), the \u201cget a second opinion\u201d button's tooltip (\u201cGet Grok's take\u201d), and the Workflow page's step-by-step explainer (\u201cGemini or Grok, whichever you picked\u201d). All four now reference SAM 1.5.8 / SAM DS 2.1 instead of the underlying provider names. Internal-only references \u2014 function names (callGemini/callGrok/requestGemini/requestGrok), changelog history, and code comments \u2014 were intentionally left alone; those aren't visible to a user.

SAM 1.5.8 / XAI 2.1

SAME BUG, DEEPER ROOT CAUSE: MATCHUP DEDUPE WAS EXACT-STRING-MATCH EVERYWHERE, CAUSING BOTH DUPLICATE PENDING RECORDS AND MISSED CACHE HITS. Drew hit the same \u201cSAM's having trouble connecting\u201d error again on \u201cPhoenix Mercury at Atlanta Dream\u201d even after the earlier \u201cat\u201d-regex fix. Investigating the live Airtable data directly surfaced the real, deeper problem: there were TWO separate Pending records for the exact same real game \u2014 \u201cMercury vs Dream\u201d and \u201cPhoenix Mercury at Atlanta Dream\u201d both existed as distinct rows, same for Dallas/Washington and Storm/Liberty. Root cause: THREE separate places all compared Bet Label text with strict, brittle exact-string equality \u2014 checkExistingPrediction (fast path), findExistingPendingRecord (post-model backstop), and critically the write-time dupe check inside _logBetToAirtableInner itself. Whenever the model phrased a matchup differently between two requests (full team names vs short nicknames, \u201cvs\u201d vs \u201cat\u201d), the write-time dupe check failed to recognize the existing Pending record and silently created a second one for the same real game \u2014 which is also exactly why the \u201cat\u201d-regex fix alone wasn't enough: even with a matching hint, there could now be a differently-worded duplicate row that a strict equality check still wouldn't find. Fixed properly this time with a shared, non-string-literal comparison: teamNickname() extracts each team's last significant word (Seattle Storm to storm, Atlanta Dream to dream), matchupNicknameKey() builds a sorted pair from both sides of a vs/v/at-separated matchup string, and sameMatchup() compares two labels by that key instead of raw text. Wired into all three call sites, replacing every prior exact LOWER({Bet Label}) = \u201c...\u201d Airtable formula with a broader Result=Pending (+ Event Date where available) fetch filtered client-side by sameMatchup(). Verified against the real duplicate pairs pulled from Airtable before shipping: \u201cMercury vs Dream\u201d / \u201cPhoenix Mercury at Atlanta Dream\u201d, \u201cStorm vs Liberty\u201d / \u201cSeattle Storm vs New York Liberty\u201d, and \u201cDallas Wings vs Washington Mystics\u201d / \u201cDallas Wings at Washington Mystics\u201d all now correctly recognize as the same game, while unrelated matchups (e.g. Mercury/Dream vs Wings/Mystics) correctly stay distinct. KNOWN FOLLOW-UP: the duplicate Pending records already sitting in Airtable from before this fix were not automatically merged \u2014 Drew was flagged to review and clean those up manually so grading doesn't double-count them.

SAM 1.5.8 / XAI 2.1

WNBA \u201cALREADY LOGGED\u201d LOOKUP FAILING FOR \u201cAT\u201d-PHRASED QUERIES; TRACED TO A REAL BUG, NOT A REGRESSION FROM TODAY'S OTHER WORK. Drew reported \u201cSAM's having trouble connecting\u201d for \u201cSeattle Storm at New York Liberty\u201d even though that exact matchup was already logged Pending. Root cause: the fast-path cached-lookup regex (vsMatch) only recognized \u201cvs\u201d / \u201cv\u201d / \u201cv.\u201d as a team separator in the user's raw message \u2014 it never matched \u201cat\u201d at all, so any \u201cTeam A at Team B\u201d phrasing (extremely common, and exactly how Drew typed it) skipped the cheap cached-reply path entirely and fell through to the full prediction pipeline every time, even for an already-logged matchup. Combined with the pre-existing, previously-documented fact that WNBA is unusually subrequest-hungry and runs close to Cloudflare's per-invocation ceiling, this made WNBA specifically prone to blowing the budget on a request that should have been a near-free cache hit. Fixed by widening the separator regex to also accept \u201cat\u201d as a separator, verified against real phrasings before shipping (\u201cSeattle Storm at New York Liberty\u201d now correctly resolves to the stored \u201cSeattle Storm vs New York Liberty\u201d Bet Label). Accepted a minor, low-cost tradeoff: a stray \u201cat\u201d in an unrelated sentence can now trigger one harmless extra Airtable lookup that simply finds no match and falls through normally. As a second, independent precaution, removed today's new line-movement odds fetch from the post-model backstop path specifically (findExistingPendingRecord) \u2014 that path only runs after the full expensive pipeline has already executed, the worst place to add one more external fetch; the fast path (now fixed) is the safe, cheap place for that feature and covers the vast majority of real cache hits. Also found and fixed a related pre-existing display bug while investigating this record directly: some older logged replies carry a legacy trailing \u201c---ODDSLINE---\u201d block containing a raw, unformatted odds JSON blob, which extractFullReplyFromNotes() was including verbatim in the text shown to a second user asking about the same matchup. Now stripped.

SAM 1.5.8 / XAI 2.1

MISSION STATEMENT: 99% TARGET REPLACED WITH A REALISTIC FAVORITES-BASED BENCHMARK. Drew asked to remove a specific line tying the 99% accuracy target to \u201call the tools you're given\u201d \u2014 that reasoning sentence was cut first. He then asked to add real-world context: most favorites win 60-65% of the time, so consistently landing 70-75% is a genuine win. Once both were in, the paragraph still stated a flat \u201c99% of the time\u201d job target sitting right next to a line calling 70-75% \u201cgenuinely winning\u201d \u2014 an internal contradiction. Fixed by replacing \u201cat least 99% of the time\u201d with \u201cas often as you possibly can\u201d in the JOB line itself, so the 60-65%/70-75% favorites framing is now the one stated benchmark instead of competing with a separate 99% figure. No other part of the mission paragraph (verify-before-you-speak, calibration, decisiveness-over-hedging) was touched.

SAM 1.5.8 / XAI 2.1

LINE MOVEMENT COMMENTARY ON CACHED (DUPLICATE-MATCHUP) REPLIES. Drew wanted a second user asking about an already-logged matchup (e.g. person B asking about Tigers vs Royals after person A already triggered a prediction) to get the identical cached message person A got, but with a comment on how the market line has moved since it was first logged, appended at the very end. Implementation is deterministic arithmetic, not a second LLM call \u2014 line movement is just \u201cprice then vs. price now,\u201d so re-invoking Gemini/Grok for it would only add latency and cost without adding judgment. Added parseOpeningLineFromText() to pull the originally-logged odds back out of the stored reply's own \u201c**Market Line (Bookmaker):**\u201d line (already persisted in Notes, no schema change needed), refactored fetchMarketOdds into a structured fetchMarketOddsData() plus a thin formatter (zero behavior change for the fresh/first-logged path), and added buildLineMovementComment() to phrase the comparison \u2014 moved toward the pick, moved away, or steady, with a \u201cnotably\u201d qualifier only on \u00b15+ point implied-probability swings. Wired into both places a cached reply can be served: the pre-model fast path (checkExistingPrediction) and the post-model backstop (findExistingPendingRecord). Fails open on both ends \u2014 no movement line is added if the original message never had odds logged (e.g. no ODDS_API_KEY at the time) or if a current line can't be found now; the cached message is served exactly as before in that case.

SAM 1.5.8 / XAI 2.1

ESPN RESPONSE CACHING ADDED (Cache API, no new bindings needed). Motivation: Drew's ZenRows free-tier usage was tracking toward ~6,000 requests/month against a 5,000 cap after just 2 days at ~200/day \u2014 he's moving to the paid 45k tier, but caching still cuts real duplicate load rather than just paying it away. The clearest waste: gradePendingBets() re-fetches the same day's ESPN scoreboard once per pending bet, every 6-hour cron tick \u2014 five WNBA bets pending on the same date meant five identical scoreboard fetches per run for data that hadn't changed. Added getEspnCached()/putEspnCached() using Cloudflare's built-in caches.default (no Workers KV namespace or wrangler.toml change required \u2014 works immediately on deploy) and wired both fetchEspnWithRetry() and fetchEspnCoreWithRetry() to check the cache first and populate it on any successful response, whether that response came from the direct fetch or the ZenRows bypass. TTL varies by endpoint volatility via getEspnCacheTtlSeconds(): scoreboard URLs get a short 5-minute TTL (games can be live mid-fetch, so this can't be cached too long, but it still collapses the same-run duplicate-bet case above); athlete/roster/team-list/statistics URLs get a 1-hour TTL since a player's season stats or a roster don't meaningfully change within a day. Fails open by design \u2014 if caches.default is ever unavailable (e.g. certain local dev setups), getEspnCached() catches and returns null rather than breaking the real fetch, so this can only reduce ZenRows/ESPN load, never introduce a new failure mode. This reuses and finally implements the fetchEspnWithRetry cacheable option, which existed in the function signature already but was dead \u2014 never actually wired to anything.

SAM 1.5.8 / XAI 2.1

CONFIDENCE CALIBRATION SWITCHED FROM GLOBAL PLATT SCALING TO PER-BAND CALIBRATION. Drew noticed the visible confidence number almost never moved from the raw simulation win% and correctly suspected the calibration wasn't really \u201clearning.\u201d Tested the actual live fitPlattScaling/applyPlattScaling against 391 real graded Gemini picks pulled straight from Airtable before touching anything: the 2-parameter global logistic fit (a=0.975, b=-0.301) was genuinely fitting, not null or broken \u2014 but SAM's raw confidence values cluster extremely tightly in the 51-65% range (a side effect of CONSERVATISM_SHRINKAGE keeping SAM cautious), and the fitted curve's zero-correction crossover point landed almost exactly on 56%, one of the single most common raw values. So most real predictions saw a 0-2 point shift or none at all, which reads as \u201cnot learning\u201d even though the math was working. Root problem: one global curve averages away band-specific miscalibration. Replaced with fitBandCalibration()/applyBandCalibration(): buckets graded picks into the same <50/50-59/60-69/70-79/80-89/90%+ bands already used for the CONFIDENCE CALIBRATION prompt block, computes each band's actual historical hit rate, and blends it with the raw stated confidence via Bayesian shrinkage (prior weight 10 \u2014 a band with only a handful of graded picks stays close to raw; a band with dozens converges toward its real hit rate). Verified against the same 391-pick dataset: this immediately surfaced that the 60-69% band (98 graded picks) was actually only hitting 52% \u2014 invisible under the old global fit because the well-calibrated 50-59% band (245 picks, 57.1% actual) dominated the aggregate average. High bands with only 1-2 graded picks (80-89%, 90%+) correctly stay close to raw rather than overfitting to noise. fetchPredictionHistory()'s returned shape renamed plattParams \u2192 calibrationParams end to end (both provider call sites in the /api/chat handler, the failover path, and the post-extractPick application block) for clarity; behavior at the call site is otherwise unchanged \u2014 still applied immediately after extractPick(), still overwrites pick.confidence before Airtable logging and the odds lookup, still rewrites the visible reply text so what the user reads matches what gets logged.

SAM 1.5.8 / XAI 2.1

STANDALONE \u201cCONFIDENCE: NN%\u201d LINE REMOVED FROM VISIBLE REPLIES. Drew flagged that the labeled confidence callout was just repeating the same number as the win% split \u2014 true by design (rule 6 defines pick.confidence as \u201cyour final win percentage\u201d) \u2014 and asked for the redundant line gone. Removed the requirement from system prompt rule 3 and from buildCachedReplyText()'s cached-reply text for consistency. The win-percentage split still appears naturally in the narrative (e.g. \u201cgiving the Blue Jays a 55% win probability\u201d); only the separate labeled line is gone. Underlying confidence tracking, Airtable logging, and calibration are unaffected \u2014 this only changes what's displayed. USER-FACING SUMMARY (Drew's own words, worth keeping verbatim): \u201cFor a while it was not showing the results of the Monte Carlo simulation because the confidence % was the same as the winner %.\u201d The standalone line was never a separate, calibrated read on the pick \u2014 it was the same simulation output shown twice under two different labels. Two changes together actually fixed this: (1) this entry, removing the redundant line so only the natural win% narrative shows; and (2) the later switch from one global Platt-scaling curve to per-band calibration (see that entry below), which lets the number genuinely move now instead of landing back on the same value it started from.

SAM 1.5.8 / XAI 2.1

ZENROWS ESPN-403 BYPASS EXTENDED TO EVERY REMAINING RAW ESPN FETCH; WNBA GRADING BUG FOUND AND FIXED. Antigravity's prior ZenRows integration only covered the main scoreboard call and UFC/MMA core-API lookups; roughly a dozen other ESPN call sites (fetchAthleteOverviewStats \u2014 the actual QB rating/PPG/ERA fetch used for every non-UFC sport, rosters, injuries, standings, team search, tennis, golf, MLB probable pitcher, and critically findFinalResult, the function gradePendingBets() calls to check whether a game is final) still used a raw fetch() with zero fallback. Root cause of \u201cWNBA not grading\u201d: findFinalResult had no ZenRows fallback, so a 403 on that specific scoreboard call silently left the bet Pending forever with no retry \u2014 now routed through fetchEspnWithRetry() like everything else. Also caught that fetchUFCFighterStats was already calling fetchEspnCoreWithRetry() but never passed env through, meaning its ZenRows fallback silently never fired either \u2014 fixed by threading env through the full call chain (espnAthleteSearch \u2192 espnAthleteSearchRaw, getUFCMatchupPowerRatings, getTennisMatchupPowerRatings \u2192 fetchTennisPlayerStats, runFieldSimulation \u2192 fetchGolfField/fetchGolfPlayerScoringAvg, getKeyPlayerFactor \u2192 fetchAthleteOverviewStats, and every other affected function). Also stubbed the previously-undefined fetchEspnViaBrowserRender() so tryEspnBypassFallbacks() can't throw a ReferenceError if a BROWSER binding is ever added without an actual implementation.

SAM 1.5.8 / XAI 2.1

"PREDICTIONS REMAINING" ON PAGE LOAD WAS ALWAYS ONE LOW — REAL BUG, DISPLAY-ONLY. Drew noticed the badge read "2 Predictions Remaining" immediately on signing in, before making any prediction at all. Root cause: checkPredictionAllowance() is written to answer "if a prediction happens right now, how many are left after it" — correct for its real call sites (right before actually processing a prediction, in /api/chat), but the page-load render was calling that exact same function purely to DISPLAY the current count, so it was always showing the number one prediction already spent, whether or not that was true. Confirmed this was purely cosmetic before touching anything: the page-load call site only ever read .predictionsLeft off the result and discarded everything else, including the deduction object and (for guests) the guestCookie — so nothing was actually being written back to Supabase or set as a cookie on page load; no real prediction was ever silently consumed by this, only the number shown was wrong. Fixed by adding a peek option to both checkPredictionAllowance() and checkGuestPredictionAllowance(): peek:true reports the true current count with no "as if consuming one" adjustment and returns no deduction/cookie at all, while every real prediction-processing call site (three of them, all unchanged) keeps the exact same default behavior as before. The one page-load call site now passes peek:true. Verified the corrected arithmetic in isolation across four cases (fresh account showing 3, mid-usage showing the right number after real predictions, and the paid-credits phase after the daily free ones are gone) before shipping.

SAM 1.5.8 / XAI 2.1

MISSION STATEMENT: EXPLICIT GOAL SENTENCE ADDED. Drew asked whether the goal of "pick the right winner consistently" was stated plainly anywhere — it wasn't; the existing mission paragraph only had the calibration framing ("be calibrated, not just confident"), which is related but not the same claim. Talked through the risk before adding it as-is: a model told its goal is raw win-rate has an easy way to look successful at that without being honest (inflate confidence, avoid flagging genuinely close calls) that a calibration-based goal doesn't allow, since a Brier score punishes over- and under-confidence equally and can't be gamed by sounding more sure. Landed on combining both in one sentence rather than picking one: "The goal is simple to state and hard to fake: be right, and be right exactly as often as you say you will be — the second half of that isn't a softer, lesser version of the first, it's what keeps the first one honest, since a stated confidence you can't back up with a real hit rate isn't actually being right, it's just sounding right." Placed right after the existing "reason for existing" sentence, before the three-point breakdown (verify/calibrate/show work), which is unchanged.

SAM 1.5.8 / XAI 2.1

EXPLICIT "Confidence: NN%" LINE REQUIRED IN EVERY PREDICTION. The win-percentage split was already required in the narrative (rule 3, unchanged), but only woven into prose (e.g. "giving the Blue Jays a 55% win probability") — there was no guaranteed standalone, unmistakable callout of the number. Added a requirement that every prediction end with a plainly labeled "Confidence: NN%" line, same final number as the rest of the reply and the logged pick, not a second/different figure. No code change needed to keep this consistent with Platt scaling: the existing text-rewrite that corrects the raw confidence number to the calibrated one already does an exact string replace of "{number}%" everywhere it appears in the reply, so this new labeled line gets corrected right along with the rest of the text automatically.

SAM 1.5.8 / XAI 2.1

STATS PAGE NOW REQUIRES SIGN-IN + MORE VISIBLE SIGNUP PROMPT. (1) /stats now checks the same session used everywhere else on the site (already computed once per request, reused here rather than recomputed) — no session redirects to /login with a return path back to /stats, same pattern /buy already used. (2) A "Don't have an account? Create one now!" line was added directly under the chat input on the main page, shown only when signed out (disappears entirely once session exists — confirmed via two separate rendered-HTML checks, one per state). The only prior way to find sign-in was a small all-caps nav link at the very top of the page, easy to miss — this puts it right where people are actually looking, in normal-weight readable text with an underlined link, not tiny mono nav styling. Confirmed no layout regression on the fixed-height chat page: input bar, send button, and the existing footer credit line all still render in their normal positions with the new line in between.

SAM 1.5.8 / XAI 2.1

RESPONSIBLE-USE DISCLAIMER GATE RESTORED. Also built in the same separate conversation as Recent Picks and also apparently never actually deployed, for the same reason. This is NOT the old "Before you enter" click-through gate (that removal was real and intentional, from a much earlier version, unrelated) — it's a newer, simpler one-time overlay: exact text as originally specified ("This site is not gambling advice. It is a simulation of specific sporting events in the most realistic way possible. Please use responsibly." plus a line about thumbs up/down feedback), a single "I Understand — Continue" button, no email or data collection, dismissed permanently per browser via a localStorage flag (sam_disclaimer_ack_v1) — separate from and layered on top of the real sign-in gate, which still controls actual access regardless of this dismissal. Verified with an actual functional test this time, not just a syntax check: rendered the real page through Node (evaluating the outer template literal properly, not a naive text extraction, after that gave a false-positive syntax error earlier tonight) and confirmed the gate shows on first visit, dismisses on click, and correctly stays dismissed after a reload.

SAM 1.5.8 / XAI 2.1

RECENT PICKS: PREDICTED WINNER LABEL MADE EXPLICIT. The Picked Winner field was already being pulled correctly from Airtable and displayed in each Recent Picks row — verified the field name my code requests ("Picked Winner") matches the real Airtable field exactly, confirmed against the actual table schema. It was just labeled "Pick: {team}", which reads ambiguously (a pick the user made vs. SAM's prediction). Relabeled to "Predicted Winner: {team}" so there's no ambiguity.

SAM 1.5.8 / XAI 2.1

RECENT PICKS RESTORED ON /stats + PREMIUM BUTTON FINISHED. Two things closed out here. (1) The "Recent Picks" section on /stats — last 10 bets from Airtable sorted by Placed Date, showing Bet Label, Picked Winner, Confidence %, and a color-coded Result badge (green win / red loss / amber push / blue pending) — had gone missing from the live worker. It was built in a separate conversation and, best evidence available, was never actually deployed before a later "pull exactly what's live" step in this thread established the deployed code as the working base going forward, which didn't have it. Re-added fetchRecentPicks() next to fetchPublicStats(), wired into the /stats handler, and re-added the section between "By Sport" and "Visual Breakdown" with matching CSS — confirmed against a fresh pull of the live worker plus the original pre-edit file to make sure nothing else was actually missing (Platt scaling, simulation score averages, premium_until pricing, footer branding, and calibration/Brier all confirmed present and untouched throughout). (2) The Premium buy button's gold+bold "PREMIUM VIP UNLIMITED" relabel, requested right before this, hadn't made it to production yet either — finished here on the same confirmed-live base: gold (#E0B84C) and bold (800 weight) on both the stars and the label text, in the buy menu and the in-chat paywall button.

SAM 1.5.8 / XAI 2.1

PRICING RESTRUCTURED — 10-PACK PRICE CHANGE + NEW EXPIRING PREMIUM TIER. CREDIT_TIERS is now just two options: 10 Predictions for $10 (was $5), and a new Premium tier — unlimited predictions for 30 days, $25. The old 100-for-$50 and lifetime-for-$200 tiers are removed from sale; anyone who already bought lifetime access keeps it (the lifetime_access check in checkPredictionAllowance was left untouched, purely grandfathered, just no longer purchasable). PREMIUM IS TIME-LIMITED, NOT PERMANENT, WHICH IS A NEW CONCEPT: added a premium_until date column directly to the existing sam_bettors Supabase table (chose to extend the table already used for sign-in/billing rather than a separate premium_subscribers table that turned out to exist in the same project but isn't the one in use here) — checkPredictionAllowance now grants unlimited (999) predictions whenever premium_until is today or later, same shape as the lifetime_access check right above it. The Stripe webhook sets premium_until to 30 days out on a successful Premium purchase, extending from the existing premium_until if it's still active rather than resetting from today, so an early renewal doesn't cost the customer days. Since Drew separately created a Payment Link directly in the Stripe dashboard for this tier (buy.stripe.com/...), which won't carry the metadata.tier field our own dynamically-created Checkout Sessions do, the webhook now falls back to matching CREDIT_TIERS by exact amount_total when metadata.tier is absent — so a $25 charge from either path resolves to the same "premium" tier and correctly credits whichever bettor's client_reference_id is attached. The in-app buy button itself still points at our own /buy?tier=premium dynamic-checkout route rather than the raw Payment Link, since that path already guarantees correct metadata and client_reference_id with no matching logic needed — the fallback exists specifically for the separate dashboard-created link. UI: the Premium option in both the buy menu and the in-chat paywall now uses the same gold styling the old Lifetime tier had (border/price color — #D4AF37/#E0B84C), with a gold star on each side of the label. Paywall copy updated from "credits never expire" to distinguish the two tiers correctly, since one of them now does expire. Blog access and ufc.grimaldi.tv access, the other two things bundled into this $25 tier, live entirely outside this worker — nothing here can gate access on those properties; this change only covers what SAM itself controls (unlimited predictions).

SAM 1.5.8 / XAI 2.1

FOOTER BRANDING UPDATED, CONSISTENT ACROSS EVERY PAGE. The old "Built for The Drew Grimaldi Podcast — comedy, politics, and entertainment live every Saturday at 11AM ET on grimaldi.tv." line (previously only on the /about and /stats pages) is replaced everywhere with a single consistent line: "Proprietary Technology built by GRIMALDI.TV". Applied to /about, /stats, /workflow, /changelog (the latter two had no footer at all before this), the login and signup pages, and the main chat app itself. The main app required care since it's a fixed 100vh flex-column layout (not a scrolling page like the others) — confirmed via a static Playwright render at desktop and mobile viewport sizes that the new line sits cleanly below the existing footer-links row without overlapping the input bar or send button, and that the scrollable message area simply absorbs the ~20px via its existing flex:1 sizing.

SAM 1.5.8 / XAI 2.1

SIMULATION SCORE AVERAGES NOW SURFACED IN THE PREDICTION, AND MADE GENUINELY REAL IN THE PROCESS. Two parts to this: (1) run_matchup_simulation's tool result already carried an "avg simulated score" per side, but nothing told the model to actually state it in the reply — it sat in the tool result unused. Added an explicit instruction (system rule 2) so any team sport with a real score line (NFL, NBA, WNBA, MLB, NHL, Soccer, College) now states the average simulated score for each side alongside the win percentage (e.g. "projected score: Lakers 112.4 - Celtics 108.7"). Doesn't apply to UFC, NASCAR, Bowling, Darts, Tennis/WTA/ITF, or Horse Racing, none of which produce a team score. (2) WHILE WIRING THIS UP, FOUND THE UNDERLYING NUMBER WASN'T ACTUALLY SIMULATED: runTeamSportSimulation() was reporting the pre-simulation lambda input (the Poisson/normal distribution's rate parameter, known before a single trial ever ran) back out as "avg simulated score" — mathematically close in expectation, but not an actual product of the 25,000 trials, which runs against this whole project's standing rule of reporting only what's really computed. Fixed by accumulating the real per-trial scoreA/scoreB values during the existing simulation loop and computing the true empirical average afterward. Confirmed this isn't just a technicality before shipping: for bivariate Poisson sports (MLB/NHL/Soccer) the two numbers converge closely at 25,000 trials as expected, but for the normal-distribution sports (NFL/NBA/WNBA/College) negative-score trials get clamped to 0 before the average, which measurably pulls the true average away from the raw lambda in low-scoring, high-variance matchups (tested case: lambda 8.0 in, true empirical average 9.8 after clamping) — exactly the kind of case this fix now reports correctly. expectedScoreA/expectedScoreB keep the same field names and same downstream usage, only the computation changed.

SAM 1.5.8 / XAI 2.1

PLATT SCALING IMPLEMENTED — CONFIDENCE IS NOW ACTUALLY RECALCULATED IN CODE, NOT JUST NUDGED BY A PROMPT INSTRUCTION. This is the real version of the calibration idea discussed across several earlier patches: instead of just telling the model "be more conservative" and trusting it to comply, this mathematically transforms the model's raw stated confidence into a corrected number before it's ever shown to a user or logged, using real historical (confidence, outcome) pairs already sitting in Airtable. IMPLEMENTATION: fitPlattScaling(records) fits a 2-parameter logistic regression (calibrated_prob = sigmoid(a × raw_confidence + b)) via plain gradient descent (1500 iterations, no external ML library — this is a Cloudflare Worker, no numpy/sklearn available) directly on the same records array fetchPredictionHistory() already fetches for the TRACK RECORD block, so this required zero new Airtable calls. Requires 20+ graded picks with a logged confidence to fit at all; returns null below that, which cleanly no-ops everything downstream (raw confidence passes through unchanged — there was real historical data to correct against yet). fetchPredictionHistory()'s return signature changed from a bare string to { text, plattParams } to carry the fitted parameters out alongside the existing prompt text; both call sites in the /api/chat handler (the primary provider call and the cross-provider failover path) updated to destructure the new shape and track a single finalPlattParams that follows whichever provider's call actually succeeded. APPLIED IMMEDIATELY AFTER extractPick(): the model's raw stated confidence (parsed from the hidden [PICK] tag) is run through applyPlattScaling() and the result overwrites pick.confidence before anything downstream sees it — Airtable logging, the odds-lookup step, everything. Also rewrites the VISIBLE reply text (an exact string replace of "{rawConfidence}%" with "{calibratedConfidence}%") so what the user actually reads matches what gets logged — without this, a user could read "91%" in the chat while Airtable silently logged "61%", which would be a real, confusing inconsistency. Sanity-checked in isolation before touching the live file: a systematically overconfident synthetic history (records saying "92%" but only winning 60% of the time) correctly pulled a fresh 91% claim down to 61%, and the visible text and logged value matched exactly after the rewrite. TWO HONEST TRADEOFFS, DOCUMENTED RATHER THAN HIDDEN: (1) The text-replace is an exact string match on "{number}%", not a semantic understanding of the reply — in the rare case another stat in the same reply happens to share the exact same percentage integer (e.g. a shooting percentage that happens to equal the confidence number), that unrelated mention would also get rewritten. Considered a targeted regex or position-based replace instead, but a blind match on the final PICK confidence is the same number the model already tries to state prominently and consistently, so the actual collision risk in practice is low; flagging it as a known limitation rather than pretending it's impossible. (2) Confidence % in Airtable now stores the CALIBRATED number going forward, not the model's raw output — there's no separate "raw confidence" field preserved. This means future re-fits are calibrating on top of already-corrected history rather than pure raw model tendency. In practice this is self-stabilizing (once well-calibrated, a, b converge toward the identity transform and stop correcting further) rather than harmful, but it does mean if the underlying model's raw tendency drifts later (e.g. a prompt or model change), that fresh drift will take longer to show up clearly in the numbers than it would against an untouched raw signal. Didn't add a second Airtable field to preserve the raw value, since that's a live schema change and Drew's explicit preference this session was reusing existing data over adding new infrastructure — noted here so it's an easy thing to revisit later if drift-masking ever becomes a real practical problem rather than a theoretical one. NOT CHANGED: teamAWinPct/teamBWinPct (the raw per-team simulation split reported in the [PICK] tag) are left untouched — those represent the simulation's raw output by design, not the "final calibrated" number, so only the single main confidence field gets the Platt transform.

SAM 1.5.8 / XAI 2.1

BRIER SCORE MADE ACTIONABLE, NOT JUST REPORTED. Drew asked about implementing an auto-weighting "reward" system using the Brier score (routing between Gemini/Grok based on which is better-calibrated per sport) — flagged before building it that true real-time per-sport routing would mean calling both models on every request to compare them live, which directly reopens a cost problem already solved once before (see the earlier changelog note: automatic background second-opinion calls were removed because they silently doubled every request's API cost). Building real auto-routing would also need a new small Airtable table to cache a periodically-computed "current best provider" value cheaply (recomputing from ~1000 records on every page load isn't viable), which is a live-schema change bigger than anything else done this session. Drew's follow-up cut through this cleanly: instead of routing between models, just have the Brier score directly shape the confidence number on the CURRENT pick — which needs zero new infrastructure, since the score is already computed from data already being fetched. Implemented as a tiered instruction attached directly to the existing Brier score line inside computeConfidenceCalibration(): Brier ≥ 0.25 (at or worse than pure coin-flip guessing) → explicit instruction to pull confidence toward the middle (55-65%) on new picks rather than trusting the simulation's raw output; Brier 0.20-0.25 (better than coin-flip but loose) → lean somewhat more conservative, especially above 80%; Brier < 0.20 (genuinely good) → explicit instruction to keep calibrating the same way rather than second-guessing a working approach. This only fires once there are 8+ graded picks with a logged confidence (same threshold used elsewhere in this block), computed per-provider like everything else here. Sanity-checked all three tiers against representative Brier values (0.10, 0.19, 0.22, 0.25, 0.41) before shipping — each mapped to the intended guidance tier correctly. No new tables, no new endpoints, no change to model-selection/routing logic — purely a smarter instruction layered onto data already flowing into the prompt.

SAM 1.5.8 / XAI 2.1

BRIER SCORE ADDED TO CONFIDENCE CALIBRATION. Drew asked whether SAM used a Brier score (the standard proper scoring rule for probabilistic forecasts: mean of (stated_confidence - actual_outcome)² across all graded picks, 0 = perfect, 0.25 = pure coin-flip guessing) — it didn't, so it was added. Computed inside the existing computeConfidenceCalibration() using the exact same records loop already reading Confidence % and Result per pick, so no new Airtable calls or storage. Unlike the bucket breakdown (which shows WHERE SAM is over/underconfident), the Brier score is a single overall summary number, and being a proper scoring rule it can't be gamed by a model shading its stated confidence away from its true belief — a model that always says 50% looks "calibrated" in a trivial sense but gets punished by Brier score for being unhelpful/unsharp, same as an overconfident model gets punished for being wrong. Sanity-checked against known reference cases before shipping: a perfect forecaster scores 0.0, pure coin-flip guessing scores exactly 0.25, an overconfident model (says 90%, only wins 50%) scores 0.41 (worse than coin-flip, correctly penalized), and a well-calibrated 70% forecaster (wins 7 of 10) scores 0.21 (better than coin-flip, correctly rewarded) — all matched expected values exactly. Reported alongside the existing bucket lines in the same hidden CONFIDENCE CALIBRATION block (only surfaced once there are 8+ graded picks with a logged confidence, same small-sample threshold used elsewhere in this block), computed per-provider like everything else in this context (Gemini and Grok get their own separate Brier score from their own separate bases). This is a diagnostic/summary number only — no new instruction was added telling SAM to directly act on it beyond what the existing calibration instruction already covers, since the bucket breakdown is still the more actionable, specific signal for adjusting behavior band-by-band.

SAM 1.5.8 / XAI 2.1

MISSION/PROBLEM STATEMENT ADDED TO SYSTEM PROMPT. Drew's observation: the prompt jumped straight from the one-line identity statement into a wall of procedural rules (never reference odds, verify before predicting, etc.) without ever telling Gemini/Grok WHY those rules exist — no articulation of the actual problem SAM is solving. Confirmed this was true by re-reading the prompt's opening. Added a new paragraph immediately after the identity line, before rule 1: names the real problem (most sports takes are either uninformed gut calls or shaped by sportsbook financial incentives, neither trustworthy), states SAM's actual answer to it (real computed simulation on independently-verified live data, never odds, with an honest self-correcting track record instead of unearned confidence), and gives an explicit fallback heuristic for situations the numbered rules don't cleanly cover ("would a real analyst with real data, real accountability, and no incentive to mislead say this?"). Distilled into three concrete priorities referenced back to existing mechanisms already in the prompt: verify-before-speaking (the existing hard verification gate), calibration over raw confidence (the existing TRACK RECORD/CONFIDENCE CALIBRATION context), and showing real work (naming actual players/stats so reasoning is checkable). This doesn't change any procedural rule or add new behavior \u2014 it's meant to give the model something to generalize from when a specific rule doesn't obviously apply, rather than only ever pattern-matching against an enumerated list.

SAM 1.5.8 / XAI 2.1

STADIUM INTELLIGENCE EXTENDED TO TENNIS/WTA/ITF — A REAL SCHEDULE-MATCHING BUGFIX, A GEOCODING BUGFIX, AND PHYSICS VERIFIED AGAINST REAL SOURCES BEFORE SHIPPING, NOT ASSUMED. Full record of everything done in this session, per Drew's request for maximum detail. (1) THE BUG THAT WAS ACTUALLY THERE: Drew asked to bring Stadium Intelligence weather to tennis too. Before writing any code, the ESPN tennis API shape was fetched live and compared against the team-sport shape already in use. Team sports (confirmed live via MLB, NFL, NBA scoreboard fetches) return a flat event.competitions[0] object with venue = {fullName, address: {city, state, country}, indoor: true/false}. Tennis (confirmed live via the ATP scoreboard) returns something structurally different: the top-level event IS the tournament (e.g. "Mifel Tennis Open by Telcel Oppo"), individual matches live nested three levels down at event.groupings[].competitions[], and each nested competition has its own venue = {fullName: "City, Country", court: "Court name"} — no address sub-object, no indoor boolean, nothing matching the team-sport shape at all. Cross-checking this against getGameSchedule's actual matching code (competitorNameHits, and the events.filter() block) confirmed the real, pre-existing consequence: since that code only ever reads e.competitions (singular, flat), and tennis events have zero entries there, get_game_schedule could NEVER match an individual tennis player pairing — only the tournament name itself, which never contains a player's name. This directly contradicts a claim made in an earlier changelog entry that tennis schedule lookup had "no known gap" — it did, silently, the whole time, and this patch is the actual fix, not a cosmetic add-on. This also matches and explains why rule 8 had already been written to make Tennis/WTA/ITF skip the schedule-verification gate entirely and rely on ESPN player-search verification instead (rule 2) — that carve-out was a correct workaround for a real underlying limitation, not an arbitrary exception. (2) THE FIX: new flattenScheduleEvents(rawEvents), called immediately after the ESPN fetch inside getGameSchedule, before any matching logic runs. For events with a real top-level competitions[] array (team sports, UFC, NASCAR, golf-shaped events), it passes them through completely unchanged — zero risk to any already-working sport. For events shaped like tennis (empty/missing top-level competitions but populated groupings[]), it walks every grouping (Men's Singles, Women's Singles, etc.) and every nested competition inside it, pulls both competitors' names from competitor.athlete.displayName (falling back to fullName), builds a synthetic pseudo-event per match with name/shortName set to "Player A vs Player B" for display, preserves the real tournament name separately as a new tournamentName field (needed later for the indoor-tournament check), and copies the nested competition's own date/status/venue up so the existing per-match code downstream (date formatting, ISO_DATE tagging, score display, and now Stadium Intelligence) works completely unmodified on the flattened result. This was the single highest-leverage fix in the session: it repairs tennis schedule lookup generally, not just for weather purposes. (3) WEATHER GEOCODING FOR TENNIS, INCLUDING A REAL BUG CAUGHT DURING TESTING: tennis venue.fullName is a flat "City, Country" string (e.g. "Los Cabos, Mexico", "Washington, USA") rather than separate city/state fields, so a new geocodeCityCountry(city, country) was added alongside the existing (unmodified) geocodeCity(city, state) used for team sports. First implementation queried Open-Meteo's free geocoding API by city name alone and took the first/most populous result — live-tested against "Los Cabos" before trusting it, and the very first result returned was a small village in Asturias, Spain (population data absent, elevation 89m, timezone Europe/Madrid) ranked ahead of the real Mexican resort city in Baja California Sur where the actual ATP tournament is played. This is exactly the kind of silent-wrong-answer failure mode the rest of this codebase has repeatedly tried to eliminate (see prior CANNOT_VERIFY/hard-verification-gate changelog entries), so geocodeCityCountry() now filters all candidate results to ones whose country field actually matches (case-insensitive substring both directions) the country ESPN provided, sorts the survivors by population, and returns null (triggering an honest "weather lookup failed" message) rather than falling back to an unrelated country's same-named town if nothing matches. Re-tested afterward with the country filter in place and confirmed it correctly resolves to Mexico. (4) INDOOR DETECTION FOR TENNIS, DOCUMENTED AS A KNOWN LIMITATION: ESPN's tennis API exposes no indoor/outdoor flag at all (confirmed absent in the same live API dump used for item 1), unlike team sports where venue.indoor is reliably present. Rather than guessing or defaulting silently, added a short, explicitly best-effort KNOWN_INDOOR_TENNIS_EVENTS array matched against the tournament name: ATP Finals, WTA Finals, Next Gen ATP Finals, Paris Masters/Rolex Paris Masters, the European Open (Antwerp), Erste Bank Open (Vienna), Swiss Indoors (Basel), Stockholm Open, and the Dallas Open. Any indoor tournament not on this list will incorrectly get treated as outdoor and attempt a real (if irrelevant) weather lookup — this is a known, named gap the same way Bowling/Darts' missing schedule source is documented elsewhere in this changelog, not something papered over. (5) PHYSICS — CHECKED AGAINST REAL SOURCES RATHER THAN ASSUMED, IN BOTH DIRECTIONS: Drew raised two separate physical claims in this session; both were checked against real reporting/research before writing a single word of system-prompt guidance, because getting either one backwards would have made every future hot/humid prediction worse, not better, in a way neither Drew nor the model would have any way to notice afterward. • TENNIS + HEAT (confirmed correct, refined): multiple sources (physics-focused tennis blogs, sports-science writeups) agree hot air is measurably less dense than cool air — one source's own numbers: going from 10°C to 38°C drops air density roughly 10%, adding an estimated 3-5 km/h of ball speed; separately reported that temperatures above roughly 32°C also harden clay specifically, raising bounce and further speeding play. Net effect: heat makes the ball fly faster and bounce higher, favoring aggressive/power hitters over grinders — the opposite direction from Drew's original framing ("hot and humid... ball moves slower"), so this half of the original claim was corrected rather than encoded as stated. • TENNIS + HUMIDITY (Drew's instinct was directionally reasonable but via the wrong mechanism, now modeled correctly): humidity's effect on air density is real but tiny and actually points the same direction as heat (water vapor, molecular weight 18, is lighter than the nitrogen/oxygen it displaces, molecular weight 28/32, so humid air is very slightly less dense — multiple physics-focused sources independently confirm this counterintuitive point and describe the pure air-density effect on ball speed as "almost imperceptible" on its own). The real, well-documented, and separate mechanism behind players and commentators calling humid conditions "heavy" is that the ball's felt absorbs moisture and physically "fluffs up" over the course of a match, adding real mass and drag — directly reported by pros at the 2026 US Open ("You'll feel it get heavier, which means it doesn't move as fast... a little slower because it's getting fluffier"). This is now modeled as its own distinct advisory (fires at ≥70% average humidity) rather than being merged into or treated as canceling out the heat effect, since they are physically different mechanisms operating on different parts of the system (surrounding air vs. the ball's own material). • TENNIS + INDOOR COURT SPEED (Drew's claim, from personal college-tennis experience, checked and confirmed): cross-referenced against tennis-surface writeups, coaching sites, and direct player quotes. Consistent majority finding: removing wind and sun lets players hit with more confidence and commit fully to lines, and indoor hard courts are frequently set up faster besides — one widely-quoted characterization of 1990s-2000s indoor events as "crazy fast" surfaces where "big servers dominated indoors." One dissenting professional viewpoint was also surfaced during research (a top server arguing arena conditions actually help his opponents more, by removing the wind-disruption edge aggressive hitters get outdoors) — noted here for completeness rather than silently discarded, though it's the minority view. Net: Drew's claim is well-supported and is now surfaced on every indoor Tennis/WTA/ITF result, worded as "generally favoring" rather than an absolute, matching the real state of the evidence. • MLB + HUMIDITY (Drew's instinct, checked and found backwards, correctly NOT implemented): the same water-vapor-is-lighter-than-N2/O2 mechanism applies to baseball. Multiple independent sources — an AccuWeather meteorologist quoted in sports reporting, a University of Arizona engineering professor, and a peer-reviewed physics paper specifically studying MLB's humidor — agree humid air is very slightly less dense, so a batted ball carries marginally FARTHER in humidity, not less. The peer-reviewed paper's own numbers: raising a stored ball's relative humidity from 30% to 50% increases fly-ball distance by about 2 feet from aerodynamics alone — the opposite direction from the offense drop MLB's humidors are actually used to produce. The real mechanism behind humidors suppressing home runs is separate: pre-conditioning the ball in humidified storage for days before use increases its mass and reduces its coefficient of restitution (how lively it is off the bat), which is a bat-contact effect, not a through-the-air drag effect — and it's now a constant, standardized equipment policy at all 30 MLB parks since 2022, not something that varies with any single day's forecast the way wind or rain does. Historical evidence cited in sourcing: Coors Field pitchers ran a 6.50 ERA from 1995-2001 (over 2 runs worse than the rest of the league) before a humidor was introduced there in 2002, after which ERA at altitude dropped roughly a full run — a real, large, but storage-based effect, unrelated to game-day weather. Conclusion: no humidity advisory was added to MLB's Stadium Intelligence output, since the ambient-weather effect is both backwards from Drew's framing and, per the same research, too small (a couple of feet) to be worth surfacing at all — correctly doing nothing here was the right call, not an oversight. (6) CODE CHANGES SUMMARY: flattenScheduleEvents() added and wired into getGameSchedule immediately after the ESPN fetch. getStadiumIntelligence() signature extended to (venue, isoDate, tournamentName, league); now branches on venue shape (team-sport address-based vs. tennis flat-string) rather than assuming one shape. New geocodeCityCountry() added alongside the untouched geocodeCity(). New KNOWN_INDOOR_TENNIS_EVENTS list + isKnownIndoorTennisEvent() helper. Open-Meteo hourly query extended to also request relative_humidity_2m (previously temperature/precipitation/windspeed/winddirection only). Advisory logic restructured from a single if/else-if into an accumulating array so multiple genuinely-independent advisories (e.g. rain AND humidity) can appear together instead of one silently overriding another. New sport-aware thresholds: heat advisory for Tennis/WTA/ITF only, fires at ≥85°F average; humidity advisory for Tennis/WTA/ITF only, fires at ≥70% average humidity; existing wind (≥15mph) and precipitation (≥50%) advisories now use tennis-specific wording (toss/serve/shot-control) when the league is Tennis/WTA/ITF, generic wording (ball flight/passing/kicking) otherwise. Rule 8 in the system prompt updated to describe all of the above to the model, including the indoor-tennis exception (mention the fast-court note even though there's no forecast) and explicit instruction not to conflate the heat and humidity effects or treat them as canceling out by default. (7) LIVE TESTING BEFORE SHIPPING: fetched a real upcoming ATP match (Gea vs Wong, Mifel Tennis Open, Los Cabos) end-to-end through the new code path — correctly flattened out of the tournament's groupings, correctly geocoded to Los Cabos, Mexico (not Spain), and returned a real forecast: 70°F, 77% humidity, 3mph wind SW, 89% precipitation chance — correctly triggering both the rain advisory and the humidity/ball-fluffing advisory together (confirming the accumulating-advisory-array change works as intended). Separately tested a known-indoor case (Turin, standing in for the ATP Finals) to confirm the indoor branch correctly fires the fast-court note with no forecast data attached, and that the KNOWN_INDOOR_TENNIS_EVENTS name-matching works. Both team-sport paths (MLB/NFL/NBA venue shape) were re-confirmed unaffected by these changes since flattenScheduleEvents() passes their already-flat event shape through untouched.

SAM 1.5.8 / XAI 2.1

STADIUM INTELLIGENCE (WEATHER) — HEADLINE FEATURE OF THIS RELEASE. SAM previously had zero weather awareness — no wind, temperature, or precipitation data fed into any matchup, which matters for outdoor sports (wind at MLB parks affecting fly balls, wind/rain at NFL and outdoor events affecting passing/kicking/footing). Branded as "Stadium Intelligence" per Drew's direction, alongside the existing tennis surface-adjustment logic (getTennisSurfaceAdjustment), which is conceptually the other half of the same idea — course/venue-specific conditions affecting the matchup. Implementation: getGameSchedule's per-match ESPN response already includes a venue object with fullName, address {city, state}, and (confirmed via live testing) an "indoor" boolean — so no new tool call or hardcoded stadium list was needed. Added getStadiumIntelligence(venue, isoDate), called automatically for every non-completed match returned by getGameSchedule: indoor venues short-circuit immediately ("no weather impact"), outdoor venues get geocoded via Open-Meteo's free geocoding API (results cached in-memory per city/state for the life of the isolate) and checked against Open-Meteo's free 16-day hourly forecast (no API key required for either endpoint). Reports average temp, max wind speed + compass direction, and max precipitation probability across the game-time window (1pm-10pm local, falling back to full-day if the game's exact hours aren't in that band), with a plain-language advisory appended when wind ≥15mph or precip chance ≥50%. Games more than 15 days out get an explicit "forecast unavailable yet" line instead of silently omitting weather. Live-tested against Wrigley Field (outdoor, correct real forecast), Videotron Centre (indoor, correctly skipped), and Lambeau Field 3 days out (correct real forecast) before deploying. Failure modes (geocode miss, forecast fetch failure) degrade to a short explanatory line rather than throwing, consistent with the rest of getGameSchedule's error handling. FOLLOW-UP (same release): Stadium Intelligence data was reaching the model but staying backstage — useful for its own reasoning, invisible to the actual user reading the prediction. Per Drew's call ("bring up the weather in the final prediction too, that can impress some people"), rule 8 now explicitly tells SAM to work real forecast numbers into the visible reply when they're plausibly relevant to the matchup (wind for MLB/NFL/Soccer/Golf, meaningful rain chance for footing/ball security), naming the actual figure rather than a vague "windy" — and to say nothing at all when the venue is indoor or conditions are unremarkable, so this doesn't turn into clutter on every single pick. Both get_game_schedule tool descriptions (Gemini and Grok schemas) updated to flag that outdoor-venue results now carry this data, so the model knows to look for it.

SAM 1.5.7 / DS 2.1

RECENT FORM (STREAK) CONTEXT ADDED, DELIBERATELY INFORMATIONAL-ONLY (post-release patch, version held at 1.5.7 per Drew's call). Drew asked about rewarding SAM for win streaks; discussed it first rather than building it blind, since a naive "streak = raise confidence" mechanic would just be the hot-hand fallacy baked into the prompt — one game's outcome doesn't make the next one more likely to hit, and it would directly fight the calibration feature added in the previous patch. Landed on a strictly informational version instead: added computeRecentForm(), which reuses the same graded-records array fetchPredictionHistory already pulls (no new Airtable calls) and reports, per league, the last 5 results as a W/L sequence plus the current same-result streak length (e.g. "NBA: last 5 = W-W-L-W-W (currently 1W in a row)"). This is appended as a new "RECENT FORM" section in the same hidden TRACK RECORD context, explicitly labeled informational-only with an instruction never to adjust confidence based on streak length since each event is statistically independent. Leagues with zero graded picks are omitted. No confidence math, calibration bands, or logging behavior were touched — this sits alongside the existing calibration block, it doesn't feed into it.

SAM 1.5.7 / DS 2.1

CONFIDENCE CALIBRATION ADDED TO TRACK RECORD CONTEXT (post-release patch, version held at 1.5.7 per Drew's call). Previously fetchPredictionHistory's TRACK RECORD block only showed raw win/loss counts (overall and by league) plus the 30 most recent graded picks with their stated confidence — useful, but it never actually told the model whether its stated confidence numbers have been trustworthy. Added computeConfidenceCalibration(), which reuses the exact same graded-records array already being fetched (no new Airtable calls, no new storage) and buckets every pick by its logged Confidence % into 50-59% / 60-69% / 70-79% / 80-89% / 90%+ bands, computing the ACTUAL win rate within each band. This is appended as a new "CONFIDENCE CALIBRATION" section in the same hidden TRACK RECORD context injected into every prompt, computed separately per provider since Gemini and Grok log to separate Airtable bases and build separate track records. If a band is running well below its stated number — e.g. the model has been saying 90%+ but that band is actually only hitting 65% — the injected instruction tells it to state a more conservative number next time it lands in that range, rather than repeating a high confidence just because the analysis feels strong. Bands with fewer than 8 graded picks are flagged "small sample, weigh lightly" so early, thin data in newer sports (WNBA, Darts, Bowling, etc.) doesn't overcorrect calibration off a handful of results; bands with zero graded picks are omitted entirely rather than shown as 0/0.

SAM 1.5.7 / DS 2.1

UFC NO LONGER GATED BY get_game_schedule (the actual fix). Drew's AI-thoughts trace showed the real failure clearly: run_matchup_simulation succeeded perfectly (Oban Elliott 31% / Michael Oliveira 69%, real ESPN tale-of-the-tape stats — confirming both are real, currently-competing fighters), but the model THEN ran a redundant get_game_schedule check per rule 8, that check didn't surface the fight, and the model declined based on it — throwing away an already-successful, fully-verified simulation. Two problems: (1) ordering was backwards — a real simulation that pulled live fighter stats is a STRONGER real-existence proof than the schedule board, since a sim literally can't run for fake fighters; (2) ESPN's UFC scoreboard often lists only a card's headline/main-card bouts and omits prelims and early prelims, so a completely real scheduled fight (this was a prelim bout) can be missing from the board entirely — making the schedule check a false-negative machine for undercard UFC fights specifically. FIX (system prompt rule 8): UFC now skips the schedule-verification gate as a required step, exactly like Tennis/WTA/ITF/Bowling/Darts already do, and relies on run_matchup_simulation's fighter-stats verification instead. If the sim returns a real result, that IS verification — proceed to the [PICK], and a get_game_schedule miss can no longer veto it. UFC only declines when run_matchup_simulation itself returns CANNOT_VERIFY. get_game_schedule may still be called for UFC to fetch the event date, but it's now a date lookup, not a veto gate. Also removed UFC from rule 8's "required schedule coverage" league list so the prompt isn't self-contradictory.

SAM 1.5.7 / DS 2.1

FUZZY FIGHTER-NAME FALLBACK (partial fix). Drew's AI-thoughts trace revealed the real cause of the last round of UFC declines: the MODEL itself passed misspelled fighter names to the tool — "Odan Elliott" (for Oban) and "Michal Oliveira" (for Michael). ESPN's search is strict and returns nothing for a garbled name, so the tool's CANNOT_VERIFY was actually correct behavior on bad input — not a code bug. Added a typo-tolerant fallback to espnAthleteSearch: if the exact full-name search finds nothing, it retries with the LAST name alone (far less likely to be typo'd), fuzzy-matches the intended full name against those results via Levenshtein edit distance, and accepts the closest result only if it's genuinely within a typo or two (never a loose same-surname match). Verified live: "Odan Elliott" now correctly resolves to Oban Elliott (edit distance 1), while genuinely fake names ("Xzyqwp Fakefighter", etc.) still correctly decline — the hard verification requirement is preserved. KNOWN LIMITATION: this can't fix every misspelling. When the surname is very common (e.g. "Oliveira" — ESPN returns 7+ MMA Oliveiras and the last-name search's top-10 may not even include the intended fighter), a misspelled first name like "Michal Oliveira" still can't be resolved safely without risking grabbing the wrong fighter, so it still declines. The more complete future fix would be to resolve fighter names against the correctly-spelled roster that get_game_schedule already pulls from ESPN's event data, rather than re-searching from the model's possibly-typo'd input — noted for later, not done here.

SAM 1.5.7 / DS 2.1

SECOND UFC BUGFIX: SCHEDULE MATCHING WASN'T BIDIRECTIONAL. Elliott vs Oliveira was still declining after the rate-limit fix, but this time from get_game_schedule (rule 8's schedule check), not the fighter-stats check — confirmed by tracing the exact failure point. Root cause: get_game_schedule's name matching only checked one direction (does the ESPN competitor's own name contain the search query). That works fine when the query is a single fighter's name, but for a two-competitor matchup the model naturally passes the combined "Fighter A vs Fighter B" string — which is LONGER than either fighter's individual name, so a competitor's short name can never "contain" it. Confirmed live: querying "Oban Elliott" alone found the event fine, but "Oban Elliott vs Michael Oliveira" (the realistic query shape) matched zero events, for every single UFC matchup tested, regardless of fighter. This wasn't UFC-only either — the same shared getGameSchedule function backs every individual-competitor sport (tennis, WTA, ITF, darts, bowling, NASCAR), so any of them could have hit the identical failure. FIX: matching is now bidirectional both ways — checks whether the competitor's name contains the query AND whether the query contains the competitor's name — with an explicit non-empty guard on both sides (a naive bidirectional check without that guard would make an empty-string competitor name silently match every single event, since "anything".includes("") is always true in JS). Re-tested live against every combination from Drew's actual matchups (combined "A vs B" strings, single names, with/without a period after "vs") — all now correctly resolve to the right event.

SAM 1.5.7 / DS 2.1

UFC FOLLOW-UP: FIXED RATE-LIMIT/CONCURRENCY BUG. After switching to ESPN, all 3 test matchups Drew tried still failed to verify. Root cause found by replicating the exact production logic live: fetchUFCFighterStats fetches both fighters via Promise.all (concurrently) — confirmed that firing both fighters' full ESPN core-API lookup chains at once reliably triggers a 503 rate-limit response, while the exact same requests spaced out by even a second succeed every time. This explains why it wasn't fighter-specific — every UFC request hit the same concurrency burst regardless of who was fighting. FIX: (1) the two fighters are now fetched sequentially instead of via Promise.all, (2) added fetchEspnCoreWithRetry — one retry after a short pause specifically for 503/429 on ESPN's core API, catching any remaining transient hits, (3) added hasRealFightStats() validation — previously, if ESPN's stats endpoint came back completely empty for a fighter, the code would silently produce a blind 50/50 power rating and present it as "real data used," which defeats the entire point of the verification requirement; now it explicitly requires at least one real fight stat (not just bio data like age/reach) before treating a fighter as verified. Re-tested live against all 3 of Drew's matchups after the fix: Elliott/Oliveira and Medić/Rodriguez now both verify successfully with complete real stats; Spasić/Luciano correctly still declines — but for a legitimate reason this time, not a bug: Spasić is making her literal UFC debut and has zero recorded fight stats anywhere (ESPN or ufcstats.com), which is exactly the "one or two declining is fine" case Drew said he's okay with.

SAM 1.5.7 / DS 2.1

WNBA SUBREQUEST FIX. Found the actual biggest cost driver in the whole pipeline: getKeyPlayerFactor's basketball branch (NBA/WNBA/CBB) was fetching individual ESPN stats for up to 15 players PER TEAM — 30 total for both teams combined — just to identify the single highest-usage player to factor into the simulation. Every other sport's key-player check is far cheaper (NFL/CFB only checks QBs, NHL only checks goalies — 1-3 candidates each), so basketball was a massive outlier. NBA gets away with this because BALLDONTLIE saves it subrequests elsewhere in the pipeline that WNBA doesn't have access to, so WNBA was eating this cost in full — the confirmed main reason it kept tipping over Cloudflare's 50-subrequest-per-invocation Workers Free plan ceiling while NBA/MLB/NFL/UFC stayed comfortably under it. Trimmed the basketball candidate pool from 15 down to 6 per team, which still reliably covers a team's actual rotation/starters. Combined with the earlier removal of the automatic background second opinion, this should give WNBA (and CBB, which has the same cost profile) enough headroom to complete reliably. Also confirmed WNBA was already a registered League option in the Bets table's select field — nothing needed there, it just had zero records against it since no WNBA pick had ever logged successfully before now.

SAM 1.5.7 / DS 2.1

UFC DATA SOURCE SWITCHED FROM ufcstats.com TO ESPN. Root cause of continued UFC declines after the diacritic fix: ufcstats.com is now serving a JavaScript bot-detection "Checking your browser..." challenge page for every single query — confirmed by testing it against Jon Jones, arguably the most famous fighter in UFC history, who still has a real profile there and still got the challenge page instead of real data. This meant the worker's fetch() could no longer get real fighter stats for ANY UFC matchup, not just debuts, and is not something to work around (bot-detection is there deliberately, so no attempt was made to defeat it). Found a full replacement instead: ESPN's own core API (sports.core.api.espn.com) hosts the exact same tale-of-the-tape data ESPN's public MMA Fightcenter page displays — confirmed byte-for-byte against a live screenshot (Uroš Medić vs Daniel Rodriguez, UFC Belgrade Aug 1 2026): significant strikes landed/min, strike accuracy, takedown average/accuracy, submission average, plus reach/age/stance, all as clean structured JSON on a domain already used everywhere else in this app. Rebuilt fetchUFCFighterStats() on ESPN (espnAthleteSearch + sports.core.api.espn.com athlete + statistics endpoints) instead of scraping ufcstats.com. Also rebuilt the power-rating formula per Drew's own manual methodology — previously each fighter's rating was computed independently against a fixed baseline; now it's a genuine head-to-head comparison (computeUFCPowerRatings, taking both fighters together), crediting whoever's better on each stat by the size of the margin, with reach and age (younger favored, per Drew) weighted as explicit factors alongside the volume/accuracy stats — sanity-tested against the real Medić/Rodriguez numbers and produces a sensible, non-extreme 48/52 split. Updated all CANNOT_VERIFY messaging, the tool description text (both Gemini/Grok copies), the NASCAR comment analogy, and the workflow page to reference ESPN instead of ufcstats.com.

SAM 1.5.7 / DS 2.1

DIACRITIC NAME-MATCHING BUGFIX. Root cause found and confirmed live: Marina Spasić vs. Stephanie Luciano (a real, scheduled UFC Belgrade fight on Aug 1, 2026 — confirmed directly against UFC.com and ESPN's own live mma/ufc scoreboard, both fighters present) was getting declined as "could not be verified," even though the fight is 100% real. Cause: get_game_schedule's local name-matching does a plain JS .includes() substring check, which is accent-sensitive — ESPN stores her name with the Serbian diacritic ("Spasić"), but the match query (typed the normal way, "Spasic", no accent) can never match it, since ć and c are different characters to a raw string comparison. This isn't UFC-specific — it silently affects any fighter, tennis player, soccer team, or darts player with an accented name (Serbian, Polish, Croatian, Brazilian-Portuguese, etc. — extremely common in MMA and tennis). FIX: added a shared stripDiacritics()/matchName() helper (Unicode NFD normalization + strip combining marks) and applied it to every local name-matching comparison across the file: get_game_schedule (the confirmed root cause), injury report team matching, BALLDONTLIE team matching, ESPN standings team matching, football-data.org soccer standings matching, darts player-list matching, TennisMyLife surface-stats name matching, fetchMarketOdds team/winner matching, and — importantly — findFinalResult's grading logic (both the side-matching and the Win/Loss winner-name comparison), since an accented name could have been silently causing incorrect grades or stuck-Pending picks even after successfully passing verification. UFC's own ufcstats.com fighter-stats check and ESPN's tennis player search are unaffected by this fix since they send the name to those services' own server-side search rather than doing local substring matching.

SAM 1.5.7 / DS 2.1

REMOVED AUTOMATIC BACKGROUND SECOND OPINION. Every Gemini-answered question used to silently trigger a full background Grok analysis (ctx.waitUntil) on every single request — its own Gemini/Grok tool-calling round trip, its own ESPN calls, its own Airtable logging. That was quietly costing every normal request roughly double its actual subrequest count, which is very likely what tipped some WNBA requests (already more subrequest-hungry than BALLDONTLIE-backed sports like NBA/MLB/NFL/NHL) over Cloudflare's 50-subrequest-per-invocation Workers Free plan ceiling, producing the generic "SAM's having trouble connecting" error. Removed the trigger and deleted the now-dead triggerGrokSecondOpinion function entirely. This isn't a loss of functionality — the "get a second opinion" button added to every message already covers this on demand through the exact same /api/chat path (including the same Airtable logging), so Grok's track record now only grows from picks people actually asked for, per Drew's call.

SAM 1.5.7 / DS 2.1

ITF TENNIS GRADING FOLLOW-UP (post-release patch, version held at 1.5.7 per Drew's call). Live-tested ESPN's tennis/atp and tennis/wta scoreboard endpoints directly after the ITF prediction fix shipped: neither one ever surfaces ITF World Tour matches — both only carry main-tour (250/500/1000-level) events, confirmed on a live date with real tournament names returned for each (ATP: Mifel Tennis Open, Mubadala DC Open; WTA: Odlum Brown VanOpen, Memphis Classic, etc., none ITF-level). The previous patch had mapped ITF to ESPN_LEAGUE_PATHS.tennis/atp as a "best-effort" grading source — that was wrong and actively misleading: it silently never found a match, so every ITF pick sat in Pending forever with no visible sign anything was broken. REMOVED that mapping. ITF now has no ESPN grading path at all, same permanent, documented gap as Bowling and Darts (findFinalResult short-circuits immediately via the existing "if (!path) return null" check instead of wasting a scoreboard fetch that could never succeed). ITF predictions themselves are unaffected — they still verify and run correctly via ESPN player-search per the earlier fix; only auto-grading of completed ITF picks is unavailable, and will need to stay a manual Airtable update until a real ITF data source is found.

SAM 1.5.7 / DS 2.1

ITF TENNIS BUGFIX (post-release patch, version held at 1.5.7 per Drew's call). ROOT CAUSE: ITF Tennis was never actually wired in anywhere despite being a listed sport — it was missing from the [PICK] league enum, missing from every "Tennis and WTA" verification/injury/simulation check in both the system prompt and the tool code, and missing from ESPN_LEAGUE_PATHS entirely. The practical effect: whenever a matchup was tagged ITF, rule 8 treated it as a league "with real schedule coverage" (since ITF wasn't in the Tennis/WTA schedule-skip exception list) and required get_game_schedule to confirm it — but get_game_schedule had no ESPN path for ITF at all, so it could never find the matchup and the request hit the hard verification gate and got declined. This is why it looked like only "some" ITF matches worked: any that got asked about using the TENNIS or WTA league tag directly (bypassing the ITF label) still went through the working ATP/WTA path and succeeded, while ones correctly tagged ITF always failed. FIX: added ITF as a first-class alias everywhere Tennis/WTA are handled — the [PICK] enum, the tool schema league lists, the injury-report individual-sport check, the run_matchup_simulation verification/CANNOT_VERIFY branch (reuses the same ESPN player-search + ranking + win/loss lookup as Tennis/WTA), the TML surface-adjustment skip (TennisMyLife only covers ATP tour level, so ITF now skips it the same way WTA already did), and rule 8's schedule-gate exception list (ITF now skips the schedule check and relies on ESPN player verification only, same as Tennis/WTA, since ITF has no reliable ESPN schedule board either). Workflow page's verified-sports list updated to include ITF alongside Tennis/WTA. (No ESPN_LEAGUE_PATHS entry was added for ITF — see the follow-up changelog entry above; there's no real ESPN source to grade ITF picks against.)

SAM 1.5.7 / DS 2.1

VERIFICATION HARD-GATE + WRONG-PITCHER BUGFIX + WORKFLOW PAGE UPDATE (post-release patch, version held at 1.5.7 per Drew's call). (1) NO MORE FAKE/THEORETICAL MATCHUPS: closed every "if we can't verify this, estimate/guess and proceed anyway" fallback across the whole prompt and tool layer. Team sports now decline outright (no [PICK], no log) if get_game_schedule can't find the real matchup or if live team stats can't be pulled — previously a stats-fetch failure fell back to a qualitative guess that still got logged. UFC/Bowling/Darts/Tennis/WTA: if their real-data check fails (ufcstats.com, pba.com, PDC tour cards, ESPN rankings), it's now a hard decline instead of the model estimating a power rating and proceeding — removed at the CODE level, not just the prompt, so it can't be talked into estimating anyway. Horse Racing is disabled entirely: there's no schedule source and no stats source for it at all (Equibase blocks scraping), so unlike every other sport there was never any way to verify a race or named horses were real — rather than leave that wide open, predictions for it now always decline. NASCAR keeps its necessary estimate (no live per-driver stats exist, full stop) but now requires get_game_schedule to first confirm both drivers are actually entered in a real race — caught a real ESPN limitation while building this: entry lists are only published for the immediate next race, not races further out, so this verification is reliable for the next race and weaker beyond that. (2) BUGFIX — get_game_schedule couldn't actually match individual athletes: its matching only checked competitions[0] and only team-style fields, so it could never match UFC fighters or NASCAR drivers at all (they use .athlete, nested across many sub-competitions per event, not .team). Fixed to search every sub-competition and match both team and athlete name fields — required for the verification gate in item 1 to work correctly instead of wrongly blocking every real UFC/NASCAR matchup. (3) DECLINED PREDICTIONS NO LONGER COST A FREE PICK: found and fixed a side effect of item 1 — the daily/paid prediction allowance was being deducted before the model even attempted verification, so a correctly-declined fake matchup was still burning one of the user's 3 free daily predictions for nothing. Deduction now only happens when a real [PICK] actually resulted. (4) WRONG-PITCHER BUGFIX (the important one): getMLBProbablePitchers picked the FIRST upcoming game between two teams within a 10-day window, with no awareness of which specific night was being asked about. Verified live and reproduced exactly — with the Tigers mid-series against the Orioles (games on consecutive nights, each with a different starter), the tool returned Game 1's pitcher (Keider Montero) regardless of which night was actually asked about, when the real answer for a later night in the series was Tarik Skubal, a completely different pitcher. Fixed by adding an eventDate parameter to run_matchup_simulation — SAM now passes the exact date it already confirmed via get_game_schedule (rule 8) straight into the pitcher lookup, which filters to that specific game instead of guessing the first one. Verified fixed live: without eventDate the tool returned Game 1's starter, with eventDate set to Game 3's date it correctly returned Game 3's actual starter. Applied the same fix to getHomeAwayContext/findScoreboardEvent (home-field advantage), which had the identical latent bug for any two teams that meet more than once in a season (e.g. divisional rematches) — not MLB-specific, just most visible there since series are so common. Confirmed the NFL/college key-player mechanism (QB rating) isn't exposed to this specific bug since it doesn't do date-based schedule matching at all, only home-field advantage shared the underlying issue and is now fixed the same way. (5) WORKFLOW TAB UPDATED: the in-app /workflow page ("How SAM works") was rewritten to match everything shipped recently instead of describing the old pipeline — added a distinctly-colored new step for the verification hard-gate (deliberately NOT reusing the existing red "logged pick" style, which would have been misleading), updated the Airtable-cache step to explain same-day-cached vs next-day-fresh-rerun behavior, updated the team-sport branch card to list home-field/injury/pitcher/key-player factors, updated the individual-sport branch card to explain the verify-or-decline policy per sport, and updated the intro/closing copy to state the no-fake-matchup guarantee alongside the existing no-odds guarantee. Legend updated with the new indicator color.

SAM 1.5.7 / DS 2.1

SIMULATION ACCURACY + LOGGING RELIABILITY PASS (post-release patch, version held at 1.5.7 per Drew's call). (1) CHATTINESS REMOVED: system prompt rule 4 no longer tells SAM to follow a prediction with podcast-style color commentary, storylines, and an invitation for the user to keep chatting — it now stops cleanly right after the prediction. (2) DOUBLE-LOGGING RACE FIXED: logBetToAirtable's dupe check (query Airtable, then write) was a classic check-then-act race — two near-simultaneous requests for the same matchup could both see "nothing pending yet" and both write, producing duplicate Pending records. Fixed with an in-worker lock keyed on provider:matchup:eventDate so overlapping calls for the same pick now queue and serialize instead of racing. (3) STALE PLAYER NAMES FIXED: SAM was naming specific players (a departed pitcher, in one reported case) from its own training knowledge with no live verification. Added a get_team_roster tool plus a stronger system-prompt rule requiring any named player to actually appear in a live ESPN roster pull. This is now deterministic, not prompt-compliance-dependent — run_matchup_simulation automatically fetches and bundles both teams' current rosters at the top of its own response for every team-sport matchup, so the data arrives before the model writes anything and can't be skipped by forgetting a separate tool call. (4) IDENTICAL CACHED REPLIES GUARANTEED: previously the "already logged" shortcut only fired on a strict "X vs Y" phrasing match before calling the model; any other phrasing skipped it and got a freshly-generated (and differently worded) reply even though the pick was already Pending in Airtable. Added a shared buildCachedReplyText() template used by both the pre-model fast path and a new post-model backstop (findExistingPendingRecord, matched on matchup + event date) — any rephrasing that slips past the fast path still gets caught after the model responds and swapped for the exact same canned message, byte-for-byte, with no re-log and no fresh odds/commentary appended. (5) ROSTER FETCH BUG FIXED FOR NBA/WNBA/SOCCER/CBB: ESPN returns MLB/NFL/NHL/CFB rosters grouped by position ({position, items:[...]}) but returns NBA/WNBA/Soccer/CBB rosters as a flat player list with no items wrapper at all — the original get_team_roster only handled the grouped shape, so it was silently returning "no roster data" for every NBA/WNBA/Soccer/CBB team since the roster-verification feature (item 3) shipped. Fixed via two new shared primitives, resolveEspnTeam() and fetchTeamRosterRaw(), that normalize both shapes into one flat player list; verified live against real rosters (Celtics: 16 players, Liverpool: 39, Rangers: 26) before shipping. (6) MLB STARTING PITCHER FOLDED INTO THE ACTUAL SIMULATION MATH: previously roster/player data only shaped the writeup, never the win%. New getMLBProbablePitchers() pulls each team's live probable starter + current ERA directly from ESPN's scoreboard (a field ESPN publishes specifically for MLB, verified live), converted via pitcherERAToFactor() into a lambda multiplier that suppresses the opposing team's expected runs — a true ace now measurably lowers the opponent's simulated scoring, not just gets a mention. (7) INJURY REPORTS NOW FEED THE MATH TOO, ALL TEAM SPORTS: new injuryReportToFactor() counts real Out/Doubtful/Questionable entries from the already-fetched injury report and scales that team's own offense/defense accordingly (capped, never a boost, only ever neutral-or-worse). This runs for every league that reaches the generic team-sport branch — NFL, NBA, WNBA, MLB, NHL, Soccer, CFB, CBB — not just MLB. (8) KEY-PLAYER QUALITY FOLDED IN FOR NFL/CFB/NBA/WNBA/CBB/NHL: new getKeyPlayerFactor() identifies each team's actual key contributor (QB for NFL/CFB via QBRating, leading scorer for NBA/WNBA/CBB via PPG, starting goalie for NHL via save%) and fetches their real current ESPN stat to adjust the simulation. Important correctness note from live testing: ESPN roster order is NOT depth-chart order (verified — Dak Prescott, Dallas's clear starter, was listed behind two backups), so "first player at the position" would have been wrong. Fixed by fetching usage (attempts/games/starts) for every same-position candidate and picking whichever one actually has current-season snaps. A second bug caught in the same testing pass: the first version of that fix let a bench player's stale career total outrank the real starter's smaller current-season total — usage is now strictly current-Regular-Season-only with zero fallback, so an inactive player correctly scores 0 usage instead of borrowing career numbers. If the identified key player is currently listed Out/IR, their stat boost is suppressed entirely (factor reverts to neutral) rather than crediting a team with a bench-level replacement's numbers — the separate injury-count factor (item 7) still applies its own penalty for that same absence, so it isn't double-ignored. Soccer was intentionally left out of this specific mechanism at Drew's call (low value while the Premier League is in its off-season with no current club-competition stats published yet, plus not worth the added per-request ESPN calls for a sport he doesn't prioritize) — Soccer still gets items 7 and 9. (9) REAL HOME-FIELD/COURT/ICE ADVANTAGE ADDED, ALL TEAM SPORTS: this was completely unmodeled before today. New findScoreboardEvent()/getHomeAwayContext() pull the actual confirmed home team for the specific matchup from ESPN's schedule (order-independent — works whether the home team is passed as teamA or teamB, verified live both ways) and feed it into the sim's existing (previously always-neutral) travel-tier multiplier. Applies universally, including MLB and Soccer, unlike item 8. (10) KNOWN REMAINING GAP: rest-days and recent-form-differential multipliers in calculateLambdaMultiplier are still unpopulated dead code, same as before this patch — not addressed today, flagged for a future pass if wanted.

SAM 1.5.7 / DS 2.1

AUTO-GRADE DATE-MATCH BUGFIX (post-release patch, version held at 1.5.7 per Drew's call): findFinalResult's exact-date check only ran when multiple candidate ESPN events matched a pick's team/fighter names (allMatches.length > 1); a single fuzzy name match skipped the date check entirely and was accepted as-is even if it fell on the wrong day within the \xB11-day search window. This let loosely-matched UFC undercard fighters (common surnames, generic names) get graded against the wrong event on the wrong date, sometimes producing a real Win/Loss off a real 'winner' flag but with a score summary of 'N/A - N/A' since MMA competitors don't carry numeric scores the way team sports do \u2014 that blank score summary was the tell. Fix: the exact Event Date check (America/New_York) now always runs whenever eventDateStr is available, regardless of how many candidates were found; if the single match doesn't land on the exact date, the pick is left Pending instead of being graded. No behavior change for the common case where the correct event is the only candidate and already falls on the right date. TENNIS + GOLF + CASINO BUTTON REMOVAL + REAL CALIBRATION SYSTEM. (1) TENNIS ADDED: real stats, not just a power-rating guess — run_matchup_simulation now looks up both named players via ESPN's player search (site.web.api.espn.com/apis/search/v2), pulls their current ATP/WTA ranking and season singles win/loss record from ESPN's core API, and blends the two into a 20-100 power rating for runFightSimulation() — same head-to-head mechanic as UFC/Bowling/Darts. Falls back to a model-estimated power rating if either name doesn't resolve to an ESPN tennis player. Schedule lookups also now work for Tennis (ATP) and WTA via the existing get_game_schedule tool, routed through ESPN's tennis/atp and tennis/wta scoreboards — no known gap here, unlike Bowling/Darts. (2) GOLF ADDED: a genuinely different shape of prediction since golf is a 100+ player field, not a two-competitor matchup — so this isn't run_matchup_simulation. A new run_field_simulation tool pulls this week's full PGA or LPGA tournament field from ESPN's scoreboard, fetches each player's real current-season scoring average per round from their ESPN overview page (in parallel), and runs a 10,000-trial Monte Carlo simulating 4 rounds per player off their own scoring-average-centered distribution — win% is the share of trials where that player posts the field's lowest 4-round total. Players ESPN has no scoring-average profile for (rookies, unranked amateurs) are simulated off the field-wide average scoring rate instead of being dropped, so the field size stays accurate. Trial count is 10,000 rather than the usual 25,000 given the cost of simulating 100+ players per trial instead of 2 — flagged in the tool's own response text, not hidden. Returns the top 10 contenders by win% plus the tournament name; the model should present this as a ranked list of contenders rather than a single [PICK], since there's no single opposing side to log a win/loss grade against. (3) SPORTSBOOK BUTTONS REMOVED: the row of 4 sportsbook/casino links (DraftKings, FanDuel, BetMGM, Caesars) under the chat bar is gone — HTML, CSS, and the openSportsbookWindow popup-window JS all removed. Version label was intentionally held at "SAM 1.5.6"/"SAM DS 2.0" through development of this batch of changes, then bumped to "SAM 1.5.7"/"SAM XAI 2.1" once the whole batch (Tennis, Golf, sportsbook removal, calibration system) was ready to ship as one release. (4) REAL CALIBRATION SYSTEM ADDED: rule 6's soft "please self-calibrate based on this history text" instruction is now backed by an actual computed number instead of relying on the LLM's own judgment. New Supabase table sam_calibration (same project as sam_bettors) holds one row per (provider, league): a running bias in percentage points, plus n_graded/wins/losses. Every time gradePendingBets grades a Win or Loss, it now also runs one online-update step — delta = 3% × (actual outcome − stated confidence), added to that league's running bias, clamped to ±15 points so no single bad streak can run away with it. run_matchup_simulation reads the current bias for the calling provider+league before returning its result and shifts both win percentages by that exact number (mirrored so they still sum to 100) — done as a single wrapper around the existing dispatcher's output text (every branch already returned "TeamName: NN% win rate" in the same shape, so one regex-based post-processor covers UFC/Bowling/Darts/Tennis/NASCAR/team-sports/etc. without touching each branch). When a bias is applied, the tool response tells the model plainly that the number is already calibrated and not to layer rule 6's soft self-calibration on top of it, so the two mechanisms don't double-count. Golf's run_field_simulation is untouched — no single win/loss to calibrate against in a full-field event. (5) CALIBRATION BACKFILL ENDPOINT: a new GET /admin/backfill-calibration?key=SESSION_SECRET route (reuses the existing session secret, no new env var) walks every already-graded pick in both Airtable bases, buckets them by league, computes each bucket's actual-hit-rate-minus-stated-confidence as a batch average (not a slow one-by-one replay), and seeds sam_calibration with that number plus the real win/loss counts — so leagues with real history (MLB, UFC) start calibrated on day one instead of drifting up from a blank bias=0 one graded pick at a time. Safe to re-run — upsert on (provider, league) means it just recomputes and overwrites, doesn't double-count. (6) KNOWN CAVEAT — SMALL-SAMPLE NOISE: Tennis, Golf, Darts, and Bowling only have a handful of graded picks each right now, so their calibration bias is currently dominated by sampling variance, not a reliable read on real miscalibration — the standard error of an observed hit rate shrinks with sample size (roughly 1/√n), so at n=4 a 3-1 or 1-3 stretch (pure chance) can swing the bias by several points, while a mature bucket like MLB (150+ graded) barely moves per pick. Don't read much into those four leagues' bias numbers until each has ~20-30+ graded picks — this isn't a bug, there's no code fix for it, it just needs more games to play out. (7) SCOPE CLARIFICATION: the calibration system (item 4) corrects confidence calibration — how far a stated win% is off from real historical hit rate for that league — not pick accuracy. It cannot fix a pick where the simulation favored the wrong side; it only tightens or loosens the confidence number attached to whichever side was already picked. The Monte Carlo simulation's underlying stats and math are unchanged and exactly as accurate (or inaccurate) as before this release. (8) POST-PREDICTION MATCHUP COMMENTARY & TRANSPARENT SIMULATION Q&A: System prompt rules updated to (a) instruct SAM to deliver lively, podcast-style color commentary, key storylines, X-factors, and game-script hot takes immediately following a prediction split, inviting user dialogue; and (b) empower SAM to answer user follow-up questions openly and transparently about how its 25,000-trial Monte Carlo simulation engine, recent-form weighting (last 8 games), injury metrics, and conservatism shrinkage work without being evasive or logging duplicate pick tags. UI + FOLLOW-UP CHAT PATCH (post-release patch, version held at 1.5.7 per Drew's call): (9) Brain emoji restored to the "Thinking..." indicator shown while a request is in flight. (10) The "SAM's Reasoning" thought bubble is now collapsible — clicking its label toggles a ▾/▸ chevron and shows/hides the reasoning box; still renders by default same as before. (11) FOLLOW-UP SIMULATION Q&A: the chat can now actually answer follow-up questions about a simulation it just ran, not just describe its methodology in the abstract. The client keeps the last 6 exchanges (user message, SAM's reply, and that turn's internal simulation trace) in an in-memory array only — nothing persisted to Airtable, Supabase, or browser storage, gone on refresh — and sends it with each new message. Server-side, a new buildHistoryContext() formats that into a plain-text block and prepends it to the augmented message sent to Gemini/Grok (both the primary-provider and cross-provider-failover paths), so a follow-up like "why the Lions and not the Bears" or "what was the split before calibration" gets answered from the real numbers/trace of that turn instead of being re-simulated or guessed at.

SAM 1.5.6 / DS 2.0

PROVIDER SWAP + AUTO SECOND OPINION + SPORTS EXPANSION. (1) DEEPSEEK REPLACED WITH GROK: the second model slot now runs on xAI's Grok (grok-4.5) via its OpenAI-compatible /v1/chat/completions endpoint, instead of DeepSeek. Same tool schema, same multi-hop tool-calling loop, same [PICK] parsing, same reasoning_content shape — only the endpoint, model name, and GROK_API_KEY (+ backups) env vars changed. Now labeled 'SAM DS 2.0' in the model picker and in logged picks (was 'SAM DS1.4'). (2) AUTO SECOND OPINION: whenever Gemini answers as the primary model (the default path), the exact same question now also gets fired at Grok in the background — no added latency or change to the user-visible reply. If Grok lands on its own clean [PICK], it's logged as a separate record (its own Model Version, its own calibration against its own track record) in the same base that used to hold DeepSeek's picks — so that base now fills with Grok's independent second opinions on every matchup Gemini handles. If the user explicitly picks Grok as primary, no redundant self-second-opinion runs. Any second-opinion failure (missing key, API error, no clean pick) is swallowed and logged server-side only — it never affects or delays the primary response. (3) SERIES/PLAYOFF BUGFIX: multi-game series between the same two teams (e.g. Twins vs Guardians, game 2 of 5) were breaking prediction logging, all for the same root cause: nothing checked the actual game date, only the team names. A pre-model cache shortcut matched on team names alone, so once game 1 of a series was Pending, every later question about the same two teams got served game 1's stale cached result instead of ever reaching Gemini, the simulation, or a new Airtable write — now it only takes that shortcut when exactly one Pending record matches; 2+ (a series) skips straight to the real pipeline. The write-time dupe guard had the same blindness and now also checks Event Date so each game of a series logs as its own record. Auto-grading had the same risk too — it could've graded game 2's Pending record with game 1's score — so it now requires an exact Event Date match before grading, and leaves the record Pending rather than guessing if none match exactly. (4) WNBA ADDED: full team-sport support (schedule, injuries, simulation) — routes through the same bivariate/normal scoring pipeline as NBA (stdDev 12, league-average baseline 83.0 pts). BALLDONTLIE doesn't cover WNBA, so it goes straight to the ESPN standings fallback for offense/defense — same behavior NFL/NBA/MLB/NHL get whenever BALLDONTLIE itself is unavailable, not a new code path. (5) NASCAR ADDED: schedule lookup works like any other ESPN-backed league (racing/nascar-premier). Since NASCAR is a 30-40 car field, not a two-team matchup, and there's no equivalent of ufcstats.com to auto-pull real per-driver stats, run_matchup_simulation treats it like the UFC power-rating fallback — a head-to-head finish-ahead-of simulation between the two named drivers, using power ratings you estimate from recent finishes/track fit/form (reuses runFightSimulation() unchanged). No team injury report for NASCAR, same reasoning as UFC (individual competitors, not team rosters). (6) GEMINI MODEL BUMP: primary model string updated from gemini-3.5-flash to gemini-3.6-flash. Grok (the DS 2.0 slot) is unaffected. (7) MODEL LABEL FIX: the model-picker label, Airtable test-ping value, and page display text were still hardcoded to "SAM 1.5.5" from before this release — all instances now correctly read "SAM 1.5.6" so logged picks and the visible label match the actual running version. (8) SIGN-IN GATE: the whole site now requires a login — a small, Drew-approved allowlist stored in the Bettors table (Username + Password Hash fields, PBKDF2-SHA256, never plaintext). Sessions are stateless signed cookies (HMAC-SHA256, 30-day expiry) — no KV/D1 needed, just one new SESSION_SECRET env var. Static assets (favicon, background image) and the login page itself stay reachable pre-auth; everything else redirects to /login without a valid session. Logged-in sessions also now carry the Airtable record ID of whoever is signed in, so every pick — including the Grok background second opinion — links back to whoever triggered it via the Bettor field, instead of logging anonymously like before. (9) BILLING: 3 free predictions per bettor per day (resets at midnight ET), then paid packs via Stripe Checkout — $5/10 predictions, $50/100 predictions, $200/lifetime unlimited. Paid credits never expire. Usage state lives on the Bettors record (Credits Remaining, Lifetime Access, Free Predictions Used Today, Free Reset Date) — same table the sign-in gate already uses. A cache hit or a failed call never counts against the daily/paid allowance — only a real, completed prediction does. Hitting the limit shows a paywall message in-chat with one-click links to each tier; a Stripe webhook (signature-verified, same HMAC-SHA256 pattern as session cookies) credits the right bettor the moment a purchase completes. Two new secrets required: STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET. (10) AUTH + BILLING MOVED TO SUPABASE: sign-in and credit/usage tracking now live in a new public.sam_bettors table in the existing grimaldi.tv Supabase project, not Airtable — real Postgres, no per-request rate limit, RLS enabled with no policies so only the worker's service-role key can touch it, never a browser. Picks/bets logging stays on Airtable exactly as before; since a cross-system linked record isn't possible, the old Bettor linked-record field on Bets is replaced by a plain-text Bettor Username field. Two new secrets required: SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY. (11) SELF-SERVE SIGNUP: anyone can now create their own account at /signup (username 3-32 chars, password 8+ chars, name/email optional) — no more Drew-approval gate on account creation. New accounts start on the standard 3-free-predictions-per-day tier like everyone else, same paywall applies. Login and signup pages now cross-link to each other. (12) BOWLING ADDED: real stats, not just a power-rating guess — run_matchup_simulation now pulls each bowler's actual most-recent-season PBA Tour scoring average directly from their pba.com profile page (PBA has no search endpoint, so this guesses the name-based URL slug directly, e.g. "EJ Tackett" -> ej-tackett) and converts it to a 20-100 power rating for runFightSimulation() — same head-to-head mechanic as UFC/NASCAR. Falls back to a model-estimated power rating only if a profile isn't found. No schedule source is wired in yet (PBA's schedule isn't on ESPN) — event date comes back as unknown until that's built, a known gap, not a silent one. (13) HORSE RACING ADDED: same power-rating head-to-head model as NASCAR (runFightSimulation, two named horses compared directly) — but unlike NASCAR, no real-stats attempt is made at all. Equibase, the official North American horse racing data source, explicitly prohibits automated scraping in its own Terms of Use and sits behind Imperva bot protection regardless, so this always uses a model-estimated power rating, by design, not as a temporary gap. (14) DARTS ADDED: real stats, using dartsdatabase.co.uk since the official pdc.tv is a JS-only app with no server-rendered data to fetch at all. No search endpoint exists on dartsdatabase.co.uk, so run_matchup_simulation fetches the real Tour Card Holders list (all 128 current PDC pros), matches the given names against it, then pulls each matched player's real current-season 3-dart average from their profile page and converts it to a 20-100 power rating for runFightSimulation() — same head-to-head mechanic as UFC/Bowling. Falls back to a model-estimated power rating if either name doesn't match a current tour card holder. No schedule source for darts either — same known gap as Bowling, event date comes back unknown until that's built separately. (15) OLD DISCLAIMER GATE REMOVED: the click-through "Before you enter" card that showed on every page load (predating the real sign-in system) is gone — HTML, CSS, and the unlockSAM/showSAM JS all removed. It was a leftover gate from before login existed and was stacking as a redundant second gate after every real sign-in. The initial greeting now fires directly on page load instead of waiting on that click. (16) PURCHASE CONFIRMATION ADDED: the /?purchase=success redirect Stripe sends users back to after Checkout used to do nothing at all — the page loaded plain, with the only signal a purchase went through being that the next prediction happened to work. Now the tier is passed through the redirect and the page shows an explicit in-chat confirmation naming what was added ("10 predictions", "100 predictions", or "lifetime access"), with a matching message on /?purchase=cancelled too. The query params are stripped via history.replaceState right after so a refresh or back-button press doesn't replay the message.

SAM 1.5.5 / DS1.4

Simulation engine supercharge (Grok recommendations). (1) BIVARIATE POISSON: Team sport simulations now use a bivariate Poisson distribution with correlation 0.28 — scores are no longer independent, capturing the real-world phenomenon that high-scoring games tend to be high-scoring for both teams (e.g. NFL shootouts, NBA pace). The shared Poisson component pulls the two teams' outputs together slightly, producing more realistic joint score distributions. (2) 25,000 TRIALS: Trial count raised from 20,000 to 25,000 for lower statistical variance and more stable win percentages across runs. (3) IMPROVED LAMBDA CALCULATOR: A dedicated calculateLambda() function now adjusts each team's expected score before simulation using four real-world factors — rest days (back-to-back penalty, well-rested bonus), travel distance tier (home/short/medium/long haul), recent form (last-5-game scoring trend vs season average), and injury severity (starter-out vs key-player-questionable penalties). These contextual modifiers stack multiplicatively on top of the existing offense/defense matchup math. (4) BRIDGING FUNCTION: A bridgeToTeamSim() function translates the adjusted lambdas and contextual metadata into the bivariate simulation, cleanly separating lambda calculation from simulation execution. (5) UFC UNCHANGED: runFightSimulation() is untouched — power-rating model still applies there. (6) STATS PAGE LINK: /stats page's Airtable link fallback updated to a fresh invite link. (7) THINKING BUBBLE EMOJI: restored the missing 🧠 on the 'Thinking...' indicator, which had been dropped entirely rather than just mis-encoded. (8) AUTO-GRADING: the existing Cron Trigger (every 6 hours) previously had no scheduled() handler to call, so it fired into nothing — added one. It now pulls every Pending record from both bases, checks ESPN's scoreboard for a completed game matching the matchup and date, and writes Win/Loss/Push into Result plus a final-score line into Notes. Anything ESPN hasn't finished yet, or where a winner can't be confidently matched, is left Pending for the next run rather than force-graded.

SAM 1.5.4 / DS1.3

Reliability and accuracy pass. (1) CROSS-MODEL FAILOVER: if every key for the requested provider fails, the worker now automatically retries the other provider (Gemini vs DeepSeek) before giving up, using whichever keys are already configured. The reply gets a short notice when this happens, and the Airtable log/Model Version field correctly credits whichever model actually answered, not the one originally requested. (2) SIMULATION TRACE EMOJI FIX: Gemini's path was using an invalid JS escape sequence for the dice-emoji run_matchup_simulation marker in the visible reasoning, so it rendered as garbled text instead of the emoji, making it look like the simulation hadn't run even when it had. Now matches DeepSeek's already-correct version. (3) TRIAL COUNT WORDING FIX: two places (system prompt rule 2, and the UFC power-rating-fallback tool response) still described the simulation as 10,000-trial in the text even though the actual simulations have run at 20,000 trials for a while; both now correctly say 20,000, matching the real trial count everywhere else. (4) DEEPER TRACK RECORD READ: fetchPredictionHistory's page cap raised from 3 pages (300 records) to 10 pages (1,000 records), so a full season's worth of graded picks feeds the calibration instead of just the most recent 300.

SAM 1.5.3 / DS1.2

Bugfix on the dual-model build. SCHEDULE LOOKUP DATE-WINDOW FIX: get_game_schedule was calling ESPN's scoreboard endpoint with no dates range, which defaults to "today only." NFL/NBA/MLB have games most days so this rarely showed, but sparse-schedule sports — UFC especially, roughly one card every 1-2 weeks — would come back as "no games found" for any fight card not happening that exact day. Fixed by passing an explicit 45-day-forward dates=YYYYMMDD-YYYYMMDD range to the same ESPN call, matching the fix already shipped on ai2/DS1.0. DEEPSEEK LABEL BUMP: the DeepSeek option in the model picker now reads "SAM DS1.2" (was DS1.0) to reflect this fix; Gemini's side stays at 1.5.3 since it was unaffected — the schedule tool is shared code, but this changelog only bumps the model-facing version numbers that actually changed behavior for the user.

SAM 1.5.3

Conservatism pass on top of 1.5.2, plus a later dual-model update. CONSERVATISM: (1) DEEPER SHRINKAGE: CONSERVATISM_SHRINKAGE lowered from 0.70 to 0.50, so every raw Monte Carlo win% is pulled further back toward a 50/50 coin flip before it's shown, logged, or handed to the model (e.g. a raw simulated 80% edge now reports as ~65% instead of ~71%). (2) SLOWER RECENT-FORM REACTIVITY: the BALLDONTLIE offense/defense blend for team sports (NFL/NBA/MLB/NHL) changed from 65% last-8-games / 35% season average to an even 50/50 split, so a hot or cold short stretch no longer dominates the simulation's inputs before shrinkage even applies. Together these two changes compound — flatter inputs feeding a flatter output curve — intentionally trading some upside on confident calls for fewer overconfident misses. DUAL-MODEL UPDATE: (3) MODEL PICKER: Added a dropdown next to the chat input so anyone can switch between SAM 1.5.3 (Gemini, gemini-3.5-flash) and SAM DS1.0 (DeepSeek, deepseek-v4-flash with thinking mode) per message — both share the same tool set, Monte Carlo simulation engine, and conservatism/calibration logic, just through each provider's own tool-calling format. Gemini remains the default; DeepSeek is opt-in via the picker. (4) MODEL-TAGGED LOGGING: Picks now record which of the two models made each call in the Model Version field, so Gemini and DeepSeek picks can be graded and compared side by side in the same Airtable base. (5) EMOJI FIX: Corrected long-standing mojibake-corrupted emoji across the suggestion chips and thinking/reasoning indicators — swapped in a plain arrow style for suggestion chips and restored a icon for the thinking/reasoning display. (6) MODEL PICKER STYLING: A two-line title/subtitle dropdown with a blue selection checkmark. (7) DEEPER THINKING: When DeepSeek is selected, its reasoning_effort runs at high for more careful reasoning on every pick; Gemini stays at its existing medium thinking level given its output tokens (including reasoning) bill at a much higher rate. (8) SAVE TO HOME SCREEN: Added a standalone button at the bottom of the page — triggers a real one-tap native install on Chrome/Android, falls back to manual steps only where no native install API exists (iOS Safari).

SAM 1.5.2

Accuracy, polish, and market-integration patch on top of 1.5.1. (1) LIVE SCORES & TIMES: Enhanced schedule lookup tool to fetch and display live/final scores and current status of matches. (2) REVERT LOGO: Restored the original favicon logo branding. (3) 20,000 SIMULATION TRIALS: Upgraded the Monte Carlo simulation trial count from 10,000 to 20,000 to increase resolution and lower statistical noise. (4) COLLEGE SPORTS SUPPORT: Configured dedicated scoring models, fallbacks, and paths for College Football (NCAAF/CFB) and College Basketball (NCAAM/CBB). (5) STANDINGS COLLISION FIX: Resolved a bug where short abbreviations like 'gp' collided with longer field names (e.g. 'avgpointsfor'), corrupting statistics and resulting in random coin-flip predictions. (6) INTEGRATED ODDS CHECKING: Allowed SAM to analyze sports betting lines, spreads, and gambling odds from the very beginning of the prediction process. (7) ODDS MISMATCH DETECTION: Instructed the AI to identify value opportunities (+EV edge) by comparing Monte Carlo win percentages against market implied probabilities. (8) PARLAY PREDICTIONS: Equipped SAM to suggest value-parlays by pairing high-edge picks together, with dedicated suggestion chips on the home and matchup views. (9) ODDS REMOVED: Reverted item (6)/(7) above — SAM no longer references, invents, or compares against sports betting lines, spreads, or gambling odds anywhere in the prediction process (there was never a real odds data source wired in, so this was a hallucination risk, not a real market signal). Picks are stat-based only again, per the original 1.0 design. (10) COLLEGE LEAGUE NAMING FIX: Added a league-alias normalizer so "College Football", "NCAA Football", "CFB", and "NCAAF" (and the basketball equivalents) all resolve to the same internal key regardless of which wording the model uses in a tool call. The [PICK] tag now logs college picks under the exact labels "College Football" / "College Basketball" to match the Airtable tracking sheet, instead of fragmenting into NCAAF/CFB/NCAAM/CBB as separate values.

SAM 1.5.1

Three-part accuracy and polish patch on top of 1.5's engine work. (1) CONSERVATISM: picks are now more conservative across the board — added a CONSERVATISM_SHRINKAGE constant (0.70) that pulls every raw Monte Carlo win% partway back toward a 50/50 coin flip before it's ever shown, logged, or handed to the model (e.g. a raw simulated 80% edge now reports as 71%, a raw 55% reports as ~54%). Applies uniformly to both the team-sport simulation and the UFC fight simulation, reflecting real uncertainty in season-average inputs rather than overstating confidence on a single computed run. It's one tunable constant (0 = always report a flat coin flip, 1 = old/no-shrinkage behavior), and stacks underneath the existing rule-6 track-record calibration — shrinkage sets a more conservative floor first, calibration can still move it from there. (2) RECENT-FORM PATTERN RECOGNITION: added to the BALLDONTLIE stats pipeline (NFL/NBA/MLB/NHL). Previously every completed game fetched for a team's season (up to 25) was averaged flat, so a hot or cold recent stretch got diluted back toward the full-season number by games from months ago. Games are now explicitly sorted newest-first (the API's own ordering wasn't guaranteed chronological), and offense/defense is computed as a weighted blend: the last 8 games count for 65% of the final number, with the fuller season average filling in the remaining 35% as a stabilizing baseline. Early in a season, before a team has more than 8 games played, it falls back to the plain season average since there's no meaningful recent-vs-season split yet. When the blend is used, the simulation's reasoning trace says so explicitly (sample size and weighting), so a recent-form-driven pick is visibly distinguishable from a plain season-average one. (3) LOGO FIX: Uncle Sam hat promo graphic. Deliberately cropped to exclude the graphic's "Betting Edge / Live odds" subtitle text so it fits the new styling and avoids conflicting with our no-gambling-odds disclaimer. Served via a dedicated route below, not inlined per-page.

SAM 1.0

Initial launch — patriot gate screen, dual-key Gemini fallback, stat-based predictions only (no gambling lines, spreads, or odds, ever).


Proprietary Technology built by GRIMALDI.TV