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
139 lines
5.4 KiB
JavaScript
139 lines
5.4 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* THE GATE — Kev's spec, verified rather than asserted.
|
|
*
|
|
* `tests/unit/supplementSystems.test.js` checks the same thresholds but inlines
|
|
* its own `validateFactor`, so it could never have caught the absence of an
|
|
* implementation. These tests import the real module, and the p-value is checked
|
|
* against values computable by hand rather than trusted.
|
|
*/
|
|
|
|
const cv = require('../../src/services/model/correlateValidator');
|
|
|
|
/** Deterministic generator: y correlated with x at roughly the requested level. */
|
|
function correlated(n, rho, seed = 7) {
|
|
let s = seed >>> 0;
|
|
const rnd = () => { s ^= s << 13; s >>>= 0; s ^= s >>> 17; s ^= s << 5; s >>>= 0; return s / 4294967296; };
|
|
const gauss = () => {
|
|
const u = Math.max(1e-12, rnd());
|
|
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * rnd());
|
|
};
|
|
const xs = []; const ys = [];
|
|
for (let i = 0; i < n; i += 1) {
|
|
const x = gauss();
|
|
xs.push(x);
|
|
ys.push(rho * x + Math.sqrt(Math.max(0, 1 - rho * rho)) * gauss());
|
|
}
|
|
return { xs, ys };
|
|
}
|
|
|
|
describe('the spec is exactly Kev\'s', () => {
|
|
it('n>=500, |r|>=0.15, p<0.05, Bonferroni on', () => {
|
|
expect(cv.VALIDATION_REQUIREMENTS).toEqual({
|
|
min_historical_instances: 500,
|
|
min_pearson_r: 0.15,
|
|
max_p_value: 0.05,
|
|
bonferroni_correction: true,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Pearson r', () => {
|
|
it('is exactly 1 and -1 on perfect lines', () => {
|
|
expect(cv.pearson([1, 2, 3, 4], [2, 4, 6, 8]).r).toBeCloseTo(1, 12);
|
|
expect(cv.pearson([1, 2, 3, 4], [8, 6, 4, 2]).r).toBeCloseTo(-1, 12);
|
|
});
|
|
|
|
it('matches a hand-computable case', () => {
|
|
// x=[1,2,3,4,5], y=[2,4,5,4,5]; means 3 and 4.
|
|
// Sxy = 4+0+0+0+2 = 6 · Sxx = 10 · Syy = 6 → r = 6/sqrt(60) = 0.774597
|
|
expect(cv.pearson([1, 2, 3, 4, 5], [2, 4, 5, 4, 5]).r).toBeCloseTo(0.774597, 6);
|
|
});
|
|
|
|
it('DROPS a pair with an unknown side — never zero-fills it', () => {
|
|
const clean = cv.pearson([1, 2, 3, 4], [2, 4, 6, 8]);
|
|
const withNull = cv.pearson([1, 2, 3, 4, 5], [2, 4, 6, 8, null]);
|
|
expect(withNull.n).toBe(4);
|
|
expect(withNull.r).toBeCloseTo(clean.r, 12);
|
|
// Zero-filling would have added a (5,0) point and wrecked it.
|
|
});
|
|
|
|
it('a constant series has no correlation, not a zero one', () => {
|
|
expect(cv.pearson([1, 1, 1, 1], [1, 2, 3, 4]).r).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('the p-value is real', () => {
|
|
it('matches known two-sided values for r and n', () => {
|
|
// r=0.5, n=10 -> t=1.6330, df=8 -> p ≈ 0.1411
|
|
expect(cv.pearsonPValue(0.5, 10)).toBeCloseTo(0.1411, 3);
|
|
// r=0.8, n=10 -> t=3.7712, df=8 -> p ≈ 0.00546
|
|
expect(cv.pearsonPValue(0.8, 10)).toBeCloseTo(0.00546, 4);
|
|
// r=0.15, n=500 -> p ≈ 0.000759
|
|
expect(cv.pearsonPValue(0.15, 500)).toBeCloseTo(0.00076, 4);
|
|
});
|
|
|
|
it('r = 0 is p = 1, and the sign of r does not change p', () => {
|
|
expect(cv.pearsonPValue(0, 500)).toBeCloseTo(1, 6);
|
|
expect(cv.pearsonPValue(-0.3, 200)).toBeCloseTo(cv.pearsonPValue(0.3, 200), 12);
|
|
});
|
|
|
|
it('is UNDEFINED (null) below 3 points, never a comforting 1.0', () => {
|
|
expect(cv.pearsonPValue(0.9, 2)).toBeNull();
|
|
});
|
|
|
|
it('the regularized incomplete beta is symmetric where it must be', () => {
|
|
expect(cv.incompleteBeta(2, 3, 0.5) + cv.incompleteBeta(3, 2, 0.5)).toBeCloseTo(1, 10);
|
|
});
|
|
});
|
|
|
|
describe('the gate', () => {
|
|
it('REFUSES below n=500 no matter how strong the correlation', () => {
|
|
const { xs, ys } = correlated(300, 0.9);
|
|
const v = cv.validateFactor(xs, ys, 1);
|
|
expect(v.validated).toBe(false);
|
|
expect(v.reason).toBe('insufficient_data');
|
|
expect(v.sample_size).toBe(300);
|
|
});
|
|
|
|
it('REFUSES a weak correlation even at huge n and tiny p', () => {
|
|
const { xs, ys } = correlated(4000, 0.08);
|
|
const v = cv.validateFactor(xs, ys, 1);
|
|
expect(Math.abs(v.pearson_r)).toBeLessThan(0.15);
|
|
expect(v.validated).toBe(false);
|
|
expect(v.reason).toBe('weak_correlation');
|
|
});
|
|
|
|
it('PASSES a real effect at sufficient n', () => {
|
|
const { xs, ys } = correlated(600, 0.30);
|
|
const v = cv.validateFactor(xs, ys, 1);
|
|
expect(v.validated).toBe(true);
|
|
expect(Math.abs(v.pearson_r)).toBeGreaterThanOrEqual(0.15);
|
|
expect(v.p_value).toBeLessThan(0.05);
|
|
});
|
|
|
|
it('BONFERRONI actually bites — the same data can pass alone and fail in a sweep', () => {
|
|
// Chosen so p sits between 0.05/40 and 0.05: significant alone, not in a sweep.
|
|
const alone = cv.validateFactor(...Object.values(correlated(520, 0.155)), 1);
|
|
const sweep = cv.validateFactor(...Object.values(correlated(520, 0.155)), 40);
|
|
expect(sweep.corrected_alpha).toBeCloseTo(0.05 / 40, 12);
|
|
expect(sweep.bonferroni_tests).toBe(40);
|
|
// Whatever the draw, the corrected bar is strictly harder.
|
|
expect(sweep.corrected_alpha).toBeLessThan(alone.corrected_alpha);
|
|
if (alone.validated && alone.p_value > 0.05 / 40) expect(sweep.validated).toBe(false);
|
|
});
|
|
|
|
it('reports corrected alpha = 0.05/tests, and 0.05 with one test', () => {
|
|
const { xs, ys } = correlated(600, 0.3);
|
|
expect(cv.validateFactor(xs, ys, 4).corrected_alpha).toBeCloseTo(0.0125, 12);
|
|
expect(cv.validateFactor(xs, ys, 1).corrected_alpha).toBeCloseTo(0.05, 12);
|
|
});
|
|
|
|
it('always says WHY it failed', () => {
|
|
const weak = cv.validateFactor(...Object.values(correlated(600, 0.02)), 1);
|
|
expect(weak.reason).toBeTruthy();
|
|
expect(weak.thresholds).toEqual({ min_n: 500, min_abs_r: 0.15, base_alpha: 0.05 });
|
|
});
|
|
});
|