Product identity + widen books for DISPLAY, model input byte-identical

IDENTITY (CLAUDE.md top + MASTER-PLAN header). VYNDR is a PREDICTIVE MODEL:
it projects what a player will DO and picks accurately. Market edge is a
BYPRODUCT of a good prediction, never the success criterion. Success =
the forecast is honest about its own confidence AND still ranks --
calibration and resolution, both. No edge/CLV term belongs in a pass/fail
gate; they are diagnostics we report, not thresholds a model must clear.
A model tuned to beat a closing line has been fitted to the market instead
of to the game.

Per-sport doctrine (Phillips 2022, classify by what players DO not by
position): each sport is its own model -- own variables, archetypes,
conditions, calibration, honest ceiling. Shared across sports: ONLY the
Bayesian inference math.

Truth Law: no fabricated data; honest-absent over invented; label
limitations in-band; provisional stays provisional until re-run;
documented is not verified.

PHASE 2 -- AGGREGATOR WIDENING (live). normalizeProps now emits every
DISPLAY book instead of 5 of 18. Before this we discarded 13 books of our
own accord and 64.8% of the MLB slate was invisible to users. Every prop
carries book_role (both/takeable/reference/dfs/offshore) so the display
layer can say WHAT a price is -- a fixed-payout DFS number and a two-way
sportsbook price are not interchangeable objects. Unknown books are still
dropped.

PHASE 3 -- MODEL GATE (the model does not move). bookRoles splits
MODEL_BOOKS (the legacy allow-list, character for character) from
DISPLAY_BOOKS. Both model paths re-filter before they pick a line:
gradeSlateService.dedupeProps (before first-row-wins AND before the limit)
and intradayRefreshService.indexOddsProps (which RE-GRADES at the current
line -- without the gate, widening would have silently moved locked lines
onto books the model has never been calibrated against). A test asserts
the graded set is byte-identical through the widening.

CURRENT_RULER_VERSION stays v1_first_book. The gate lifts only when the
MLB calibration is re-run on the consensus ruler and v2 is promoted.

HONEST FRAMING, recorded in the plan: this is an AGGREGATOR win and it
does NOT fix the model. WNBA still abstains -- a model problem, not a
coverage problem; it is better covered than MLB. MLB isotonic still
provisional. The consensus is MARKET, not SHARP: pinnacle, matchbook and
polymarket are 0% on both sports, so no sharp anchor exists in our feed.

Two superseded tests updated to stronger properties rather than deleted:
roleOf now names the KIND of book, and the normalizer test asserts the
display set widens WHILE the model set does not.

Gates: 4,027 tests / 322 suites green; next build exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
This commit is contained in:
Kev
2026-08-01 00:50:54 -04:00
parent 1372e6bcf7
commit f0543b57a4
9 changed files with 278 additions and 18 deletions
+26 -2
View File
@@ -6,7 +6,24 @@ const { getAbbreviation } = require('./teamMap');
// left OUT — it's the canonical "not-allowed" example in the tests, and
// VYNDR surfaces regulated US books.)
// 'thescore' = theScore Bet (PENN), successor to the defunct ESPN BET (item 7).
const ALLOWED_BOOKS = new Set(['draftkings', 'fanduel', 'betmgm', 'caesars', 'fanatics', 'bet365', 'hardrockbet', 'pointsbet', 'betrivers', 'pinnacle', 'thescore']);
//
// ORDER ZERO (2026-08-01) — the allow-list is now TWO sets, because it was
// answering two different questions with one answer.
//
// DISPLAY_BOOKS — what the SURFACES may show. Widened to everything the feed
// already sends. Before this, we discarded 13 of PropLine's
// 18 books and 64.8% of the MLB slate was invisible to users.
// Line shopping across books is the category's #1 free hook.
// MODEL_BOOKS — what may reach a GRADE. Byte-identical to the old list on
// purpose: the model has never been measured against the new
// books, and it must not start eating them until the MLB
// calibration is re-run on the consensus ruler.
//
// `ALLOWED_BOOKS` is kept as an alias for MODEL_BOOKS so existing model-side
// callers (combatAdapter, consensusRuler's incumbent) keep their old meaning.
// Every emitted prop now carries `book_role` so no consumer has to re-derive it.
const { DISPLAY_BOOKS, MODEL_BOOKS, roleOf } = require('../config/bookRoles');
const ALLOWED_BOOKS = MODEL_BOOKS;
const MARKET_MAP = {
// NBA / WNBA props
@@ -107,7 +124,9 @@ function normalizeProps(eventsWithOdds) {
if (!Array.isArray(event.bookmakers)) continue;
for (const bookmaker of event.bookmakers) {
if (!ALLOWED_BOOKS.has(bookmaker.key)) continue;
// DISPLAY gate — the model re-filters to MODEL_BOOKS downstream
// (gradeSlateService.dedupeProps, intradayRefreshService.indexOddsProps).
if (!DISPLAY_BOOKS.has(bookmaker.key)) continue;
if (!Array.isArray(bookmaker.markets)) continue;
@@ -142,6 +161,11 @@ function normalizeProps(eventsWithOdds) {
player: entry.player,
home_team: homeTeam,
away_team: awayTeam,
// What KIND of price this is. 'dfs' = fixed-payout pick'em, which is
// NOT a market price and must never enter a consensus or a fair
// value — it is shown for breadth and tagged so nothing downstream
// can mistake it for a book.
book_role: roleOf(bookmaker.key),
game_time: gameTime,
stat_type: statType,
book: bookmaker.key,