Under-querying vs out of data: the answer depends on the unit

The platoon test's n=452 described how much of the JOIN survived, not how
much data exists. There are 1,266 clean settled hits rows and zero
quarantined ones. platoon_splits had been ingested from tonight's lineups
only (315 players), so any hitter who settled a prop without appearing in
an ingest-day lineup was silently absent from every test.

Backfilled all 380 hitters (81 fetched, 0 unresolved). Re-ran on 1,059
rows, up from 452.

THE DEMOTION IS THE HEADLINE. pitcher_contact_profile, the strongest
proven factor in the programme (-0.0064, CI [-0.0113,-0.0014]), roughly
halved to -0.0034 on more than double the sample and its corrected
interval now spans zero. The Bonferroni denominator also rose to 55,
which widens every interval -- but a denominator cannot move a point
estimate, and that halved on its own.

platoon and platoon_severity now clear the bar and are NOT promoted.
Upper bound -0.0001, on season-to-date splits that contain the games they
predict: measured contamination is 4.5% median, 12.4% at p90, 137% worst.
I had assumed ~1%. They stay CANDIDATE pending point-in-time splits.

GAME-LEVEL IS A DIFFERENT PROBLEM. game_context held zero weather rows
ever -- not because the fetcher was wrong (it correctly targets
Open-Meteo's archive) but because ledger_entries keys a game as
mlb:2026-08-03:Away@Home and game_context keys it as mlb:823437. Every
lookup missed and NULL columns read as honest absence. Third occurrence
of that class.

Fixed the join: 96/101 settled games now carry actual archived weather,
park dimensions backfilled 15 -> 30 venues.

But 928 total_bases rows sit on 47 games at 17.6 rows per game. Park and
weather assign one value per game, so resampling rows would have
manufactured a pass. factorGate now resamples clusters when rows carry
one and judges sample against effective_n; unclustered rows keep the
original path byte-for-byte. Verdict: 47 clusters < 500, and the point
estimate is +0.0011 -- worse, not merely unproven.

Weather needs ~57 more days. Park dimensions need never: there are 30
ballparks in MLB, so a venue-constant factor can never reach 500
independent units. That bar was built for player-level factors and does
not transfer.

Wind is refused. We have speed and bearing for all 96 games; we lack park
orientation, and 220 degrees is blowing out at one park and in at
another. Using speed alone would assert an effect while discarding the
sign that decides what it is.

Counter and frozen clusters untouched.

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-05 19:30:17 -04:00
parent 6452926732
commit 7b85934dc3
10 changed files with 965 additions and 7 deletions
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env node
'use strict';
/**
* backfill-context — WERE WE WAITING, OR UNDER-QUERYING?
*
* The platoon test ran on 452 rows against 1,266 clean settled hits rows in the
* ledger, so "48 short of the gate" was never a statement about how much data
* exists. It was a statement about how much the JOIN survived — and the join was
* losing rows to inputs we simply had not fetched for every player.
*
* This backfills the inputs (pure sample, zero waiting) and reports exactly
* where each row is lost, so the next "we need more data" claim is a measured
* one rather than an inherited one.
*
* ── THE ONE HONEST CAVEAT, STATED UP FRONT ───────────────────────────────
* Platoon splits from statsapi are SEASON-TO-DATE as of the moment they are
* fetched. Applying today's split to a 2026-07-15 game means the split contains
* that game. For a ~400-PA season line one game is roughly a quarter of one
* percent, so the contamination is small — but it is real, it runs in the
* flattering direction, and it is why this is labelled a reconstruction rather
* than a clean point-in-time backtest.
*
* SUPABASE_URL=... node scripts/backfill-context.js
*/
require('dotenv').config();
const { createClient } = require('@supabase/supabase-js');
const ctx = require('../src/services/lineupContextService');
const mlb = require('../src/services/adapters/mlbStatsAdapter');
const { knownNumber } = require('../src/utils/known');
const SB_URL = process.env.SUPABASE_URL;
const SB_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY;
const SEASON = Number(process.env.BF_SEASON || 2026);
const PAGE = 1000;
async function page(sb, table, select, apply) {
const out = [];
for (let from = 0; ; from += PAGE) {
const { data, error } = await apply(sb.from(table).select(select)).range(from, from + PAGE - 1);
if (error) throw error;
if (!data || data.length === 0) break;
out.push(...data);
if (data.length < PAGE) break;
}
return out;
}
async function main() {
if (!SB_URL || !SB_KEY) throw new Error('SUPABASE_URL / service key required');
const sb = createClient(SB_URL, SB_KEY, { auth: { persistSession: false } });
// Every hitter who appears on a CLEAN settled row — the true denominator.
const led = await page(sb, 'ledger_entries', 'player_key, player_name, stat, outcome, quarantine_reason',
(q) => q.eq('sport', 'mlb').is('user_id', null).in('stat', ['hits', 'total_bases'])
.in('outcome', ['hit', 'miss']));
const need = new Map();
for (const r of led) {
if ((r.quarantine_reason || '').startsWith('nontakeable_book')) continue;
if (!need.has(r.player_key)) need.set(r.player_key, r.player_name);
}
const have = new Set((await page(sb, 'platoon_splits', 'player_key', (q) => q.eq('sport', 'mlb')))
.map((r) => r.player_key));
const missing = [...need.entries()].filter(([k]) => !have.has(k));
console.error(`[backfill] hitters on clean settled rows: ${need.size}; splits already held: ${have.size}; to fetch: ${missing.length}`);
const asOf = ctx.dateET();
const rows = [];
let unresolved = 0;
for (const [key, name] of missing) {
let found = null;
try { found = await mlb.searchPlayer(name); } catch { found = null; }
if (!found || !found.id) { unresolved += 1; continue; }
const sp = await ctx.fetchPlatoonSplits(found.id, SEASON, {});
if (!sp) continue; // absent, never a symmetric guess
rows.push({ player_key: key, player_name: name, source_id: found.id, ...sp });
}
let written = 0;
for (let i = 0; i < rows.length; i += 200) {
const batch = rows.slice(i, i + 200).map((r) => ({ ...r, sport: 'mlb', season: SEASON, as_of_date: asOf }));
const { error } = await sb.from('platoon_splits')
.upsert(batch, { onConflict: 'as_of_date,sport,season,player_key' });
if (!error) written += batch.length;
else console.error('[backfill] write failed:', error.message);
}
console.log(JSON.stringify({
hitters_on_clean_settled_rows: need.size,
splits_held_before: have.size,
attempted: missing.length,
unresolved_by_name: unresolved,
no_splits_available: missing.length - unresolved - rows.length,
written,
caveat: 'season-to-date splits applied to past games contain those games — small (~0.25% of a 400-PA line) but real and flattering',
}, null, 2));
process.exit(0);
}
main().catch((e) => { console.error(e); process.exit(1); });
+8 -1
View File
@@ -165,18 +165,23 @@ async function main() {
byPlayer.set(r.player_key, cur); byPlayer.set(r.player_key, cur);
} }
const loss = { no_batter_profile: 0, thin_base_rate: 0, no_opponent: 0, no_pitcher: 0, kept: 0 };
const rows = []; const rows = [];
for (const r of clean) { for (const r of clean) {
const bat = batters.get(r.player_key); const bat = batters.get(r.player_key);
const bp = byPlayer.get(r.player_key); const bp = byPlayer.get(r.player_key);
if (!bp || bp.n < 3) continue; if (!bat) loss.no_batter_profile += 1;
if (!bp || bp.n < 3) { loss.thin_base_rate += 1; continue; }
// Leave-one-out so a row never contributes to its own baseline. // Leave-one-out so a row never contributes to its own baseline.
const baseline = (bp.w - (r.outcome === 'hit' ? 1 : 0)) / (bp.n - 1); const baseline = (bp.w - (r.outcome === 'hit' ? 1 : 0)) / (bp.n - 1);
const faced = oppBy.get(`${r.player_key}|${r.game_date}`) || null; const faced = oppBy.get(`${r.player_key}|${r.game_date}`) || null;
const nick = faced ? String(faced).split(' ').pop() : null; const nick = faced ? String(faced).split(' ').pop() : null;
const def = faced ? (defByTeam.get(faced) || defByTeam.get(nick)) : null; const def = faced ? (defByTeam.get(faced) || defByTeam.get(nick)) : null;
if (!faced) loss.no_opponent += 1;
const starterId = faced ? startersBy.get(`${r.game_date}|OPP:${faced}`) : null; const starterId = faced ? startersBy.get(`${r.game_date}|OPP:${faced}`) : null;
const pit = starterId != null ? pitchersById.get(Number(starterId)) : null; const pit = starterId != null ? pitchersById.get(Number(starterId)) : null;
if (faced && !pit) loss.no_pitcher += 1;
loss.kept += 1;
rows.push({ rows.push({
id: r.id, id: r.id,
archetype: archOf.get(`${r.player_key}|${r.game_date}`) || null, archetype: archOf.get(`${r.player_key}|${r.game_date}`) || null,
@@ -244,6 +249,8 @@ async function main() {
console.log(JSON.stringify({ console.log(JSON.stringify({
baseline: "each row scored against the player's OWN leave-one-out base rate — the honest 'he's due' null", baseline: "each row scored against the player's OWN leave-one-out base rate — the honest 'he's due' null",
total_rows: rows.length, total_rows: rows.length,
clean_settled_rows_available: clean.length,
row_loss: loss,
cumulative_bonferroni: mc, cumulative_bonferroni: mc,
gate: 'a factor must MOVE the prediction AND improve out-of-sample Brier; movement alone is THEATER', gate: 'a factor must MOVE the prediction AND improve out-of-sample Brier; movement alone is THEATER',
results, results,
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env node
'use strict';
/**
* prove-park-weather — DOES PARK GEOMETRY AND AIR READ TOTAL BASES?
*
* Run through the two-part gate like every other factor, with one addition that
* changes the answer: the rows are CLUSTERED BY GAME. Park and weather assign a
* single value to every hitter in a ballpark on a night, so eighteen prop rows
* from one game are one reading of that game's conditions. Resampling rows would
* treat them as eighteen and hand back an interval far tighter than the evidence
* supports — which is how a gate passes a factor on sample it never had.
*
* SUPABASE_URL=... node scripts/prove-park-weather.js
*/
require('dotenv').config();
const { createClient } = require('@supabase/supabase-js');
const pw = require('../src/services/model/parkWeather');
const fg = require('../src/services/model/factorGate');
const tl = require('../src/services/model/testLedger');
const { knownNumber } = require('../src/utils/known');
const SB_URL = process.env.SUPABASE_URL;
const SB_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY;
const PAGE = 1000;
async function page(sb, table, select, apply) {
const out = [];
for (let from = 0; ; from += PAGE) {
const { data, error } = await apply(sb.from(table).select(select)).range(from, from + PAGE - 1);
if (error) throw error;
if (!data || data.length === 0) break;
out.push(...data);
if (data.length < PAGE) break;
}
return out;
}
/** Hit-type shares → expected total bases, so a reshape has a consequence. */
const tbFromShares = (s) => s.single + 2 * s.double + 3 * s.triple + 4 * s.home_run;
async function main() {
const sb = createClient(SB_URL, SB_KEY, { auth: { persistSession: false } });
const parks = await page(sb, 'park_dimensions', '*', (q) => q.eq('sport', 'mlb'));
const byVenue = new Map();
for (const p of parks) if (!byVenue.has(p.venue_id)) byVenue.set(p.venue_id, p);
const league = pw.leagueGeometry([...byVenue.values()]);
const ctx = await page(sb, 'game_context', 'game_id, venue_id, wx_temp_f, wx_wind_speed_mph, wx_wind_direction_deg', (q) => q);
const ctxBy = new Map(ctx.map((c) => [c.game_id, c]));
const led = await page(sb, 'ledger_entries',
'game_id, game_date, player_key, stat, line, side, outcome, quarantine_reason, p_win, proj_hits_p_over',
(q) => q.eq('sport', 'mlb').is('user_id', null).eq('stat', 'total_bases').in('outcome', ['hit', 'miss']));
const clean = led.filter((r) => !(r.quarantine_reason || '').startsWith('nontakeable_book'));
// League-typical hit-type shares — the shape the atom reshapes.
const BASE_SHARES = { single: 0.655, double: 0.195, triple: 0.017, home_run: 0.133 };
const baseTb = tbFromShares(BASE_SHARES);
const loss = { no_context: 0, no_venue: 0, no_park: 0, no_baseline: 0, kept: 0 };
const rows = [];
for (const r of clean) {
const c = ctxBy.get(r.game_id);
if (!c) { loss.no_context += 1; continue; }
if (c.venue_id == null) { loss.no_venue += 1; continue; }
const dims = byVenue.get(c.venue_id);
if (!dims) { loss.no_park += 1; continue; }
const baseline = knownNumber(r.p_win);
if (baseline === null) { loss.no_baseline += 1; continue; }
const read = pw.parkWeatherRead({ dims, wx: c, league });
if (!read) { loss.no_park += 1; continue; }
// The atom reshapes hit type; the consequence for total bases is the ratio
// of expected bases per hit under the reshaped shape.
const shaped = pw.applyToShares(BASE_SHARES, read);
const ratio = tbFromShares(shaped) / baseTb;
const conditioned = Math.max(0.01, Math.min(0.99, baseline * ratio));
rows.push({
cluster: r.game_id, // ONE reading per game — the whole point
baseline,
conditioned,
won: r.outcome === 'hit' ? 1 : 0,
});
loss.kept += 1;
}
// Cumulative Bonferroni across the programme lifetime — this hypothesis is
// one more test, and the bar rises for it like every other.
const mc = await tl.recordAndCount(tl.supabaseStore(sb), [
{ sport: 'mlb', stat: 'total_bases', archetype: null, interaction: 'factor:park_weather_hit_type', target: 'outcome' },
]);
const verdict = fg.adjudicate(rows, {
factor: 'park_weather_hit_type',
stat: 'total_bases',
cumulativeTests: mc.cumulative_tests,
});
const games = new Set(rows.map((r) => r.cluster)).size;
const venues = new Set(clean.map((r) => ctxBy.get(r.game_id)?.venue_id).filter((v) => v != null)).size;
console.log(JSON.stringify({
clean_settled_tb_rows: clean.length,
rows_built: rows.length,
row_loss: loss,
distinct_games: games,
distinct_venues: venues,
rows_per_game: games ? Math.round((rows.length / games) * 10) / 10 : null,
cumulative_tests: mc.cumulative_tests,
verdict,
honest_note: 'sample judged in GAMES, not prop rows — park and weather vary per game',
}, null, 2));
process.exit(0);
}
main().catch((e) => { console.error(e); process.exit(1); });
+170
View File
@@ -0,0 +1,170 @@
#!/usr/bin/env node
'use strict';
/**
* reconstruct-game-environment — GIVE THE PAST GAMES THEIR REAL CONDITIONS.
*
* `game_context` has never held a single weather reading. The reason is not the
* fetcher, which is correct and points at Open-Meteo's ARCHIVE endpoint; it is
* that nothing ever joined. The ledger keys a game as
* `mlb:2026-08-03:WashingtonNationals@PhiladelphiaPhillies` and game_context
* keys it as `mlb:823437`, so every lookup missed and the columns stayed NULL —
* which reads exactly like "the weather was unavailable" rather than "the two
* tables have never been introduced." The same class of failure as the doubled
* /leaderboard path: graceful degradation wearing the mask of honest absence.
*
* This walks the dates in the settled ledger, resolves each slug to the real
* statsapi game and venue, and writes a game_context row keyed by the LEDGER's
* slug so the join exists. Then it pulls the actual archived weather for that
* date and location.
*
* ARCHIVE, NOT FORECAST — asking the forecast endpoint about a past date returns
* a re-forecast, which is a model's opinion about the past, not the past. Absent
* stays NULL; nothing here is imputed.
*
* SUPABASE_URL=... node scripts/reconstruct-game-environment.js
*/
require('dotenv').config();
const axios = require('axios');
const { createClient } = require('@supabase/supabase-js');
const SB_URL = process.env.SUPABASE_URL;
const SB_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY;
const PAGE = 1000;
const SCHEDULE = (d) => `https://statsapi.mlb.com/api/v1/schedule?sportId=1&date=${d}&hydrate=venue(location)`;
const ARCHIVE = (lat, lon, date) =>
`https://archive-api.open-meteo.com/v1/archive?latitude=${lat}&longitude=${lon}`
+ `&start_date=${date}&end_date=${date}`
+ '&hourly=temperature_2m,wind_speed_10m,wind_direction_10m,precipitation'
+ '&temperature_unit=fahrenheit&wind_speed_unit=mph';
const squash = (s) => String(s || '').toLowerCase().replace(/[^a-z]/g, '');
async function page(sb, table, select, apply) {
const out = [];
for (let from = 0; ; from += PAGE) {
const { data, error } = await apply(sb.from(table).select(select)).range(from, from + PAGE - 1);
if (error) throw error;
if (!data || data.length === 0) break;
out.push(...data);
if (data.length < PAGE) break;
}
return out;
}
const get = async (url) => (await axios.get(url, { timeout: 60_000 })).data;
async function main() {
const sb = createClient(SB_URL, SB_KEY, { auth: { persistSession: false } });
const led = await page(sb, 'ledger_entries', 'game_id, game_date, stat, outcome, quarantine_reason',
(q) => q.eq('sport', 'mlb').is('user_id', null).in('stat', ['hits', 'total_bases'])
.in('outcome', ['hit', 'miss']));
const clean = led.filter((r) => !(r.quarantine_reason || '').startsWith('nontakeable_book'));
// Distinct games, as the LEDGER names them.
const games = new Map();
for (const r of clean) if (r.game_id && !games.has(r.game_id)) games.set(r.game_id, r.game_date);
const dates = [...new Set([...games.values()])].sort();
console.error(`[env] ${games.size} distinct settled games across ${dates.length} dates`);
// date -> statsapi games, indexed by the same squashed away@home the slug uses.
const resolved = new Map();
const venues = new Map();
for (const d of dates) {
let sched = null;
try { sched = await get(SCHEDULE(d)); } catch { sched = null; }
for (const day of (sched && sched.dates) || []) {
for (const g of day.games || []) {
const away = squash(g.teams?.away?.team?.name);
const home = squash(g.teams?.home?.team?.name);
resolved.set(`${d}|${away}@${home}`, g);
const v = g.venue || {};
if (v.id && !venues.has(v.id)) {
const loc = v.location || {};
venues.set(v.id, {
venue_id: v.id,
venue_name: v.name || null,
lat: loc.defaultCoordinates?.latitude ?? null,
lon: loc.defaultCoordinates?.longitude ?? null,
});
}
}
}
}
// Join each ledger slug to its real game + venue.
const rows = []; let unmatched = 0;
for (const [gid, date] of games) {
const m = /^mlb:(\d{4}-\d{2}-\d{2}):(.+?)@(.+)$/.exec(gid);
if (!m) { unmatched += 1; continue; }
const g = resolved.get(`${m[1]}|${squash(m[2])}@${squash(m[3])}`);
if (!g) { unmatched += 1; continue; }
rows.push({
game_id: gid, // the LEDGER's key — this is the whole fix
game_date: date,
venue_id: g.venue?.id ?? null,
source_game_pk: g.gamePk ?? null,
});
}
console.error(`[env] matched ${rows.length}, unmatched ${unmatched}, venues seen ${venues.size}`);
for (let i = 0; i < rows.length; i += 200) {
const { error } = await sb.from('game_context')
.upsert(rows.slice(i, i + 200), { onConflict: 'game_id' });
if (error) console.error('[env] context write failed:', error.message);
}
// ACTUAL archived weather, one call per (venue, date) that we need.
const need = new Map();
for (const r of rows) {
const v = venues.get(r.venue_id);
if (!v || v.lat == null || v.lon == null) continue;
need.set(`${r.venue_id}|${r.game_date}`, { v, date: r.game_date });
}
console.error(`[env] fetching ${need.size} venue-days of archived weather`);
const wx = new Map();
for (const [k, { v, date }] of need) {
try {
const p = await get(ARCHIVE(v.lat, v.lon, date));
const h = p && p.hourly;
if (h && Array.isArray(h.time) && h.time.length) {
const i = Math.min(h.time.length - 1, 19); // ~7pm local, typical first pitch
wx.set(k, {
wx_temp_f: h.temperature_2m?.[i] ?? null,
wx_wind_speed_mph: h.wind_speed_10m?.[i] ?? null,
wx_wind_direction_deg: h.wind_direction_10m?.[i] ?? null,
wx_precip_mm: h.precipitation?.[i] ?? null,
wx_source: 'open_meteo_archive',
});
}
} catch { /* absent stays absent */ }
}
let withWx = 0;
for (let i = 0; i < rows.length; i += 200) {
const batch = rows.slice(i, i + 200).map((r) => {
const w = wx.get(`${r.venue_id}|${r.game_date}`);
if (w) withWx += 1;
return w ? { ...r, ...w } : r;
});
const { error } = await sb.from('game_context').upsert(batch, { onConflict: 'game_id' });
if (error) console.error('[env] weather write failed:', error.message);
}
console.log(JSON.stringify({
settled_games: games.size,
matched_to_statsapi: rows.length,
unmatched,
distinct_venues: venues.size,
venue_days_requested: need.size,
venue_days_returned: wx.size,
game_rows_with_actual_weather: withWx,
source: 'open_meteo_archive (actual, not re-forecast)',
}, null, 2));
process.exit(0);
}
main().catch((e) => { console.error(e); process.exit(1); });
+157
View File
@@ -0,0 +1,157 @@
# Were we out of data, or not using what we had?
**Both — and which one it is depends entirely on what unit a factor varies over.**
That distinction turned out to matter more than the sample counts themselves.
---
## 1. Player-level factors: we were under-querying
The platoon test reported n=452 and "48 short of the gate." That number described
how much of the JOIN survived, not how much data exists.
| | used | actually available |
|---|---|---|
| clean settled `hits` rows | 452 | **1,266** |
| clean settled `total_bases` rows | 383 | **928** |
| quarantined `hits` rows | — | **0** |
| hitters with platoon splits | 298 | 380 needed |
`platoon_splits` had been ingested from *tonight's lineups only* — 315 players —
so any hitter who settled a prop but was not in a lineup on an ingest day was
silently absent from every test. Backfilling all 380 (`scripts/backfill-context.js`)
took one pass and no waiting: **81 hitters fetched, 0 unresolved, coverage now
380/380.**
Re-run on the full clean history (`scripts/prove-hit-factors.js`, rows 452 → **1,059**):
| factor | n | mean shift | Brier Δ | CI (corrected, 55 tests) | verdict |
|---|---|---|---|---|---|
| `defense_by_direction` | 782 | 0.0127 | 0.0031 | [0.0050, 0.0013] | **PROVES** |
| `platoon` | 1,056 | 0.0268 | 0.0033 | [0.0062, 0.0001] | PROVES\* |
| `platoon_severity` | 700 | 0.0218 | 0.0038 | [0.0076, 0.0001] | PROVES\* |
| `defense` | 912 | 0.0289 | 0.0038 | [0.0079, +0.0002] | NOT_PROVEN |
| `pitcher_contact_profile` | 1,059 | 0.0259 | 0.0034 | [0.0078, +0.0006] | **NOT_PROVEN — demoted** |
| `park_hits` | 619 | 0.0190 | 0.0037 | [0.0076, +0.0005] | NOT_PROVEN |
### 1a. The demotion is the real headline
`pitcher_contact_profile` was the strongest proven factor in the programme
(Brier 0.0064, CI [0.0113, 0.0014]). On more than double the sample its point
estimate **roughly halved to 0.0034** and the corrected interval now spans zero.
Two things moved at once and honesty requires naming both: the cumulative
Bonferroni denominator also rose to 55, which widens every interval. But the
denominator cannot touch a *point estimate*, and that halved on its own. This is
the standing second line doing exactly what it exists for — more data demoting a
favourite rather than confirming it.
### 1b. \*The two platoon passes are NOT promoted
Both clear the bar with an upper bound of **0.0001**. That is as marginal as a
pass can be, and they ride a reconstructed input:
`platoon_splits` are **season-to-date**, so applying today's split to a game from
2026-07-15 means the split contains that game. Measured, not assumed:
- median contamination **4.5%** of the split's plate appearances
- p90 **12.4%**
- worst **137%** (call-ups whose scored games outnumber their split sample)
I had originally estimated ~1%. It is four and a half times that, and it runs in
the flattering direction on a result whose margin is one ten-thousandth. These
stay **CANDIDATE — pending point-in-time splits**. Promoting a 4.5%-contaminated
input on a 0.0001 bound would be exactly the kind of pass this programme keeps
having to retract.
---
## 2. Game-level factors: genuinely short, and no backfill fixes it
`game_context` held **zero** weather readings, ever. The fetcher was correct and
already pointed at Open-Meteo's **archive** endpoint. The failure was that the
two tables had never been introduced:
```
ledger_entries.game_id = mlb:2026-08-03:WashingtonNationals@PhiladelphiaPhillies
game_context.game_id = mlb:823437
```
Every lookup missed, and NULL weather columns read exactly like "the weather was
unavailable." **Same class as the doubled `/leaderboard` path: graceful
degradation wearing the mask of honest absence.** That is now three occurrences;
it is the failure mode this codebase produces most reliably.
Fixed in `scripts/reconstruct-game-environment.js` — resolves each ledger slug to
its real statsapi game and venue, writes `game_context` keyed by the *ledger's*
key, then pulls actual archived weather:
- 101 settled games → **96 matched**, 30 venues
- **96/96 venue-days returned real archived weather** (`open_meteo_archive`)
- park dimensions backfilled 15 → **30 venues**, zero dimension changes observed
### 2a. Park dimensions: what I verified and what I did not
statsapi serves only **current** venue geometry — it has no historical record. My
capture window is 2026-08-04 to 08-05, so "no mid-season change" is verified
across *two days*, which is nearly no verification at all. Applying current
dimensions to July games is the order's stated allowance and it is almost
certainly fine, but I did not verify it and will not claim to have.
### 2b. Why 928 rows are 47 readings
Park and weather assign **one value per game**. The 928 clean settled
`total_bases` rows sit on **47 distinct games — median 17.6 rows per game.**
Eighteen hitters in one ballpark on one night are one reading of that ballpark,
not eighteen.
Resampling rows would treat them as independent and return an interval far
tighter than the evidence supports. `factorGate.improvement` now resamples
**clusters** when rows carry one, and `adjudicate` judges sample against
`effective_n`. Rows without a cluster keep the original path byte-for-byte.
`scripts/prove-park-weather.js`:
```
rows_built 828 | distinct_games 47 | rows_per_game 17.6
brier_delta +0.0011 (WORSE, not merely unproven)
effective_n 47
VERDICT: CANDIDATE_PENDING_SAMPLE — 47 independent clusters < 500
```
---
## 3. The verdict: tested-now vs real-wait
| factor | unit it varies over | units held | ceiling | real wait |
|---|---|---|---|---|
| platoon, defence-by-direction | **hitter-game** | 1,059 | none | **none — answered now** |
| weather | **game** | 47 | none | **~57 days** at 7 games/settled-day |
| park dimensions | **venue** | 30 | **30, permanently** | **never** |
The last row is arithmetic, not pessimism. **There are 30 ballparks in MLB.** A
factor constant per venue can never accumulate 500 independent units no matter
how long the ledger runs. A park-geometry effect is only ever validatable as a
fixed effect with many games per park under a hierarchical model — never under a
bar expressed in independent units. The n≥500 bar was designed for player-level
factors and quietly does not transfer.
**So: we were under-querying at the player level, and genuinely short at the game
level — and for park geometry specifically, "wait for more data" was never going
to be the answer.**
---
## 4. Wind is refused
`parkWeather` reads temperature, elevation and geometry. It does **not** read
wind, and says so on every read (`wind_readable: false`).
We have the wind — Open-Meteo returns speed and bearing for all 96 games. What we
lack is **park orientation**: which compass direction each stadium's centre field
faces. A 15 mph wind from 220° is blowing out to right at one park and straight in
at another, and those are opposite predictions.
The tempting move is to use wind *speed* alone as a magnitude of disruption. That
asserts an effect while discarding the sign that determines what the effect is.
Wind stays unreadable until orientation is a real column.
+50 -5
View File
@@ -92,11 +92,41 @@ function improvement(rows, iters = 4000, seed = 20260805, cumulativeTests = 1) {
if (usable.length < 30) return null; if (usable.length < 30) return null;
const rnd = makeRnd(seed); const rnd = makeRnd(seed);
const diffs = []; const diffs = [];
// ── PSEUDO-REPLICATION ────────────────────────────────────────────────────
// A factor that assigns ONE value per game (park, weather, opposing starter)
// gives every prop row in that game the identical treatment. Resampling ROWS
// then treats 18 hitters in one ballpark as 18 independent readings of that
// ballpark, and the interval collapses to a width the evidence never earned —
// so the gate PASSES a factor on sample it does not have. Measured here: 928
// total_bases rows carry only 53 distinct games.
//
// When rows carry a `cluster`, resample whole clusters. The interval then
// reflects the unit the treatment actually varies over. Rows without a
// cluster keep the original row-resampling path byte-for-byte.
const clustered = usable.some((r) => r.cluster != null);
const groups = new Map();
if (clustered) {
for (const r of usable) {
const k = String(r.cluster);
if (!groups.has(k)) groups.set(k, []);
groups.get(k).push(r);
}
}
const keys = clustered ? [...groups.keys()] : null;
for (let it = 0; it < iters; it += 1) { for (let it = 0; it < iters; it += 1) {
const b = []; const c = []; const y = []; const b = []; const c = []; const y = [];
for (let i = 0; i < usable.length; i += 1) { if (clustered) {
const r = usable[Math.floor(rnd() * usable.length)]; for (let i = 0; i < keys.length; i += 1) {
b.push(r.baseline); c.push(r.conditioned); y.push(r.won > 0 ? 1 : 0); const g = groups.get(keys[Math.floor(rnd() * keys.length)]);
for (const r of g) { b.push(r.baseline); c.push(r.conditioned); y.push(r.won > 0 ? 1 : 0); }
}
} else {
for (let i = 0; i < usable.length; i += 1) {
const r = usable[Math.floor(rnd() * usable.length)];
b.push(r.baseline); c.push(r.conditioned); y.push(r.won > 0 ? 1 : 0);
}
} }
diffs.push(brier(c, y) - brier(b, y)); diffs.push(brier(c, y) - brier(b, y));
} }
@@ -113,6 +143,9 @@ function improvement(rows, iters = 4000, seed = 20260805, cumulativeTests = 1) {
const ys = usable.map((r) => (r.won > 0 ? 1 : 0)); const ys = usable.map((r) => (r.won > 0 ? 1 : 0));
return { return {
n: usable.length, n: usable.length,
// The number the gate must actually judge sample against.
effective_n: clustered ? keys.length : usable.length,
cluster_unit: clustered ? 'cluster' : 'row',
brier_baseline: round4(brier(usable.map((r) => r.baseline), ys)), brier_baseline: round4(brier(usable.map((r) => r.baseline), ys)),
brier_conditioned: round4(brier(usable.map((r) => r.conditioned), ys)), brier_conditioned: round4(brier(usable.map((r) => r.conditioned), ys)),
brier_delta: round4(brier(usable.map((r) => r.conditioned), ys) - brier(usable.map((r) => r.baseline), ys)), brier_delta: round4(brier(usable.map((r) => r.conditioned), ys) - brier(usable.map((r) => r.baseline), ys)),
@@ -138,8 +171,20 @@ function adjudicate(rows, opts = {}) {
const base = { factor: opts.factor || null, archetype: opts.archetype || null, stat: opts.stat || 'hits', movement: mv, improvement: imp }; const base = { factor: opts.factor || null, archetype: opts.archetype || null, stat: opts.stat || 'hits', movement: mv, improvement: imp };
if (mv.n < minN) { // Sample is judged in the unit the FACTOR varies over, not the unit the rows
return { ...base, verdict: 'CANDIDATE_PENDING_SAMPLE', reason: `n ${mv.n} < ${minN}`, rows_needed: minN - mv.n }; // happen to arrive in. A game-level factor with 928 rows across 53 games has
// 53 readings, and calling that 928 is how a gate passes something on sample
// it never had.
const effN = imp && imp.effective_n != null ? imp.effective_n : mv.n;
if (effN < minN) {
const unit = imp && imp.cluster_unit === 'cluster' ? 'independent clusters' : 'rows';
return {
...base,
verdict: 'CANDIDATE_PENDING_SAMPLE',
reason: `${effN} ${unit} < ${minN}`
+ (effN !== mv.n ? ` (${mv.n} rows, but the factor varies over ${effN} clusters — the rows are not independent readings)` : ''),
rows_needed: minN - effN,
};
} }
if (mv.mean_abs_shift === null || mv.mean_abs_shift < minMove) { if (mv.mean_abs_shift === null || mv.mean_abs_shift < minMove) {
// It never moved the number, so it cannot be reading anything. // It never moved the number, so it cannot be reading anything.
+179
View File
@@ -0,0 +1,179 @@
'use strict';
/**
* parkWeather — PARK GEOMETRY AND AIR, READ ONTO HIT TYPE.
*
* The crude park factor is a single number per stadium ("Coors inflates offence
* 1.15x") applied to every hitter and every outcome alike. It fails for the same
* reason team-average defence failed: it is not the unit the causal story runs
* through. A deep left-centre gap does not create hits, it converts fly balls
* that would have been caught into DOUBLES, and it converts home runs into
* outs. Those move total bases in opposite directions, and one multiplier
* cannot express both.
*
* So this atom does not touch P(hit). It reshapes the HIT-TYPE distribution —
* single / double / triple / home run — and lets the total-bases convolution
* carry the consequence.
*
* ── WIND IS REFUSED, AND THAT IS THE POINT ───────────────────────────────
* Wind is the largest weather effect on carry, and we have the wind: Open-Meteo
* returns speed and compass bearing for every one of these games. What we do NOT
* have is park ORIENTATION — which compass direction each stadium's centre field
* faces. Without it, a 15 mph wind from 220° is unresolvable: it is blowing out
* to right at one park and straight in at another, and those are opposite
* predictions.
*
* The tempting move is to use wind SPEED alone as a magnitude of disruption.
* That is fabrication with a plausible face — it asserts an effect while
* discarding the sign that determines what the effect IS. Wind stays unreadable
* and says so, until orientation is a real column. `wind_readable: false` is the
* honest carrier of that.
*
* ── WHAT IS ACTUALLY READ ────────────────────────────────────────────────
* AIR DENSITY temperature and elevation. Both have unambiguous sign — warmer
* and higher is thinner air is more carry — and neither needs
* orientation to interpret. Under a closed roof, temperature is
* the building's, not the sky's, so it is neutralised.
* GEOMETRY each park against the league, per direction. Short lines make
* home runs; deep gaps make doubles and triples out of the same
* batted ball.
*/
const { knownNumber } = require('../../utils/known');
/** Bound on how far this atom may reshape any single hit-type share. */
const MAX_EFFECT = 0.15;
/** Reference conditions — the shares are calibrated to a temperate sea-level park. */
const REF_TEMP_F = 72;
const REF_ELEVATION_FT = 500;
/** Per-degree and per-1000ft carry response, applied to the home-run share. */
const CARRY_PER_DEG_F = 0.004;
const CARRY_PER_KFT = 0.030;
const isClosed = (roof) => /dome|closed|retractable/i.test(String(roof || ''));
/**
* League geometry, computed from the parks actually held rather than hardcoded,
* so it cannot drift away from the data it is compared against.
*/
function leagueGeometry(parks) {
const keys = ['left_line', 'left_center', 'center', 'right_center', 'right_line'];
const out = {};
for (const k of keys) {
const vals = (parks || []).map((p) => knownNumber(p[k])).filter((v) => v !== null);
out[k] = vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : null;
}
return out;
}
/**
* The read for one game.
*
* @param {object} dims a park_dimensions row
* @param {object} wx { wx_temp_f, wx_wind_speed_mph, wx_wind_direction_deg }
* @param {object} league output of leagueGeometry
* @returns {object|null} null when there is nothing readable — never a 1.0 that
* looks measured.
*/
function parkWeatherRead({ dims, wx, league } = {}) {
if (!dims || !league) return null;
const closed = isClosed(dims.roof_type);
const temp = knownNumber(wx && wx.wx_temp_f);
const elev = knownNumber(dims.elevation);
// ── AIR ────────────────────────────────────────────────────────────────
// Under a closed roof the outside temperature is not the air the ball flies
// through, so it contributes nothing rather than contributing zero.
let carry = 0;
const airParts = [];
if (!closed && temp !== null) {
carry += (temp - REF_TEMP_F) * CARRY_PER_DEG_F;
airParts.push('temperature');
}
if (elev !== null) {
carry += ((elev - REF_ELEVATION_FT) / 1000) * CARRY_PER_KFT;
airParts.push('elevation');
}
// ── GEOMETRY ───────────────────────────────────────────────────────────
// Lines govern home runs; gaps and centre govern extra bases on balls that
// stay in the park. Deeper than league = fewer home runs, more doubles.
const rel = (k) => {
const v = knownNumber(dims[k]); const l = knownNumber(league[k]);
return v !== null && l !== null && l > 0 ? (v - l) / l : null;
};
const lines = [rel('left_line'), rel('right_line')].filter((v) => v !== null);
const gaps = [rel('left_center'), rel('right_center'), rel('center')].filter((v) => v !== null);
const lineDepth = lines.length ? lines.reduce((a, b) => a + b, 0) / lines.length : null;
const gapDepth = gaps.length ? gaps.reduce((a, b) => a + b, 0) / gaps.length : null;
if (lineDepth === null && gapDepth === null && !airParts.length) return null;
const clamp = (v) => Math.max(-MAX_EFFECT, Math.min(MAX_EFFECT, v));
// Deep lines suppress home runs; thin air and heat restore them.
const hr = clamp(carry - (lineDepth ?? 0) * 1.2);
// Deep gaps turn caught fly balls into doubles and the occasional triple.
const dbl = clamp((gapDepth ?? 0) * 0.8 - carry * 0.3);
const tpl = clamp((gapDepth ?? 0) * 1.5);
// Singles are the residual: what the ball did instead of clearing the fence.
const sgl = clamp(-(hr * 0.25 + dbl * 0.35));
return {
readable: true,
multipliers: {
single: round4(1 + sgl),
double: round4(1 + dbl),
triple: round4(1 + tpl),
home_run: round4(1 + hr),
},
carry: round4(carry),
line_depth_vs_league: round4(lineDepth),
gap_depth_vs_league: round4(gapDepth),
roof_closed: closed,
air_inputs: airParts,
// Stated on every read so a consumer cannot mistake silence for neutrality.
wind_readable: false,
wind_reason: 'park orientation unknown — a bearing cannot be resolved to out or in',
};
}
/** A checkable sentence, or nothing. */
function explain(read, parkName) {
if (!read || !read.readable) return null;
const m = read.multipliers;
const bits = [];
if (read.line_depth_vs_league !== null) {
bits.push(`lines ${read.line_depth_vs_league >= 0 ? 'deeper' : 'shorter'} than league`);
}
if (read.gap_depth_vs_league !== null) {
bits.push(`gaps ${read.gap_depth_vs_league >= 0 ? 'deeper' : 'shorter'}`);
}
if (read.air_inputs.length) bits.push(`air via ${read.air_inputs.join(' and ')}`);
return `${parkName || 'this park'}${bits.join(', ')}; home runs x${m.home_run}, doubles x${m.double}`
+ (read.roof_closed ? ' (roof closed, outside temperature not applied)' : '');
}
/** Reshape a hit-type share vector, renormalised so it stays a distribution. */
function applyToShares(shares, read) {
if (!shares || !read || !read.readable) return shares || null;
const m = read.multipliers;
const out = {
single: (knownNumber(shares.single) ?? 0) * m.single,
double: (knownNumber(shares.double) ?? 0) * m.double,
triple: (knownNumber(shares.triple) ?? 0) * m.triple,
home_run: (knownNumber(shares.home_run) ?? 0) * m.home_run,
};
const sum = out.single + out.double + out.triple + out.home_run;
if (!(sum > 0)) return shares;
for (const k of Object.keys(out)) out[k] = round4(out[k] / sum);
return out;
}
const round4 = (v) => (v == null || !Number.isFinite(v) ? null : Math.round(v * 10000) / 10000);
module.exports = {
parkWeatherRead, leagueGeometry, applyToShares, explain,
MAX_EFFECT, REF_TEMP_F, REF_ELEVATION_FT, CARRY_PER_DEG_F, CARRY_PER_KFT,
};
+51
View File
@@ -153,3 +153,54 @@ describe('the measurements themselves', () => {
expect(fg.adjudicate(r, { factor: 'backwards' }).verdict).toBe('THEATER'); expect(fg.adjudicate(r, { factor: 'backwards' }).verdict).toBe('THEATER');
}); });
}); });
describe('pseudo-replication — sample counted in the unit the factor varies over', () => {
// A game-level factor (park, weather, opposing starter) hands every prop row
// in a game the identical treatment. Eighteen hitters in one ballpark are one
// reading of that ballpark, not eighteen.
const build = (games, perGame, seed = 1) => {
let s = seed;
const rnd = () => (s = (s * 1103515245 + 12345) % 2147483648) / 2147483648;
const rows = [];
for (let g = 0; g < games; g += 1) {
const shift = (rnd() - 0.5) * 0.06; // the game's treatment
// Outcomes are correlated WITHIN a game — a high-scoring night lifts every
// hitter in it. That shared component is exactly what row-resampling
// cannot see and what makes 18 rows worth far less than 18 readings.
const gameLevel = (rnd() - 0.5) * 0.5;
for (let i = 0; i < perGame; i += 1) {
const base = 0.3 + rnd() * 0.4;
const p = Math.max(0.02, Math.min(0.98, base + gameLevel));
rows.push({ cluster: `g${g}`, baseline: base, conditioned: base + shift, won: rnd() < p ? 1 : 0 });
}
}
return rows;
};
it('judges sample by CLUSTERS, so 900 rows over 50 games is 50 readings', () => {
const rows = build(50, 18);
const v = fg.adjudicate(rows, { factor: 'park', minN: 500 });
expect(rows.length).toBeGreaterThan(500); // looks like plenty
expect(v.verdict).toBe('CANDIDATE_PENDING_SAMPLE'); // and is not
expect(v.improvement.effective_n).toBe(50);
expect(v.improvement.cluster_unit).toBe('cluster');
expect(v.reason).toMatch(/not independent readings/);
});
it('the clustered interval is WIDER than the row interval on the same rows', () => {
// This is the whole hazard: resampling rows would have manufactured a
// confidence the evidence never supported.
const rows = build(40, 20, 7);
const clustered = fg.adjudicate(rows, { factor: 'park', minN: 10 });
const flat = fg.adjudicate(rows.map(({ cluster, ...r }) => r), { factor: 'park', minN: 10 });
const width = (v) => v.improvement.ci[1] - v.improvement.ci[0];
expect(width(clustered)).toBeGreaterThan(width(flat));
});
it('rows with no cluster keep the original row-resampling behaviour', () => {
const rows = build(40, 20, 3).map(({ cluster, ...r }) => r);
const v = fg.adjudicate(rows, { factor: 'x', minN: 10 });
expect(v.improvement.cluster_unit).toBe('row');
expect(v.improvement.effective_n).toBe(v.improvement.n);
});
});
+125
View File
@@ -0,0 +1,125 @@
'use strict';
/**
* Park geometry and air, read onto hit type.
*
* The failure these guard against is the one a single park multiplier cannot
* even express: a deep gap and a short line push total bases in OPPOSITE
* directions, and a model that collapses them to one number is confidently
* wrong at both ends.
*/
const pw = require('../../src/services/model/parkWeather');
const LEAGUE_PARKS = [
{ left_line: 330, left_center: 375, center: 405, right_center: 375, right_line: 330 },
{ left_line: 335, left_center: 380, center: 410, right_center: 375, right_line: 325 },
{ left_line: 325, left_center: 370, center: 400, right_center: 370, right_line: 335 },
];
const league = pw.leagueGeometry(LEAGUE_PARKS);
const park = (o) => ({
left_line: 330, left_center: 375, center: 405, right_center: 375, right_line: 330,
roof_type: 'Open', elevation: 500, ...o,
});
const wx = (t) => ({ wx_temp_f: t, wx_wind_speed_mph: 12, wx_wind_direction_deg: 220 });
describe('geometry separates the two things one park factor cannot', () => {
it('deep gaps make doubles and triples; short lines make home runs', () => {
const deepGaps = pw.parkWeatherRead({ dims: park({ left_center: 410, right_center: 410, center: 440 }), wx: wx(72), league });
const shortLines = pw.parkWeatherRead({ dims: park({ left_line: 300, right_line: 300 }), wx: wx(72), league });
expect(deepGaps.multipliers.double).toBeGreaterThan(1);
expect(deepGaps.multipliers.triple).toBeGreaterThan(1);
expect(shortLines.multipliers.home_run).toBeGreaterThan(1);
// The whole reason a single multiplier fails: these two parks both "inflate
// offence" and they inflate completely different offence.
expect(deepGaps.multipliers.home_run).toBeLessThan(shortLines.multipliers.home_run);
});
it('a deep park suppresses home runs relative to a shallow one', () => {
const deep = pw.parkWeatherRead({ dims: park({ left_line: 360, right_line: 360 }), wx: wx(72), league });
expect(deep.multipliers.home_run).toBeLessThan(1);
});
});
describe('air is read where it exists and nowhere else', () => {
it('heat adds carry, cold removes it', () => {
const hot = pw.parkWeatherRead({ dims: park(), wx: wx(95), league });
const cold = pw.parkWeatherRead({ dims: park(), wx: wx(45), league });
expect(hot.multipliers.home_run).toBeGreaterThan(cold.multipliers.home_run);
expect(hot.carry).toBeGreaterThan(0);
expect(cold.carry).toBeLessThan(0);
});
it('altitude carries on its own', () => {
const denver = pw.parkWeatherRead({ dims: park({ elevation: 5200 }), wx: wx(72), league });
const sea = pw.parkWeatherRead({ dims: park({ elevation: 20 }), wx: wx(72), league });
expect(denver.multipliers.home_run).toBeGreaterThan(sea.multipliers.home_run);
});
it('a CLOSED roof does not apply the outside temperature', () => {
// The ball is not flying through the weather; pretending otherwise would
// read a dome game off the sky above it.
const domeHot = pw.parkWeatherRead({ dims: park({ roof_type: 'Dome' }), wx: wx(95), league });
const domeCold = pw.parkWeatherRead({ dims: park({ roof_type: 'Dome' }), wx: wx(45), league });
expect(domeHot.multipliers.home_run).toBeCloseTo(domeCold.multipliers.home_run, 6);
expect(domeHot.air_inputs).not.toContain('temperature');
expect(domeHot.air_inputs).toContain('elevation');
});
it('absent temperature contributes nothing rather than a reference value', () => {
const r = pw.parkWeatherRead({ dims: park(), wx: { wx_temp_f: null }, league });
expect(r.air_inputs).not.toContain('temperature');
expect(r.readable).toBe(true);
});
});
describe('wind is refused, loudly', () => {
it('never reads wind, and says so on every read', () => {
// Speed and bearing are both present. They are still not enough: without
// park orientation the same bearing is blowing out at one park and in at
// another, and using speed alone would assert an effect while discarding
// the sign that decides what the effect is.
const calm = pw.parkWeatherRead({ dims: park(), wx: { wx_temp_f: 72, wx_wind_speed_mph: 0, wx_wind_direction_deg: 0 }, league });
const gale = pw.parkWeatherRead({ dims: park(), wx: { wx_temp_f: 72, wx_wind_speed_mph: 35, wx_wind_direction_deg: 220 }, league });
expect(gale.multipliers).toEqual(calm.multipliers);
expect(gale.wind_readable).toBe(false);
expect(gale.wind_reason).toMatch(/orientation/);
});
});
describe('honesty', () => {
it('no park at all → null, not a neutral-looking read', () => {
expect(pw.parkWeatherRead({ dims: null, wx: wx(72), league })).toBeNull();
expect(pw.parkWeatherRead({ dims: park(), wx: wx(72), league: null })).toBeNull();
});
it('a league-average park in reference air leaves the shape alone', () => {
const r = pw.parkWeatherRead({ dims: park({ left_line: league.left_line, right_line: league.right_line, left_center: league.left_center, right_center: league.right_center, center: league.center }), wx: wx(pw.REF_TEMP_F), league });
expect(r.multipliers.home_run).toBeCloseTo(1, 2);
expect(r.multipliers.double).toBeCloseTo(1, 2);
});
it('the effect is bounded however absurd the park', () => {
const absurd = pw.parkWeatherRead({ dims: park({ left_line: 200, right_line: 200, elevation: 30000 }), wx: wx(130), league });
for (const v of Object.values(absurd.multipliers)) {
expect(v).toBeLessThanOrEqual(1 + pw.MAX_EFFECT + 1e-9);
expect(v).toBeGreaterThanOrEqual(1 - pw.MAX_EFFECT - 1e-9);
}
});
it('reshaped shares remain a distribution', () => {
const r = pw.parkWeatherRead({ dims: park({ left_center: 410, right_center: 410 }), wx: wx(90), league });
const out = pw.applyToShares({ single: 0.66, double: 0.20, triple: 0.02, home_run: 0.12 }, r);
const sum = Object.values(out).reduce((a, b) => a + b, 0);
expect(sum).toBeCloseTo(1, 3);
expect(out.double).toBeGreaterThan(0.20);
});
it('NO read means NO sentence', () => {
expect(pw.explain(null, 'Coors Field')).toBeNull();
expect(pw.explain(pw.parkWeatherRead({ dims: park({ elevation: 5200 }), wx: wx(72), league }), 'Coors Field'))
.toMatch(/Coors Field/);
});
});
+1 -1
View File
File diff suppressed because one or more lines are too long