c7cc8f5e52
PREMISE CORRECTION FIRST. statModel.js and correlateValidator.js do not exist in this repository. The validation spec's only prior form is src/services/python/blueprints/unconventional.py -- a Flask blueprint in the Python service that is offline in production, scoring NBA factors against a warehouse that was never populated -- and tests/unit/supplementSystems.test.js requires only fs and path while defining its own validateFactor inline at line 368. Those tests assert a re-implementation of the thresholds, not an implementation, which is exactly why they passed for months while nothing was connected. The diagnosis behind the order is right -- every challenger was measured without a gate -- but the cause is that there was no gate on the Node side to import. So it is built, to the exact spec. correlateValidator: n>=500, |r|>=0.15, p<0.05, Bonferroni across the sweep. The p-value is exact rather than approximated (t-transform through a regularized incomplete beta) and is verified in the suite against known values, because scipy is not available here. Pairs with an unknown side are dropped, never zero-filled -- a zero-fill inside a correlation does not add noise, it invents a point at the origin. THE RUN, hits, n=570, Bonferroni-8: every skill feature fails, and not narrowly. The strongest marginal correlation against the counter's residual is 0.062 against a 0.15 bar. That is an effect-size failure at a sample that would have found a real effect comfortably -- a clean, well-powered negative. The head-to-head agrees: value engine 0.0499 against the counter's 0.166, delta -0.116 with CI [-0.189, -0.043]. Not promoted. THE RUN, total bases, n=295: cannot be tested, and that is the finding. hard_hit_pct shows a marginal r of 0.153 -- above the threshold -- and exit velo 0.124, refused solely because n is 205 short of 500. It is the most encouraging number this work has produced, and it is what the physics predicts: contact quality governs extra bases, not whether a grounder finds a hole. We have been testing skill inputs on the one stat where they should not matter much. Two things the run forced. Feature verdicts are now PER STAT, because marking these DEAD sport-wide on hits evidence would have killed, for total bases, the features that look most alive there -- per-sport doctrine one level deeper. And the gate now reports r and p even when underpowered, because "not enough data yet" and "nothing here" demand opposite decisions and a bare refusal was hiding the best signal on the board. Next: build the compound TB projection (skillProjection still refuses total bases by design, since a deterministic bases-per-hit made P(TB>=2) identical to P(hits>=1)), accrue to n>=500, re-run this gate. Leave hits alone. 4,200 tests green (334 suites); web build exit 0; counter byte-identical. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
223 lines
12 KiB
JavaScript
223 lines
12 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* THE FIVE DISCIPLINES, asserted as behaviour.
|
|
*
|
|
* These are not coverage tests. Each block locks one discipline so it cannot be
|
|
* quietly abandoned later — which is exactly how the previous engine drifted
|
|
* into a frequency counter with six wired-but-dead features hanging off it.
|
|
*/
|
|
|
|
const reg = require('../../src/services/model/featureRegistry');
|
|
const sk = require('../../src/services/model/skillProjection');
|
|
|
|
const BOMBER = { k_pct: 0.28, bb_pct: 0.11, barrel_pct: 0.16, hard_hit_pct: 0.52, avg_exit_velo: 93.5, avg_launch_angle: 19 };
|
|
const GHOST = { k_pct: 0.14, bb_pct: 0.07, barrel_pct: 0.02, hard_hit_pct: 0.30, avg_exit_velo: 85.0, avg_launch_angle: 6 };
|
|
const ACE = { k_pct: 0.32, bb_pct: 0.05, hard_hit_pct: 0.30, whiff_pct: 0.33 };
|
|
const BATTING_PRACTICE = { k_pct: 0.15, bb_pct: 0.10, hard_hit_pct: 0.46, whiff_pct: 0.18 };
|
|
|
|
afterEach(() => reg.__reset());
|
|
|
|
// ── DISCIPLINE 3 ──────────────────────────────────────────────────────────
|
|
describe('D3 — earn its place or it is out', () => {
|
|
it('starts with an essentially EMPTY proven set — nothing is assumed to work', () => {
|
|
const live = reg.liveFeatures('mlb');
|
|
// Only the incumbent counter is PROVEN, and only because it was measured.
|
|
expect([...live]).toEqual(['recent_frequency_prior']);
|
|
});
|
|
|
|
it('refuses promotion without a real sample, positive lift, and a CI above zero', () => {
|
|
expect(reg.promote('mlb', 'batter_barrel_pct', null).ok).toBe(false);
|
|
expect(reg.promote('mlb', 'batter_barrel_pct', { n: 10, lift: 0.05, ci95: [0.01, 0.09] }).ok).toBe(false); // n too small
|
|
expect(reg.promote('mlb', 'batter_barrel_pct', { n: 500, lift: -0.02, ci95: [-0.05, -0.01] }).ok).toBe(false); // negative lift
|
|
expect(reg.promote('mlb', 'batter_barrel_pct', { n: 500, lift: 0.03, ci95: [-0.01, 0.07] }).ok).toBe(false); // CI straddles zero
|
|
expect(reg.statusOf('mlb', 'batter_barrel_pct')).toBe(reg.STATUS.CANDIDATE);
|
|
});
|
|
|
|
it('promotes on sufficient evidence, and records WHY', () => {
|
|
const ev = { n: 600, lift: 0.04, ci95: [0.012, 0.068], measured_at: '2026-09-01' };
|
|
expect(reg.promote('mlb', 'batter_barrel_pct', ev).ok).toBe(true);
|
|
expect(reg.isLive('mlb', 'batter_barrel_pct')).toBe(true);
|
|
expect(reg.liveFeatures('mlb').has('batter_barrel_pct')).toBe(true);
|
|
});
|
|
|
|
it('keeps DEAD features BY NAME so they are not silently rebuilt', () => {
|
|
const s = reg.summary('mlb');
|
|
const deadKeys = s.dead.map((d) => d.key);
|
|
expect(deadKeys).toEqual(expect.arrayContaining([
|
|
'champion_opp_rank_adj', 'champion_home_away_adj', 'champion_consistency_pull',
|
|
]));
|
|
// and each carries the reason it died
|
|
expect(s.dead.find((d) => d.key === 'champion_home_away_adj').why).toMatch(/harmful|IMPROVED/i);
|
|
});
|
|
|
|
it('per-sport: a feature proven for MLB says nothing about another sport', () => {
|
|
reg.promote('mlb', 'batter_barrel_pct', { n: 600, lift: 0.04, ci95: [0.01, 0.07] });
|
|
expect(reg.isLive('nba', 'batter_barrel_pct')).toBe(false);
|
|
expect(reg.statusOf('nba', 'batter_barrel_pct')).toBeNull();
|
|
});
|
|
|
|
it('THE GATE: with only PROVEN features allowed, the skill model REFUSES', () => {
|
|
// The proven set carries no contact-quality feature yet, so there is no
|
|
// forward read to make. Refusing is the correct output — this is what stops
|
|
// an unproven model reaching a user by accident.
|
|
const out = sk.projectSkill({
|
|
batter: BOMBER, pitcher: ACE, archetype: 'BOMBER', statType: 'hits', line: 0.5,
|
|
expectedPa: 4.2, allowed: reg.liveFeatures('mlb'),
|
|
});
|
|
expect(out).toBeNull();
|
|
});
|
|
|
|
it('with CANDIDATES allowed (the challenger), it produces a read', () => {
|
|
const out = sk.projectSkill({
|
|
batter: BOMBER, pitcher: ACE, archetype: 'BOMBER', statType: 'hits', line: 0.5,
|
|
expectedPa: 4.2, allowed: reg.candidateFeatures('mlb'),
|
|
});
|
|
expect(out).not.toBeNull();
|
|
expect(out.p_over_line).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe('D3 — verdicts are PER STAT, because the physics differ', () => {
|
|
it('a feature can be DEAD for one stat and still CANDIDATE for another', () => {
|
|
// The first real gate run: every skill feature failed for `hits` (n=570,
|
|
// max |r| 0.062) while hard-hit rate showed the strongest marginal
|
|
// correlation yet seen for `total_bases` (r=0.153). A sport-wide DEAD mark
|
|
// would have killed, on hits evidence, the features most alive on TB.
|
|
reg.recordStatVerdict('mlb', 'hits', 'batter_barrel_pct', reg.STATUS.DEAD,
|
|
{ n: 570, r: -0.037, note: 'failed the gate for hits' });
|
|
expect(reg.statusForStat('mlb', 'hits', 'batter_barrel_pct')).toBe(reg.STATUS.DEAD);
|
|
expect(reg.statusForStat('mlb', 'total_bases', 'batter_barrel_pct')).toBe(reg.STATUS.CANDIDATE);
|
|
expect(reg.candidateFeaturesForStat('mlb', 'hits').has('batter_barrel_pct')).toBe(false);
|
|
expect(reg.candidateFeaturesForStat('mlb', 'total_bases').has('batter_barrel_pct')).toBe(true);
|
|
});
|
|
|
|
it('a stat with no verdict inherits the sport-level status', () => {
|
|
expect(reg.statusForStat('mlb', 'runs', 'batter_exit_velo')).toBe(reg.STATUS.CANDIDATE);
|
|
expect(reg.statusForStat('mlb', 'runs', 'champion_home_away_adj')).toBe(reg.STATUS.DEAD);
|
|
});
|
|
|
|
it('the live gate is per stat too, and still PROVEN-only', () => {
|
|
expect(reg.liveFeaturesForStat('mlb', 'hits').has('batter_barrel_pct')).toBe(false);
|
|
expect([...reg.liveFeaturesForStat('mlb', 'hits')]).toEqual(['recent_frequency_prior']);
|
|
});
|
|
});
|
|
|
|
// ── DISCIPLINE 1 ──────────────────────────────────────────────────────────
|
|
describe('D1 — skill, not results: the pitcher actually moves the read', () => {
|
|
const run = (pitcher) => sk.projectSkill({
|
|
batter: BOMBER, pitcher, archetype: 'BOMBER', statType: 'hits', line: 0.5, expectedPa: 4.2,
|
|
});
|
|
|
|
it('a better pitcher LOWERS the projection for the same hitter', () => {
|
|
const vsAce = run(ACE);
|
|
const vsBP = run(BATTING_PRACTICE);
|
|
expect(vsAce.p_over_line).toBeLessThan(vsBP.p_over_line);
|
|
expect(vsAce.projected_value).toBeLessThan(vsBP.projected_value);
|
|
});
|
|
|
|
it('this is the thing the counter cannot do — no pitcher at all is a different read', () => {
|
|
const noPitcher = run(null);
|
|
expect(noPitcher).not.toBeNull();
|
|
expect(noPitcher.pitcher_applied).toBe(false);
|
|
expect(run(ACE).pitcher_applied).toBe(true);
|
|
});
|
|
|
|
it('the odds ratio returns league when both sides are league (the identity)', () => {
|
|
expect(sk.oddsRatio(0.222, 0.222, 0.222)).toBeCloseTo(0.222, 6);
|
|
});
|
|
|
|
it('an average pitcher leaves the batter rate untouched', () => {
|
|
expect(sk.oddsRatio(0.30, 0.222, 0.222)).toBeCloseTo(0.30, 6);
|
|
});
|
|
|
|
it('shrinks a thin sample toward its anchor, and leaves a full one alone', () => {
|
|
expect(sk.shrink(0.60, 10, 200, 0.39)).toBeLessThan(0.45); // 10-PA callup
|
|
expect(sk.shrink(0.60, 5000, 200, 0.39)).toBeGreaterThan(0.59); // established
|
|
});
|
|
});
|
|
|
|
// ── DISCIPLINE 2 ──────────────────────────────────────────────────────────
|
|
describe('D2 — archetype SELECTS features, it does not nudge', () => {
|
|
it('the SAME hitter read through two archetypes gets materially different reads', () => {
|
|
const asBomber = sk.projectSkill({ batter: BOMBER, pitcher: ACE, archetype: 'BOMBER', statType: 'hits', line: 0.5, expectedPa: 4.2 });
|
|
const asGhost = sk.projectSkill({ batter: BOMBER, pitcher: ACE, archetype: 'GHOST', statType: 'hits', line: 0.5, expectedPa: 4.2 });
|
|
// Not a rounding difference — a different feature map entirely.
|
|
expect(Math.abs(asBomber.p_over_line - asGhost.p_over_line)).toBeGreaterThan(0.15);
|
|
});
|
|
|
|
it("a BOMBER's read is driven by barrels; a GHOST's is nearly blind to them", () => {
|
|
expect(sk.featureMapFor('BOMBER').hitWeights.barrel).toBeGreaterThan(0.4);
|
|
expect(sk.featureMapFor('GHOST').hitWeights.barrel).toBeLessThan(0.1);
|
|
expect(sk.featureMapFor('GHOST').hitWeights.gb_speed).toBeGreaterThan(0.5);
|
|
expect(sk.featureMapFor('BOMBER').hitWeights.gb_speed).toBe(0);
|
|
});
|
|
|
|
it('barrel rate moves a BOMBER and barely moves a GHOST — features silent where they do not apply', () => {
|
|
const move = (arch) => {
|
|
const lo = sk.hitOnContact({ batter: { ...BOMBER, barrel_pct: 0.04 }, pitcher: ACE, park: 1, archetype: arch });
|
|
const hi = sk.hitOnContact({ batter: { ...BOMBER, barrel_pct: 0.20 }, pitcher: ACE, park: 1, archetype: arch });
|
|
return hi - lo;
|
|
};
|
|
expect(move('BOMBER')).toBeGreaterThan(move('GHOST') * 3);
|
|
});
|
|
|
|
it('an unknown archetype falls back to a balanced map, not a refusal', () => {
|
|
const out = sk.projectSkill({ batter: BOMBER, pitcher: ACE, archetype: 'NOT_A_REAL_ONE', statType: 'hits', line: 0.5, expectedPa: 4.2 });
|
|
expect(out).not.toBeNull();
|
|
expect(out.archetype).toBe('NOT_A_REAL_ONE');
|
|
});
|
|
});
|
|
|
|
// ── HONESTY ───────────────────────────────────────────────────────────────
|
|
describe('honesty — unknown is not zero, and absent beats invented', () => {
|
|
it('no hitter profile → NO forward read (null), never a league-average guess', () => {
|
|
expect(sk.projectSkill({ batter: null, pitcher: ACE, statType: 'hits', line: 0.5 })).toBeNull();
|
|
expect(sk.projectSkill({ batter: {}, pitcher: ACE, statType: 'hits', line: 0.5 })).toBeNull();
|
|
});
|
|
|
|
it('a missing skill input is SILENT — it does not act as a measured zero', () => {
|
|
const full = sk.hitOnContact({ batter: BOMBER, pitcher: ACE, park: 1, archetype: 'BOMBER' });
|
|
const noBarrel = sk.hitOnContact({ batter: { ...BOMBER, barrel_pct: null }, pitcher: ACE, park: 1, archetype: 'BOMBER' });
|
|
const zeroBarrel = sk.hitOnContact({ batter: { ...BOMBER, barrel_pct: 0 }, pitcher: ACE, park: 1, archetype: 'BOMBER' });
|
|
expect(noBarrel).not.toBeNull();
|
|
// A REAL zero is a fact and must read far lower than an ABSENT one.
|
|
expect(zeroBarrel).toBeLessThan(noBarrel);
|
|
expect(Math.abs(noBarrel - full)).toBeLessThan(Math.abs(zeroBarrel - full));
|
|
});
|
|
|
|
it('total_bases is REFUSED rather than shipped as a relabelled hits curve', () => {
|
|
// A deterministic bases-per-hit multiplier made P(TB>=2) identical to
|
|
// P(hits>=1). Refusing is correct until a real per-hit bases distribution
|
|
// exists (tb-v1's compound shape).
|
|
expect(sk.projectSkill({ batter: BOMBER, pitcher: ACE, archetype: 'BOMBER', statType: 'total_bases', line: 1.5 })).toBeNull();
|
|
});
|
|
|
|
it('the distribution is a real distribution and P(>=k) is monotone', () => {
|
|
const out = sk.projectSkill({ batter: BOMBER, pitcher: ACE, archetype: 'BOMBER', statType: 'hits', line: 0.5, expectedPa: 4.2 });
|
|
expect(out.distribution.reduce((a, b) => a + b, 0)).toBeCloseTo(1, 2);
|
|
let prev = 1;
|
|
for (let k = 1; k <= 5; k += 1) {
|
|
const p = sk.atLeast(out.distribution, k);
|
|
expect(p).toBeLessThanOrEqual(prev + 1e-9);
|
|
prev = p;
|
|
}
|
|
});
|
|
|
|
it('a hitter exactly at league on every axis lands on league BABIP — no invented lean', () => {
|
|
const leagueBat = {
|
|
k_pct: sk.LEAGUE.k_pct, bb_pct: sk.LEAGUE.bb_pct,
|
|
barrel_pct: sk.LEAGUE.barrel_pct, hard_hit_pct: sk.LEAGUE.hard_hit_pct,
|
|
avg_exit_velo: sk.LEAGUE.avg_exit_velo, avg_launch_angle: 12,
|
|
};
|
|
const boc = sk.hitOnContact({ batter: leagueBat, pitcher: { hard_hit_pct: sk.LEAGUE.hard_hit_pct }, park: 1, archetype: 'DEFAULT' });
|
|
expect(boc).toBeCloseTo(sk.LEAGUE.babip, 2);
|
|
});
|
|
|
|
it('opportunity matters: more projected PA means a higher chance of one hit', () => {
|
|
const few = sk.projectSkill({ batter: BOMBER, pitcher: ACE, archetype: 'BOMBER', statType: 'hits', line: 0.5, expectedPa: 2 });
|
|
const many = sk.projectSkill({ batter: BOMBER, pitcher: ACE, archetype: 'BOMBER', statType: 'hits', line: 0.5, expectedPa: 5 });
|
|
expect(many.p_over_line).toBeGreaterThan(few.p_over_line);
|
|
});
|
|
});
|