From 18bf3ecb51b4118aaae24ce04690eb9a3aea7977 Mon Sep 17 00:00:00 2001 From: Kev Date: Sun, 19 Jul 2026 17:56:09 -0400 Subject: [PATCH] Reconcile the record: STATE.md header + Model Train arc 1 spec (docs only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arc 1 (7a925f4) shipped without a spec and without a STATE.md entry — the plan lived only in a session context that was lost. Both written from the code on disk, not from memory. - STATE.md: header was stale at a8e383e; now 7a925f4 (pushed, NOT yet deploy-fingerprinted). New top section records de-vig + EV + takeable/ value gates + hero v2 + the value triplet, the real config values, and the 274/3289 -> 276/3306 test baseline. - specs/model-train.md (NEW, per CLAUDE.md rule #1): every knob and its ACTUAL default (TAKEABLE_ODDS_CEILING -160, TAKEABLE_ODDS_MAX +200, VALUE_EV_THRESHOLD 2, JUICE_ODDS_FLOOR -400, RARE_EVENT_LINE_MAX 0.5); the gate AS BUILT (flat price-only refusal at -400 pre-feature, NOT edge-aware, no -250 wall; the -160..+200 band never refuses a grade and is enforced on the hero alone); the triplet + hero v2; and a checklist of what is NOT built. Recorded honestly rather than assumed: EDGE_FLEX_WALL, HARD_JUICE_WALL, LADDER_ODDS_MAX and MIN_RUNG_PROBABILITY do not exist in the codebase; no frontend reads ev_pct/fair_odds/model_odds/book_odds/suppressed_reason yet; the value fields are ungated to free tier; a second EV implementation (processing/EVCalculator.js) still coexists with devig.evPct. No engine code touched. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA --- specs/STATE.md | 30 +++++- specs/model-train.md | 214 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 243 insertions(+), 1 deletion(-) create mode 100644 specs/model-train.md diff --git a/specs/STATE.md b/specs/STATE.md index 5ca519b..954e8c1 100644 --- a/specs/STATE.md +++ b/specs/STATE.md @@ -1,5 +1,33 @@ # 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. +### As of `7a925f4` (main, pushed to gitea; NOT yet fingerprinted live), 2026-07-19. This file opens every future session. Update it when a train ships. + +## MODEL TRAIN — ARC 1 (ENGINE) SHIPPED (2026-07-19, on main `7a925f4`, pushed, NOT yet deploy-fingerprinted) +Spec written retroactively: `specs/model-train.md` (arc list + what's open). Goal of the +train: make "real opportunities at takeable prices" the ENGINE, not a display filter. +Arc 1 = steps 1–6, backend only. **No frontend consumes the new fields yet** — the +triplet/VALUE marker/refusal copy are Design's Session-2 surfaces. +- **De-vig** (`src/utils/devig.js`, NEW): two-way multiplicative de-vig → fair prob + + fair American price per side + `overround`. One side unpriced → fair is NULL, never + faked. Also `americanToImpliedProb` / `americanToDecimal` / `impliedProbToAmerican`. +- **EV** (`devig.evPct`): `ev_pct = modelProb × decimal − 1`, at the graded side's + ACTUAL price. This is the ranking signal now, replacing raw |model − consensus|. +- **Takeable / value gates** (`src/config/valueEngine.js`, NEW): `TAKEABLE_ODDS_CEILING` + **−160**, `TAKEABLE_ODDS_MAX` **+200**, `VALUE_EV_THRESHOLD` **2** (%) — all env-tunable. + `takeable` = price in band; `value` = takeable AND ev ≥ threshold. Strict null guards + (`Number(null)===0` would have made a missing price "takeable"). +- **Hero v2** (`heroPropService`): the hero is the highest `ev_pct` among **takeable** + A/B reads — a huge model-vs-line gap on a −900 line is trivia, not an opportunity. + Falls back to most-recent-real graded read on an empty slate (unchanged). +- **Value triplet** on every graded read: `book_odds` · `fair_odds` · `model_odds` + (+ `fair_prob`, `overround`, `devig_method`). Wired inside `analyzeViaEngine1`'s + existing p_win/Kelly block; rides the `...result` spread through snapshot / hero / + scan. Documented in `BACKEND_HANDOFF.md`. +- **Test baseline: 276 suites / 3306 tests green** (was 274/3289), `next build` exit 0. + New suites: `devig.test.js`, `valueEngine.test.js` (+ `heroPropService.test.js` rewritten). +- **Knobs NOT in this arc** (asked about, confirmed absent from the codebase): + `EDGE_FLEX_WALL`, `HARD_JUICE_WALL`, `LADDER_ODDS_MAX`, `MIN_RUNG_PROBABILITY`. + The only price wall on disk is the flat `JUICE_ODDS_FLOOR` (−400) from the + rare-event work below — it is **not** edge-aware, and there is no −250 wall. ## 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. diff --git a/specs/model-train.md b/specs/model-train.md new file mode 100644 index 0000000..56f84f5 --- /dev/null +++ b/specs/model-train.md @@ -0,0 +1,214 @@ +# MODEL TRAIN — VALUE ENGINE + +**Status:** Arc 1 (steps 1–6) SHIPPED on main `7a925f4` (2026-07-19). Arcs 2+ OPEN. +**Written retroactively** (2026-07-19) per CLAUDE.md rule #1 — Arc 1 was built from a +plan that lived only in a session context that was lost. Everything in the "SHIPPED" +sections below was read off the code ON DISK, not from that plan or from memory. + +**Doctrine (Kev):** VYNDR promotes bets people actually take — roughly the **−160 to ++200** band — that ALSO carry a genuine vig-free edge. Not plus-money-only, not heavy +chalk. *Grade* answers "is this a good read"; *value* answers "does the price pay you." +They are separate fields and must stay separate. + +--- + +## 1. WHAT ARC 1 SHIPPED + +### 1.1 De-vig — `src/utils/devig.js` (new) + +Two-way **multiplicative** (proportional / normalized-implied-probability) de-vig. +Each side's implied probability is divided by the sum of both; the sum's excess over 1 +is the `overround`. Method is recorded on the payload as `devig_method:'multiplicative'`. + +| Export | Behavior | +|---|---| +| `americanToImpliedProb(a)` | `+`: `100/(a+100)`; `−`: `−a/(−a+100)`. Null on non-finite or `0`. | +| `americanToDecimal(a)` | Total-return multiple incl. stake. Null on non-finite or `0`. | +| `impliedProbToAmerican(p)` | Null outside `(0,1)`. `p>0.5` → negative (favorite). | +| `devigTwoWay(overOdds, underOdds)` | `{method, overround, over:{fair_prob,fair_odds}, under:{…}}`. **Returns `null` if EITHER side is missing/invalid** — fair values are never synthesized from one side. | +| `evPct(modelProb, american)` | `modelProb × decimal − 1`, as a percentage rounded to **one decimal**. Null on bad input. | + +Rounding: probabilities/overround to 3 dp (`round3`); `ev_pct` to 1 dp. + +### 1.2 Config knobs — the ACTUAL values on disk + +**`src/config/valueEngine.js`** (new — the promotion gates): + +| Knob | Env var | Default in code | Meaning | +|---|---|---|---| +| `TAKEABLE_ODDS_CEILING` | `TAKEABLE_ODDS_CEILING` | **−160** | Most-juiced favorite we will PROMOTE. | +| `TAKEABLE_ODDS_MAX` | `TAKEABLE_ODDS_MAX` | **+200** | Longest dog we will PROMOTE. | +| `VALUE_EV_THRESHOLD` | `VALUE_EV_THRESHOLD` | **2** (i.e. 2 % EV) | Minimum EV for the `value` flag. | + +- `isTakeable(american)` → `a >= −160 && a <= +200`. **Strict**: `null`, `''`, + `undefined`, and non-finite are `false` (the `Number(null)===0` fabrication trap). +- `isValue(american, evPct)` → `isTakeable(american) && Number.isFinite(evPct) && evPct >= 2`. + +**`src/config/rareEventMarkets.js`** (pre-existing, from the same-day rare-event work — +the refusal layer that sits UNDERNEATH the promotion gates): + +| Knob | Env var | Default in code | Meaning | +|---|---|---|---| +| `JUICE_ODDS_FLOOR` | `JUICE_ODDS_FLOOR` | **−400** | Graded side at or past this price → **refused, not graded**. | +| `RARE_EVENT_LINE_MAX` | — (constant) | **0.5** | Line at/below which the rare-event rules apply. | +| `RARE_EVENT_STATS` | — (constant) | `doubles, triples, home_runs, stolen_bases, steals, blocks` | Backup list for props with NO odds. | + +**Knobs that DO NOT EXIST** (verified by grep across `src/`, `web/src/`, `tests/`): +`TAKEABLE_CEILING` (real name is `TAKEABLE_ODDS_CEILING`), `EDGE_FLEX_WALL`, +`HARD_JUICE_WALL`, `LADDER_ODDS_MAX`, `MIN_RUNG_PROBABILITY`. If those were in the +original plan, they are **unbuilt** — see §3. + +### 1.3 What the gate ACTUALLY does today + +Reported as-built, not as-designed. There are **two independent layers**, and only the +first one can refuse a grade: + +**Layer A — the refusal wall (`JUICE_ODDS_FLOOR`, −400).** In `analyzeViaEngine1`, the +FIRST thing that happens — before `computeFeaturesForProp`, before any projection: + +``` +if (isTooJuiced(rawProp)) → suppressedRareResult('juiced_no_edge') +``` + +Characteristics of the shipped behavior: +- **A flat price wall, NOT edge-aware.** It looks only at the graded side's American + price. No EV, no projection, no edge, no flex band participates in the decision. +- **The wall is −400, not −250.** There is no −250 constant anywhere in the codebase. +- It runs **pre-feature**, so a refused prop costs zero feature computation. +- Odds absent → `isTooJuiced` is `false` (can't judge from a price that isn't there); + the structural `RARE_EVENT_STATS` rule is the backup for that case. +- Output is a refusal, not a bad grade: `grade:null`, `insufficient_data:true`, + `suppressed:true`, `suppressed_reason:'juiced_no_edge'`, plus branded "No read —…" + copy in `reasoning.summary`. + +The other two refusals in the same family: `rare_event_under` (rare stat, under, line +≤ 0.5 — pre-feature, needs no projection) and `rare_event_over_below_line` (rare stat, +over, line ≤ 0.5, and the model does NOT project above the line — checked after the +projection exists). + +**Layer B — the takeable band (−160 … +200).** This gate **never refuses a grade**. +It is a *promotion* filter, applied only where a read is featured: +- Today its ONLY enforcement point is `heroPropService.pickHeroProp` + (`if (!isTakeable(g.book_odds)) continue`). +- Every graded read still carries `takeable` / `value` as flags for consumers. +- The full board shows every graded read regardless of band. Parlay Lab is exempt by + design (juiced legs combine into takeable payouts). + +So: **edge-aware promotion, price-only refusal.** The `-400` wall decides what gets +graded; EV + the `−160/+200` band decide what gets promoted. + +### 1.4 The value triplet + +Attached in `analyzeViaEngine1`'s existing p_win/Kelly `try` block (so it inherits the +"real quantile probability × real book odds, or nothing" rule, and the whole block is +additive — a throw never breaks the read): + +| Field | Source | Absent when | +|---|---|---| +| `book_odds` | the graded side's actual price (`under_odds` if under, else `over_odds`) | no side odds | +| `fair_odds`, `fair_prob` | `devigTwoWay(over, under)` for the graded side | **either** side unpriced | +| `model_odds` | `impliedProbToAmerican(pWin)` | no quantile probability | +| `overround`, `devig_method` | from the de-vig | either side unpriced | +| `ev_pct` | `evPct(pWin, sideOdds)` | no pWin or no side odds | +| `takeable` | `isTakeable(sideOdds)` | no side odds | +| `value` | `isValue(sideOdds, ev)` | no ev | + +The intended render (Design's, not built): *"book −145 · vig-free −132 · model −110."* + +### 1.5 Hero v2 — `heroPropService` + +Old rule: largest `|projection − line|` among A/B candidates. **New rule:** among A/B +candidates, keep only those with a finite `ev_pct` **and** a takeable `book_odds`, then +take the **highest `ev_pct`**. The `gap` is still computed and returned for display, but +it no longer selects. Empty-slate fallback (most recent real graded read, any grade, by +timestamp) is unchanged. `toHero` now also passes through +`ev_pct / value / takeable / book_odds / fair_odds / model_odds`. + +### 1.6 Distribution + tests + +- Fields ride the existing `...result` / `...data` spreads into `grades:{sport}`, + `/api/snapshot/:sport`, `/api/hero-prop`, `/api/scan`. Documented in + `BACKEND_HANDOFF.md` ("Value Engine fields"). +- `src/utils/tierGating.js` deletes `alt_lines` (below Desk) and `kelly` (below the + Kelly tier). It does **not** touch the value fields — `ev_pct`, `value`, `takeable`, + and the triplet are currently visible to **every tier including free**. That is an + unmade product decision, not a verified choice (see §3). +- Suite: **276 suites / 3306 tests**, green, `next build` exit 0. New: + `tests/unit/devig.test.js`, `tests/unit/valueEngine.test.js`; + `tests/unit/heroPropService.test.js` rewritten for the EV rule. + +--- + +## 2. LAWS THIS TRAIN INHERITS (do not relax) + +- **Absent beats wrong.** One side unpriced → `fair_odds` is absent, never + extrapolated. No price → `takeable` is absent, not `false`-by-coercion. +- **`Number(null) === 0`** is the standing fabrication bug of this codebase. Every new + numeric path gets a strict guard (`valueEngine.isTakeable` is the model). +- **Grade ≠ value.** An A read with `value:false` is honest output: right read, price + gone. Never let the value flag re-letter a grade. +- **Refusal is a product surface**, not an error. Refused reads carry branded copy. +- Market values are REAL book numbers at a timestamp; only projection / grade / edge / + EV / fair price are model output and must be labeled MODEL. + +--- + +## 3. NOT YET BUILT — checklist to reconcile against the full arc list + +Verified absent from the codebase as of `7a925f4`. Kev supplies the complete arc list; +mark each done/open against this. + +**Engine / gates** +- [ ] Edge-aware juice wall — a soft/flex band where a big enough edge can survive a + juiced price (`EDGE_FLEX_WALL`, `HARD_JUICE_WALL` — neither exists; today's wall + is the single flat `JUICE_ODDS_FLOOR` −400). +- [ ] `TAKEABLE_ODDS_*` enforced on any surface beyond the daily hero (featured rows, + top-of-board, alerts). Today: hero only. +- [ ] Alt-line ladder value gates (`LADDER_ODDS_MAX`, `MIN_RUNG_PROBABILITY` — neither + exists). The ladder in `analyzeViaEngine1` is still the S62 build: fixed shifts + `[−1, −0.5, 0, +0.5, +1]`, filtered to `line > 0`, sorted by `edge_pct`. It has + **no odds ceiling and no per-rung probability floor**, and its rungs carry no + `ev_pct` / `fair_odds` / price at all. +- [ ] EV-rank the board itself. `slateAdapter.flattenToEdgeBoard` still ranks on + `edge` (guarded by `EDGE_BOARD_SANE_MAX = 40`), not `ev_pct`. +- [ ] Reconcile the orphan `src/services/processing/EVCalculator.js` (a separate, + older EV path used only by `UnifiedOddsProvider`) against `devig.evPct` — two EV + implementations currently coexist. +- [ ] Multi-way / three-way de-vig, and alternatives to multiplicative (Shin, + power/log). Two-way multiplicative is the only method implemented. +- [ ] Best-price de-vig across books (today the triplet de-vigs whichever single + book's two-sided price came through on the prop). + +**Surfaces (nothing on the frontend reads these fields yet — grep-verified zero hits +for `ev_pct` / `fair_odds` / `model_odds` / `book_odds` / `suppressed_reason` under +`web/src/`)** +- [ ] Value triplet rendered on the reveal card / board row / hero. +- [ ] VALUE marker (and the honest "A but no value" state). +- [ ] Visible-refusal moment — the "No read —…" copy is generated and shipped on the + payload but is not displayed anywhere. +- [ ] EV shown as the headline number instead of / alongside edge %. + +**Product / policy** +- [ ] Tier policy for the value fields — currently ungated to free (§1.6). +- [ ] Alerts on takeable +EV reads (the config comment anticipates "future alerts"). +- [ ] Ledger/record split by `value` (does the model's +EV subset beat its overall + record?). `ledger_entries` stores no EV or fair price today. +- [ ] Settlement/CLV interaction with fair price — CLV is still locked-vs-closing + LINE, not vs closing FAIR price. (Note: **C4 — CLV capture is broken** and + suppressed on public surfaces; see `specs/audit-data/clv-capture-broken.md`. + Any fair-price CLV work is blocked behind C4.) + +**Verification debt** +- [ ] Arc 1 is **not deploy-fingerprinted**. Post-deploy, confirm `/api/hero-prop` + returns a hero with a takeable `book_odds` and a finite `ev_pct`, and that + `/api/snapshot/mlb` grades carry the triplet. +- [ ] Known upstream contamination: **`specs/audit-data/mlb-grade-degradation.md`** — + projection == 0 on ~9/25 MLB grades and a broken `edge_pct` scale. EV is + computed from the quantile probability, not from `edge_pct`, so it is not + directly poisoned — but hero ranking and any EV-vs-edge comparison sit on top of + that pipeline. Fix the degradation before trusting arc-1 numbers in aggregate. + +--- + +*Arc 1 recorded 2026-07-19 from the code at `7a925f4`. Update this file in the same +commit as any arc that ships.*