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
+44
View File
@@ -1,5 +1,49 @@
# VYNDR — Claude Code Project Context
---
# 🔷 PRODUCT IDENTITY — READ FIRST, EVERY SESSION
**VYNDR IS A PREDICTIVE MODEL.** It projects what a player will **DO**, and picks
accurately. It reads and pulls the market apart — a student of the game that is
also an aggregator.
**Market edge is a BYPRODUCT of a good prediction. It is NEVER the success
criterion.**
> **SUCCESS = the forecast is honest about its own confidence AND still ranks.**
> Calibration (does 60% mean 60%?) *and* resolution (do higher forecasts actually
> hit more often?). Both, or it isn't working.
**No edge or CLV term belongs in a pass/fail gate.** CLV and market-relative edge
are *diagnostics we report*, never thresholds a model must clear to ship. A model
that forecasts honestly and ranks correctly is working even in a week the market
moved against it; a model tuned to beat a closing line has been fitted to the
market instead of to the game.
## PER-SPORT DOCTRINE
*(Rashad Phillips, "Basketball Position Metric," 2022 — classify players by **what
they do**, not by position labels.)*
**Each sport is its OWN model** — its own variables, archetypes, conditions,
calibration and honest ceiling. The **only** thing shared across sports is the
**Bayesian inference math**. Never one model fit to all sports; never a sport
stubbed in on another sport's template and counted as covered.
## TRUTH LAW
- **No fabricated data anywhere.** If it renders a number, it comes from the
database or it doesn't render. `Number(null) === 0` is the classic breach.
- **Honest-absent beats invented.** An empty state is a valid answer.
- **Label limitations in-band** — e.g. "market consensus, **not sharp**",
"RECORD BUILDING", "MODEL · LEARNING".
- **Provisional results stay provisional until re-run.** A measurement taken
against an instrument that has since changed is not a result; it is a result
*pending*.
- **Verify by inducing the real code path on demand** — never wait on a cron slot
to find out whether something works. Documented ≠ verified.
---
## What This Is
Sports betting intelligence SaaS. Real software product.
Three tiers: Free (5 scans), Analyst ($19.99 / $14.99 founder), Desk ($49.99 / $34.99 founder).
+59 -1
View File
@@ -1,6 +1,64 @@
# VYNDR — MASTER PLAN
**Single source of truth. Sessions EXECUTE against this and UPDATE it in place.**
Created 2026-07-31 by consolidation. Nothing built in this pass.
Created 2026-07-31 by consolidation. Last updated 2026-08-01.
---
## 🔷 PRODUCT IDENTITY — the thing being built
**VYNDR IS A PREDICTIVE MODEL.** It projects what a player will **DO** and picks
accurately — reading and pulling the market apart. Student of the game, and an
aggregator.
**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. **No edge/CLV term belongs in a pass/fail gate** —
> they are diagnostics we report, not thresholds a model must clear to ship.
**PER-SPORT DOCTRINE** (Rashad Phillips, *Basketball Position Metric*, 2022 —
classify players by **what they do**, not position labels): each sport is its OWN
model, with its own variables, archetypes, conditions, calibration and honest
ceiling. Shared across sports: **only the Bayesian inference math.**
**TRUTH LAW:** no fabricated data anywhere · honest-absent over invented · label
limitations in-band ("market consensus, **not sharp**") · **provisional results
stay provisional until re-run** · documented ≠ verified.
---
## ▶ NEXT EXECUTABLE ORDER
**The MLB calibration RE-RUN against the consensus ruler.** Everything
model-shaped is downstream of it:
- MLB isotonic `p_win` cannot promote until re-run (it was calibrated against
`v1_first_book`).
- The newly-visible props cannot feed the model until it promotes.
- Every edge/CLV number resets to `ruler_version = v2_consensus` at that boundary
and **must not be pooled** with what came before.
*Blocked on nothing. This is the next build.*
---
## 📍 STATE AS OF 2026-08-01 (Order Zero, measured on prod with the real key)
| finding | number | what it means |
|---|---|---|
| **MLB slate invisible to us** | **64.8%** | our own allow-list, not the feed — now widened for DISPLAY |
| **books/prop, MLB** | 3.61 feed → 0.57 after filter | the filter cost, quantified |
| **books/prop, WNBA** | **4.21** feed → 1.20 | **WNBA is BETTER covered than MLB** |
| **consensus ruler** | **MARKET, not SHARP** | `pinnacle`/`matchbook`/`polymarket` = **0%** on both sports. No sharp anchor exists in our feed. Permanent limitation, not a milestone |
| **ruler delta** (consensus incumbent) | MLB mean +1.50 pts, median 0, **17% of props move ≥5 pts** | rulers genuinely differ; "better" is unproven |
| **MLB isotonic `p_win`** | **PROVISIONAL** | calibrated on the bent ruler; does not promote until re-run |
| **WNBA** | **still abstains** | a MODEL problem, not a coverage problem — coverage was never its constraint |
| **soccer** | **settles** — ~15 competitions, 30d | "grades into a void" is a **$19/mo Pro-tier** problem, not a data problem |
| **CLV + results feeds** | `/odds/closing` + `/movement` **redacted**; `/results` + `/exports/resolved-props` **403** | free tier behaving exactly as its contract says |
| **accrual clock** | **sequential, post-completion** | see §11. Pre-completion data does not count and is never pooled |
**The honest framing:** widening books is an **AGGREGATOR** win. **It does not fix
the model.** Do not let the free-side win read as model progress.
> **HOW TO USE:** this supersedes ad-hoc re-derivation. Before any order, read the
> phase you're in. After any order, tick the item and add one line. **Do not
+46 -1
View File
@@ -102,14 +102,59 @@ const MIN_CONSENSUS_BOOKS = 2;
const roleOf = (book) => {
const b = String(book || '').toLowerCase();
if (EXCLUDED_FROM_PRICING.has(b)) return 'excluded';
if (DFS_PLATFORMS.has(b)) return 'dfs';
if (OFFSHORE_OR_INTL.has(b)) return 'offshore';
if (REFERENCE_BOOKS.has(b) && TAKEABLE_BOOKS.has(b)) return 'both';
if (REFERENCE_BOOKS.has(b)) return 'reference';
if (TAKEABLE_BOOKS.has(b)) return 'takeable';
return 'unknown';
};
/**
* MODEL_BOOKS — the ONLY books whose prices may reach a grade.
*
* This is the legacy `oddsNormalizer.ALLOWED_BOOKS`, character for character,
* ON PURPOSE. Widening what the SURFACES show is an aggregator change; widening
* what the MODEL eats is a model change, and the model has never been measured
* against those books. Until the MLB calibration is re-run on the consensus
* ruler, the model's input stays byte-identical. Two different questions, two
* different sets — that separation is the whole point of this file.
*/
const MODEL_BOOKS = Object.freeze(new Set([
'draftkings', 'fanduel', 'betmgm', 'caesars', 'fanatics', 'bet365',
'hardrockbet', 'pointsbet', 'betrivers', 'pinnacle', 'thescore',
]));
/**
* DISPLAY_BOOKS — everything the surfaces may SHOW.
*
* Line shopping across many books is the category's #1 free-tier hook and the
* acquisition surface we have never had. PropLine already sends these; we were
* throwing 13 of 18 away, which made 64.8% of the MLB slate invisible.
*
* Every prop carries a `book_role` so the display layer can be honest about
* WHAT each price is: a DFS pick'em number and a two-way sportsbook price are
* not the same object and must never be rendered as interchangeable.
*/
const DISPLAY_BOOKS = Object.freeze(new Set([
...MODEL_BOOKS,
...REFERENCE_BOOKS,
...DFS_PLATFORMS,
...OFFSHORE_OR_INTL,
]));
/** May this book's price reach a grade? (Not "may we show it" — see below.) */
const isModelBook = (book) => MODEL_BOOKS.has(String(book || '').toLowerCase());
/** May we render this book at all? */
const isDisplayBook = (book) => DISPLAY_BOOKS.has(String(book || '').toLowerCase());
/** May this book price the fair-probability ruler? DFS/offshore: never. */
const isReferenceBook = (book) => REFERENCE_BOOKS.has(String(book || '').toLowerCase());
/** Can a user actually place this bet? Drives best-price + deep links. */
const isTakeableBook = (book) => TAKEABLE_BOOKS.has(String(book || '').toLowerCase());
module.exports = {
MODEL_BOOKS, DISPLAY_BOOKS,
isModelBook, isDisplayBook, isReferenceBook, isTakeableBook,
TAKEABLE_BOOKS, REFERENCE_BOOKS, EXCLUDED_FROM_PRICING,
DFS_PLATFORMS, OFFSHORE_OR_INTL, PHANTOM_ON_PROPLINE,
EXCHANGES, SHARP, US_MAJORS,
+12
View File
@@ -26,16 +26,28 @@
// We dedupe to unique player+stat+line and cap how many we grade, because
// each grade fans out to feature computation. Grading runs at most once per
// cache-miss per sport, but we still bound the herd.
const { isModelBook } = require('../config/bookRoles');
const DEFAULT_LIMIT = 25;
const DEFAULT_CONCURRENCY = 5;
const DEFAULT_TTL = 7200; // 2 hours — matches the spec's grades-cache TTL.
// Collapse the multi-book prop rows to one entry per gradeable prop.
//
// ORDER ZERO GATE (2026-08-01). `normalizeProps` now emits every DISPLAY book so
// the surfaces can shop lines, which means DFS pick'em and exchange rows arrive
// here for the first time. The MODEL must not eat them: it has never been
// measured against those books, and a fixed-payout DFS number is not a market
// price at all. So we re-filter to MODEL_BOOKS BEFORE the first-row-wins pick
// and before the limit — which makes the graded set byte-identical to what it
// was before the widening. This gate lifts only when the MLB calibration is
// re-run on the consensus ruler and v2 is promoted.
function dedupeProps(props, limit) {
const seen = new Set();
const out = [];
for (const p of props || []) {
if (!p || !p.player || !p.stat_type || p.line == null) continue;
if (!isModelBook(p.book)) continue;
const key = `${p.player}::${p.stat_type}::${p.line}`;
if (seen.has(key)) continue;
seen.add(key);
+8
View File
@@ -36,6 +36,8 @@ const STEAM_NOISE = 0.5; // ignore movement below this (both directions)
const REGRADE_TRIGGER = 1.0; // moved-against threshold that triggers a re-grade
const SNAP_TTL = 24 * 3600; // keep in sync with snapshotService
const TICKER_MOVE_CAP = 6;
const { isModelBook } = require('../config/bookRoles');
const HISTORY_CAP = 24; // {t, line} points per grade (S6 sparklines)
const GRADE_RANK = { 'A+': 0, A: 1, 'A-': 2, 'B+': 3, B: 4, 'B-': 5, 'C+': 6, C: 7, 'C-': 8, D: 9, F: 10 };
@@ -43,10 +45,16 @@ const rank = (g) => (g && GRADE_RANK[g] !== undefined ? GRADE_RANK[g] : 99);
const sideOver = (dir) => String(dir || 'over').toLowerCase() !== 'under';
// ORDER ZERO GATE — same reasoning as gradeSlateService.dedupeProps. This index
// feeds line-movement detection and RE-GRADES props at the current line, so it
// is a MODEL path: it must see only MODEL_BOOKS. Without this gate the widened
// display feed would silently move locked lines onto DFS/exchange numbers the
// model has never been calibrated against.
function indexOddsProps(props) {
const map = {};
for (const p of props || []) {
if (!p || !p.player || !p.stat_type) continue;
if (!isModelBook(p.book)) continue;
const k = `${nameKey(p.player)}|${String(p.stat_type).toLowerCase()}`;
if (!map[k]) map[k] = p;
}
+6
View File
@@ -382,6 +382,12 @@ async function probe(path, params, note, httpGet) {
...(rows != null ? { csv_rows: rows } : {}),
...(Object.keys(headers).length ? { headers } : {}),
shape: scrubKeys(summarise(body)).slice(0, 700),
// The REASON, verbatim. A bare 403 does not distinguish "free tier
// excludes this" from "this key lacks a scope" from "your plan lapsed",
// and the difference is a $19/mo decision. PropLine puts it in `detail`.
...(status >= 400 && body && typeof body === 'object'
? { detail: scrubKeys(JSON.stringify(body.detail != null ? body.detail : body)).slice(0, 600) }
: {}),
};
} catch (err) {
return { path, verdict: 'no', note, error: scrubKeys(err && err.message) };
+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,
+36 -1
View File
@@ -19,7 +19,13 @@ describe('bookRoles — the three-way split', () => {
for (const dfs of roles.DFS_PLATFORMS) {
expect(roles.REFERENCE_BOOKS.has(dfs)).toBe(false);
expect(roles.EXCLUDED_FROM_PRICING.has(dfs)).toBe(true);
expect(roles.roleOf(dfs)).toBe('excluded');
// roleOf now names the KIND ('dfs'), which is stronger than 'excluded':
// the display layer needs to say WHAT a price is, not just that it can't
// price. Exclusion from pricing is asserted on the line above.
expect(roles.roleOf(dfs)).toBe('dfs');
expect(roles.isReferenceBook(dfs)).toBe(false);
expect(roles.isModelBook(dfs)).toBe(false);
expect(roles.isDisplayBook(dfs)).toBe(true); // shown for breadth, tagged
}
});
@@ -144,3 +150,32 @@ describe('consensusRuler — the incumbent it is challenging', () => {
expect(compareRulers([q('prizepicks', 1.5, -119, -119)], 1.5, 'over', { allowedBooks: LIVE_ALLOWED }).delta_pts).toBeNull();
});
});
describe('bookRoles — display vs model separation (Order Zero Phase 2/3)', () => {
it('MODEL_BOOKS is the legacy allow-list, unchanged — the model must not move', () => {
expect([...roles.MODEL_BOOKS].sort()).toEqual([
'bet365', 'betmgm', 'betrivers', 'caesars', 'draftkings', 'fanatics',
'fanduel', 'hardrockbet', 'pinnacle', 'pointsbet', 'thescore',
].sort());
});
it('DISPLAY_BOOKS is a strict superset of MODEL_BOOKS', () => {
for (const b of roles.MODEL_BOOKS) expect(roles.DISPLAY_BOOKS.has(b)).toBe(true);
expect(roles.DISPLAY_BOOKS.size).toBeGreaterThan(roles.MODEL_BOOKS.size);
});
it('NO DFS or offshore book may reach the model, however visible it is', () => {
for (const b of [...roles.DFS_PLATFORMS, ...roles.OFFSHORE_OR_INTL]) {
expect(roles.isDisplayBook(b)).toBe(true);
expect(roles.isModelBook(b)).toBe(false);
expect(roles.isReferenceBook(b)).toBe(false);
}
});
it('an exchange is visible and may rule, but is never TAKEABLE', () => {
for (const b of roles.EXCHANGES) {
expect(roles.isDisplayBook(b)).toBe(true);
expect(roles.isTakeableBook(b)).toBe(false);
}
});
});
+41 -13
View File
@@ -66,27 +66,55 @@ describe('oddsNormalizer', () => {
});
});
it('filters out books not in the allowed set', () => {
// SUPERSEDED 2026-08-01 (Order Zero). This used to assert bovada was
// DROPPED. It is now emitted for DISPLAY — we were discarding 13 of the
// feed's 18 books, which made 64.8% of the MLB slate invisible. The
// property that replaces it is strictly stronger: the display set widens,
// every row is TAGGED with what kind of price it is, an unknown book is
// still dropped, and the MODEL set does not move.
it('emits every DISPLAY book, tagged with its role, and still drops unknown books', () => {
const { MODEL_BOOKS } = require('../../src/config/bookRoles');
const event = makeEvent({
bookmakers: [
makeBookmaker('bovada', [
bookmakers: ['bovada', 'draftkings', 'prizepicks', 'novig', 'not_a_real_book'].map((k) =>
makeBookmaker(k, [
makeMarket('player_points', [
makeOutcome('Over', 'Jokic', -110, 26.5),
makeOutcome('Under', 'Jokic', -110, 26.5),
]),
]),
makeBookmaker('draftkings', [
makeMarket('player_points', [
makeOutcome('Over', 'Jokic', -110, 26.5),
makeOutcome('Under', 'Jokic', -110, 26.5),
]),
]),
],
])),
});
const result = normalizeProps([event]);
expect(result).toHaveLength(1);
expect(result[0].book).toBe('draftkings');
const byBook = Object.fromEntries(result.map((r) => [r.book, r.book_role]));
expect(byBook).toEqual({
draftkings: 'both',
bovada: 'reference',
novig: 'reference',
prizepicks: 'dfs', // shown for breadth, tagged, never a market price
});
expect(byBook.not_a_real_book).toBeUndefined(); // unknown books still dropped
expect(ALLOWED_BOOKS).toBe(MODEL_BOOKS); // the alias still means MODEL
});
it('MODEL INPUT IS BYTE-IDENTICAL despite the display widening', () => {
// The grader re-filters to MODEL_BOOKS before first-row-wins, so widening
// what the surfaces show cannot change a single graded prop. This is the
// gate that lifts only when the consensus ruler is promoted.
const { dedupeProps } = require('../../src/services/gradeSlateService').__internals;
const event = makeEvent({
bookmakers: ['prizepicks', 'novig', 'bovada', 'draftkings'].map((k) =>
makeBookmaker(k, [
makeMarket('player_points', [
makeOutcome('Over', 'Jokic', -110, 26.5),
makeOutcome('Under', 'Jokic', -110, 26.5),
]),
])),
});
const graded = dedupeProps(normalizeProps([event]), 25);
expect(graded).toHaveLength(1);
expect(graded[0].book).toBe('draftkings'); // exactly what it was before
});
it('maps every market key to its internal stat_type (NBA + soccer)', () => {