Repair the champion: it was reading ten games, not a season

PHASE 0 — the defect is real past the peek. Against a FAIR point-in-time
baseline (each player's rate over games strictly before that date, >=10
prior games, box scores back to 05-01), the served champion LOSES on all
four stats, three of four CIs excluding zero:

  hits  0.00251 vs 0.00774  CI [-0.0074,-0.0011]
  TB    0.00393 vs 0.00619  CI [-0.0055,-0.0003]
  rbi   0.02481 vs 0.03133  CI [-0.0153,-0.0005]
  runs  0.00181 vs 0.00683  CI [-0.0114,+0.0008]

PHASE 1 — the cause is the WINDOW, not the weights. estimateProbability
builds its base rate as the frequency over every row it is handed, and
featureCache.getStatRows handed it res.last10. So the "season rate" was a
TEN-GAME rate, and 0.4 of the forecast was the last five OF THOSE TEN. The
0.40 recency weight costs resolution on all four stats (-0.00086,
-0.00107, -0.00562, -0.00365). Nudges are mixed and small -- harmful on
hits and rbi, marginally helpful on TB and runs -- so they are left alone.

PHASE 2 — two lines, no new data, no extra API call, because fullLog was
already fetched by the same adapter call that produced last10:
getStatRows now reads fullLog, and RECENCY_WEIGHT goes 0.40 -> 0.20.

  hits  0.00251 -> 0.00817  (tripled; now above the fair baseline)
  TB    0.00393 -> 0.00734  (above baseline; vs old CI [0.0020,0.0067])
  rbi   0.02481 -> 0.02727  (still below baseline, CI includes zero)
  runs  0.00181 -> 0.00436  (still below baseline, CI includes zero)

Gate stated exactly: hits and TB now exceed the fair baseline on the point
estimate; rbi and runs remain below but EVERY CI now includes zero, so no
stat reliably loses to a frequency table. That is a tie on rbi/runs, not a
win, and it is reported as one. Only TB's improvement over the old
champion is CI-confirmed; the rest are directional.

STALE-FIT GATE: CALIBRATION_DEPLOYED is now EMPTY. The low-param maps were
fitted on the retired forecast and fromLedger cannot rescue them -- settled
ledger rows still carry OLD p_win, so refitting today would refit the
retired forecast. Nothing is served calibrated until dates settle under
the repaired champion, and the favourite-longshot bias must be re-measured
rather than assumed to survive. The shadow duel is void.

PHASE 3 — the hits factor lift is NOT re-measured, and cannot be yet: it
needs settled rows produced BY the repaired champion, which ships in this
commit. Replaying would score the factors against a reconstruction rather
than the served forecast. Deferred, explicitly. The factors remain wired
and transmitting; only their lift is unquantified on the new baseline.

PHASE 4 — standing flag, and it is large: EVERY factor verdict in this
programme, every null and every THEATER, was measured against a champion
worse than a frequency table. Signal added to noise reads as noise. Prior
verdicts may deserve re-audit. Logged, not re-run.

Re-queued not built: rbi lineup-slot / RISP opportunity through the
two-part gate, now landing on a repaired champion.

