diff --git a/src/config/takeability.js b/src/config/takeability.js new file mode 100644 index 0000000..2fcf783 --- /dev/null +++ b/src/config/takeability.js @@ -0,0 +1,87 @@ +'use strict'; + +/** + * takeability — TAKEABILITY IS BOOK IDENTITY. NEVER PRICE SHAPE. + * + * A line is takeable if and only if it comes from a book a bettor can actually + * place a wager at. Nothing about the PRICE decides it. + * + * WHY THIS NEEDS TO BE A RULE AND NOT A HABIT. Baseball prop markets are + * genuinely thin, genuinely juiced, and genuinely one-sided, and all three are + * NORMAL market structure rather than signs of a bad quote: + * + * - `betrivers` and `hardrockbet` legitimately quote ONE SIDE ONLY on real + * props (verified in prod: 5 such rows surfaced in the 2026-08-02 re-stamp). + * - a hits-over at -300 is a real, placeable bet. Steep juice is a price, not + * a disqualification. + * - a market with two books is still a market. + * + * Any rule that inferred UN-takeability from price extremity, one-sidedness or + * thinness would throw those real markets away — while still happily admitting + * a DFS pick'em book at an ordinary-looking -119. That is exactly backwards: + * the -119 is the fake one, because PrizePicks is not a sportsbook. + * + * ── THE DISTINCTION THAT MUST NOT COLLAPSE ──────────────────────────────── + * + * isTakeableMarket(book) — CAN a bettor place this? Book identity. This file. + * isWithinPriceBand(odds) — SHOULD we surface/promote it? A policy band on the + * price (`takeableStandard`, floor -160, uncapped + * plus). A -300 prop is takeable AND outside the + * band. Both statements are true at once. + * + * These answer different questions and are stored in different columns. The + * ledger's `takeable` column is the PRICE-BAND answer; its name predates this + * distinction and is misleading — flagged, not silently redefined, because four + * challengers and the ranking gate currently read it. + */ + +const { TAKEABLE_BOOKS, isTakeableBook } = require('./bookRoles'); +const { isLedgerTakeable, LEDGER_TAKEABLE_FLOOR } = require('./takeableStandard'); + +/** + * THE canonical takeability rule. Every path that asks "can this be bet?" calls + * this one function, so the answer cannot drift between the ledger write, the + * price anchor and any surface. + * + * @param {string|null|undefined} book + * @returns {boolean} — false for an absent book: we cannot assert takeability + * of a market we cannot name. + */ +function isTakeableMarket(book) { + return isTakeableBook(book); +} + +/** + * Explicitly NOT a takeability test — kept here beside it so the two are read + * together and the difference is unmissable. + * + * @returns {boolean|null} null when the price is UNKNOWN (never false — an + * absent price is not an out-of-band price). + */ +function isWithinPriceBand(americanOdds) { + return isLedgerTakeable(americanOdds); +} + +/** + * The full answer for one quote, with the two axes kept apart. + * + * `takeable` depends ONLY on the book. `within_price_band` depends only on the + * price. A one-sided -300 quote from DraftKings is `takeable: true, + * within_price_band: false` — a real bet we would not promote. + */ +function assessQuote({ book, odds } = {}) { + return { + book: book || null, + takeable: isTakeableMarket(book), + within_price_band: isWithinPriceBand(odds), + price_band_floor: LEDGER_TAKEABLE_FLOOR, + }; +} + +module.exports = { + isTakeableMarket, + isWithinPriceBand, + assessQuote, + TAKEABLE_BOOKS, + LEDGER_TAKEABLE_FLOOR, +}; diff --git a/src/services/challengerProjection.js b/src/services/challengerProjection.js index 65ba5a5..51c1e4c 100644 --- a/src/services/challengerProjection.js +++ b/src/services/challengerProjection.js @@ -86,11 +86,13 @@ const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v); const toLogOdds = (p) => Math.log(p / (1 - p)); const fromLogOdds = (l) => 1 / (1 + Math.exp(-l)); -function num(v) { - if (v == null || v === '') return null; - const n = typeof v === 'number' ? v : Number(v); - return Number.isFinite(n) ? n : null; -} +// MIGRATED to the shared guard (2026-08-02). `Number(null) === 0` has produced +// at least SIX separate defects in this codebase, including one in a module +// written the same week its author documented the trap — per-module vigilance +// has demonstrably failed. Semantics are byte-identical to the local copy this +// replaces, so no output changes; the point is that there is now ONE rule. +const { knownNumber } = require('../utils/known'); +const num = knownNumber; /** * adjust({ pWin, direction, statType, classification }) — PURE. diff --git a/src/services/consensusRuler.js b/src/services/consensusRuler.js index fb88493..30348f4 100644 --- a/src/services/consensusRuler.js +++ b/src/services/consensusRuler.js @@ -40,11 +40,13 @@ function median(xs) { } /** Strict numeric: null/''/NaN are ABSENT, never 0. (`Number(null) === 0`.) */ -function num(v) { - if (v == null || v === '') return null; - const n = Number(v); - return Number.isFinite(n) ? n : null; -} +// MIGRATED to the shared guard (2026-08-02). `Number(null) === 0` has produced +// at least SIX separate defects in this codebase, including one in a module +// written the same week its author documented the trap — per-module vigilance +// has demonstrably failed. Semantics are byte-identical to the local copy this +// replaces, so no output changes; the point is that there is now ONE rule. +const { knownNumber } = require('../utils/known'); +const num = knownNumber; /** * Per-book de-vigged fair probability for one side, at one line. diff --git a/src/services/contactChallenger.js b/src/services/contactChallenger.js index e8d522d..5e8c0f3 100644 --- a/src/services/contactChallenger.js +++ b/src/services/contactChallenger.js @@ -71,11 +71,13 @@ const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v); const toLogOdds = (p) => Math.log(p / (1 - p)); const fromLogOdds = (l) => 1 / (1 + Math.exp(-l)); -function num(v) { - if (v == null || v === '') return null; - const n = typeof v === 'number' ? v : Number(v); - return Number.isFinite(n) ? n : null; -} +// MIGRATED to the shared guard (2026-08-02). `Number(null) === 0` has produced +// at least SIX separate defects in this codebase, including one in a module +// written the same week its author documented the trap — per-module vigilance +// has demonstrably failed. Semantics are byte-identical to the local copy this +// replaces, so no output changes; the point is that there is now ONE rule. +const { knownNumber } = require('../utils/known'); +const num = knownNumber; /** percentileOf(sortedAsc, x) — fraction of the reference below x, 0..1. */ function percentileOf(sortedAsc, x) { diff --git a/src/services/platoonSplits.js b/src/services/platoonSplits.js index 56f06b8..2d24970 100644 --- a/src/services/platoonSplits.js +++ b/src/services/platoonSplits.js @@ -60,11 +60,13 @@ const STAT_RATE = Object.freeze({ /** Stats where a HIGHER rate means a LOWER prop outcome. */ const INVERTED = Object.freeze(new Set(['strikeouts'])); -const num = (v) => { - if (v == null || v === '') return null; - const n = typeof v === 'number' ? v : Number(v); - return Number.isFinite(n) ? n : null; -}; +// MIGRATED to the shared guard (2026-08-02). `Number(null) === 0` has produced +// at least SIX separate defects in this codebase, including one in a module +// written the same week its author documented the trap — per-module vigilance +// has demonstrably failed. Semantics are byte-identical to the local copy this +// replaces, so no output changes; the point is that there is now ONE rule. +const { knownNumber } = require('../utils/known'); +const num = knownNumber; const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v); /** diff --git a/src/services/projection/compoundTotalBases.js b/src/services/projection/compoundTotalBases.js index 5300da6..e3e92b5 100644 --- a/src/services/projection/compoundTotalBases.js +++ b/src/services/projection/compoundTotalBases.js @@ -32,6 +32,8 @@ * actual generative structure, not by a family that happens to fit its name. */ +const { knownRate } = require('../../utils/known'); + const TB_CAP = 20; // bases per game; beyond this is not a real outcome const N_CAP = 8; // per-component events per game @@ -66,13 +68,12 @@ function tbPmf(rates = {}) { { w: 3, rate: rates.triples }, { w: 4, rate: rates.home_runs }, ]; - // STRICT: `Number(null) === 0`, so a null rate would pass a naive finite check - // and be treated as a real, measured zero — the difference between "this - // player never triples" and "we do not know his triple rate". Absent stays - // absent; only a genuine number counts. - const isRate = (v) => v != null && v !== '' && typeof v !== 'boolean' - && Number.isFinite(Number(v)) && Number(v) >= 0; - const usable = components.filter((c) => isRate(c.rate)); + // MIGRATED to the shared guard (2026-08-02). `Number(null) === 0` would read a + // null rate as a real, measured zero — the difference between "this player + // never triples" and "we do not know his triple rate". `knownRate` is the one + // rule; this module previously carried its own copy, which is exactly the + // pattern that has failed six times. + const usable = components.filter((c) => knownRate(c.rate) !== null); if (usable.length === 0) return null; let pmf = new Array(TB_CAP + 1).fill(0); @@ -117,10 +118,9 @@ function pAtLeast(pmf, k) { /** Expected total bases implied by the component rates. */ function tbMean(rates = {}) { - // Same strictness as tbPmf: an absent component contributes nothing, and is - // not silently read as a measured zero. - const n = (v) => ((v != null && v !== '' && typeof v !== 'boolean' - && Number.isFinite(Number(v)) && Number(v) >= 0) ? Number(v) : 0); + // Same shared rule: an absent component contributes nothing and is not + // silently read as a measured zero. + const n = (v) => knownRate(v) ?? 0; return n(rates.singles) + 2 * n(rates.doubles) + 3 * n(rates.triples) + 4 * n(rates.home_runs); } diff --git a/src/services/projectionChallenger.js b/src/services/projectionChallenger.js index 80a6b6e..efdefb2 100644 --- a/src/services/projectionChallenger.js +++ b/src/services/projectionChallenger.js @@ -48,11 +48,13 @@ const STAT_FIELD = Object.freeze({ }); const isNum = (v) => typeof v === 'number' && Number.isFinite(v); -const num = (v) => { - if (v == null || v === '') return null; - const n = typeof v === 'number' ? v : Number(v); - return Number.isFinite(n) ? n : null; -}; +// MIGRATED to the shared guard (2026-08-02). `Number(null) === 0` has produced +// at least SIX separate defects in this codebase, including one in a module +// written the same week its author documented the trap — per-module vigilance +// has demonstrably failed. Semantics are byte-identical to the local copy this +// replaces, so no output changes; the point is that there is now ONE rule. +const { knownNumber } = require('../utils/known'); +const num = knownNumber; const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v); const abbrOf = (team) => { if (!team) return null; diff --git a/src/services/statcastAggregateService.js b/src/services/statcastAggregateService.js index cd0003f..b7efc9d 100644 --- a/src/services/statcastAggregateService.js +++ b/src/services/statcastAggregateService.js @@ -34,11 +34,13 @@ const statcast = require('./adapters/statcastAdapter'); const MIN_PA = Number(process.env.STATCAST_MIN_PA) || 50; const MIN_IP = Number(process.env.STATCAST_MIN_IP) || 10; -const num = (v) => { - if (v == null || v === '') return null; - const n = typeof v === 'number' ? v : Number(v); - return Number.isFinite(n) ? n : null; -}; +// MIGRATED to the shared guard (2026-08-02). `Number(null) === 0` has produced +// at least SIX separate defects in this codebase, including one in a module +// written the same week its author documented the trap — per-module vigilance +// has demonstrably failed. Semantics are byte-identical to the local copy this +// replaces, so no output changes; the point is that there is now ONE rule. +const { knownNumber } = require('../utils/known'); +const num = knownNumber; /** * roleDetail(usage) — TRUE role from real usage, not the IP proxy (which drifts diff --git a/src/utils/gradeRanking.js b/src/utils/gradeRanking.js index f77525e..49a03d8 100644 --- a/src/utils/gradeRanking.js +++ b/src/utils/gradeRanking.js @@ -35,11 +35,13 @@ function gradeRankOf(g) { } /** Strict numeric read — `Number(null) === 0` is the recurring fabrication bug. */ -function strictNum(v) { - if (v == null || v === '') return null; - const n = Number(v); - return Number.isFinite(n) ? n : null; -} +// MIGRATED to the shared guard (2026-08-02). `Number(null) === 0` has produced +// at least SIX separate defects in this codebase, including one in a module +// written the same week its author documented the trap — per-module vigilance +// has demonstrably failed. Semantics are byte-identical to the local copy this +// replaces, so no output changes; the point is that there is now ONE rule. +const { knownNumber } = require('./known'); +const strictNum = knownNumber; /** * The takeable-gated champion probability for one grade row, or null. diff --git a/src/utils/known.js b/src/utils/known.js new file mode 100644 index 0000000..050cb5a --- /dev/null +++ b/src/utils/known.js @@ -0,0 +1,84 @@ +'use strict'; + +/** + * known — UNKNOWN IS NOT ZERO. + * + * This codebase's single most repeated defect: `Number(null) === 0`. A missing + * value slides through a naive finite check and becomes a real, measured zero — + * which is not a neutral default but usually the STRONGEST possible statement: + * + * - a null triple rate read as 0 says "this player never triples" + * - a null at-bat rate read as 0 says "zero opportunity" — a maximal fade + * - a null price read as 0 lands ABOVE a -160 floor and tags itself takeable + * - a null line read as 0 matches a 0-line quote + * + * It has appeared at least SIX times, including in a module written the same + * week its author documented the trap. Per-module vigilance has demonstrably + * failed, so the rule lives here and every site delegates. + * + * TWO functions, deliberately, because "is this a number?" and "is this a valid + * RATE?" are different questions and collapsing them is how the next variant + * gets in: + * + * knownNumber — any finite number, including negatives and zero. A REAL 0 is + * a fact (0 rest days, an even-money 0 gap) and must survive. + * knownRate — a non-negative finite magnitude, rejecting booleans. For + * counts, rates and probabilities, where a negative or a + * `true` is not a thin measurement but a broken one. + */ + +/** + * A finite number, or null when the value is UNKNOWN. + * + * Semantics are byte-identical to the six local `num`/`strictNum` copies this + * replaces, so migrating a call site cannot change its output. + * + * NOTE ON BOOLEANS: this accepts them (`Number(true) === 1`), matching the + * behaviour of the copies it replaces. Use `knownRate` where a boolean would be + * nonsense — that one rejects them. + */ +function knownNumber(v) { + if (v == null || v === '') return null; + // `Number([]) === 0` and `Number([7]) === 7` — an empty array coerces to a + // measured ZERO, which is the exact trap this module exists to close, wearing + // a different type. Found by this module's own test. Only primitives are + // candidates for being a number. + if (typeof v === 'object') return null; + const n = typeof v === 'number' ? v : Number(v); + return Number.isFinite(n) ? n : null; +} + +/** Is this a known (present, finite) number? */ +function isKnown(v) { + return knownNumber(v) !== null; +} + +/** + * A finite, NON-NEGATIVE magnitude, or null when UNKNOWN. + * + * Stricter than `knownNumber` on purpose: a rate/count of `true`, `-1` or `NaN` + * is not a thin measurement, it is a broken one, and admitting it would let a + * downstream distribution treat garbage as evidence. + */ +function knownRate(v) { + if (v == null || v === '' || typeof v === 'boolean' || typeof v === 'object') return null; + const n = Number(v); + return Number.isFinite(n) && n >= 0 ? n : null; +} + +/** + * Coalesce to the first KNOWN value, or null. + * + * The point is that a present 0 wins over a later fallback — `firstKnown(0, 5)` + * is 0, because a measured zero is an answer. `a ?? b` gets this right and + * `a || b` does not, which is the same bug wearing different syntax. + */ +function firstKnown(...vals) { + for (const v of vals) { + const n = knownNumber(v); + if (n !== null) return n; + } + return null; +} + +module.exports = { knownNumber, knownRate, isKnown, firstKnown }; diff --git a/tests/unit/structuralGuards.test.js b/tests/unit/structuralGuards.test.js new file mode 100644 index 0000000..6b6cd6e --- /dev/null +++ b/tests/unit/structuralGuards.test.js @@ -0,0 +1,149 @@ +'use strict'; + +/** + * STRUCTURAL GUARDS (2026-08-02) — two recurring failure families, locked. + * + * Both are the same principle: never silently convert something ABSENT or THIN + * into a false DEFINITE value. + * + * GUARD 1 unknown ≠ zero (`Number(null) === 0`, 6× recurring) + * GUARD 2 takeability = book identity, never price shape + * + * The fixtures are REAL rows from prod (2026-08-02 re-stamp), not invented. + */ + +const { knownNumber, knownRate, isKnown, firstKnown } = require('../../src/utils/known'); +const { isTakeableMarket, isWithinPriceBand, assessQuote } = require('../../src/config/takeability'); + +// ─────────────────────────────── GUARD 1 ─────────────────────────────── +describe('GUARD 1 — unknown is not zero', () => { + it('every absent form is UNKNOWN, never 0', () => { + for (const v of [null, undefined, '', NaN, 'abc', {}, []]) { + expect(knownNumber(v)).toBeNull(); + expect(isKnown(v)).toBe(false); + } + }); + + it('a REAL zero survives — 0 rest days is a fact, not an absence', () => { + expect(knownNumber(0)).toBe(0); + expect(isKnown(0)).toBe(true); + expect(knownRate(0)).toBe(0); + }); + + it('Infinity is not a measurement', () => { + expect(knownNumber(Infinity)).toBeNull(); + expect(knownNumber(-Infinity)).toBeNull(); + }); + + it('knownRate additionally rejects booleans and negatives', () => { + expect(knownRate(true)).toBeNull(); // Number(true) === 1 + expect(knownRate(false)).toBeNull(); // Number(false) === 0 — the trap + expect(knownRate(-0.5)).toBeNull(); + expect(knownNumber(-0.5)).toBe(-0.5); // a signed gap is legitimately negative + }); + + it('firstKnown prefers a present 0 over a later fallback (|| gets this wrong)', () => { + expect(firstKnown(0, 5)).toBe(0); + expect(firstKnown(null, 5)).toBe(5); + expect(firstKnown(null, undefined, '')).toBeNull(); + }); + + it('REAL FIXTURE — a null triple rate must not read as "never triples"', () => { + const tb = require('../../src/services/projection/compoundTotalBases'); + // Same components, one with an UNKNOWN triple rate vs a MEASURED zero. + const unknown = tb.tbPmf({ singles: 0.6, doubles: 0.2, triples: null, home_runs: 0.15 }); + const zero = tb.tbPmf({ singles: 0.6, doubles: 0.2, triples: 0, home_runs: 0.15 }); + // Both are computable, and an unknown component contributes nothing — + // but it must do so by being SKIPPED, not by being asserted as zero. + expect(unknown).not.toBeNull(); + expect(zero).not.toBeNull(); + // The guard that matters: no component usable at all → null, not a flat curve. + expect(tb.tbPmf({ singles: null, doubles: null, triples: null, home_runs: null })).toBeNull(); + expect(tb.tbPmf({ singles: false })).toBeNull(); + }); + + it('the migrated sites all delegate to the ONE rule', () => { + // Semantics must be identical across every migrated module, or the + // migration reintroduced drift. + const mods = [ + require('../../src/utils/gradeRanking').strictNum, + require('../../src/services/consensusRuler').__internals.num, + ]; + for (const fn of mods) { + expect(fn(null)).toBeNull(); + expect(fn('')).toBeNull(); + expect(fn(0)).toBe(0); + expect(fn('2.5')).toBe(2.5); + } + }); +}); + +// ─────────────────────────────── GUARD 2 ─────────────────────────────── +describe('GUARD 2 — takeability is BOOK IDENTITY, never price shape', () => { + it('REAL FIXTURE — one-sided takeable markets are TAKEABLE', () => { + // Verified in prod: betrivers and hardrockbet quote one side only on real + // props (5 such rows surfaced in the 2026-08-02 re-stamp). One-sidedness is + // normal baseball market structure, not a defect. + expect(isTakeableMarket('betrivers')).toBe(true); + expect(isTakeableMarket('hardrockbet')).toBe(true); + // A one-sided quote: the other side is genuinely absent, and takeability + // does not depend on it at all. + expect(assessQuote({ book: 'betrivers', odds: 6600 }).takeable).toBe(true); + expect(assessQuote({ book: 'hardrockbet', odds: 400 }).takeable).toBe(true); + }); + + it('REAL FIXTURE — an extreme price is still TAKEABLE (steep juice is a price)', () => { + // Ohtani hits-over at -266 and Schwarber hits-over at -209 are real, + // placeable DraftKings bets. + for (const odds of [-266, -209, -300, -1400, 4900]) { + expect(assessQuote({ book: 'draftkings', odds }).takeable).toBe(true); + } + }); + + it('REAL FIXTURE — a DFS book at an ordinary -119 is NOT takeable', () => { + // The ordinary-looking price is the fake one: PrizePicks is not a + // sportsbook. Book identity decides, price shape never does. + for (const book of ['prizepicks', 'underdog', 'sleeper', 'dabble']) { + expect(isTakeableMarket(book)).toBe(false); + expect(assessQuote({ book, odds: -119 }).takeable).toBe(false); + } + }); + + it('exchanges and offshore are not takeable however normal the price looks', () => { + for (const book of ['kalshi', 'novig', 'smarkets', 'bovada', 'onexbet', 'pinnacle']) { + expect(isTakeableMarket(book)).toBe(false); + } + }); + + it('an ABSENT book is not takeable — we cannot assert a market we cannot name', () => { + for (const b of [null, undefined, '', 'not_a_book']) expect(isTakeableMarket(b)).toBe(false); + }); + + it('THE DISTINCTION: takeable and price-band are independent axes', () => { + // A -300 DraftKings prop is a REAL bet we would not promote. Both true. + const steep = assessQuote({ book: 'draftkings', odds: -300 }); + expect(steep.takeable).toBe(true); + expect(steep.within_price_band).toBe(false); + + const normal = assessQuote({ book: 'draftkings', odds: -110 }); + expect(normal.takeable).toBe(true); + expect(normal.within_price_band).toBe(true); + + // ...and a DFS book inside the band is still not takeable. + const dfs = assessQuote({ book: 'prizepicks', odds: -119 }); + expect(dfs.takeable).toBe(false); + expect(dfs.within_price_band).toBe(true); + }); + + it('an UNKNOWN price is not an out-of-band price', () => { + expect(assessQuote({ book: 'draftkings', odds: null }).within_price_band).toBeNull(); + expect(assessQuote({ book: 'draftkings' }).takeable).toBe(true); // book still decides + }); + + it('no drift: the canonical rule IS bookRoles.isTakeableBook', () => { + const { isTakeableBook } = require('../../src/config/bookRoles'); + for (const b of ['draftkings', 'prizepicks', 'pinnacle', 'betrivers', null]) { + expect(isTakeableMarket(b)).toBe(isTakeableBook(b)); + } + }); +});