Files
vyndr/specs/model-train.md
T
builtbykev 669479097c Model Train G-b/C-cal: gate simulation + calibration report (docs only)
REPORT-FIRST per the arc order. G-a is HELD — the data changes the
recommended dials. No engine code touched.

Replayed against live ledger_entries (576 rows, 6 game days, 470 settled)
because the "30 days of stored snapshots" does not exist: snapshot Redis
keys are latest/previous only at 24h TTL, and no backtest harness exists
anywhere in the repo.

Findings that change the plan:
- The -400 floor shipped this morning was the whole win: past -400 hit
  80.3% against an 86.9% breakeven = -13.29u / -7.7% ROI on 173 settled.
- Arc 2's incremental cut over the live gate is ~11 props in 6 days. The
  only material change is gating the flex band behind 2x EV.
- The flex band (-161..-250) is our BEST band (+2.2% ROI, n=70) and the
  takeable band is flat (-0.3%, n=209) — the opposite of the assumption
  behind EDGE_FLEX_WALL. Recommend shipping the knob with enforcement
  OFF until EV is persisted and measured.
- ev_pct/p_win are on NO ledger row, so the EV half of the gate cannot be
  replayed at all. C-led (persist EV) is now the highest-leverage item.
- Confidence is monotonic but understates hit rate by ~20-25 points, and
  the entire public ledger contains only B and C grades — zero A/A+.
  That breaks hero v2 (isAB) and undermines "A-RATED" copy. Escalated.
- L-a answered: alt_lines carry NO odds and the feed has no alternate
  markets. L-b is blocked on a data source, not engine work.
- C-led needs no odds backfill (locked_odds 99.1% populated).
- U-deg: the projection==0 leak is already closed (0 since 07-18).
- C4 confirmed in data (359/376 MLB closes == the lock). Stays suppressed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
2026-07-19 18:04:29 -04:00

14 KiB
Raw Blame History

MODEL TRAIN — VALUE ENGINE

Status: Arc 1 (steps 16) 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.

2A. ARC 2+ BOARD — STATUS (Kev's arc list, 2026-07-19)

Full arc definitions live in the Session-63 order. Status only here; update as each ships.

Item Status Note
G-a the real gate HELD for ruling Built nothing yet — the G-b report changes the recommended dials. See specs/audit-data/gate-simulation.md §2.4.
G-b gate simulation REPORTED specs/audit-data/gate-simulation.md. Replayed over the ledger, NOT snapshots (no 30d snapshot store exists).
G-c never-empty honesty open Rare but real on thin MLB nights.
S-a line-moved truth open Shares plumbing with C-clv — build together.
S-b board ranks on EV open Blocked-ish: EV not persisted; live payload has it.
L-a per-rung odds? REPORTED — NO Rungs are synthetic, carry no price. Feed offers no alternate markets. §3 of the report.
L-b per-rung EV ladder BLOCKED Needs a real price per rung. Probe PropLine first.
C-cal calibration REPORTED Confidence monotonic but ~20-25pts miscalibrated; only B/C grades ever emitted.
C-led ledger speaks value open — recommended next locked_odds already 99.1% populated: units/ROI need no backfill. EV columns are net-new, forward-only.
C-clv fix C4 open Confirmed broken in data (359/376 MLB closes == lock). Keep suppressed.
D-ref visible refusals partial Copy already ships on refusals; new gate_* reasons land with G-a.
D-par parlay lab open Gate must NOT apply inside the Lab.
D-tier tier gating DECIDED, not enforced Free/Analyst: value marker + grade + triplet. Desk: ladder + per-rung EV + Kelly. Today ladder/Kelly are already Desk-gated; triplet ungated = correct per this ruling.
D-ev consolidate EV open devig.evPct vs processing/EVCalculator.js (used only by UnifiedOddsProvider).
U-deg MLB degradation STATUS REPORTED projection==0 leak already closed (0 occurrences since 07-18). edge_pct scale still broken.
U-fp Arc 1 fingerprint open Do it on the first deploy this train ships.

Backtest harness

None exists (grep-verified). migrations/006 defines grade_outcomes + player_calibrated_weights and no code reads or writes them. The G-b/C-cal replay was done in SQL against ledger_entries; a real harness is still owed before any weight change.

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.