Reliever chain: Link 1 proves, Link 2 does not, and the premise inverts
The causal insight is right -- the game is a sequence and the matchup does shift mid-game. The direction is backwards, measured on 93,663 plate appearances from 1,238 games pulled free from statsapi. LINK 1 PROVES. Starter batters-faced, point-in-time from his own prior starts only, clustered on the pitcher: MAE 3.2226 -> 2.7990, delta -0.4236, CI [-0.6006,-0.2731] at 0.9995 corrected for 107 tests, 1,706 starts across 204 pitchers. It finds the tail the chain needed -- early exits are a 23.2% base rate, model-flagged starts are 34.0% early, lift +10.8pp. Scope correction inside Link 1: the order specifies fatigue x GAME SCRIPT, but game script is not available at grade time -- whether he gets hit tonight is the thing being projected, not an input to it. Only the workload half is measured; the in-game half is recorded as a live feature, out of scope, rather than quietly folded in. LINK 2 DOES NOT PROVE, twice over. Model accuracy 17.2% vs an 8.6% baseline -- doubling it sounds good and is not, since naming a specific arm is wrong five times in six. And structurally the entity is the BULLPEN: 39,629 post-starter plate appearances across 30 clubs is 30 readings, below the 40-cluster floor, the same permanent ceiling as park geometry and team defence. LINK 3 NOT RUN, per the order's own rule. THE PREMISE IS REFUTED, and this chains on nothing so it was safe to measure: vs STARTER n=48,492 hit rate 0.2444 +/-0.0038 vs BULLPEN n=35,760 hit rate 0.2373 +/-0.0044 The pen is 0.7pp HARDER. The specific effect the chain exists to exploit -- early exit making later at-bats softer -- is +0.0010 on 35,760 PAs. A well-powered null, not a sample problem. What IS real is times through the order: TTO1 0.2351 -> TTO2 0.2515 -> TTO3 0.2518. A starter does decay as the lineup sees him again, but that advantage is SURRENDERED when he leaves, not extended -- the pen is harder than his second and third time through. A modern bullpen is a queue of fresh specialists throwing one inning each; there is no tiring arm to punish. So the insight survives inverted, and Link 1 stays valuable for the opposite reason it was built: a likely early hook predicts the hitter LOSES his third-time-through look (0.2518 -> 0.2373 on that PA). The mispricing is on hitters who get an EXTRA look at a starter going deep. BUILT: predictionGate.js + tests -- the two-part gate for a continuous prediction. factorGate binarises outcomes for Brier, which would destroy a target like batters faced. Same discipline, same THEATER verdict, real scale. PRE-REGISTERED NOT RUN: Link 2' using a PA-weighted bullpen AGGREGATE rather than a named arm. Recorded rather than substituted in -- running Link 3 on a swapped-in Link 2 is the assumed-link failure the order forbids. Given the premise result its expected value is now low. PARALLEL TRACK logged: total_bases n=948 pooled, BOMBER x TB 340, short by 160. Sample-readiness only, not a verdict. Counter and frozen clusters byte-identical. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
This commit is contained in:
@@ -27,3 +27,5 @@ out/
|
|||||||
|
|
||||||
# Vercel
|
# Vercel
|
||||||
.vercel/
|
.vercel/
|
||||||
|
|
||||||
|
.seq-cache/
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ingest-game-sequences — the raw material for the reliever chain.
|
||||||
|
*
|
||||||
|
* Every link in this chain needs something the ledger does not carry: when the
|
||||||
|
* starter actually left, and which arm actually faced each plate appearance.
|
||||||
|
* Both are free from statsapi (playByPlay + boxscore), on the same host we
|
||||||
|
* already use for game logs and schedules.
|
||||||
|
*
|
||||||
|
* Caches to disk so Link 1, 2 and 3 all read one fetch rather than three.
|
||||||
|
*
|
||||||
|
* node scripts/ingest-game-sequences.js # default window
|
||||||
|
* SEQ_FROM=2026-06-01 SEQ_TO=2026-08-04 node scripts/ingest-game-sequences.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
const FROM = process.env.SEQ_FROM || '2026-06-15';
|
||||||
|
const TO = process.env.SEQ_TO || '2026-08-04';
|
||||||
|
const OUT = process.env.SEQ_OUT || path.join(process.cwd(), '.seq-cache', 'sequences.json');
|
||||||
|
const CONCURRENCY = 6;
|
||||||
|
|
||||||
|
const get = async (url) => (await axios.get(url, { timeout: 45_000 })).data;
|
||||||
|
|
||||||
|
/** Innings pitched come as '5.2' meaning five and TWO THIRDS — parseFloat is wrong. */
|
||||||
|
function ipToOuts(ip) {
|
||||||
|
if (ip == null) return null;
|
||||||
|
const [whole, frac] = String(ip).split('.');
|
||||||
|
const w = Number(whole); const f = Number(frac || 0);
|
||||||
|
if (!Number.isFinite(w)) return null;
|
||||||
|
return w * 3 + (Number.isFinite(f) ? f : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function datesBetween(from, to) {
|
||||||
|
const out = [];
|
||||||
|
const d = new Date(`${from}T12:00:00Z`);
|
||||||
|
const end = new Date(`${to}T12:00:00Z`);
|
||||||
|
while (d <= end) { out.push(d.toISOString().slice(0, 10)); d.setUTCDate(d.getUTCDate() + 1); }
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pool(items, fn, n = CONCURRENCY) {
|
||||||
|
const out = []; let i = 0;
|
||||||
|
await Promise.all(Array.from({ length: n }, async () => {
|
||||||
|
while (i < items.length) {
|
||||||
|
const idx = i; i += 1;
|
||||||
|
try { out[idx] = await fn(items[idx]); } catch { out[idx] = null; }
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
return out.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadGame(g) {
|
||||||
|
const pk = g.gamePk;
|
||||||
|
const [box, pbp] = await Promise.all([
|
||||||
|
get(`https://statsapi.mlb.com/api/v1/game/${pk}/boxscore`),
|
||||||
|
get(`https://statsapi.mlb.com/api/v1/game/${pk}/playByPlay`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const sides = {};
|
||||||
|
for (const side of ['home', 'away']) {
|
||||||
|
const t = box.teams[side];
|
||||||
|
if (!t) return null;
|
||||||
|
const arms = (t.pitchers || []).map((id) => {
|
||||||
|
const pl = t.players[`ID${id}`];
|
||||||
|
const s = pl && pl.stats && pl.stats.pitching;
|
||||||
|
if (!s) return null;
|
||||||
|
return {
|
||||||
|
id: Number(id),
|
||||||
|
name: pl.person && pl.person.fullName,
|
||||||
|
started: Number(s.gamesStarted || 0) === 1,
|
||||||
|
bf: s.battersFaced == null ? null : Number(s.battersFaced),
|
||||||
|
outs: ipToOuts(s.inningsPitched),
|
||||||
|
pitches: s.pitchesThrown == null ? null : Number(s.pitchesThrown),
|
||||||
|
};
|
||||||
|
}).filter(Boolean);
|
||||||
|
sides[side] = { team: t.team && t.team.name, abbr: t.team && t.team.abbreviation, arms };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every plate appearance in order, with who threw it.
|
||||||
|
const pas = [];
|
||||||
|
for (const p of pbp.allPlays || []) {
|
||||||
|
const m = p.matchup || {}; const a = p.about || {};
|
||||||
|
if (!m.batter || !m.pitcher) continue;
|
||||||
|
pas.push({
|
||||||
|
batter: Number(m.batter.id),
|
||||||
|
batter_name: m.batter.fullName,
|
||||||
|
pitcher: Number(m.pitcher.id),
|
||||||
|
bats: m.batSide && m.batSide.code,
|
||||||
|
throws: m.pitchHand && m.pitchHand.code,
|
||||||
|
inning: a.inning,
|
||||||
|
half: a.halfInning,
|
||||||
|
idx: a.atBatIndex,
|
||||||
|
event: p.result && p.result.eventType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!pas.length) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
gamePk: pk,
|
||||||
|
date: g.officialDate || (g.gameDate || '').slice(0, 10),
|
||||||
|
venue_id: g.venue && g.venue.id,
|
||||||
|
home: sides.home, away: sides.away,
|
||||||
|
pas,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const dates = datesBetween(FROM, TO);
|
||||||
|
console.error(`[seq] ${dates.length} dates ${FROM} -> ${TO}`);
|
||||||
|
|
||||||
|
const allGames = [];
|
||||||
|
for (const d of dates) {
|
||||||
|
try {
|
||||||
|
const s = await get(`https://statsapi.mlb.com/api/v1/schedule?sportId=1&date=${d}&hydrate=venue`);
|
||||||
|
for (const day of s.dates || []) {
|
||||||
|
for (const g of day.games || []) {
|
||||||
|
if (String(g.status && g.status.detailedState) === 'Final') allGames.push(g);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* absent day */ }
|
||||||
|
}
|
||||||
|
console.error(`[seq] ${allGames.length} final games; fetching sequences`);
|
||||||
|
|
||||||
|
const games = await pool(allGames, loadGame);
|
||||||
|
fs.mkdirSync(path.dirname(OUT), { recursive: true });
|
||||||
|
fs.writeFileSync(OUT, JSON.stringify({ from: FROM, to: TO, games }));
|
||||||
|
|
||||||
|
const starters = games.reduce((s, g) =>
|
||||||
|
s + ['home', 'away'].filter((k) => g[k].arms.some((a) => a.started)).length, 0);
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
dates: dates.length,
|
||||||
|
final_games: allGames.length,
|
||||||
|
games_loaded: games.length,
|
||||||
|
starter_games: starters,
|
||||||
|
plate_appearances: games.reduce((s, g) => s + g.pas.length, 0),
|
||||||
|
cache: OUT,
|
||||||
|
}, null, 2));
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => { console.error(e); process.exit(1); });
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LINK 1 — does a starter's exit point predict, before the game?
|
||||||
|
*
|
||||||
|
* Target: batters faced by the starter, because that is what decides how many of
|
||||||
|
* a hitter's plate appearances come against him rather than the pen.
|
||||||
|
*
|
||||||
|
* ── WHAT A PRE-GAME PREDICTOR CAN AND CANNOT SEE ─────────────────────────
|
||||||
|
* The order specifies fatigue profile x GAME SCRIPT ("getting hit -> pulled
|
||||||
|
* early"). Game script is not available when a prop is graded: whether he gets
|
||||||
|
* hit tonight is the thing we are trying to project, not an input to it. Using
|
||||||
|
* it would be reading the answer.
|
||||||
|
*
|
||||||
|
* So the honest pre-game form of Link 1 is the fatigue and workload half alone —
|
||||||
|
* the starter's own history, strictly truncated to starts BEFORE the game being
|
||||||
|
* predicted. That is measured here. The in-game half is a LIVE feature, not a
|
||||||
|
* grade-time one, and it is recorded as out of scope rather than quietly folded
|
||||||
|
* in.
|
||||||
|
*
|
||||||
|
* Baseline: the league mean batters faced — the naive "a starter goes about six"
|
||||||
|
* null this must beat to be worth anything.
|
||||||
|
*
|
||||||
|
* node scripts/link1-pull-timing.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
require('dotenv').config();
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const pg = require('../src/services/model/predictionGate');
|
||||||
|
const tl = require('../src/services/model/testLedger');
|
||||||
|
const { createClient } = require('@supabase/supabase-js');
|
||||||
|
|
||||||
|
const CACHE = process.env.SEQ_OUT || path.join(process.cwd(), '.seq-cache', 'sequences.json');
|
||||||
|
/** Starts needed before we will read a pitcher's own history at all. */
|
||||||
|
const MIN_PRIOR = 3;
|
||||||
|
/** Shrinkage: how many prior starts before his own mean carries half the weight. */
|
||||||
|
const STABILIZE = 5;
|
||||||
|
/** A start at or under this many batters faced is an EARLY EXIT — the edge case. */
|
||||||
|
const EARLY_BF = 20;
|
||||||
|
|
||||||
|
const mean = (xs) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : null);
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const { games } = JSON.parse(fs.readFileSync(CACHE, 'utf8'));
|
||||||
|
|
||||||
|
// Every starter-game, in chronological order.
|
||||||
|
const starts = [];
|
||||||
|
for (const g of games) {
|
||||||
|
for (const side of ['home', 'away']) {
|
||||||
|
const s = (g[side].arms || []).find((a) => a.started);
|
||||||
|
if (!s || s.bf == null) continue;
|
||||||
|
starts.push({ date: g.date, gamePk: g.gamePk, pitcher: s.id, name: s.name, bf: s.bf, outs: s.outs, pitches: s.pitches });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
starts.sort((a, b) => String(a.date).localeCompare(String(b.date)) || a.gamePk - b.gamePk);
|
||||||
|
|
||||||
|
const leagueMean = mean(starts.map((s) => s.bf));
|
||||||
|
|
||||||
|
// POINT-IN-TIME: each start is predicted only from starts strictly before it.
|
||||||
|
const history = new Map();
|
||||||
|
const rows = [];
|
||||||
|
for (const s of starts) {
|
||||||
|
const prior = history.get(s.pitcher) || [];
|
||||||
|
if (prior.length >= MIN_PRIOR) {
|
||||||
|
const own = mean(prior.map((p) => p.bf));
|
||||||
|
const w = prior.length / (prior.length + STABILIZE);
|
||||||
|
rows.push({
|
||||||
|
pitcher: s.pitcher,
|
||||||
|
name: s.name,
|
||||||
|
cluster: s.pitcher, // his starts are not independent readings
|
||||||
|
baseline: leagueMean,
|
||||||
|
prediction: w * own + (1 - w) * leagueMean,
|
||||||
|
actual: s.bf,
|
||||||
|
prior_starts: prior.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
history.set(s.pitcher, prior.concat([s]));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { starts, leagueMean, rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const { starts, leagueMean, rows } = main();
|
||||||
|
|
||||||
|
let cumulative = 1;
|
||||||
|
try {
|
||||||
|
const sb = createClient(process.env.SUPABASE_URL,
|
||||||
|
process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY,
|
||||||
|
{ auth: { persistSession: false } });
|
||||||
|
const mc = await tl.recordAndCount(tl.supabaseStore(sb), [
|
||||||
|
{ sport: 'mlb', stat: 'starter_bf', archetype: null, interaction: 'link1:pull_timing', target: 'actual_exit' },
|
||||||
|
]);
|
||||||
|
cumulative = mc.cumulative_tests;
|
||||||
|
} catch { /* offline: reported below */ }
|
||||||
|
|
||||||
|
const verdict = pg.adjudicate(rows, {
|
||||||
|
link: 'link1_pull_timing',
|
||||||
|
loss: 'absolute',
|
||||||
|
cumulativeTests: cumulative,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Does it find the EARLY EXITS specifically? That is where the edge lives —
|
||||||
|
// being right about a median start is worth nothing to this chain.
|
||||||
|
const early = rows.filter((r) => r.actual <= EARLY_BF);
|
||||||
|
const late = rows.filter((r) => r.actual > EARLY_BF);
|
||||||
|
const predEarly = rows.filter((r) => r.prediction <= EARLY_BF + 2);
|
||||||
|
const hitRate = predEarly.length
|
||||||
|
? predEarly.filter((r) => r.actual <= EARLY_BF).length / predEarly.length : null;
|
||||||
|
const baseEarlyRate = rows.length ? early.length / rows.length : null;
|
||||||
|
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
link: 'LINK 1 — starter pull timing',
|
||||||
|
starter_games_total: starts.length,
|
||||||
|
league_mean_bf: round2(leagueMean),
|
||||||
|
gated_rows: rows.length,
|
||||||
|
distinct_pitchers: new Set(rows.map((r) => r.pitcher)).size,
|
||||||
|
cumulative_tests: cumulative,
|
||||||
|
verdict,
|
||||||
|
early_exit_analysis: {
|
||||||
|
definition: `actual batters faced <= ${EARLY_BF}`,
|
||||||
|
early_exits: early.length,
|
||||||
|
normal_starts: late.length,
|
||||||
|
base_rate_of_early_exit: round4(baseEarlyRate),
|
||||||
|
flagged_early_by_model: predEarly.length,
|
||||||
|
of_those_actually_early: round4(hitRate),
|
||||||
|
lift_over_base_rate: hitRate !== null && baseEarlyRate !== null ? round4(hitRate - baseEarlyRate) : null,
|
||||||
|
note: 'the chain needs the EARLY tail, not the median start',
|
||||||
|
},
|
||||||
|
scope_note: 'GAME SCRIPT is deliberately excluded — whether he gets hit tonight is the thing being projected, not an input available at grade time',
|
||||||
|
}, null, 2));
|
||||||
|
process.exit(0);
|
||||||
|
})();
|
||||||
|
|
||||||
|
const round2 = (v) => (v == null ? null : Math.round(v * 100) / 100);
|
||||||
|
const round4 = (v) => (v == null ? null : Math.round(v * 10000) / 10000);
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LINK 2 — can we say WHICH arm faces the later plate appearances?
|
||||||
|
*
|
||||||
|
* Link 1 proved, so this link is allowed to be attempted at all. It is gated the
|
||||||
|
* same way: predict the reliever who actually threw a given post-starter plate
|
||||||
|
* appearance, against a naive baseline, point-in-time.
|
||||||
|
*
|
||||||
|
* BASELINE the team's most-used reliever to date — "guess the busiest arm"
|
||||||
|
* PREDICTION the reliever that team has most often used IN THIS INNING to
|
||||||
|
* date, which is the cheapest expression of bullpen ROLE
|
||||||
|
*
|
||||||
|
* Loss is misclassification: 0 when the named arm actually threw it, 1 otherwise.
|
||||||
|
*
|
||||||
|
* ── THE REPLICATION UNIT IS THE BULLPEN, AND THERE ARE THIRTY ────────────
|
||||||
|
* Bullpen usage is a team-level process — the same manager, the same arms, the
|
||||||
|
* same roles all season — so errors are correlated within team and the entity
|
||||||
|
* this prediction rides on is the club. That caps replication at 30 whatever the
|
||||||
|
* row count, exactly like park geometry and team defence. Reported explicitly
|
||||||
|
* rather than dissolved into a row count of tens of thousands.
|
||||||
|
*
|
||||||
|
* node scripts/link2-reliever-identity.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
require('dotenv').config();
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const pg = require('../src/services/model/predictionGate');
|
||||||
|
const tl = require('../src/services/model/testLedger');
|
||||||
|
const { createClient } = require('@supabase/supabase-js');
|
||||||
|
|
||||||
|
const CACHE = process.env.SEQ_OUT || path.join(process.cwd(), '.seq-cache', 'sequences.json');
|
||||||
|
|
||||||
|
/** Pick the key with the highest count; null when there is nothing to pick from. */
|
||||||
|
function argmax(counter) {
|
||||||
|
let best = null; let bestN = -1;
|
||||||
|
for (const [k, v] of counter) if (v > bestN) { best = k; bestN = v; }
|
||||||
|
return bestN > 0 ? best : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function build() {
|
||||||
|
const { games } = JSON.parse(fs.readFileSync(CACHE, 'utf8'));
|
||||||
|
games.sort((a, b) => String(a.date).localeCompare(String(b.date)) || a.gamePk - b.gamePk);
|
||||||
|
|
||||||
|
// Point-in-time bullpen histories, accumulated as we walk forward in time.
|
||||||
|
const overall = new Map(); // team -> Map(pitcherId -> appearances)
|
||||||
|
const byInning = new Map(); // `team|inning` -> Map(pitcherId -> appearances)
|
||||||
|
const rows = [];
|
||||||
|
|
||||||
|
for (const g of games) {
|
||||||
|
for (const side of ['home', 'away']) {
|
||||||
|
const team = g[side].abbr || g[side].team;
|
||||||
|
if (!team) continue;
|
||||||
|
const starter = (g[side].arms || []).find((a) => a.started);
|
||||||
|
if (!starter) continue;
|
||||||
|
const relievers = new Set((g[side].arms || []).filter((a) => !a.started).map((a) => a.id));
|
||||||
|
if (!relievers.size) continue;
|
||||||
|
|
||||||
|
// This side PITCHES in the opposite half-inning.
|
||||||
|
const half = side === 'home' ? 'top' : 'bottom';
|
||||||
|
const post = g.pas.filter((p) => p.half === half && p.pitcher !== starter.id);
|
||||||
|
|
||||||
|
for (const pa of post) {
|
||||||
|
const ov = overall.get(team);
|
||||||
|
const inn = byInning.get(`${team}|${pa.inning}`);
|
||||||
|
const basePick = ov ? argmax(ov) : null;
|
||||||
|
const modelPick = inn ? argmax(inn) : basePick;
|
||||||
|
// No history yet is honestly unreadable, not a wrong guess.
|
||||||
|
if (basePick === null || modelPick === null) continue;
|
||||||
|
rows.push({
|
||||||
|
cluster: team,
|
||||||
|
baseline: Number(basePick) === pa.pitcher ? 1 : 0,
|
||||||
|
prediction: Number(modelPick) === pa.pitcher ? 1 : 0,
|
||||||
|
actual: 1,
|
||||||
|
inning: pa.inning,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now fold this game into history — never before predicting from it.
|
||||||
|
if (!overall.has(team)) overall.set(team, new Map());
|
||||||
|
const ovm = overall.get(team);
|
||||||
|
for (const r of relievers) ovm.set(String(r), (ovm.get(String(r)) || 0) + 1);
|
||||||
|
for (const pa of post) {
|
||||||
|
const k = `${team}|${pa.inning}`;
|
||||||
|
if (!byInning.has(k)) byInning.set(k, new Map());
|
||||||
|
const m = byInning.get(k);
|
||||||
|
m.set(String(pa.pitcher), (m.get(String(pa.pitcher)) || 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const rows = build();
|
||||||
|
|
||||||
|
let cumulative = 1;
|
||||||
|
try {
|
||||||
|
const sb = createClient(process.env.SUPABASE_URL,
|
||||||
|
process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY,
|
||||||
|
{ auth: { persistSession: false } });
|
||||||
|
const mc = await tl.recordAndCount(tl.supabaseStore(sb), [
|
||||||
|
{ sport: 'mlb', stat: 'reliever_identity', archetype: null, interaction: 'link2:reliever_identity', target: 'actual_arm' },
|
||||||
|
]);
|
||||||
|
cumulative = mc.cumulative_tests;
|
||||||
|
} catch { /* offline */ }
|
||||||
|
|
||||||
|
const verdict = pg.adjudicate(rows, {
|
||||||
|
link: 'link2_reliever_identity',
|
||||||
|
loss: 'absolute',
|
||||||
|
cumulativeTests: cumulative,
|
||||||
|
});
|
||||||
|
|
||||||
|
const acc = (k) => (rows.length ? rows.filter((r) => r[k] === 1).length / rows.length : null);
|
||||||
|
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
link: 'LINK 2 — reliever identity',
|
||||||
|
post_starter_plate_appearances: rows.length,
|
||||||
|
distinct_bullpens: new Set(rows.map((r) => r.cluster)).size,
|
||||||
|
cumulative_tests: cumulative,
|
||||||
|
baseline_accuracy: round4(acc('baseline')),
|
||||||
|
model_accuracy: round4(acc('prediction')),
|
||||||
|
verdict,
|
||||||
|
structural_note: 'the entity this prediction rides on is the BULLPEN, and there are 30 — row count cannot create replication that does not exist',
|
||||||
|
}, null, 2));
|
||||||
|
process.exit(0);
|
||||||
|
})();
|
||||||
|
|
||||||
|
const round4 = (v) => (v == null ? null : Math.round(v * 10000) / 10000);
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# The reliever chain — Link 1 proves, Link 2 does not, and the premise inverts
|
||||||
|
|
||||||
|
**The causal insight is right: the game is a sequence and the matchup does shift
|
||||||
|
mid-game. The direction is backwards.** Measured on 93,663 plate appearances from
|
||||||
|
1,238 games, the bullpen is *harder* than the starter, not softer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The chain, link by link
|
||||||
|
|
||||||
|
### LINK 1 — starter pull timing: **PROVES**
|
||||||
|
|
||||||
|
Target is batters faced, because that is what decides how many of a hitter's
|
||||||
|
plate appearances come against the starter rather than the pen. Point-in-time:
|
||||||
|
each start predicted only from that pitcher's starts strictly before it, shrunk
|
||||||
|
toward the league mean by prior-start count. Baseline is the league mean — the
|
||||||
|
naive "a starter goes about six."
|
||||||
|
|
||||||
|
```
|
||||||
|
1,706 starts · 204 pitchers · clustered on the pitcher · 107 cumulative tests
|
||||||
|
MAE 3.2226 (baseline) -> 2.7990 (model) delta -0.4236
|
||||||
|
CI [-0.6006, -0.2731] at 0.9995 VERDICT: PROVES
|
||||||
|
```
|
||||||
|
|
||||||
|
It finds the tail, which is what the chain needed: early exits (≤20 batters
|
||||||
|
faced) occur at a **23.2%** base rate, and among model-flagged starts they occur
|
||||||
|
at **34.0%** — lift **+10.8pp**.
|
||||||
|
|
||||||
|
**Scope correction made here:** the order specifies fatigue profile × *game
|
||||||
|
script* ("getting hit → pulled early"). Game script is not available when a prop
|
||||||
|
is graded — whether he gets hit tonight is the thing being projected, not an
|
||||||
|
input to it. Using it would be reading the answer. Only the fatigue/workload half
|
||||||
|
is measured above; the in-game half is a LIVE feature, recorded as out of scope
|
||||||
|
rather than quietly folded in.
|
||||||
|
|
||||||
|
### LINK 2 — reliever identity: **NOT PROVEN**, on two independent grounds
|
||||||
|
|
||||||
|
Predict which arm throws a given post-starter plate appearance. Baseline: the
|
||||||
|
team's most-used reliever to date. Model: the arm that team has most often used
|
||||||
|
*in that inning* to date — the cheapest expression of bullpen role.
|
||||||
|
|
||||||
|
```
|
||||||
|
39,629 post-starter plate appearances · 30 bullpens
|
||||||
|
baseline accuracy 8.6% -> model accuracy 17.2%
|
||||||
|
VERDICT: PENDING_SAMPLE — 30 independent clusters < 40
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **On merit.** Doubling the baseline sounds good and is not: naming a specific
|
||||||
|
arm is **wrong five times out of six.**
|
||||||
|
2. **Structurally.** Bullpen usage is a team-level process — same manager, same
|
||||||
|
arms, same roles all season — so the entity this prediction rides on is the
|
||||||
|
club, and there are 30. Row count cannot create replication that does not
|
||||||
|
exist. **The same permanent ceiling as park geometry (30 venues) and team
|
||||||
|
defence (26 teams).**
|
||||||
|
|
||||||
|
### LINK 3 — shifted matchup: **NOT RUN**
|
||||||
|
|
||||||
|
Per the order's own discipline, a link that does not prove does not feed the
|
||||||
|
next. Link 3 needs the reliever's profile, and Link 2 cannot say whose profile it
|
||||||
|
is.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The premise, tested directly — because that chains on nothing
|
||||||
|
|
||||||
|
This required no unproven link, so it was safe to measure, and it is the finding
|
||||||
|
that matters most:
|
||||||
|
|
||||||
|
| | n | hit rate per PA | ±95% |
|
||||||
|
|---|---|---|---|
|
||||||
|
| vs **STARTER** | 48,492 | **0.2444** | 0.0038 |
|
||||||
|
| vs **BULLPEN** | 35,760 | **0.2373** | 0.0044 |
|
||||||
|
| bullpen \| starter exited early | 14,672 | 0.2379 | 0.0069 |
|
||||||
|
| bullpen \| starter went normal | 21,088 | 0.2369 | 0.0057 |
|
||||||
|
|
||||||
|
**The bullpen is 0.7pp HARDER than the starter**, and the intervals barely
|
||||||
|
overlap. The specific effect the chain was built to exploit — an early exit
|
||||||
|
making later at-bats softer — is **+0.0010, indistinguishable from zero on 35,760
|
||||||
|
plate appearances.** That is a well-powered null, not a sample problem.
|
||||||
|
|
||||||
|
### What IS real: times through the order
|
||||||
|
|
||||||
|
| | n | hit rate |
|
||||||
|
|---|---|---|
|
||||||
|
| TTO 1 | 21,596 | 0.2351 |
|
||||||
|
| TTO 2 | 18,426 | **0.2515** |
|
||||||
|
| TTO 3 | 8,278 | **0.2518** |
|
||||||
|
|
||||||
|
A starter does decay as the lineup sees him again: **+1.6pp from first look to
|
||||||
|
second.** But that advantage is **surrendered when he leaves, not extended** —
|
||||||
|
the pen (0.2373) is harder than the starter's second and third time through
|
||||||
|
(0.2515).
|
||||||
|
|
||||||
|
The mechanism is a modern bullpen: a queue of specialists throwing max effort for
|
||||||
|
one inning each, fresh, often handedness-matched. There is no tiring arm to
|
||||||
|
punish.
|
||||||
|
|
||||||
|
### The insight survives, inverted
|
||||||
|
|
||||||
|
The sequence framing is correct and the edge is real — it just points the other
|
||||||
|
way. **A hitter's soft spot is a starter still in the game on the third time
|
||||||
|
through, and an early hook takes it away.** So Link 1 remains valuable, for the
|
||||||
|
opposite reason it was built: flagging a likely early exit predicts that a hitter
|
||||||
|
*loses* his third-time-through look (0.2518 → 0.2373, a −1.45pp shift on that
|
||||||
|
plate appearance) — a downgrade signal, not an upgrade.
|
||||||
|
|
||||||
|
That is also market-relevant in the way the order wanted, with the sign flipped:
|
||||||
|
if a line is set on the starter's matchup, the mispricing is on hitters who will
|
||||||
|
get an *extra* look at a starter going deep.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Built
|
||||||
|
|
||||||
|
- `src/services/model/predictionGate.js` (+ tests) — the two-part gate for a
|
||||||
|
CONTINUOUS prediction. `factorGate` binarises outcomes for Brier, which would
|
||||||
|
destroy a target like batters faced. Same discipline: movement AND out-of-sample
|
||||||
|
improvement, paired bootstrap, clustered, cumulative-corrected. Names THEATER
|
||||||
|
the same way.
|
||||||
|
- `scripts/ingest-game-sequences.js` — per-PA batter/pitcher/hand/inning/result
|
||||||
|
plus boxscore exit lines, free from statsapi. 1,238 games cached.
|
||||||
|
- `scripts/link1-pull-timing.js`, `scripts/link2-reliever-identity.js`.
|
||||||
|
|
||||||
|
## Pre-registered, NOT run
|
||||||
|
|
||||||
|
**Link 2′ — bullpen AGGREGATE instead of a named arm.** Naming the arm fails, but
|
||||||
|
a PA-weighted aggregate of the pen's contact-allowed and handedness profile may
|
||||||
|
be knowable, and the hitter×bullpen unit would have real replication where the
|
||||||
|
bullpen alone has 30. This is recorded rather than substituted in, because
|
||||||
|
running Link 3 on a swapped-in Link 2 is precisely the assumed-link failure the
|
||||||
|
order forbids. Given the premise result above, its expected value is now low.
|
||||||
|
|
||||||
|
## Parallel track — total_bases per-archetype (logged, not run)
|
||||||
|
|
||||||
|
Sample audit only: `total_bases` settled n=948 pooled; BOMBER × TB **340**,
|
||||||
|
short by 160 against the gate. No archetype slot is testable yet. Per the S88
|
||||||
|
lesson, this is a *sample-readiness* note and not a verdict — and per
|
||||||
|
`specs/per-archetype-grade-bands.md`, the grade does not yet separate within any
|
||||||
|
archetype on hits, so a TB rescale would face the same second blocker.
|
||||||
|
|
||||||
|
Counter and frozen clusters byte-identical.
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* predictionGate — the two-part gate for a CONTINUOUS prediction.
|
||||||
|
*
|
||||||
|
* factorGate answers this for probabilities, where the loss is Brier. A link in
|
||||||
|
* the reliever chain predicts a quantity — how many batters the starter faces,
|
||||||
|
* which arm throws the fourth plate appearance — so the loss is squared error or
|
||||||
|
* a hit rate, and the binarisation factorGate applies to outcomes would silently
|
||||||
|
* destroy the target.
|
||||||
|
*
|
||||||
|
* The discipline is identical and deliberately so:
|
||||||
|
*
|
||||||
|
* (a) MOVEMENT the prediction differs from the naive baseline at all
|
||||||
|
* (b) IMPROVEMENT paired bootstrap on the loss difference, interval excluding
|
||||||
|
* zero at the cumulative-corrected level
|
||||||
|
*
|
||||||
|
* A link that predicts the league average very precisely has learned nothing, and
|
||||||
|
* without (a) it would pass (b) by tying. That is the same THEATER failure the
|
||||||
|
* probability gate exists to name, wearing different units.
|
||||||
|
*
|
||||||
|
* ── WHY CLUSTERING MATTERS HERE TOO ──────────────────────────────────────
|
||||||
|
* The same starter appears many times in a season, so his starts are not
|
||||||
|
* independent readings: a pitcher the model happens to fit well contributes a
|
||||||
|
* run of correlated wins. Clustering on the pitcher makes the interval reflect
|
||||||
|
* how many ARMS we have read, not how many starts we have counted.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { knownNumber } = require('../../utils/known');
|
||||||
|
|
||||||
|
const MIN_N = 500;
|
||||||
|
const MIN_CLUSTERS = 40;
|
||||||
|
|
||||||
|
const mean = (xs) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : null);
|
||||||
|
|
||||||
|
/** Absolute error, the honest default for a quantity a user would read. */
|
||||||
|
const absLoss = (pred, actual) => Math.abs(pred - actual);
|
||||||
|
/** Squared error, when large misses should dominate. */
|
||||||
|
const sqLoss = (pred, actual) => (pred - actual) ** 2;
|
||||||
|
|
||||||
|
function makeRnd(seed) {
|
||||||
|
let s = seed >>> 0;
|
||||||
|
return () => { s ^= s << 13; s >>>= 0; s ^= s >>> 17; s ^= s << 5; s >>>= 0; return s / 4294967296; };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Array} rows [{ baseline, prediction, actual, cluster? }]
|
||||||
|
* @param {object} opts { loss, iters, seed, cumulativeTests, minN, minClusters }
|
||||||
|
*/
|
||||||
|
function adjudicate(rows, opts = {}) {
|
||||||
|
const loss = opts.loss === 'squared' ? sqLoss : absLoss;
|
||||||
|
const usable = (rows || []).filter((r) =>
|
||||||
|
knownNumber(r && r.baseline) !== null
|
||||||
|
&& knownNumber(r && r.prediction) !== null
|
||||||
|
&& knownNumber(r && r.actual) !== null);
|
||||||
|
|
||||||
|
const n = usable.length;
|
||||||
|
const shifts = usable.map((r) => Math.abs(r.prediction - r.baseline));
|
||||||
|
const movement = {
|
||||||
|
n,
|
||||||
|
mean_abs_shift: n ? round4(mean(shifts)) : null,
|
||||||
|
max_abs_shift: n ? round4(Math.max(...shifts)) : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const minN = opts.minN ?? MIN_N;
|
||||||
|
const minClusters = opts.minClusters ?? MIN_CLUSTERS;
|
||||||
|
const base = { link: opts.link || null, movement };
|
||||||
|
|
||||||
|
if (n < minN) {
|
||||||
|
return { ...base, verdict: 'PENDING_SAMPLE', reason: `n ${n} < ${minN}`, rows_needed: minN - n };
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
if (clustered && keys.length < minClusters) {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
verdict: 'PENDING_SAMPLE',
|
||||||
|
reason: `${n} rows but only ${keys.length} independent clusters < ${minClusters}`,
|
||||||
|
clusters_needed: minClusters - keys.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const lossBase = mean(usable.map((r) => loss(r.baseline, r.actual)));
|
||||||
|
const lossPred = mean(usable.map((r) => loss(r.prediction, r.actual)));
|
||||||
|
const delta = lossPred - lossBase; // negative = the link is better
|
||||||
|
|
||||||
|
const rnd = makeRnd(opts.seed ?? 20260806);
|
||||||
|
const iters = opts.iters ?? 3000;
|
||||||
|
const diffs = [];
|
||||||
|
for (let it = 0; it < iters; it += 1) {
|
||||||
|
const b = []; const p = [];
|
||||||
|
if (clustered) {
|
||||||
|
for (let i = 0; i < keys.length; i += 1) {
|
||||||
|
for (const r of groups.get(keys[Math.floor(rnd() * keys.length)])) {
|
||||||
|
b.push(loss(r.baseline, r.actual)); p.push(loss(r.prediction, r.actual));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (let i = 0; i < n; i += 1) {
|
||||||
|
const r = usable[Math.floor(rnd() * n)];
|
||||||
|
b.push(loss(r.baseline, r.actual)); p.push(loss(r.prediction, r.actual));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
diffs.push(mean(p) - mean(b));
|
||||||
|
}
|
||||||
|
diffs.sort((x, y) => x - y);
|
||||||
|
const tests = Math.max(1, Math.round(knownNumber(opts.cumulativeTests) ?? 1));
|
||||||
|
const alpha = 0.05 / tests;
|
||||||
|
const q = (x) => round4(diffs[Math.floor(Math.min(diffs.length - 1, Math.max(0, x * (diffs.length - 1))))]);
|
||||||
|
const ci = [q(alpha / 2), q(1 - alpha / 2)];
|
||||||
|
|
||||||
|
const improvement = {
|
||||||
|
n,
|
||||||
|
effective_n: clustered ? keys.length : n,
|
||||||
|
cluster_unit: clustered ? 'cluster' : 'row',
|
||||||
|
loss_baseline: round4(lossBase),
|
||||||
|
loss_prediction: round4(lossPred),
|
||||||
|
loss_delta: round4(delta),
|
||||||
|
ci,
|
||||||
|
ci_level: round4(1 - alpha),
|
||||||
|
bonferroni_tests: tests,
|
||||||
|
improves: ci[1] < 0,
|
||||||
|
degrades: ci[0] > 0,
|
||||||
|
};
|
||||||
|
const out = { ...base, improvement };
|
||||||
|
|
||||||
|
if (movement.mean_abs_shift === null || movement.mean_abs_shift < (opts.minMovement ?? 0)) {
|
||||||
|
return { ...out, verdict: 'INERT', reason: 'the link never departs from the naive baseline' };
|
||||||
|
}
|
||||||
|
if (improvement.improves) {
|
||||||
|
return { ...out, verdict: 'PROVES', reason: `beats the naive baseline by ${-improvement.loss_delta} (CI ${JSON.stringify(ci)} at ${improvement.ci_level}, corrected for ${tests} tests)` };
|
||||||
|
}
|
||||||
|
if (delta < 0) {
|
||||||
|
return {
|
||||||
|
...out,
|
||||||
|
verdict: 'NOT_PROVEN_AT_CORRECTED_BAR',
|
||||||
|
reason: `point estimate improves by ${-improvement.loss_delta} but the corrected interval spans zero (${JSON.stringify(ci)})`,
|
||||||
|
note: 'a real candidate held to a rising bar — not theatre',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...out,
|
||||||
|
verdict: 'THEATER',
|
||||||
|
reason: `moves ${movement.mean_abs_shift} off the baseline while accuracy does NOT improve (delta ${improvement.loss_delta})`,
|
||||||
|
consequence: 'wiring this would make the projection LOOK like it read the game script while reading nothing',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const round4 = (v) => (v == null || !Number.isFinite(v) ? null : Math.round(v * 10000) / 10000);
|
||||||
|
|
||||||
|
module.exports = { adjudicate, absLoss, sqLoss, MIN_N, MIN_CLUSTERS };
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two-part gate for a CONTINUOUS prediction.
|
||||||
|
*
|
||||||
|
* Same discipline as factorGate, different units — and the same dangerous
|
||||||
|
* failure: a link that moves off the naive baseline while predicting nothing
|
||||||
|
* makes the projection LOOK like it read the game script.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const pg = require('../../src/services/model/predictionGate');
|
||||||
|
|
||||||
|
/** n rows where the prediction tracks truth to a given degree. */
|
||||||
|
function rows(n, { skill = 1, clusters = 60, seed = 5 } = {}) {
|
||||||
|
let s = seed;
|
||||||
|
const rnd = () => (s = (s * 1103515245 + 12345) % 2147483648) / 2147483648;
|
||||||
|
const out = [];
|
||||||
|
for (let i = 0; i < n; i += 1) {
|
||||||
|
const actual = 20 + (rnd() - 0.5) * 12;
|
||||||
|
const noise = (rnd() - 0.5) * 12;
|
||||||
|
out.push({
|
||||||
|
cluster: `c${i % clusters}`,
|
||||||
|
baseline: 20,
|
||||||
|
prediction: 20 + skill * (actual - 20) + (1 - skill) * noise,
|
||||||
|
actual,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('a link that genuinely predicts', () => {
|
||||||
|
it('PROVES when it beats the naive baseline out-of-sample', () => {
|
||||||
|
const v = pg.adjudicate(rows(1200, { skill: 0.8 }), { link: 'good' });
|
||||||
|
expect(v.verdict).toBe('PROVES');
|
||||||
|
expect(v.improvement.loss_delta).toBeLessThan(0);
|
||||||
|
expect(v.improvement.ci[1]).toBeLessThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT binarise the target — that is why factorGate cannot do this job', () => {
|
||||||
|
// Brier collapses the outcome to 0/1. A target like "batters faced" would be
|
||||||
|
// destroyed by that, so the loss here stays on the real scale.
|
||||||
|
const v = pg.adjudicate(rows(1200, { skill: 0.9 }), { link: 'scale' });
|
||||||
|
expect(v.improvement.loss_baseline).toBeGreaterThan(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the failures it must name', () => {
|
||||||
|
it('THEATER — moves off the baseline and predicts nothing', () => {
|
||||||
|
const v = pg.adjudicate(rows(1200, { skill: 0 }), { link: 'noise' });
|
||||||
|
expect(v.verdict).toBe('THEATER');
|
||||||
|
expect(v.movement.mean_abs_shift).toBeGreaterThan(0);
|
||||||
|
expect(v.consequence).toMatch(/LOOK like it read/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('INERT — never departs from the baseline at all', () => {
|
||||||
|
const flat = rows(1200, { skill: 0 }).map((r) => ({ ...r, prediction: r.baseline }));
|
||||||
|
const v = pg.adjudicate(flat, { link: 'flat', minMovement: 0.01 });
|
||||||
|
expect(v.verdict).toBe('INERT');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('thin sample is PENDING, never a verdict', () => {
|
||||||
|
const v = pg.adjudicate(rows(100, { skill: 0.9 }), { link: 'thin' });
|
||||||
|
expect(v.verdict).toBe('PENDING_SAMPLE');
|
||||||
|
expect(v.rows_needed).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('replication is counted in arms, not in starts', () => {
|
||||||
|
it('refuses when the entity it rides on has too few clusters', () => {
|
||||||
|
// 39,629 post-starter plate appearances across 30 bullpens is 30 readings.
|
||||||
|
const v = pg.adjudicate(rows(5000, { skill: 0.9, clusters: 30 }), { link: 'bullpen' });
|
||||||
|
expect(v.verdict).toBe('PENDING_SAMPLE');
|
||||||
|
expect(v.reason).toMatch(/30 independent clusters < 40/);
|
||||||
|
expect(v.clusters_needed).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the clustered interval is wider than the unclustered one', () => {
|
||||||
|
const r = rows(1500, { skill: 0.5, clusters: 45 });
|
||||||
|
const clustered = pg.adjudicate(r, { link: 'a' });
|
||||||
|
const flat = pg.adjudicate(r.map(({ cluster, ...x }) => x), { link: 'b' });
|
||||||
|
const w = (v) => v.improvement.ci[1] - v.improvement.ci[0];
|
||||||
|
expect(w(clustered)).toBeGreaterThan(w(flat));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the cumulative correction widens the interval', () => {
|
||||||
|
const r = rows(1500, { skill: 0.6 });
|
||||||
|
const one = pg.adjudicate(r, { cumulativeTests: 1 });
|
||||||
|
const many = pg.adjudicate(r, { cumulativeTests: 108 });
|
||||||
|
expect(many.improvement.ci_level).toBeGreaterThan(one.improvement.ci_level);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('honesty', () => {
|
||||||
|
it('an unreadable row is dropped, never zero-filled', () => {
|
||||||
|
const r = rows(600, { skill: 0.8 });
|
||||||
|
r[0].prediction = null; r[1].actual = null; r[2].baseline = null;
|
||||||
|
const v = pg.adjudicate(r, { minN: 100 });
|
||||||
|
expect(v.improvement.n).toBe(597);
|
||||||
|
});
|
||||||
|
});
|
||||||
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user