Serving-path change by design; the byte-identical invariant inverted and
all four stats move. Nine frozen model modules verified unchanged. No
Bonferroni slot -- resolution accounting on the champion's own knobs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
This commit is contained in:
Kev
2026-08-07 03:28:33 -04:00
parent 65ca6493db
commit 929fd81940
7 changed files with 414 additions and 17 deletions
+16 -1
View File
@@ -278,7 +278,22 @@ async function getStatRows(playerName, sport, statType) {
if (sp === 'mlb') {
const mlbStats = require('../adapters/mlbStatsAdapter');
const res = await mlbStats.getPlayerStats(playerName);
const logs = (res && res.found && Array.isArray(res.last10)) ? res.last10 : [];
// THE FULL SEASON LOG, NOT last10.
//
// This is the one line that made the champion worse than a frequency
// table. `estimateProbability` computes its base rate as the frequency
// over EVERY row it is given, so feeding it ten games meant the "season
// rate" was a ten-game rate — and then 0.4 of the forecast was the last
// five OF THOSE TEN. Measured point-in-time, a true season frequency
// out-resolved the served champion on all four stats (hits 0.00774 vs
// 0.00251, rbi 0.03133 vs 0.02481).
//
// `fullLog` is already fetched in the same adapter call that produced
// last10, so this costs nothing: no extra request, no new dependency.
const logs = (res && res.found)
? (Array.isArray(res.fullLog) && res.fullLog.length ? res.fullLog
: (Array.isArray(res.last10) ? res.last10 : []))
: [];
// MLB logs are chronological (most recent LAST) — reverse to match.
for (const g of [...logs].reverse()) push(g && g.date, mlbStatValue(g && g.stat, statType));
return rows;
@@ -17,6 +17,19 @@
*/
const CV_VOLATILE_THRESHOLD = 0.40;
/**
* How much of the forecast is the last five games.
*
* Was 0.40. Measured point-in-time against a fair season-frequency baseline, a
* 0.40 weight COST resolution on every stat — hits 0.00086, total_bases
* 0.00107, rbi 0.00562, runs 0.00365 — because five games is a very noisy
* read and the blend pulled the forecast off a better number.
*
* 0.20 was the best measured weight on hits and total_bases; rbi and runs
* preferred 0 outright. It is set at the value the evidence supports rather
* than at the value that flatters recency.
*/
const RECENCY_WEIGHT = 0.20;
const PROB_FLOOR = 0.10;
const PROB_CEIL = 0.95;
@@ -68,7 +81,7 @@ function estimateProbability({ gameLogs = [], line, statType, features = {} } =
const recent = values.slice(0, Math.min(5, values.length));
const recencyRate = frequencyOver(recent, numericLine);
const weighted = recencyRate != null
? 0.6 * base + 0.4 * recencyRate
? (1 - RECENCY_WEIGHT) * base + RECENCY_WEIGHT * recencyRate
: base;
let p = weighted;
+22 -8
View File
@@ -298,16 +298,30 @@ async function loadPitcherArsenals(sport) {
* guard — it fitted a = -0.032, which would invert the forecast rather than
* flatten it. Both now serve RAW.
*/
const CALIBRATION_DEPLOYED = Object.freeze(['hits', 'total_bases']);
const CALIBRATION_DEPLOYED = Object.freeze([]);
/**
* The DIRECTION of the correction is bootstrap-robust; its MAGNITUDE is fitted
* on few dates and deliberately shrunk toward identity. The customer-facing
* letter is unchanged; this is what the internal record says.
* NOTHING IS SERVED CALIBRATED, AND THIS IS DELIBERATE.
*
* The low-parameter maps were fitted on the OLD forecast — the one whose base
* rate was a ten-game frequency. That distribution no longer exists: the
* champion now reads the full season log at a 0.20 recency weight, which tripled
* its resolution on hits (0.00251 -> 0.00817) and roughly doubled it on
* total_bases and runs.
*
* A calibration map applied to a forecast it was not fitted on is the stale-fit
* trap this session has already been caught by once, and it corrects toward a
* bias the new forecast may not have. `fromLedger` cannot rescue it either: the
* settled ledger rows still carry OLD p_win values, so refitting today would fit
* the retired forecast again.
*
* So calibration is OFF until enough dates settle under the repaired champion to
* refit honestly, and the favourite-longshot bias must be re-measured on the new
* forecast rather than assumed to have survived. Serving the raw repaired number
* is the honest state, not a regression.
*
* The shadow duel is likewise void — it accumulated against the old forecast.
*/
const CALIBRATION_BASIS = Object.freeze({
hits: 'direction_robust_magnitude_provisional',
total_bases: 'direction_robust_magnitude_provisional',
});
const CALIBRATION_BASIS = Object.freeze({});
async function runSnapshot(sport, opts = {}) {
const sp = String(sport || '').toLowerCase();