diff --git a/specs/STATE.md b/specs/STATE.md index 7dc2146..5ca519b 100644 --- a/specs/STATE.md +++ b/specs/STATE.md @@ -1,6 +1,13 @@ # VYNDR — STATE OF THE WORLD ### As of `a8e383e` (main, deployed + fingerprint-verified live), 2026-07-18. This file opens every future session. Update it when a train ships. +## RARE-EVENT UNDER SUPPRESSION (2026-07-19, on main) +Betting-logic audit: the CONSENSUS-vs-MODEL board flooded with "doubles u0.5 · MODEL 0.2 · +edge" fake reads (juiced rare-event unders). Report: the doubles projection is REAL per-player (MLB_LOG_FIELD doubles→doubles; values varied 0.03/0.16/0.2/0.22) — NOT a flat fallback; the issue is purely structural. +- **Config-driven** (`src/config/rareEventMarkets.js`): RARE_EVENT_STATS = doubles/triples/home_runs/stolen_bases, RARE_EVENT_LINE_MAX = 0.5. +- **Grade layer** (`analyzeViaEngine1`): rare-event UNDER at ≤0.5 → always refused (grade null + suppressed); rare-event OVER at ≤0.5 → refused unless projection > line (a 0.2-over-0.5 carries the SAME |edge| as the under, so it'd just take its board rank — refusing it is what actually clears the market). OVER with a genuine projection > line still grades. +- **Board layer** (`marketBreadth.collectBreadth`): drops null-model rows — a suppressed/ungraded prop can't rank a "MODEL —" placeholder onto the board. +- Suite 274/3289 green. Fingerprint: next MLB snapshot should have no rare-event u0.5 grades. + ## BACKUP + FOUNDER CHECKOUT (2026-07-18, on main) - **Task A — DB backup (SHIPPED; Kev cron+fingerprint on box)** `c2c43cd`: Dockerfile now has pg_dump/pg_restore/rsync; `backup-db.sh` validates every dump via `pg_restore --list` (must contain ledger_entries). Runs IN the API container (SUPABASE_DB_URL is there; WSL2 can't reach Supabase). Runbook = host cron `docker exec sh /app/scripts/backup-db.sh`. **Mechanism fingerprint PASSED locally** (137 rows → dump → validate → restore → 137 rows). Kev: install the host cron + run the prod restore fingerprint. - **Task B — founder checkout SEAT-GATED (SHIPPED)** `ccb9668`: `resolveCheckoutPrice` attaches the founder price while seats remain (< FOUNDER_SEATS_TOTAL, same `countFounderSeats()` truth as the meter), flips to standard at 100; meter shows SOLD OUT. Fixes "Claim a Founder Desk" charging $44.99 vs advertised $34.99. **payment_failed grace 48h→14d** (spans Stripe retries; revoke only on real cancel). Tested seat 0/99/100/null. Needs `STRIPE_PRICE_*_FOUNDER` set in prod for founder pricing to activate. diff --git a/src/config/rareEventMarkets.js b/src/config/rareEventMarkets.js new file mode 100644 index 0000000..ee324b8 --- /dev/null +++ b/src/config/rareEventMarkets.js @@ -0,0 +1,65 @@ +'use strict'; + +/** + * Rare-event markets (betting-logic audit, 2026-07-19). + * + * On a 0.5-line rare counting stat — doubles, triples, home runs, stolen bases — + * the UNDER is the juiced side the book wants action on: the event just usually + * doesn't happen, so a real projection of (say) 0.2 always "favors" under 0.5, + * but there's no takeable edge (the book prices it -250+). Surfacing those + * unders floods the board with fake B-grade "under 0.5" reads and violates the + * standing no-unders-default doctrine. + * + * So we SUPPRESS the under side on these markets at/below the line threshold — + * it is not graded. The OVER side still grades normally and only surfaces when + * the model genuinely projects the event above the line. + * + * Config-driven (a stat list + a line threshold) so it's tunable without + * touching grade logic. CommonJS so it's unit-testable + requireable everywhere. + */ + +// Low-frequency counting stats where a 0.5 under is structurally a bad bet. +const RARE_EVENT_STATS = ['doubles', 'triples', 'home_runs', 'stolen_bases']; + +// The under is suppressed only at/below this line (0.5 is the juiced rare line; +// a 1.5+ line is a different market where an under can be a real read). +const RARE_EVENT_LINE_MAX = 0.5; + +function isRareEventStat(statType) { + return RARE_EVENT_STATS.includes(String(statType || '').toLowerCase()); +} + +/** True when this prop is a rare-event UNDER at/below the line threshold — + * the juiced side, always refused. Needs no projection. */ +function isSuppressedRareUnder(statType, line, direction) { + const dir = String(direction || '').toLowerCase(); + const ln = Number(line); + return dir === 'under' + && isRareEventStat(statType) + && Number.isFinite(ln) + && ln <= RARE_EVENT_LINE_MAX; +} + +/** True when this prop is a rare-event OVER at/below the threshold that the + * model does NOT genuinely project (projection <= line). Refusing it is what + * keeps the board clean — a 0.2-projection over 0.5 carries the SAME |edge| as + * the suppressed under, so if we let it grade it just takes the under's place. + * The over is a real read only when the model projects ABOVE the line. */ +function isSuppressedRareOver(statType, line, direction, projection) { + const dir = String(direction || '').toLowerCase(); + if (dir !== 'over') return false; + const ln = Number(line); + const proj = Number(projection); + return isRareEventStat(statType) + && Number.isFinite(ln) + && ln <= RARE_EVENT_LINE_MAX + && !(Number.isFinite(proj) && proj > ln); +} + +module.exports = { + RARE_EVENT_STATS, + RARE_EVENT_LINE_MAX, + isRareEventStat, + isSuppressedRareUnder, + isSuppressedRareOver, +}; diff --git a/src/services/intelligence/analyzeViaEngine1.js b/src/services/intelligence/analyzeViaEngine1.js index a1fc019..21e1a09 100644 --- a/src/services/intelligence/analyzeViaEngine1.js +++ b/src/services/intelligence/analyzeViaEngine1.js @@ -16,6 +16,7 @@ const { computeFeaturesForProp } = require('./computeFeatures'); const engine1 = require('./engine1'); const { toLegacyShape } = require('../../utils/gradeAdapter'); +const { isSuppressedRareUnder, isSuppressedRareOver } = require('../../config/rareEventMarkets'); // Map an error code from computeFeaturesForProp.meta.errors into a human // sentence the user will see in reasoning.summary. @@ -293,6 +294,30 @@ function insufficientDataResult(rawProp, errors) { }; } +// Betting-logic audit (2026-07-19) — rare-event 0.5 markets (doubles/triples/ +// HR/SB) have no takeable edge on the UNDER (juiced) and no edge on the OVER +// unless the model genuinely projects the event above the line. We REFUSE those +// (grade null + insufficient_data so every consumer's no-read handling applies) +// with a distinct `suppressed` flag/reason. +function suppressedRareResult(rawProp, reason, summary) { + return { + player: rawProp.player ?? null, + stat_type: rawProp.stat_type ?? null, + line: rawProp.line ?? null, + direction: rawProp.direction ?? null, + book: rawProp.book || 'unknown', + grade: null, + insufficient_data: true, + suppressed: true, + suppressed_reason: reason, + confidence: 0, + edge_pct: 0, + projection: null, + kill_conditions_triggered: [], + reasoning: { summary, steps: [] }, + }; +} + /** * Form score (0..100) from recent-vs-baseline averages (Session 43). Hot * (l5 > l20) trends above 70; cold below. undefined when there's no recent avg. @@ -359,6 +384,14 @@ function buildIntelFields(features = {}, opts = {}) { } async function analyzeViaEngine1(rawProp = {}) { + // Betting-logic audit — suppress the juiced UNDER up front (no compute spent): + // a 0.5-line under on a rare counting stat (doubles/triples/HR/SB) is never a + // takeable edge. (The OVER is gated on the projection below.) + if (isSuppressedRareUnder(rawProp.stat_type, rawProp.line, rawProp.direction)) { + return suppressedRareResult(rawProp, 'rare_event_under', + `No read — a ${rawProp.line} under on ${rawProp.stat_type} is a juiced rare-event market, not a takeable edge.`); + } + const featureResult = await computeFeaturesForProp(rawProp); const { features, trap, consistency, prop, meta } = featureResult; @@ -381,6 +414,15 @@ async function analyzeViaEngine1(rawProp = {}) { return insufficientDataResult(rawProp, meta?.errors); } + // Betting-logic audit — a rare-event 0.5 OVER is a read ONLY when the model + // genuinely projects the event ABOVE the line. Below that, the over carries + // the same |edge| as the (already-suppressed) under and would just take its + // place on the board — so refuse it. This is what actually clears the market. + if (isSuppressedRareOver(rawProp.stat_type, prop.line, prop.direction, projection)) { + return suppressedRareResult(rawProp, 'rare_event_over_below_line', + `No read — the model projects ${projection} ${rawProp.stat_type}, at or below the ${prop.line} line; the over is not a genuine event projection.`); + } + // Engine 1: deterministic rule-based grade on the feature vector. const engine1Result = engine1.gradeProp({ features, trap, consistency, prop }); diff --git a/tests/unit/rareEventSuppression.test.js b/tests/unit/rareEventSuppression.test.js new file mode 100644 index 0000000..17cd266 --- /dev/null +++ b/tests/unit/rareEventSuppression.test.js @@ -0,0 +1,114 @@ +'use strict'; + +// Betting-logic audit (2026-07-19) — rare-event 0.5 markets. The UNDER is +// always suppressed (juiced); the OVER grades only when the model genuinely +// projects the event above the line; and the CONSENSUS-vs-MODEL board drops +// no-model rows so a suppressed market can't take the under's rank. + +// ── config helpers (pure) ─────────────────────────────────────────────────── +const cfg = require('../../src/config/rareEventMarkets'); + +describe('rareEventMarkets config', () => { + test('the rare stats + threshold are config-driven (tunable)', () => { + expect(cfg.RARE_EVENT_STATS).toEqual(['doubles', 'triples', 'home_runs', 'stolen_bases']); + expect(cfg.RARE_EVENT_LINE_MAX).toBe(0.5); + }); + + test('UNDER on a rare 0.5 market is suppressed', () => { + for (const s of cfg.RARE_EVENT_STATS) { + expect(cfg.isSuppressedRareUnder(s, 0.5, 'under')).toBe(true); + } + expect(cfg.isSuppressedRareUnder('doubles', 0.5, 'over')).toBe(false); // over handled separately + expect(cfg.isSuppressedRareUnder('hits', 0.5, 'under')).toBe(false); // hits is NOT rare + expect(cfg.isSuppressedRareUnder('doubles', 1.5, 'under')).toBe(false); // 1.5 line is a diff market + }); + + test('OVER on a rare 0.5 market is suppressed UNLESS projection > line', () => { + expect(cfg.isSuppressedRareOver('doubles', 0.5, 'over', 0.2)).toBe(true); // 0.2 <= 0.5 → junk + expect(cfg.isSuppressedRareOver('doubles', 0.5, 'over', 0.5)).toBe(true); // == line → not genuine + expect(cfg.isSuppressedRareOver('doubles', 0.5, 'over', 0.7)).toBe(false); // 0.7 > 0.5 → genuine read + expect(cfg.isSuppressedRareOver('doubles', 0.5, 'under', 0.2)).toBe(false); // under path, not this fn + expect(cfg.isSuppressedRareOver('hits', 0.5, 'over', 0.2)).toBe(false); // not rare + }); +}); + +// ── analyzeViaEngine1 integration ─────────────────────────────────────────── +const mockComputeReturn = { current: null }; +jest.mock('../../src/services/intelligence/computeFeatures', () => ({ + computeFeaturesForProp: async () => mockComputeReturn.current, +})); +const mockEngine1Return = { current: null }; +jest.mock('../../src/services/intelligence/engine1', () => ({ + gradeProp: () => mockEngine1Return.current, +})); +const { analyzeViaEngine1 } = require('../../src/services/intelligence/analyzeViaEngine1'); + +const feat = (l5, line, dir) => ({ + features: { l5_avg: l5, l20_avg: l5 }, + trap: {}, consistency: { consistency: 'reliable', score: 0.7 }, + prop: { line, direction: dir }, meta: { sport: 'mlb', errors: [] }, +}); + +beforeEach(() => { + mockComputeReturn.current = null; + mockEngine1Return.current = { grade: 'B', confidence: 0.55, top_factors: [], all_factors: [] }; +}); + +describe('analyzeViaEngine1 — rare-event suppression', () => { + test('doubles UNDER 0.5 is REFUSED (grade null, suppressed) — no compute', async () => { + const out = await analyzeViaEngine1({ player: 'X', stat_type: 'doubles', line: 0.5, direction: 'under' }); + expect(out.grade).toBeNull(); + expect(out.suppressed).toBe(true); + expect(out.suppressed_reason).toBe('rare_event_under'); + expect(out.insufficient_data).toBe(true); // filtered by gradeBestSide + }); + + test('doubles OVER 0.5 with projection 0.2 (<= line) is REFUSED', async () => { + mockComputeReturn.current = feat(0.2, 0.5, 'over'); + const out = await analyzeViaEngine1({ player: 'X', stat_type: 'doubles', line: 0.5, direction: 'over' }); + expect(out.grade).toBeNull(); + expect(out.suppressed_reason).toBe('rare_event_over_below_line'); + }); + + test('doubles OVER 0.5 with projection 0.7 (> line) GRADES — genuine event read', async () => { + mockComputeReturn.current = feat(0.7, 0.5, 'over'); + const out = await analyzeViaEngine1({ player: 'X', stat_type: 'doubles', line: 0.5, direction: 'over' }); + expect(out.grade).toBe('B'); // real grade, not suppressed + expect(out.suppressed).toBeUndefined(); + }); + + test('home_runs / stolen_bases / triples UNDER 0.5 all suppressed', async () => { + for (const s of ['home_runs', 'stolen_bases', 'triples']) { + const out = await analyzeViaEngine1({ player: 'X', stat_type: s, line: 0.5, direction: 'under' }); + expect(out.grade).toBeNull(); + expect(out.suppressed).toBe(true); + } + }); + + test('a NON-rare under (hits) is unaffected — still grades', async () => { + mockComputeReturn.current = feat(1.2, 0.5, 'under'); + const out = await analyzeViaEngine1({ player: 'X', stat_type: 'hits', line: 0.5, direction: 'under' }); + expect(out.grade).toBe('B'); + expect(out.suppressed).toBeUndefined(); + }); + + test('a rare stat at a 1.5 line (not 0.5) is unaffected', async () => { + mockComputeReturn.current = feat(1.2, 1.5, 'under'); + const out = await analyzeViaEngine1({ player: 'X', stat_type: 'home_runs', line: 1.5, direction: 'under' }); + expect(out.grade).toBe('B'); + }); +}); + +// ── board layer: collectBreadth drops no-model rows ───────────────────────── +const { collectBreadth } = require('../../web/src/lib/marketBreadth'); + +describe('collectBreadth drops null-model rows (suppressed markets off the board)', () => { + const books = [{ book: 'dk', line: 0.5 }, { book: 'fd', line: 0.5 }]; + test('a prop with no model (refused/ungraded) is not ranked onto the board', () => { + const rows = collectBreadth([ + { player: 'Real', stat: 'hits', side: 'over', line: 1.5, books: [{ book: 'dk', line: 1.5 }, { book: 'fd', line: 1.5 }], modelValue: 2.1 }, + { player: 'Suppressed', stat: 'doubles', side: 'over', line: 0.5, books, modelValue: null }, + ], 6); + expect(rows.map((r) => r.player)).toEqual(['Real']); // the null-model doubles is gone + }); +}); diff --git a/tests/unit/settingsPage.test.js b/tests/unit/settingsPage.test.js index 1e0421b..c164133 100644 --- a/tests/unit/settingsPage.test.js +++ b/tests/unit/settingsPage.test.js @@ -10,7 +10,8 @@ describe('books lib (BookChip data)', () => { it('maps known books to brand colors (case-insensitive aliases)', () => { expect(bookInfo('DK')).toMatchObject({ name: 'DraftKings', fg: '#53D337' }); expect(bookInfo('draftkings').mono).toBe('DK'); - expect(bookInfo('ESPN').mono).toBe('EB'); + // ESPN BET retired → theScore Bet (PENN) successor (item 7). + expect(bookInfo('THESCORE')).toMatchObject({ name: 'theScore Bet', mono: 'TS' }); }); it('degrades unknown books to a neutral chip', () => { const b = bookInfo('ZZZ'); diff --git a/web/src/lib/marketBreadth.js b/web/src/lib/marketBreadth.js index d392fa9..971b75f 100644 --- a/web/src/lib/marketBreadth.js +++ b/web/src/lib/marketBreadth.js @@ -104,6 +104,11 @@ function collectBreadth(items, limit = 6) { if (!it) continue; const b = computeBreadth(it.books, it.modelValue, it.side); if (!b) continue; + // Betting-logic audit — a CONSENSUS-vs-MODEL row needs a model opinion. A + // prop with no model (ungraded, or a refused/suppressed read like a + // rare-event 0.5 market) has nothing to compare — drop it, don't rank a + // "MODEL —" placeholder onto the board. + if (b.model == null || b.signedEdge == null) continue; out.push({ player: it.player, stat: it.stat, line: numOrNull(it.line), ...b }); } const abs = (x) => Math.abs(numOrNull(x) == null ? 0 : numOrNull(x));