# VYNDR — Build State ## Last Updated 2026-08-12 ## WNBA v1 (2026-08-13) — the possession/usage feed established ✅ Spec: `specs/wnba-possession-feed.md`. 4,905 tests / 377 suites green, web build exit 0. **DATA INFRA ONLY — no chainFn, no archetype wiring, no shadow, nothing served. MLB / chain shadow / A8 / CALIBRATION_DEPLOYED untouched (test-asserted).** - **SOURCE ESTABLISHED BEFORE PLUMBING.** Python `nba_api` rejected as a dependency (offline in prod). **ESPN site API** chosen — free, no auth, already the host for WNBA schedules/box/live. Verified live 2026-08-13. - **chainFn inputs: ALL PRESENT.** minutes SERVED; usage / possessions / pace / TS% / eFG% **DERIVED EXACTLY** from box components (`POSS = FGA−OREB+TOV+ 0.44·FTA`; the 0.44 free-throw-trip coefficient is the only estimated term). **"Derived" ≠ "proxied"** — these are the quantities themselves, recomputed. Game-state for the `redistribute` hook (starter, final_margin) SERVED. MISSING and named: shot location, on/off lineups, opponent DRtg (the last is derivable later from the same rows). - **POINT-IN-TIME IS NATIVE — no `statcast_history` twin needed.** Stores **PER-GAME** rows, not a season aggregate upserted in place, so an as-of profile is `WHERE game_date < asOf` over immutable completed box scores. **Strictly `<`** — a game ON the date may tip after grade time (`isPreGame`). This is the statcast lesson applied at design time instead of retrofitted. - **`wnba_player_game`** (migration 039), key `(game_id, source_id)`, registered in `tableKeys`. Every derived rate stored **beside its components** so it is re-derivable (`factorFreeze` rule for a feed). Scoped to the chainFn — not a stats dump (the `closing_captures` lesson). - **MEASURED, full live season:** 5,085 rows · 256 games · 241 players · 15 teams · 2026-05-01→08-12 · **0 fetch errors**. minutes/possessions/pace/starter/margin 5085/5085; usage 5072/5085. ts_pct/efg_pct gaps are **correct refusals** (no attempts ⇒ no percentage), not missing data. - **TWO CONTAMINATING GAMES FOUND AND EXCLUDED:** an exhibition vs **Nigeria** (05-02) and the **All-Star game** (07-25). Filter reads **ESPN's own `/teams`**, not a hardcoded fifteen — this league has expanded twice in three years. An EMPTY teams response filters NOTHING (a failed feed must not masquerade as an honest absence — the `fielding_oaa` lesson). Concrete effect: Natasha Howard 37→36 games, usage 21.897→22.057. - **AS-OF VERIFIED ON REAL DATA through the real function:** asOf 06-24 → 18 games (usage 24.826), asOf 08-12 → 35 (22.057), asOf 12-31 → 36 (22.027) — the profile MOVES with the date. Same-day game EXCLUDED; thin history REFUSED (null, never a league-average stand-in). Usage is **minutes-weighted** (the lineup-K-rate lesson). - **⛔ NOT INGESTED TO THE DB.** `SUPABASE_DB_PASSWORD` fails pooler auth and the direct host is IPv6-only (unreachable from WSL2). Migration 039 written and UNAPPLIED; `ingestRange` written and has never written a row. All coverage above was measured by running the real parsers + real as-of read **in memory** over the live season — parse/derivation/as-of verified, **DB round-trip not**. Migration 038 also still outstanding. ## chain v3 (2026-08-12) — E[PA] fixed; artifact vs signal separated ✅ Spec: `specs/chain-v1.md` §9. 4,882 tests / 376 suites green, web build exit 0. **Still SHADOW — served byte-identical, `CALIBRATION_DEPLOYED` `[]`, counter serves, A8 / hitsFactors / WNBA untouched.** - **THE ORDER WAS HALF RIGHT, and the right half was a real defect.** The v2 shadow passed **no `lineupSlotFor`**, so every hitter ran on `DEFAULT_PA = 4.1` — "a regular" asserted about the leadoff man and the nine hole alike. `rate × opportunity` with opportunity held constant across the lineup. FIXED: `matchupKeys` now carries `batting_order` (one extra column on a read it already does — no new query); **posted slot on 491/553 props (88.8%)**. - **WRONG about the conversion:** `paDistribution` is a mean-preserving two-point mixture and `atLeast(Binomial(n,p),1)` **IS** `1−(1−p)^n` averaged over n — the order's proposed formula is what the code already computed. The defect was the INPUT E[PA], never the structure. - **WRONG about the direction, and this matters:** claimed "5.7pts BELOW on 92%". MEASURED **+4.5pp ABOVE**, below on **33.6%**. There was never a uniform low bias to collapse. A/B on identical rows: real E[PA] mean **+0.0450** / below 33.6%; constant 4.1 **+0.0322** / below 39.6%. The fix moved the chain FURTHER above — correct, not a regression (real slots raise E[PA] for the top of the order, and top-of-order hitters dominate the prop board). - **THE DIAGNOSTIC — the answer is BOTH, not the order's either/or.** Over side, n=3,705, quintiles of opposing-pitcher K%: Q1 (soft) **+0.0749** → Q5 (hard) **+0.0256**; spread **+0.0494**, Pearson r = **−0.119**; `below %` **monotone across all five** (27.8 → 30.8 → 34.1 → 35.0 → 38.6). - A **difficulty-correlated component** (~+0.049) — the chain conditions. - A **uniform positive offset** (~+0.026) surviving into the HARDEST quintile — does not move with the matchup, so it is not conditioning. Unexplained. Calling the whole result "signal" would bury it. - **THE CAVEAT THAT TRAVELS WITH IT:** the chain reads opposing-pitcher K% directly (log5 in `paOutcome`) and the counter reads nothing about the pitcher, so the correlation is close to mechanical — it proves the WIRING reaches the number, **not** that the adjustment is right. Correctness needs settled outcomes against the stored triple. - **Open item:** the ~+0.026 floor. Untested candidates — the chain is unclamped where the counter clamps `[0.10,0.95]` (chain min 0.016 vs counter 0.050); `LEAGUE.babip = 0.291`; the ±35% BABIP bound. ## chain v2 (2026-08-12) — the hand split plumbed; the premise corrected ✅ Spec: `specs/chain-v1.md` §8. 4,877 tests / 376 suites green, web build exit 0. **Still SHADOW — served payload byte-identical, `CALIBRATION_DEPLOYED` still `[]`, counter still serves, A8 / hitsFactors / WNBA untouched.** - **THE ORDER'S PREMISE WAS WRONG, and checking it first was the work.** Claimed "paOutcome returned null on 596/725, 82% fell back to the seasonal rate". MEASURED: fire rate **9,752/9,792 (99.6%)**, sole refusal reason `no_batter_profile: 40`. `paOutcome`/`hitOnContact` **read no handedness at all** — a hand split cannot change whether they run. And **there is no fallback path**: a refusal DROPS the atom, it never becomes a season rate. (The "425/425" figure is from `7c8ef8b`, the A1–A7 deploy verification, not the chain.) - **The premise was right about what MATTERS, though:** the chain read a hitter's SEASON rates, which already average his platoon split over whatever hands he faced. That is a season read wearing a matchup read's clothes. - **The split now enters at the RATE** (`p_hit_per_pa × platoonRead`), then the binomial over PA — not at the output probability, which would scale a number already through the opportunity term. A test asserts the no-split path is **arithmetically identical to `projectSkill`** so the restructuring cannot become a second model. - **MEASURED: hand split fires on 288/553 unique props (52.1%)**, from 0. Of the 265 that don't, **190 (72%) are principled refusals** — `insufficient_split_ sample` 130 (the 60-PA floor), `switch_hitter` 60. Only `no_pitcher_hand` 71 is a plumbing gap, and it is the A5 shape: split present, pitcher hand absent. - **Divergence WIDENED**: median |div| 0.082 → **0.095**, over-side lean +2.9pp → **+4.5pp**. Fired rows disagree slightly LESS than refused rows (0.091 vs 0.100) — **confounded, not an effect**: 60+PA-both-sides means an established regular, and the counter has more log on him too. - **Counting bug caught in the first draft:** the hand-split rate was counted per LEG while divergence was sliced per BLOCK (a prop's over+under share one block), reporting 48.1% and 55.1% for the same fact. One denominator now, taken from what is persisted. - **Second unreachable seam found:** `hitsFactorContext.build` / `matchupKeys.build` were gated on an inline `getSupabaseServiceClient()`, so the whole factor + hand-split path was untestable — "it is wired" could only rest on reading the code, which is how A5 shipped three factors that never fired. Both now take `deps.supabase ||`. ## chain v1 (2026-08-12) — the engine made whole, shadowed on MLB ✅ Spec: `specs/chain-v1.md`. 4,859 tests / 376 suites green, web build exit 0. **SERVED BY NOTHING — served payload byte-identical, proven with the shadow on and off. `CALIBRATION_DEPLOYED` still `[]`. A8, hitsFactors and WNBA untouched.** - **THE CORE WAS A SHELL OF ITS OWN HEADER.** `chainFn` — the atom→probability slot — was described in the header and **absent from the code** (no parameter, no call site, no export). That is why the "portable core" was not portable: with no slot for that stage, MLB's real work lived in `scripts/`, called by no pipeline. Added, defaulting to identity-on-`p` so every prior caller is unchanged. An unreadable atom is DROPPED and COUNTED, never `p=0`. - **Correlation un-clamped to [-1,1], direction follows the sign.** `|corr|` interpolates from independence toward the **Fréchet bound its sign selects** (+1 → `min(p)`, −1 → `max(0, Σp−(n−1))`). Positive is arithmetically unchanged. Basketball's negative usage-competition case was previously *inexpressible*. - **`redistribute` now reaches chainAcross too.** Reaching only the team read left `selfCheck` comparing post- to pre-redistribution atoms and flagging an inconsistency the model had just manufactured. `prepareAtoms` is exported so both readings are built from identical legs. - **Baseball's chainFn** (`baseballChain.js`) routes `paOutcome` → Binomial(PA, p_hit) over `paDistribution`. Opportunity is a **LOOKUP** (`PA_BY_SLOT`, 4.65→3.85), not a fit — baseball's opportunity is fixed, which is why it is the clean first fill. Hits only; TB's verdict is already muddy. - **First real measurement** (`scripts/chain-shadow-probe.js`, 9,376 graded rows, 08-07→08-12): chain fires on **99.6%**; median |divergence| **0.082**; 40.7% differ by ≥0.10, 10.3% by ≥0.20, 12.9% agree inside 0.02. The two-sided signed symmetry is **arithmetic** (both sides sampled); the over-side slice leans **+2.9pp, higher on 60.7%**. Divergence is not merit. - **The triple `(chain_p, counter_p, outcome)`** lands on `model_snapshots. chain_shadow` (migration 038), side-aligned. `selfCheck` is labelled `vacuous: true` — no independent team read exists, so agreement is arithmetic. - **Found + fixed:** `runSnapshot`'s `deps` was an allowlist of 17 keys while 14 call sites read unlisted `deps.X` — every one permanently `undefined`, every documented injection seam a comment only. `...opts` spread first. ### ⛔ BLOCKING PRECONDITION — migration 038 BEFORE this deploys `retentionService.rowsFromSides` now declares `chain_shadow` on **every** row (it must — PostgREST builds a bulk insert from the FIRST row's shape, so a key present on only some rows is dropped for the whole batch). If the column does not exist, **every retention insert fails**, and retention is best-effort, so it fails SILENTLY — the exact "retention writes nothing" shape the S64 ops alarm exists to catch. Apply `supabase/migrations/038_chain_shadow.sql` first. Not applied yet: this branch is uncommitted, pending Roundtable review. ## Reclaim 2 (2026-08-12) — lock_lines dropped, writer retired ✅ 4,803 tests / 374 suites, web build exit 0, app healthy. **Moat untouched (`ledger_entries`, `model_snapshots`); grade path untouched.** - **DROPPED `lock_lines`**: 367,595 rows / 104 MB, zero readers anywhere, zero dependents (no FK/view/trigger). Dump held all 367,595 rows, verified first. - **RETIRED the writer** (`LOCK_LINE_CAPTURE=1` to re-arm; also needs the table re-created). Was 36,440 rows/day — 23% of all DB growth. - **DB 510 MB → 406 MB — 81% of cap, +94 MB headroom.** Under the free-tier cap for the first time in months. Growth ~45 → ~35 MB/day. - 10 new tests; `buildLockRows` kept and still covered. ### STILL OPEN — the survey's conclusion stands B1 (`2271f46`) and B2 (`fb00102`) are pushed but **NOT deployed** — prod is on `f61ec6b`. Until B2 lands, `missed_window` resumes at ~66 MB/day tonight and eats the 94 MB headroom in ~36 hours. **Deploy is the next action.** Even with B2 live, the hot floor (~372 MB, +33 MB/day) crosses 500 MB in ~4-5 days. Reclaim 2 bought ~3 days, not a solution. Pro remains the arithmetic answer; the Roundtable decides. ## Fix B2 (2026-08-12) — halt the closing_captures bleed at source ✅ 4,793 tests / 373 suites, web build exit 0. **No rows deleted. Grade untouched. Readers untouched. No R2 involved.** - `closingCapture.persist()` no longer writes `missed_window` rows (both odds null). Priced + `one_sided_price` + the rare fault refusals persist unchanged. Revert with `CLOSING_PERSIST_MISSED_WINDOW=1`. - **Write reduction, measured on 7 days of production: 400,469 → 67,862 rows/day (−83.1%); ~80 MB/day → ~13.5 MB/day at 199.6 bytes/row.** - **Ops alarm intact** — `captureRateAlarm` is pure and the caller still passes the full in-memory array, so a late pass still pages even though the refusals are no longer stored. 13 tests, incl. a 10-priced/90-late pass asserting the alarm still fires at 0.10. - **The 4,236,398 historical rows are now STATIC** (975 MB of a 1,314 MB DB). The cleanup is now a calm scheduled decision, not a race — and a delete will no longer be undone by regrowth. ## Fix B1 (2026-08-12) — dCLV close-leg filter ✅ 4,780 tests / 372 suites, web build exit 0. **Grade untouched (0 grade-related diff lines); no rows deleted or archived.** - Added `.is('missed_reason', null)` + both-odds-not-null to the close leg (`ledgerService:625`), matching `attachClosingProb:455`. Filter applied BEFORE the limit, so it is "latest PRICED" not "latest, hopefully priced". - **dCLV: 100% `unknown` → flat 45.8% / negative 23.0% / positive 22.8% / unknown 8.3%** on 600 real settled rows. ClvBadge (analyst/desk) will render **MOVED TOWARD US 114 · MOVED AWAY 117** per 600 rows — ~38% of rows gain a badge that showed nothing before. - 8 regression tests, incl. one proving the fixture actually exercises the old bug (an unfiltered read returns `unknown` on the same data). - **`missed_window` is now genuinely unread** — the precondition the RECLAIM order correctly stopped on. ### RE-STAMP DECISION (described, NOT run) 14,083 settled rows carry a persisted `dclv_state='unknown'`. `settleLedger` is first-computation-wins, so they will NOT recompute. To correct them: `UPDATE ledger_entries SET dclv_computed_at = NULL WHERE dclv_state = 'unknown'` then re-run the settle pass — it would recompute from the (unchanged, immutable) `closing_captures` and `model_snapshots` history. Arguments both ways: FOR — those rows are wrong, and the history to fix them exists. AGAINST — first-computation-wins exists so a badge a user saw never flips; these rows currently show NO badge, so a re-stamp only ADDS badges (it cannot flip a shown one), which weakens the objection considerably. Recommend running it, as its own order, AFTER the fix has been live a full slate and the new-row distribution is confirmed against the simulation. ## DEPLOY (2026-08-12 03:10 UTC) — A1–A7 LIVE, grade unchanged ✅ Commit **f61ec6b** pushed to gitea, auto-deployed by Coolify, first slot wrote 2026-08-12T03:10:52Z. Pre-deploy gate: 4,772 tests / 371 suites, web build exit 0, read-integrity harness 34/34. - **GRADE UNCHANGED — the pass condition. 425 frozen rows, 425 live-unfed, ZERO leaks.** The live context never received the join keys, so `analyzeViaEngine1` never adjusted `p_win`. Structural, not statistical. - **Shadow accruing:** 353 rows with `would_fire`; `pitcher_contact_profile` 353, `defense_by_direction` 255, `platoon_severity` 249; avg 2.43 factors/row; multiplier min 0.786 / median 0.973 / max 1.225; **all 353 would move**. 18 distinct opponents, 18 distinct pitchers. - **A3 stamp FLIPPED:** ledger aligned-marker rows 0 → **625**. History NOT re-stamped (old marker still 19,316) — by design. - **A3 settlement drained on the cron:** 28,894 → **38,421** settled; settled_through 2026-08-08 → **2026-08-10**. - **Writes landing under the cap** — but the DB is now **1,303 MB vs a 500 MB free-tier cap (261%)**, up from 1,037 MB at Phase 0. Reclaim is the next order. - **ACCRUAL CLOCK: DATE 0 = 2026-08-12.** A8 runnable ≈5 slates out. ## Fix A0 (2026-08-09) — Withdraw unknowable verdicts + read-integrity harness ✅ 4,569 tests / 363 suites green (repo config). **Measure-only: no production reader edited, no eligibility opened, no delete, no settle, no schema.** - **SHIPPED** `specs/read-integrity-harness.md` (spec-first), `src/utils/readIntegrity.js` (pure + injectable core), `scripts/read-integrity.js` (CLI + declarative 25-reader registry), `src/services/model/withdrawnVerdicts.js` (append-only withdrawal record), 30 new unit tests across two suites. - **AC5 verified on prod:** the harness reproduces the probe — known-corrupt `prove-hit-factors:136` at 16.5%, known-clean `challenger-scoreboard:112` at 0%. - **Baseline: 11 FAIL / 13 PASS / 1 KEY_NOT_UNIQUE of 25.** Worst `champion-ablation:173` 33.6%, `proven-status:76` 32.6%, `calibrationService.fromLedger:110` (the one LIVE reader) 24.8%. - **WITHDRAWN:** `defense_by_direction` + `pitcher_contact_profile` on hits → `WITHDRAWN_PENDING_REAUDIT`. Nothing is PROVEN on hits from the factor gate now. - **OPEN, urgent, NOT in this order's scope:** (1) the live `calibrationService.fromLedger` reader is 24.8% corrupt in production; (2) `hitsFactors.js` still serves both withdrawn factors AND `platoon_severity`, which never passed the gate at all. Both are A1/A2 decisions. ## Fix A1 (2026-08-09) — safePaginate + the first corrected reader ✅ 4,592 tests / 365 suites green, web build exit 0. **Served grade FROZEN; `CALIBRATION_DEPLOYED` still `[]`; nothing re-certified, re-deployed or un-withdrawn.** - **SHIPPED** `src/utils/safePaginate.js` (stable ORDER BY on a unique key + runtime uniqueness guard + error propagation, built on the tested `readIntegrity.walk`), 13 unit tests; `calibrationService.loadSettledRows` extracted + converted, 11 unit tests. - **HARNESS: FAIL 24.8% → PASS (0 dup / 0 missing of 2,490)**, measured through the REAL function via the new `readerRows` arm (`arm_a: real_reader_function`). The unfixed twin `lowParamService.fromLedger:78` still measures 24.8% — the control that proves the PASS is not a registry edit. - **CORRECTION to A0:** this reader was never live. `CALIBRATION_DEPLOYED = []` (snapshotService.js:301) means the loop never runs; calibrationService is only the SHADOW; `chainAcross` has no callers. **A1 changed no served number.** - **MAP DELTA (reported, NOT deployed):** certified-band error −0.046 → +0.010, ceiling 0.833 → 0.810, span unchanged at 0.50–0.70, `fitted_through` shifted a day because duplicates moved the time split. ## Fix A2 (2026-08-09) — safePaginate rolled across every FAIL reader ✅ 4,628 tests / 366 suites, web build exit 0. **Served grade FROZEN; `CALIBRATION_DEPLOYED` still `[]`; both hits verdicts still withdrawn; no eligibility opened, no delete/settle.** - **11 FAIL → 0 FAIL.** Full 26-reader baseline: **25 PASS + 1 KEY_NOT_UNIQUE** (the `batter_spray` composite-key data fact, unchanged and correctly named). Worst corruption 33.6% → **0%**. - Every fixed reader verified through the harness's `real_reader_function` arm, i.e. the code that actually runs — not a restated query, never a `fixed:true`. - **PRIMARY calibrator first:** `lowParamService.fromLedger:78` 24.8% → PASS. - **7 scripts converted** via `pageSafe` + named `READS` exports + a `require.main === module` guard (needed because the harness imports them and several write to `mc_test_ledger`; verified unchanged at 168 rows). - **RESISTED THE HELPER:** the context tables have composite PKs with no single unique column, so `paginate()` cannot order them. They measure 0% and are left on `page()`, documented in-file. Composite-key ordering is the open item. - **OPEN:** an unresolved intermittent in `snapshotService.test.js` (2/30 on branch, 0/29 at HEAD — not statistically distinguishable, assertion never captured). Recorded, not dismissed. ## Fix A2b (2026-08-09) — every read clean by construction ✅ 4,699 tests / 366 suites, web build exit 0. **Grade FROZEN; `CALIBRATION_DEPLOYED` still `[]`; verdicts still withdrawn; no eligibility, no settlement, no version-stamp, no delete. No context data altered.** - **34 readers, 34 PASS, 0 FAIL, 0 KEY_NOT_UNIQUE** — up from 26 readers / 1 KEY_NOT_UNIQUE. 30 of 34 measured through the REAL reader function. - **safePaginate takes composite keys**; `src/utils/tableKeys.js` holds the real constraints from `pg_index`. All 7 context tables converted; `batter_spray` resolved (wrong key, not bad data). - **The 3 A2 legacy reads converted**; `KNOWN_LEGACY_READS` is now empty. - **`calibrate-hits.js` found at 24.7%** during the sweep and converted. - **The unordered `page()` helper is DELETED from all 15 scripts.** - **Write-scripts assessed, data untouched:** omission-only failure mode; read is 0% today; historical exposure bounded at ~6.6 players / ~0.16 games. ## Fix A3 (2026-08-09) — eligibility can honestly count ✅ 4,723 tests / 367 suites, web build exit 0. **Grade FROZEN; `CALIBRATION_DEPLOYED` still `[]`; `hitsFactors`, `hitsFactorContext`, `withdrawnVerdicts`, `reAuditEligibility`, `snapshotService` all EMPTY DIFF. No verdict re-run, no calibration re-fit, no historical re-stamp.** - **ONE version source:** `src/config/modelVersion.js` = `engine1@2026-08-07-fullwindow`. Both `ledgerService` and `retentionService` import it; the twin defaults that silently blocked eligibility are gone. - **Snapshot settlement on the cron:** `snapshotSettlementService` wired into `snapshotScheduler` beside the ledger settle. Outcomes only, no context reconstruction. Settled **15,484 → 28,894**; repaired-champion **0 → 7,556**; 0 rows settled with a null actual_value. 24 new unit tests. - **Eligibility (counted, not run):** 2 eligible dates against 10/10/14/14. All four measurements remain BLOCKED — now for the honest reason. ## Fix A4 (2026-08-09) — as-of-correct context ✅ 4,735 tests / 368 suites, web build exit 0, harness still 34/34 PASS. **Grade output UNCHANGED (proven 400/400); inputs NOT frozen (that is A5); no re-audit run, no verdict re-run, no calibration re-fit; `CALIBRATION_DEPLOYED` still `[]`.** - `hitsFactorContext.build(sb, {asOf})` — dated reads bounded `.lte('as_of_date')`, latest-within-bound, **refusal on absence** (never nearest/latest). - Dated statcast comes from `statcast_history` (aggregates keeps one date); verified identical to aggregates at the head, 1,414 rows / 0 diffs. - **Coverage on the two re-audit dates: spray/platoon/handedness 99% and 98.9%**, 2 players short per date. **Defence 0% from the row** — `opponent` is NULL on every settled repaired-champion row; it needs the game-log join. - 12 new unit tests incl. the contamination lock (never a row dated after asOf). ## Fix A5 (2026-08-10) — input freeze, and a finding that reframes it ✅ 4,751 tests / 369 suites, web build exit 0. **Grade UNCHANGED (496/496); no re-audit, no verdict re-run, no calibration; `CALIBRATION_DEPLOYED` still `[]`.** - **FINDING: the three hits factors NEVER FIRE.** 596/596 real props skipped, multiplier 1 every time, because `prop.opponent` / `prop.opposing_pitcher` are never set by anything. They are wired and inert. Plumbing them is a MODEL CHANGE and needs its own order. - **`model_snapshots.factor_inputs` jsonb** (migration 034, APPLIED) freezes the raw inputs at grade time — never the multiplier, which stays recomputable via `factorFreeze.recompute()`. Proven: frozen == read (496/496), recompute == live (496/496). - **Forward-only.** Existing 7,556 settled rows keep NULL `factor_inputs` and NULL `opponent`; defence re-audit on them still needs the game-log join. ## Fix A6 (2026-08-10) — join keys plumbed into a SHADOW resolve ✅ 4,762 tests / 370 suites, web build exit 0. **Served grade FROZEN; no re-audit, no verdict reinstated, no calibration; `CALIBRATION_DEPLOYED` still `[]`.** - `matchupKeys` resolves opponent + opposing pitcher from `lineup_context` (as-of) + schedule probables. Refuses rather than guessing. - **Key resolution 248/308 (80.5%)** on 2026-08-09; 60 refused (no lineup row). - **Shadow fire: pitcher_contact 248, defense_by_direction 185, platoon_severity 147** — up from 0/0/0. Multiplier median 1.017, range 0.803–1.250; 245/248 would move the served number. - Served p_win identical on every evaluated row; shadow recompute-check 248/248. ## Fix A7 (2026-08-11) — shadow accrual proven + A8 pre-registered ✅ 4,772 tests / 371 suites, web build exit 0. **Nothing turned on. Served grade frozen; `CALIBRATION_DEPLOYED` still `[]`; no verdict reinstated; gate NOT run.** - **Accrual is automatic on the scheduled pass** — proven end-to-end through the real `gradeAndCacheSlate → onGraded → rowsFromSides` chain (10 new tests), plus assertions that `runSnapshot` builds the key index pre-grade and degrades safely. - **Complete (would_fire, outcome) pairs today: 0.** `factor_inputs` is 0/122,276 — A5/A6 are not deployed. Accrual begins at deploy. - **`specs/a8-shadow-factor-gate.md`** pre-registers the two-part gate (movement AND Brier, cluster-resampled, cumulative-Bonferroni, new-test α) with named fallbacks. Not run. - **Binding constraint is CLUSTERS: ~10.5 settled games/slate ⇒ `MIN_CLUSTERS` 40 in ~4 slates**, while `MIN_N` 500 is met in 1–2. A8 runnable ≈5 slates after deploy. ### Next - **A8 — run the pre-registered gate** once ≈5 slates have accrued. - **A9 — turn the factors on LIVE**, only if A8 returns PROVES, and with its own before/after on the served board. - **(superseded) A7 — turn the factors on LIVE.** This is the biggest served-grade change in VYNDR's history: ~80% of hits props would move, median +1.7%, tails ±20-25%. Gate it on (a) accrued shadow evidence, (b) a re-proof of the withdrawn verdicts on rows where the factors actually fire. - **(superseded) A6 — plumb `opponent` + `opposing_pitcher` into the graded prop.** This is a MODEL CHANGE (grades will move): measure the delta against the frozen `available` fields first, then decide. - **(superseded) A5 — freeze the factor inputs onto the graded row** so a re-audit does not depend on context tables at all (and so defence stops needing a game-log join). - **(done in A4) as-of-correct context** (`hitsFactorContext.build` has no as-of cutoff; it also orders context walks on a non-unique prefix). **The re-audit RUN is gated on this, not on clean reads.** - **A2c (was A2b)** — composite-key ordering in `safePaginate` so the context tables can be made safe by construction rather than clean by luck; then convert the 3 known legacy reads and register `backfill-context` / `reconstruct-game-environment`. - **A2c** — chase the `snapshotService.test.js` intermittent to an assertion. - **(superseded)** roll `safePaginate` across the remaining FAIL readers, starting with `lowParamService.fromLedger:78` (the PRIMARY calibrator, 24.8%). Each must move FAIL → PASS on a measured re-run through `readerRows`, never on the presence of an `.order()` clause. Remaining: `champion-ablation:173` 33.6%, `proven-status:76` 32.6%, `prove-hit-factors:131/136`, `prove-tb-factors:149/154`, `cluster-prove:337`, `tb-solo-and-interactions:260`, `build-grade-bands:49/54`. - **A3** — decide what to do about `hitsFactors.js` serving two withdrawn factors plus `platoon_severity`, which never passed the gate. - **Re-certification of calibration is a separate gated order** on the accrual clock — not unlocked by A1. ## Session 94 (2026-08-04) — Causally-correct platoon + park inputs ✅ 4,307 tests / 344 suites green, build exit 0. Counter + frozen clusters byte-identical. - **PLATOON SEVERITY built + tested: n=452, 48 short of the gate.** CANDIDATE_PENDING — not proven, not theatre. Uses each hitter's own vs-LHP/ vs-RHP split, shrunk by the smaller side's PA, refused below 60 PA. It moves LESS than flat platoon (0.021 vs 0.026), consistent with the pattern. - **The refusal costs sample honestly** — 452 vs 741 rows is exactly the hitters whose splits are unreadable. - **PARK DIMENSIONS INGESTED** (free, statsapi venue endpoint): fence distances, roof, turf, elevation. Prod-verified 15 venues. Joined by real `venue_id` from the schedule, never inferred from the home team. - **RAW WEATHER RETAINED** (temp/wind_mph/wind_dir/precip) — and the old guard that dropped the environment entirely when the multiplier was 1 is fixed, which had been discarding the forecast on every ordinary night. - **PLATOON/PARK ingest prod-verified:** 270 lineups, 265 platoon, 15 park dims. - **NOT built: park+weather→hit-type.** Its inputs landed this session and carry ONE as_of date; testing it on total_bases needs accumulated dated rows, so building it now would be plausibility not proof. - **Proven for hits: pitcher_contact_profile, defense_by_direction.** ## Session 93 (2026-08-04) — Causally-correct defence atom PROVES ✅ 4,297 tests / 342 suites green, build exit 0. Counter + frozen clusters byte-identical. - **ATOM 1 BUILT AND PROVEN.** `defense_by_direction` (spray×trajectory × positional OAA, joined by handedness) **PROVES** on hits: n=528, Brier −0.0034, CI [−0.0059,−0.0009] at 99.9%. Team-average `defense` remains NOT PROVEN. Kev's insight confirmed: the crude operationalization was the problem. - **It moves the number LESS (0.013 vs 0.030) and is more reliably right** — bigger movement is often the tell, not the signal. - **Zero new sourcing**, as predicted: Savant `leaderboard/batted-ball` has pull/straight/oppo × gb/air (609 hitters, free); per-position OAA is a regrouping of the fielding feed. Prod-verified: spray 609, team_defense 31. - **Proven set for hits now: `pitcher_contact_profile` + `defense_by_direction`.** Both pooled; per-archetype still sample-blocked (BOMBER 225–294, GHOST 86–125). - **ATOM 2 INPUT-BLOCKED.** Weather free source PASSES (Open-Meteo wired, exposes temp/wind_mph/wind_dir/precip) but raw fields are collapsed to a scalar and `wx_forecast` is 0/1119. **Park dimensions are not ingested at all** — a hit-type conversion needs them. Not half-built. - **Next:** retain raw weather fields (cheap), source park dimensions, then build ATOM 2. Sequence/reliever layer stays gated behind proven atoms. ## Session 92 (2026-08-04) — The two-part factor gate; one factor proves ✅ 4,286 tests / 340 suites green, build exit 0. Counter + frozen clusters byte-identical. - **`factorGate.js`** — a factor must MOVE the prediction off the base rate AND improve out-of-sample Brier. Movement alone = **THEATER**, rejected by name. Baseline is the player's leave-one-out base rate (the literal "he's due" null). - **Cumulative correction applied to the INTERVAL** (99.9% at 50 tests). This flipped defense and platoon out of "proves" — a plain 95% CI would have shipped two unproven factors. - **NOT_PROVEN_AT_CORRECTED_BAR added** as distinct from THEATER; conflating them would repeat "insufficient evidence = evidence of absence". - **RESULT (hits, n=741): `pitcher_contact_profile` PROVES** (Brier −0.0066, CI [−0.0114,−0.0016]). defense (−0.0043) and platoon (−0.0039) NOT_PROVEN at the corrected bar. park_hits sample-blocked (n=405). **Zero theater.** - Per-archetype all sample-blocked (BOMBER 252–294, GHOST 67–125). - **Spec gaps found:** approach identities (SPRAY/DAMAGE-DEALER/COUNT-WORKER) don't exist; `parkFactors` has no hits-specific factor (hits → run_base). - **Grade rescale NOT run** — it was gated on factors proving, and one pooled factor with a −0.0066 Brier gain is not a factor-informed distribution. ## Session 91 (2026-08-04) — Hits calibrated point-in-time; parlay partially unblocked ✅ 4,275 tests / 339 suites green, build exit 0. Counter + frozen clusters byte-identical (`p_win` untouched). - **PARTIAL PASS.** Fit <2026-08-02 (n=589) → held-out >= (n=383). Corrected held-out: 0.477→0.506, 0.587→0.580, 0.667→0.603 (raw was +0.191/+0.279/+0.246). Ordering preserved. **Certified band 0.40–0.60 (n=276, err −0.029).** - **HONEST CEILING 0.667** — no 80%+ hit reads survive calibration. 4-leg ticket at ceiling = **0.198**, against 0.686 implied by the raw numbers. - **Banded certification** (`certifyBands`/`inCertifiedBand`) instead of a blanket flag: middle honest (−0.029/+0.007), edges not (−0.167/+0.063). - **`calibrationService`** fits strictly-before-today, splits by TIME, returns null on thin history (⇒ nothing stackable). Wired into the snapshot: hits grades carry `p_win_calibrated` + `calibrated`; `p_win` untouched. - **Parlay surface unblocked for in-band legs only** — `chainAcross` compounds them, cross-game preferred, same-game a labelled approximation. - Caught: a pass condition that demanded ≥0.70 bins exist would have failed the map for succeeding (calibration removes that band). ## Session 90 (2026-08-04) — chaining-v1: portable chain + the calibration gate ✅ 4,269 tests / 339 suites green, build exit 0. Counter + frozen clusters byte-identical. - **HIT-PARLAY BLOCKED (the order's own prerequisite, failed decisively).** n=972: predicted 0.911 → actual 0.630; flat ~63% above 0.70. A 4-leg 91% ticket is 0.686 by the model, 0.157 in fact. `chainAcross` refuses uncalibrated atoms — verified end-to-end on real data. - **`calibration.js`** — reliability table, `isCalibrated` gate (tolerance 0.05, high-end weighted), and `fitIsotonic` (monotone: ordering preserved, numbers corrected). Real map: 0.65→0.594, 0.85→0.639, 0.91→0.639. - **`chain.js`** — the portable core. Aggregator pluggable (ACROSS=parlay, UP=score), sport parts as inputs, archetype-`redistribute` hook (dormant in baseball, live in basketball). Self-check flags internal inconsistency as LOW CONFIDENCE; market divergence flags a contested script WITHOUT claiming we're right. `propagate` is shrinkage-weighted by sample. - **NOT built this order:** the independent game-script projection. It needs proven team-level atoms + out-of-sample validation vs actual margins, and no atom has passed the gate yet — building it now would be plausibility, not proof. - **Next:** fit the point-in-time isotonic map and re-gate hits; that is the unlock for the parlay surface. ## Session 89 (2026-08-04) — Lineup + baserunner context ingested ✅ Spec: commit history + `src/services/lineupContextService.js`. 4,250 tests / 338 suites green, build exit 0. Counter + frozen clusters byte-identical. - **THE INPUT RBI/RUNS ALWAYS NEEDED, ingested free from statsapi.** `lineup_context` (batting order, 153 rows / 10 games) + `hitter_opportunity` (RISP share, 149 rows, range 0.170–0.528). Both DATED in the PK. - **Rung 2 was cheap:** situational splits give the RISP aggregate in ONE call per player, not play-by-play reconstruction. - **Prod-verified** via the new `POST /api/internal/lineup-context/refresh` (added because the first run wrote 0 while the parser worked locally — a 0 is a wiring bug until proven otherwise). - **Coherence check passes:** top RISP-share hitters all bat 4th/5th. - DRIVER/CATALYST theories now INPUT-READY, sample-blocked. Proofs run later under native cumulative correction — ingesting is not proving. ## Session 88 (2026-08-04) — Re-adjudication: nothing to demote, hole closed ✅ Spec: `specs/re-adjudication.md`. 4,238 tests / 337 suites green, build exit 0. Counter byte-identical. Nothing recalibrated — nothing needed to be. - **PROVEN SET IS EMPTY, verified 3 ways** (proven-status, featureRegistry summary, validatedSkills). Zero conditioning entries ever reached PROVEN, so STEP 3 (demote) and STEP 4 (recalibrate) are vacuous — correctly. - **Correction: the cumulative gate did NOT catch a false positive last session.** It caught nothing; it tightened α 0.0026 → 0.0013, demonstrating the mechanism. - **THE REAL HOLE, CLOSED:** `promote()` could bypass cumulative correction. `isSufficient` now requires `bonferroni_tests`, refuses anything below the cumulative count, and refuses a p that doesn't clear 0.05/tests. Same guard on `recordConditioning(PROVEN)`. Verified: no-correction / per-session-8-vs-38 / weak-p all refused; cumulative-38 with p=0.0005 accepted. - **Cumulative correction now NATIVE on all analysis paths** — pitcher-prove-k and tb-solo-and-interactions migrated off per-session counts. - **`reAblation.js` built** (standing second line): pure/injectable, records both p-values + both test counts per verdict, `PENDING_RETEST` when there is no fresh measurement (absence is not evidence). - **Net effect on the proven set: ZERO.** No demotions, no recalibrations, no ledger event — announcing a recalibration that changed nothing would itself be a false signal of rigour. ## Session 87 (2026-08-03) — Defence ingested; cumulative correction locked ✅ Spec: `specs/defense-ingest-and-cumulative-correction.md`. 4,228 tests / 336 suites green, build exit 0. Counter + clusters byte-identical. - **DEFENCE INGESTED (free):** Statcast OAA feed → 514 fielders → `team_defense` (31 teams, dated from row one). Prod-verified: fielding rows 514, team_defense_written 31. Cubs +56 best, Mariners −29 worst. - **CUMULATIVE BONFERRONI LOCKED** (`testLedger.js` + `mc_test_ledger`): the denominator is now distinct hypotheses across the programme lifetime, not the session. Demonstrated 19 → 38, α 0.0026 → 0.0013. Re-tests don't inflate it. - **THE PREDICTED DIFFERENTIAL APPEARS:** defence solo r = **+0.130 GHOST** (contact/speed) vs **−0.018 BOMBER** (power). Exactly "defence matters, and for whom". Both UNDERPOWERED (n=104/245, p=0.188 vs α=0.0013) — signal shape only. - **Bug class recorded:** the feed 404'd on a doubled `/leaderboard` path and, because feeds degrade to an empty index by design, reported "0 rows" — which reads like an honest absence. Any feed reporting 0 is suspect. - **Nothing proved → nothing recalibrated, nothing shipped.** validatedSkills() is {} everywhere; proven set still EMPTY. - **Next:** sample only. GHOST×hits needs ~396 more rows, BOMBER×hits ~213. Prefer re-testing standing candidates — every new hypothesis now tightens α for everything after it. ## Session 86 (2026-08-03) — Conditioning registry + a probe so "proven" stops drifting ✅ Spec: `specs/conditioning-registry.md`. 4,221 tests / 335 suites green, build exit 0. Counter + batter model + pitcher engine byte-identical. - **`scripts/proven-status.js`** recomputes the proven set from the ledger. PROVEN_SET = **EMPTY**. Built because four consecutive orders opened by calling null results proven; prose decays, a recomputed number does not. - **COUNTING BUG CAUGHT:** joining model_snapshots to ledger_entries fans out (one snapshot row per cycle) — BOMBER x hits read 641, true distinct 287. Fixed in both the analysis and the status probe. - **NO archetype x stat reaches the gate.** BOMBER x hits 287 (short 213) is closest; pitcher archetypes untestable (58 settled Ks total). - **Structured registry built:** `recordConditioning` keys archetype x SKILL x interaction x status + lift, with the skill tag ENFORCED (untagged refused, PROVEN-without-evidence refused). `validatedSkills()` = {} everywhere, by design. - **BOMBER x hits conditioning tested, all UNDERPOWERED:** arsenal (barrel x breaking share) incr +0.043, batted-ball (launch x pitcher GB) +0.001, contact quality −0.020/−0.015, K x K −0.063. Within BOMBER the counter still leads (0.218 vs 0.160). - **Bug fixed mid-run:** `fromStatcastRow` doesn't carry pitch_mix, so the arsenal category read n=0 — it was measuring nothing, not failing. - **DEFENSE: genuinely not derivable** from ingested data (no OAA/DRS; pitching proxies conflate skills). Needs Savant's free fielding feed — not sourced, because sourcing it to test at n=282 answers nothing. - **Nothing proved → nothing recalibrated, nothing shipped.** ## Session 85 (2026-08-03) — Rung 1 derived free; the cap fix fingerprinted ✅ Spec: `specs/lineup-k-rate-rung1.md`. 4,221 tests / 335 suites green, build exit 0. Counter + batter cluster + pitcher engine byte-identical. - **CAP FIX VERIFIED IN PROD: 334 -> 907 grades/snapshot; strikeouts 6 -> 17.** n>=500 for Ks is ~a week out instead of ~3 months. NOTE: the manual internal snapshot endpoint now 524s at Cloudflare (>100s) but COMPLETES server-side. - **RUNG 1 DERIVED, zero new sourcing:** opposing-team K-rate from the roster joined to batter k_pct we already ingest, 94.7% coverage — now PA-WEIGHTED. That change flipped its contribution: unweighted HURT (0.174->0.129), PA-weighted HELPS (0.174->0.195). Head-to-head delta +0.259, CI [−0.0167,+0.5645] — nearly excluding zero, still INCONCLUSIVE at n=57. - **Within-archetype:** FLAME incremental −0.152, non-FLAME +0.145 — opposite signs, invisible when pooled (+0.077). But n=20/24 and the direction contradicts the theory. Structure to re-test, not a finding. - **Rungs 2/3 NOT triggered** — Rung 1 is n-blocked, not failed. Do not source confirmed lineups. - **Nothing proven, nothing calibrated, nothing shipped.** Counter is still anti-predictive on Ks (−0.064); skill model leads by 0.26. - **Next:** wait ~1 week for n>=500 + a statcast_history window, re-run, re-test the strata at ~200/stratum, and give arm_angle a registry entry + mechanism. ## Session 84 (2026-08-03) — Pitcher engine built; the cap was eating the board ✅ Spec: `specs/pitcher-engine-strikeouts.md`. 4,221 tests / 335 suites green, build exit 0. Batter model + counter byte-identical (verified by diff). - **THE REAL FIND: the grade cap, not pitcher data.** 1,244 unique gradeable props/slate; the 500 cap graded ~334, and first-row-wins-in-feed-order gave pitchers 6 props a slate. Raised 500 -> 1500 on measured cost (~179s for the full board at concurrency 5, cron 5x/day). Unblocks EVERY n-blocked stat. Pitcher props were never being refused (graded 5, refused 0, suppressed 0). - **`pitcherEngine.js` — own archetypes (FLAME/SCALPEL/SINKER/DEFAULT), own inputs (stuff), own projection** (log5 K% vs THIS lineup x batters faced). Test asserts its weight keys differ from the batter engine's. 17 tests. - **Strikeouts NOT proven** (n=57 vs 500): pitch-v1 0.1285 vs counter −0.0639, delta +0.192 CI [−0.098,+0.509]. Four solo features clear the |r|>=0.15 bar and fail only on n — arm_angle −0.250 (largest in the programme), whiff +0.213, k_pct +0.206, chase +0.195. - **The counter is ANTI-PREDICTIVE on Ks (−0.064)** — recent K counts track opponent and workload, not skill. - **Bug caught:** `resolveTeam` needs an abbreviation; the game log gives names, so lineup coverage was 0% and the theorized carrier was never tested. Fixed via NAME_TO_ABBR → 94.7%. The carrier still shows no incremental signal (n=54). - **Calibration not reached** — nothing passed BAR 1. - **Next:** let the cap accrue (~2 weeks to n>=500), re-run with a point-in-time window from statcast_history; give arm_angle a registry entry + mechanism. ## Session 83 (2026-08-03) — Batter cluster measured; the proven set is EMPTY ✅ Spec: `specs/batter-cluster-prove.md`. 4,204 tests / 334 suites green, build exit 0. skillProjection byte-identical (TB frozen, verified by diff); counter untouched. - **PREMISE CORRECTED: total_bases has NOT passed BAR 1.** It is inconclusive at parity (CI includes zero) and contaminated. Installing it as the "proven reference" would make the cluster's bar "be inconclusive at parity". - **HITS CLOSED — well-powered negative.** n=803 CLEARS the gate sample bar, so features were tested not refused: max |r| 0.053, interactions ≈0, head-to-head −0.096 CI [−0.165,−0.029]. - **Others n-blocked:** TB 383, rbi 391, HR 228, runs 188. Leads: home_runs barrel r=−0.135 (negative = a correction, not a predictor); runs K×K incremental +0.132 (largest in cluster). - **RBI is half-unmodellable** — power × opportunity, and baserunner state is not ingested at all. - **statcast_history retention LIVE + verified in prod** (1,387 rows, as_of 2026-08-03). First run failed on a drifted hand-written schema; table now mirrors the source via LIKE. Usable point-in-time window starts 2026-08-04. - **Stage B has nothing to calibrate.** Proven set is empty. - **Next (waiting, not building):** let history accrue a week + TB/rbi reach n>=500, then re-run `scripts/cluster-prove.js`. Ranked: TB → rbi (needs baserunner state) → HR → runs. Do not re-run hits. ## Session 82 (2026-08-03) — TB solo+interactions; point-in-time validation unblocked ✅ Spec: `specs/tb-solo-and-interactions.md`. 4,204 tests / 334 suites green, build exit 0, counter byte-identical. - **BLOCKER FOUND + FIXED FORWARD:** `statcast_aggregates` keeps ONE as-of date (upsert in place). Yesterday's backtest was clean only because the refresh was dead code and the table sat at 2026-07-21; fixing the cron destroyed the window. New `statcast_history` table + retention on every refresh (best-effort, never fails the refresh). Until it accrues, all skill results are CONTAMINATED. - **SOLO (n=383, Bonferroni-12): nothing passes.** hard_hit_pct marginal r=0.135 (p=0.0080) fails both the 0.15 bar and α=0.00417 — and DRIFTED DOWN from 0.153 at n=295. Everything else <0.09. - **INTERACTIONS: none pass.** barrel×power_archetype is the only one whose incremental partial (−0.101) exceeds its parts (0.019), at n=260. A lead. - **Caught a fabricated finding:** the archetype proxy was a transform of barrel itself, so the "interaction" was barrel² — it produced the only positive result until a scale-free collinearity check + real `model_snapshots.archetype` labels replaced it. - **COMBINED vs COUNTER on TB: 0.2718 vs 0.2647, delta +0.0071, INCONCLUSIVE** — the first challenger that did not LOSE (hits was −0.116, CI excluding zero). - **BUILT: compound TB projection** (per-PA bases convolution, barrel→HR share, exit velo→XBH share). Replaces the refusal; non-degeneracy locked by test. - **Next:** let statcast_history accrue a point-in-time window (~a week) while TB reaches n>=500 (~117 short), then re-run. Do not re-run hits. ## Session 81 (2026-08-03) — The gate, built and run: hits is dead, total bases is the stat ✅ Spec: `specs/stagea-gate-result.md`. 4,200 tests / 334 suites green, build exit 0. Counter byte-identical (zero diff on probabilityEstimator/analyzeViaEngine1). - **PREMISE CORRECTED:** statModel.js and correlateValidator.js do NOT exist in this repo. The spec lived only in an offline Python blueprint, and supplementSystems.test.js inlines its own validateFactor (requires just fs/path). Nothing to connect — so the gate was BUILT to spec. - **`correlateValidator.js`** — n>=500, |r|>=0.15, p<0.05, Bonferroni. Exact p-value (incomplete beta), unit-verified against known values. - **GATE RUN, hits (n=570, Bonferroni-8): EVERYTHING FAILS.** Max marginal |r| 0.062 vs the 0.15 bar — an effect-size failure at a well-powered n. Head-to-head also loses: 0.0499 vs counter 0.166, delta −0.116 CI [−0.189,−0.043]. - **GATE RUN, total_bases (n=295): CANNOT TEST — and that is the finding.** hard_hit_pct marginal r=0.153 (above threshold), exit_velo 0.124; refused only on n. ~205 more settled rows needed. Matches the physics: contact quality drives extra bases, not singles. - **Architecture change the run forced:** per-STAT feature verdicts, so a feature dead for hits stays alive for TB. Gate now reports r/p when underpowered. - **Next:** build the compound TB value projection (per-hit bases distribution from launch/barrel — skillProjection still refuses TB by design), accrue to n>=500, re-run the gate. Leave hits alone. Do not lower the bar. ## Session 80 (2026-08-03) — The skill engine: built, gated, and Stage A honestly lost ✅ Spec: `specs/skill-engine-architecture.md`. 4,182 tests / 333 suites green, build exit 0. - **BUILT `src/services/model/`:** `featureRegistry` (CANDIDATE/PROVEN/DEAD per sport; `liveFeatures()` = PROVEN only; promotion needs n>=200 + positive lift + CI excluding zero, no override) and `skillProjection` (PA outcome tree, log5 odds-ratio K/BB, archetype-selected contact quality, Binomial over a PA distribution). 22 tests assert the five disciplines as BEHAVIOUR. - **The gate works by construction:** with only PROVEN features allowed the projection returns NULL. Registry ships with ONE proven feature (the counter). - **STAGE A: skill-v1 LOSES → NOT PROMOTED.** Out-of-sample (profiles frozen 07-21, only later games scored), 570 rows, 91.9% pitcher coverage: resolution 0.0499 vs champion 0.166, delta −0.116 CI [−0.189,−0.043]. Not selective either (top-8 hit 50%, lift −0.065). - **Two false starts caught:** (1) units — statcast stores PERCENTAGES, raw rows made bip negative and refused 568/576; now one chokepoint `fromStatcastRow`. (2) an INVALID first verdict — ledger team/opponent are NULL, so the pitcher resolved for 1 of 570 rows and it was silently measuring a batter-only model. Fixed via each player's statsapi game log. - **Not exercised yet (so the loss is real but partial):** park (passed 1.0), handedness, opportunity_drift, and PA projection is season-PA/103. And the skill profiles carry NO recency while the champion has last-5. - **Fixed: Statcast nightly refresh was UNREACHABLE CODE** — inside tick() below the HOURS_UTC guard while testing h===11. Never ran; 13 days stale; both alerts in the same dead branch. Now its own tick; test rewritten to catch it. - **Next:** recency into the skill profile, wire park/handedness/opportunity_drift, real PA from lineup slot, then re-run Stage A. ## Session 79 (2026-08-03) — Reality assessment vs the FORWARD-PROJECTION objective ✅ Spec: `specs/forward-model-reality-assessment.md`. READ-ONLY (src/web untouched). - **Finding: the forward model's parts all EXIST and are all wired downstream of the grade.** `probabilityEstimator` (the served p_win) reads 3 features + the game log. Statcast/arsenal/park/weather/platoon/archetype load in `snapshotService` AFTER grading, into challenger columns nothing serves. `mlbContext` has zero consumers. - **Statcast nightly refresh is DEAD CODE by guard** — tick() returns for hours not in HOURS_UTC (14,19,22,1,3); the block tests h===11. Data frozen at 2026-07-21 (13 days stale); its own failure alert is in the same dead branch. - **Inputs are HAVE** — 1,354 statcast rows, handedness complete both sides, pitch mix/velo/break, GB/FB, barrel, exit velo, launch. MISSING: team defense (OAA/DRS), catcher framing/umpire. PARTIAL: batter GB/FB (in `metrics` JSONB), lineup slot (role tables 0 rows). - **Design shows the COUNTER.** Factor labels are all `l5_hot_vs_line`-family plus NBA leftovers (refs, coach pace). The card's forward-read slots (archetypeBlend "Why this grade", vyndrIntel.matchup, propDNA) exist and go unfilled. Needs feeding, not redesign. - **STAGED DISTANCE:** Stage A (forward baseball model) = ONE real build, ZERO data acquisitions — assemble hitter profile × pitcher stuff × conditions as the SPINE with frequency demoted to a prior; risk is sample, not feasibility. Stage B (calibrated + scouting surface) = short once A exists (clamp/calibration already diagnosed + swap the factor vocabulary). Stage C (per sport) = blocked on mechanism data we do not have for NBA/WNBA (ESPN is box scores, Python service offline) and soccer is odds-api quota-blocked. - **Verdict re-checks:** proj-v1.1 + hits-v1 stay refuted AS DISTRIBUTION SWAPS (neither tested a matchup-fed projection); arch-v1 is market-relative by construction = the one measured on the wrong axis; "AT CEILING" is provisional. ## Session 78 (2026-08-03) — Champion decomposed: the edge is a hit-rate counter ✅ Spec: `specs/champion-input-diagnosis.md`. READ-ONLY (src/web untouched); 4,159 tests green. - **The champion is 5 lines.** base = empirical frequency of (stat > THIS line), 0.6/0.4 blend with last-5, ±0.03 opponent, ±0.015 home/away, cv>0.40 pull, clamp [0.10,0.95]. It reads 3 features; featureCache retains a dozen more that p_win never touches. - **Exact analytic ablation, per stat, paired bootstrap.** Removing ALL THREE adjustments changes resolution by nothing everywhere (hits −0.0059, TB −0.0015, rbi +0.0106, runs +0.0130, walks +0.0008) — and rbi's home/away is mildly HARMFUL (+0.0053, CI excludes 0). ~100% of the edge is base+recency. - **Pooled 0.46 is an artifact** — per stat 0.196 (hits) … 0.499 (rbi). Corrected last session's reading; paired differences unaffected. - **BIGGEST LOSS = the clamp.** 20.6% of settled rows pinned to a constant (no ranking possible there), and `0.900` covers home_runs-under truly 99.5% AND hits-under truly 51.9%. Global over-prediction +3.5pt (TB +7.6). No new data needed to fix. - **One real lead: `opportunity_drift`** (residual +0.156 hits, +0.145 TB — repeats across stats, unlike the weather hits which sit inside the expected false-positive count). We ALREADY compute it; arch-v1's opportunity axis extracts nothing from it. Wrong implementation, not a missing feature. - **Archetype: UNMEASURABLE** — 2 of 41 labels have testable n. Not refuted. - **Next order priority:** (1) clamp + calibration, (2) opportunity as a rate scaler, (3) prune the diluting axes, (4) get archetype coverage. Explicitly NOT another projection variant. - Flagged: `model_snapshots.outcome` NULL on all 22,032 rows — retention is never settled, so replays must join the ledger for labels. ## Session 77 (2026-08-03) — Settlement was dead for two days; scoreboard now readable ✅ Specs: `specs/challenger-scoreboard.md`, `specs/odds-429-diagnosis.md`. 4,159 tests / 332 suites green, web build exit 0. - **THE FIND.** Three challenger axes read exactly ZERO settled rows. Not low — zero, on games played days earlier, with `settle_attempts = 0`. `settleLedger` refetched rows via `.in('id', ids)`; 500 UUIDs = an 18,499-char URL the fetch layer rejects, and the result was destructured with no error binding, so it returned all-zeros indistinguishable from a clean "nothing to settle". Volume-triggered: 2026-08-01 was the first day past the 500-row limit. The zero-settle ops alarm reads those same return values and was blind to it. - **FIXED + DRAINED.** One query, all columns, no id list; failed fetches surface. `captureClosing` chunked at 100 (same defect family). 1,444 rows from 08-01 settled (1,376 hit/miss + 68 void, 0 remaining). Settled n **493 → 1,741**. - **SCOREBOARD — nothing promoted, nothing earned it.** arch-v1 n=1,741 Δ0.0000 CI[−0.0050,+0.0054] (moves 76% of rows by 2.5pp mean = active movement carrying no information); contact-v1 n=1,055 +0.0008 inconclusive; proj-v1.1 ladder n=1,664 **−0.0301 CI[−0.0543,−0.0060] = reliably WORSE**. matchup/tb-v1/hits-v1 STILL PENDING (rows dated 08-02+, settle after ET midnight). Champion byte-identical; all challengers stay wired. - **429 DIAGNOSED (read-only) — premise refuted with numbers.** PropLine 5/3,000 daily (0.17%); the 429 is **odds-api at 478/500 monthly, blocked at 95%**, surfacing whenever PropLine returns empty. One snapshot = ONE PropLine call per sport. Change-based pull is NOT the fix and no tier upgrade is needed. Could NOT verify PropLine movement endpoints (auth-gated docs, prod-only keys) — not asserted. Book-breadth invariant recorded: we never discard books; DFS is excluded from PRICING only. - **Next:** the silent PropLine fall-through (empty slate must not report the backup's 429); diagnose the projection family's INPUTS (two independent measurements now say it trails the champion). ## Session 76 (2026-08-02) — hits-v1: a challenger built, measured, and REFUTED ✅ Spec: `specs/hits-v1-binomial.md`. 4,156 tests / 332 suites green, web build exit 0. Scope was hits only; champion, ladder, ranking, calibration, reference ruler and the four accruing challenger verdicts are byte-identical (the diff has ZERO deleted lines). - **What was built.** `src/services/projection/binomialHits.js` — hits as a bounded conversion: `N ~ the player's empirical at-bat distribution`, `hits | N ~ Binomial(N, q)`. At the 0.5 line (84% of real hits rows) this states `P(>=1) = 1 − E[(1−q)^N]` directly instead of inferring P(0) from a count family. The multiplier scales `q` (conversion), never `N` (opportunity). Wired in `projectionChallenger` as `proj_hits_p_over` / `proj_hits_meta` (new ledger columns, migration applied). - **STEP 0 first — inputs before model.** `scripts/hits-input-coverage.js`: 30/30 real ledger players, 100% combined-input coverage, mean 3.518 AB/G, mean per-AB rate 0.248. - **FIRING, on the real board.** `scripts/verify-hits-v1.js` runs the production `attachProjection` over the live prod snapshot: 158/159 hits props (99.4%), one honest abstention. 94 of 159 props sit OUTSIDE the promotion band and 93 were modelled anyway — scoping by book identity kept 59% of the board a price-shape rule would have deleted. - **AND IT LOST.** Point-in-time replay (log truncated strictly before each row's game_date, real grade-time multiplier), hits-only, direction-aligned, n=242: resolution champion **0.195** / ladder **0.048** / hits-v1 **0.026**. Paired bootstrap: hits-v1 − ladder = −0.022, CI95 [−0.046, −0.0003]. NOT PROMOTED. - **The finding is what it eliminates.** Family was wrong AND mean was not the constraint (hits-v1 moved the line-0.5 mean 0.554→0.581 toward a 0.598 base rate while resolution FELL). The hits deficit is per-prop DISCRIMINATION — the ladder's inputs, not its distribution. - **A pre-registered branch recorded as WRONG.** The spec's fallback ("hits may be genuinely low-resolution for anyone") is refuted by the champion scoring 0.276 on the identical 189 rows. Kept in the doc rather than deleted. - **Next order is a DIAGNOSIS, not a model:** what does the champion's `p_win` read on a hits prop that the projection ladder does not? Building another projection variant first would repeat this session's mistake. - Logged not fixed: local `.env` has a transposed Supabase ref — local scripts need `SUPABASE_URL=` override; prod unaffected. ## Session S11 (a1 board, 2026-07-12) — Live Tracking: the read locked, the game watched ✅ Spec: `specs/LIVE-TRACKING.md` (+ ROW-GRAMMAR §2/§3 S11 amendment). 2698 → **2757 tests** (229 suites), web build exit 0. - **`liveTrackingService`** — MLB statsapi (`schedule?hydrate=linescore` identifies Live games + inning in ONE call → boxscore per live game) + WNBA ESPN (scoreboard 'in' → summary boxscore). Pure parsers built on REAL feed shapes captured while PHI@DET was live (bottom 8th, 2026-07-11). LOCAL `LIVE_BOX_FIELD` map (batting/pitching split + IP thirds — deliberately decoupled from outcomeService.MLB_LOG_FIELD, same rule as settlement). A player with empty box stats is ABSENT, never 0. - **POLLING RULE** — `live:{sport}:{date}` TTL 90s, written cache-aside by public `GET /api/live/:sport` (60/min, mounted in app.js + Next proxy `app/api/live/[sport]`). Quota: 1 schedule + N-live-games boxscore calls per 90s across ALL users; zero boxscore calls when nothing is live. - **`lib/liveProgress.js`** (pure, CommonJS) — `propState` (over: HIT ✓ / ON PACE / NEEDS N beats-the-push; under: HOLDS-IF — never hit until final, LINE PASSED amber when exceeded; never red in-progress), `buildLiveIndex`, `attachLiveProgress` (graded+unsettled+non-dead props only, joined on nameKey + the new canonical `statType` strip field, state vs the LOCKED line), `gameLiveProximity` + `sortLiveFirst`. - **LIVE SLATE MODE** — Slate polls `/api/live/{sport}` every 60s ONLY while live mlb/wnba games are on screen; live games with tracked props float to the top by proximity-to-hit. `StatStrip.LiveTracker` renders `3/1.5 TB · ▼8th` + game-progress bar + state chip in the ROW-GRAMMAR OUTCOME slot (proto-outcome; actions suppressed while live); GameCard shows "TRACKING — READ LOCKED PRE-GAME" once per live card. GRADES NEVER CHANGE IN-GAME. - **Acceptance (real feed)**: `Bryce Harper 3/1.5 TB · ▼8th → HIT ✓`, `Cristopher Sánchez 1/2.5 ER · ▼8th → HOLDS` — full pipeline on the live capture. Verify tonight: `curl -s https://vyndr.app/api/live/mlb | head -c 400` (or `node -e "require('./src/services/liveTrackingService').getLiveTracking('mlb').then(o=>console.log(JSON.stringify(o).slice(0,400)))"` on the box during a live window). ## Session S6 (a1 board, 2026-07-11) — Display: the full picture under the grammar ✅ Spec/law: `specs/ROW-GRAMMAR.md` (locked by `tests/unit/rowGrammar.test.js`). 2654 → **2698 tests** (226 suites), web build exit 0. - **ROW-GRAMMAR.md** — canonical prop-row slot order (identity → viability → stat+line → market context → model output → outcome → actions → provenance), color law (green=edge/best/positive, amber=caution/steam, red=settled-negative only, dim=absent), mark law, mobile stacking, no-truncation law. StatStrip violations FIXED: MovementChip moved before the grade (market before model); ViabilityChips moved after the archetype (identity is one contiguous run). - **Line-movement sparklines** — `intradayRefreshService.trackHistory` appends real {t, line} points per grade (seeded with the lock, deduped when flat, capped 24) inside the snapshot it already writes back. `StatStrip.LineSparkline` renders at ≥3 points (green toward / amber against / dim flat — never red). - **Last-10 dot strips** — `services/last10Dots` (streaksService accessors) → `/api/snapshot/:sport` attaches `last10_dots` from `rosterlogs:{sport}`; `StatStrip.DotStrip` renders ●/○ vs the LOCKED line, newest first. - **CLV distribution** — `getModelAggregate` emits `clv_distribution` (7 signed buckets, outliers clamped) ONLY past the n≥20 gate (null below — gate stays centralized). Ledger MODEL header renders the green/red/dim bar strip. - **Global search** — SearchModal (⌘K / Ctrl-K via GlobalHosts, `window.__search`), players per sport via /api/players/search + static `lib/teams.js` (30 MLB / 30 NBA / 13 WNBA; soccer deliberately absent — no canonical registry). Nav search icon (mobile path) + Search first in the BottomTabBar More sheet. Explore tab untouched. - **Landing LCP** — `fade-up` floored at opacity .6 (hero h1 is contentful on first frame; also fixes the S33 visible-floor rule) + IBM Plex Mono `preload:false` (4 decorative font files off the slow-4G critical path). PageSpeed re-measure is a post-deploy runbook step (cannot run here). ## Session S9 (a1 board, 2026-07-11) — Slip Reader: zero-API OCR ✅ Spec: `specs/a1-s9-slip-reader.md`. +34 tests (24 unit + 10 integration). - **slipReader service** — tesseract.js (self-hosted WASM, Apache-2.0, zero API spend) + PURE per-book layout parsers (DraftKings, FanDuel, BetMGM, Caesars) on OCR-text fixtures. Per-field confidence; below 0.6 → field null + `needs_review` — the reader never guesses. Stats normalize to the scan-route vocabulary; names through `playerName`. - **POST /api/slips/parse** — auth, free 1/day paid 10/day (`slips:{user}: {day}` Redis + memory mirror), 4MB image cap, text paste path. Next proxy added. Mounted in app.js. - **/slip page** (gated) — upload/paste screenshot → manual-correct UI (amber = uncertain) → per-leg grade via existing /api/scan (honest refusals) → add-all to Parlay Lab → "VYNDR read my slip" share card. - **Acceptance: FULL image path** — synthetic DK slip PNG (sharp SVG→PNG) → real tesseract.js OCR → parser → 3/3 complete legs. Untested on a real phone screenshot yet — grab one post-deploy. - Vision-model upgrade logged post-revenue in specs/vyndr-roadmap.md. ## Session S10 (a1 board, 2026-07-11) — Public Ledger Profiles v1 ✅ Stage-3 seed (Strava for betting), zero out-of-pocket. Branch off day1/a1-board (aaafc3e). 2574 → **2612 tests** (220 suites), web build exit 0. Spec: `specs/a1-s10-public-profiles.md`. - **Migration `022_public_profiles.sql`** — COMMITTED, NOT APPLIED (founder applies migrations). `public_profiles` (user_id PK → auth.users, handle UNIQUE + regex CHECK, `published` DEFAULT FALSE). RLS: published rows readable by anyone, own row by owner, writes service-role only. - **API** — `/api/profiles`: `GET/POST /me` (requireAuth; handle regex 400, taken handle 409, published only flips on explicit boolean true) and public `GET /:handle` (published → user-scoped 30d aggregate + settled rows newest 50, same columns as /api/ledger; unknown AND unpublished → byte-identical 404 — no existence leak). `getModelAggregate` gained a `userId` option (public `.is('user_id', null)` default untouched). - **Frontend** — `/u/[handle]` (PUBLIC, in OPEN_ROUTES): server shell with "CLV-verified record — @handle · VYNDR" metadata + client record (hit% + beat-close% at n≥20 else RECORD BUILDING; outcome + CLV chips) + OG card (Node runtime, never edge). Settings PUBLIC PROFILE section: claim handle + one explicit publish toggle with the private-by-default copy. - **Next**: profile OG card could embed the live record once n≥20 profiles exist; user-scan settlement coverage beyond MLB is the limiting factor. ## Session S3 (a1 board, 2026-07-11) — Affiliate + Partnership Plumbing ✅ Zero out-of-pocket; everything config-flip-ready but DISABLED/organic. 2398 → **2439 tests** (209 suites), web build exit 0. - **BOOK IT deep links** — `web/src/lib/bookLinks.js` + `affiliateConfig.js` (every book `enabled:false`; Impact/Partnerize param shapes documented). StatStrip BOOK IT is a real anchor now (organic); scan hand-off links moved to the builder. Every book anchor renders `rel="sponsored noopener noreferrer"`. - **Best-price marker** — `slateAdapter.detectBestBook` (≥2 books, SAME line, differing prices, else null) + green dot in StatStrip; `books[]` (the grouped odds rows) now threads Slate → strips instead of being discarded by pickLine. - **Partner refs** — `?ref=CODE` → 90d first-party `vyndr_ref` cookie (first-touch, `PartnerRefCapture` in layout) → signup metadata `partner_ref` → internal `GET /api/partners/report/:code` (zeros + note until the TODO migration in docs/PARTNERS.md adds `user_profiles.partner_ref`; NOT run). - **docs/PARTNERS.md** — ref link spec, Stripe promo-code mapping convention (partner code == promotion code), TODO migration SQL. ## Session S7 (a1 board, 2026-07-11) — Newsletter: THE VYNDR REPORT ✅ Branch off day1/a1-board (e4d2e79). 2398 → **2429 tests** (207 suites), web build exit 0. Spec: `specs/feature-a1-s7-newsletter.md`. Runbook: `docs/NEWSLETTER.md`. - **Capture**: `NewsletterCapture` (dark terminal, mono data) on the landing (below FAQ) + `/welcome` (the real signup success surface — the signup "done" card auto-redirects in 1.5s, useless for a form). Double-opt-in note under the field; "Signups open soon" when Listmonk env is unset. - **Backend**: `POST /api/newsletter/subscribe` (public, 10/min IP limit, honeypot, server-side email validation) → Listmonk subscribers API with `preconfirm_subscriptions:false` (Listmonk sends the confirmation). No env → calm 200 `{ ok:false, reason:'not configured' }`. Next proxy `web/src/app/api/newsletter/subscribe/route.ts` (S25 rule). - **Assembly**: `newsletterService.buildDailyReport` — signals from `snapshot:{sport}:latest`, STREAK WATCH via rosterLogs → streaksService → streakLens (one row per player), THE RECORD via `getModelAggregate` (percentage ONLY when hit_pct != null — the n≥20 gate — else "RECORD BUILDING · N pending"). RG footer (21+, 1-800-GAMBLER, `{{ UnsubscribeURL }}`) in html + text. VOICE lint locked by tests: no `!`, no banned vocabulary, numbers only from injected pipeline data. - **Send**: `sendDailyReport` creates + starts a Listmonk campaign; operator-triggered ONLY via `POST /api/internal/newsletter/send` (internal key). NOT scheduled. Empty report (0 signals + 0 streaks) refuses to send. - **Env for Coolify** (all four required, else graceful no-op): `LISTMONK_URL`, `LISTMONK_USER`, `LISTMONK_TOKEN`, `LISTMONK_LIST_ID`. ## Session S2 (a1 board, 2026-07-11) — BUILT ✅ COMPLIANCE + APPROVAL PACK Branch worktree off `day1/a1-board`. 2429 tests (206 suites), web build exit 0 (`next build --webpack`). No backend routes touched. - **/responsible-gambling REBUILT sincerely** — 21+, 1-800-GAMBLER (1-800-426-2537) as the primary helpline, 17-state resource list, warning signs, state self-exclusion guidance, links to the EXISTING /settings Responsible Play + Danger Zone surfaces. Zero marketing adjacency (no pricing/scan/upgrade links — a test enforces it). - **/terms + /privacy redrafted** as honest approval-pack drafts (marked DRAFT: JULY 2026). Entity details are placeholder tokens Kev must fill: [ENTITY NAME], [STATE OF FORMATION], [ARBITRATION VENUE], [CONTACT EMAIL]. Privacy sub-processors now match reality (Stripe, Supabase, Resend, Sentry; PostHog described honestly — autocapture off, banner is disclosure-only, NOT consent-gated). - **/methodology NEW** (server component + metadata, footer COMPANY link, OPEN_ROUTES): pipeline → engine → 11-step letter grades, refusals (insufficient_data), VYNDR Originals (41), Ledger settle (locked at grade time, box-score settle, CLV vs captured close, n≥20, public revisions), why misses are public. - **/about audited** to North Star framing: data intelligence platform, THE PROOF card (Ledger + n≥20 + refusals + methodology link). Kept the "give it back" h1 (Phase-E test) + Wordmark (QA.5). - **Footer**: helpline updated 1-800-522-4700 → 1-800-GAMBLER; Methodology link added. Compliance audit: footer is global in the root layout, no nested layout or CSS suppresses it on any page — zero pages missing it. - **content/articles/** — 5 Ghost-ready seed drafts (front matter, VOICE v1.1, zero exclamation points, test-enforced) + docs/GHOST-PUBLISHING.md manual runbook (no Ghost credentials used; nothing auto-posts). - Tests: tests/unit/compliancePages.test.js (31 assertions, source-text style). ## Current Phase SHIP BUILD v59.0 — Overnight session: ledger team/opponent addendum, work-order 1.6 closed (canonical player keys + slate join invariant), Phase 2 slate UX, Phase 3 mobile P0. DEPLOY GATE: Coolify by Sat 10 AM ET; first snapshot locks against freshly posted lines (manual internal trigger). ## Session 8 (A1 board, 2026-07-11) — SHIPPED ✅ OPS: THE PRODUCT WATCHES ITSELF Branch off day1/a1-board tip. 2398 → 2437 tests (208 suites). Backend-only. - **Settlement alarm** (`snapshotScheduler`): outcome/ledger settle pass THROW → ntfy high. Morning zero-settle alarm: ledger settle found pre-today Postgres rows but settled none → page once per ET date (signal = settleLedger's own return values — survives the Redis-TTL failure mode that silently killed morning settles in S60; documented in `opsWatch.zeroSettleAlarm`). Genuinely empty yesterday never alerts. - **Persistent-failure pager** (`opsWatch.createFailureTracker`, pure): 3 consecutive error / skipped-'no props' slots for a sport pages once (high); any good slot resets and re-arms. Single-slot misses stay quiet. - **Quota** (`opsWatch.checkQuotaDaily`): odds-api ≥80% after a snapshot run → one alert/day, Redis-deduped (`ops:quota_day:{provider}:{date}`). - **Box health** (`services/systemHealth`, pure + injectable): fs.statfs + os mem → `{disk_pct, mem_pct}`; pages high at disk>85 / mem>90. - **Daily pulse** 13:00 UTC (9 AM EDT; `PULSE_HOUR_UTC` to move), ONE notification: ledger rows yesterday (`ledgerService.countRowsForDate`, 'n/a' when Supabase off — never a fabricated 0), settles last 24h, quota pct, disk/mem, 'desk pack: see /desk'. Dedupe: in-process date + `ops:pulse:{date}`. - **docs/OPS-RUNBOOK.md**: Uptime Kuma monitors (vyndr.app 200, api /api/health keyword 'healthy', /api/snapshot/summary), Coolify deploy-failure webhook → ntfy, ntfy phone-subscription steps. - Live acceptance alert POSTed to ntfy.sh/vyndr-pipeline-kev2026. - All alert copy VOICE v1.1 — deadpan, numbers, zero exclamation points (tests lint for `!`). Spec: `specs/session-8-ops-watch.md`. ## Session 60 (night2/full-board, 2026-07-11) — BUILT ✅ THE WHOLE BOARD (awaiting "merge the train") Branch `night2/full-board` off d10bb4c — ZERO pushes to main. 2352 → **2396 tests** (205 suites), web build exit 0. Sessions A–H all shipped: - **A** AUTONOMY.md + P0: SNAP_TTL 6h→24h (the 11h overnight cron gap expired the snapshot before the morning settle — accuracy loop silently settled nothing; the Postgres ledger settle was immune). - **B** Streaks engine RESURRECTED + fed: the snapshot pipeline now writes rosterlogs:{sport} from the stats it already fetches (producers were all external/unarmed — that's why panels never rendered). Form heat (hot hitters/sluggers/shooters, Σ/Σ rate math) + THE LENS (built-vs, tonight's matchup + opposing SP ERA, step-up/down, one-line read). LIVE acceptance: 32 real rows (Turang 12-gm on-base, Reynolds 8-gm, Pratt 7-gm, Meidroth 5-gm, Cortes 5-gm). - **C** Aggregator mounted: Explore = SEO server shell + hub (leaders + full streaks + hot lists); streaks FREE for all tiers (the picture is free); landing teaser fixed (pointed at off-season NBA → never showed); one stat selection filters props+streaks+hotlists; player-dossier ACTIVE STREAKS block (+?player= on /api/streaks). - **D** Phase 2.5: 20-min odds-only refresh in slate hours (skips snapshot slots, INTRADAY_REFRESH=0 kill switch, POST /api/internal/refresh/all). STEAM ▲ (with, no re-grade) / VALUE ▲ (against ≥1.0, re-grade holds) / PUBLIC revision (grade drops → revised_from_grade, strikethrough). Refresh-fidelity closing capture; ticker MOVEs at refresh frequency. Quota: ≤144 PropLine calls/day vs 9,000 free capacity. Post-deploy: set SNAPSHOT_EXPECTED_INTERVAL=1200 and SYNC goes live. - **E** Phase 4: /api/players/search built (didn't exist — the Ohtani bug); nameKey fuzzy match live-verified vs the real 1,299-player list. §7 reveal (steps → DECLASSIFIED stamp → 90ms staggered panels, reduced-motion safe). PRIOR READS chips. Parlay: shared statLabel, 1-leg provisional grade, Nav entry via window.__openParlay. - **F** Phase 5: archetype definition line on-page; VYNDR-on-team (ModelRecord team scope); WNBA minutes-usage; Settings meter from the LIVE limiter (3/day free, 15/day analyst, ∞ desk); per-tier calibration chips (n≥20 PER TIER). - **G** Phase 6: landing first-paint root cause KILLED (page blocked its render on Supabase auth init for anonymous visitors; now a sync localStorage gate — anon paints instantly). Proof Strip: A-tier-gated header, "TONIGHT'S BOARD" fallback, yesterday's settled reads with outcome chips when ungraded. /api/content/top-signals + /streak-watch (zero-grade format) + /daily-report (self-flags do_not_post until n≥20). Per-player OG images (Node runtime) + player metadata. - **H** Design pass: QA.20–22 locks (STEAM amber/VALUE green/STALE-only red, revisions struck through, new data surfaces mono, honest waiting copy everywhere). ### The word: "merge the train" → main fast-forwards → Coolify ships board #2. ## Session 59 (2026-07-10 overnight) — SHIPPED ✅ ADDENDUM + 1.6 + PHASE 2 + PHASE 3 Backend 2327 → **2352 tests** (+25), 202 suites. Web build clean (exit 0). Migration 020 (team/opponent) APPLIED while ledger_entries was at 0 rows — zero backfill needed; VYNDR-on-team unblocked. - **Task A** — `ledger_entries.team/opponent` populated in both write paths (pipeline: stats-resolve team + prop's other side via nickname match, NEVER guessed; scan: snapshot-grade team, opponent null). Roadmap: Phase 4.5 (WNBA ESPN box-score settlement — DUE ~Jul 24) + Phase 5 calibration- by-grade-tier logged. - **Task B (work-order 1.6 CLOSED)** — `mlbStatsAdapter.searchPlayer` now resolves via canonical `nameKey` (the old matcher DELETED accents: "Sánchez"→"snchez"≠"sanchez", then substring-guessed → wrong player's last-10). Fallback = UNIQUE last-name+first-initial or null. `buildPlayerStripsFromProps` gained the JOIN INVARIANT: a graded prop whose player's real team isn't a game participant is DROPPED (TB player can't render under MIL@PIT — suite fails if it regresses). Snapshot grades now carry `team`. Root cause of /team "No active props": grades:{sport} TTL (2h) < cron gap (5h) → now SNAP_TTL (6h). - **Task C (Phase 2)** — 2.1 sport tabs are THE filter, URL-driven (?sport=, deep-linkable); the dashboard's duplicate legacy tablist is GONE (legacy sections subscribe via onTabChange). 2.2 cards cap at 6 graded props sorted A+→F with "ALL N READS →" expanding in place. 2.3 waiting states show the REAL next pipeline run ("Grades post ~6:00 PM ET" via lib/pipelineSchedule — mirrors SNAPSHOT_HOURS_UTC; update BOTH if the cron changes). 2.4 = TTL fix + join fix (verify on the live Yankees page post-deploy). - **Task D (Phase 3)** — ROOT CAUSE of vanished mobile nav: HIDE_ON included '/' — the landing had NO nav at 390px (desktop links hidden, hamburger retired S37, tab bar suppressed). '/' removed. 3.2: html/body overflow-x hidden <768; GAME LINES collapses to a best-line summary + "N BOOKS ⌄" expander <640; venue (.gc-venue) drops first on phones. - **Live verification:** raw ESPN (no date pin) STILL returns the Jun 13 NYK@SA Finals game today; the pinned fetch returns 0 games, 0 off-date. ### Morning deploy runbook (Sat, before ~10 AM ET) 1. Push already sent to gitea (Coolify betonblk) — confirm deploy finished. 2. `GET /api/internal/snapshot/status` (x-internal-key) → cron_armed true. 3. When the day's lines post: `POST /api/internal/snapshot/all` (x-internal-key) → first public ledger rows lock against fresh lines. 4. Verify: `SELECT count(*), count(*) FILTER (WHERE team IS NOT NULL) FROM ledger_entries WHERE user_id IS NULL;` + one sample row. ## Session 58 (2026-07-10) — SHIPPED ✅ PHASE 1: TRUTH INFRASTRUCTURE Backend 2309 → **2327 tests** (+18), 201 suites. Web build clean (exit 0). Spec: `specs/phase-1-truth-infrastructure.md`. Migration `supabase/migrations/019_ledger_entries.sql` APPLIED to prod (betonblk) — dedupe (`UNIQUE NULLS NOT DISTINCT`) + RLS verified live (anon reads public rows only; anon INSERT rejected 42501). - **ledgerService** — recordPipelineGrades (public model record, idempotent ignoreDuplicates upsert), captureClosing (every snapshot overwrites today's closing_line/odds from the real feed; last write before game start = close), settleLedger (outcome + actual + SIGNED CLV: over = locked−closing), getModelAggregate (30d; percentages NULL under 20 settles). No-ops without SUPABASE env. `Number(null)===0` fabrication bug caught by tests → strict numOrNull everywhere. - **Write paths:** snapshotService → ledger (user_id null, priority path); Next /api/scan → ledger row for AUTHED users only (anon would pollute the public record). Refused reads write NOTHING (no scan_history either) and don't burn a scan. - **Honest refusal (work-order 1.5):** analyzeViaEngine1 returns `insufficient_data: true, grade: null` when the model has no projection (l5→l20→{stat}_per_90→xG for soccer). The web gradeAdapter no longer displays the LINE as the projection (the audit's model==line degenerate); GradeResultCard renders "—" absent states; scan page renders "INSUFFICIENT DATA — NO READ". - **/ledger:** MY READS | MODEL tabs; model header shows "H-M · X% HIT · Y% BEAT CLOSE" only at n≥20, else "RECORD BUILDING" + pending count. ModelRecord component mounted on landing + player hero (deferred-render). - **SYNC (Task 5):** thresholds vs SNAPSHOT_EXPECTED_INTERVAL (default 18000s): normal <1.5x · amber ≥1.5x · STALE red ≥3x. - **Phase 2.5 LOGGED** in specs/vyndr-roadmap.md (intraday refresh + STEAM/VALUE/revision handling; build after Phase 3). - **Settle trigger:** scheduler settle pass + POST /api/internal/ledger/settle. ## Session 57 (2026-07-10) — SHIPPED ✅ PHASE 0: KILL THE LIES Backend 2289 → **2309 tests** (+20), 199 suites. Web build clean (exit 0). Spec: `specs/phase-0-kill-the-lies.md`. Work order: Phase 0 of the overhaul (audit-driven; Phases 1–6 queued). ### 0.1 — Fake NBA game (root cause found + fixed at the source) - `scheduleService.fetchScheduleFromEspn` hit ESPN with NO `?dates=` param and NO date filter → off-season ESPN returns the NEAREST slate (the Jun 13 NYK@SA Finals game rendered as "tonight" all July). Now pins `?dates=YYYYMMDD` AND filters events to the requested ET date; undated events dropped. - Honest per-sport empty states: `web/src/lib/emptyState.js` (month-aware — "NBA returns in October." vs in-season "No NBA games today."), used by the dashboard NO SLATE block + the Slate empty state. ### 0.2 — Fake header counters deleted - `liveTick.js` stripped to `{ tick }` (1s re-render pulse only). The fake auto-incrementing "247 graded", sin-driven brain-%, aPlus/cascades: DEAD. - New `GET /api/snapshot/summary` (public, cache-only, registered before `/:sport`) → `{ graded, updated_at, sports }`. Next proxy added. - `HeartbeatBar` shows the REAL graded count + SYNC = elapsed since the last pipeline run (amber past 5 min). No data → "SYNC —", nothing invented. ### 0.3 — Ticker: real items only - Nav's hardcoded fallback items (fake Wembanyama A+/"NYK vs SA Q3"/invented Tatum MOVE) deleted. MOVE stays — `computeLineDeltas` is real movement. - Spec §3 rule: <4 real items → ticker renders nothing. It publishes `--ticker-h` (32px/0px) on ``; layout main paddingTop and Slate sticky top are now `calc(base + var(--ticker-h))` so the header collapses cleanly. ### 0.4 — /terminal retired (fabricated surface) - Route now `redirect('/dashboard')`. Layouts preserved UNROUTED at `web/src/components/intel/TerminalTemplates.tsx` (§12 content-engine templates — feed real data before ever routing them). - Nav PRIMARY = Slate/Scan/Ledger. BottomTabBar: Terminal→Explore. PWA manifest shortcut Terminal→Ledger. routes.js: `/terminal` out of OPEN_ROUTES; `#terminal` → `/dashboard`. ### 0.5 — ›Query nav pill deleted (duplicate /scan link) ### Next (work order Phase 1 — starts the settle clock) - `ledger_entries` table + write-on-grade (user scans AND pipeline pre-grades) - Settlement worker; populate /ledger (personal + MODEL tabs) - Degenerate scan output (model==line, +0% edge) → honest "INSUFFICIENT DATA" - Canonical player_key normalization (blocks 2.4, 4.1) ## Session 56 (2026-07-10) — SHIPPED ✅ THE FULL AUDIT Backend 2276 → **2289 tests** (+13), 197 suites. Web build clean (exit 0). Docs: `specs/propline-audit.md`, `specs/vyndr-roadmap.md`, `specs/audit-data/propline-audit-raw.json`, `scripts/propline-audit.js`. ### Research (Phases 1–3, 5) — verified against LIVE APIs - Fetched real data: The Odds API `/v4/sports` (in-season now: MLB, WNBA, NFL preseason, NBA summer league, 40+ soccer leagues; NHL dark), a real MLB boxscore (Braves@Pirates 2026-07-09 — every settle field confirmed), ESPN WNBA/soccer scoreboards. PropLine inventory derived from code (no dev keys). - **Headline bug found:** `oddsNormalizer` mapped `batter_rbis → 'rbis'` but the entire grade/feature/outcome chain keys on `'rbi'` → **every PropLine RBI prop silently failed to grade AND settle.** Fixed. - Coverage truth: **MLB is the only end-to-end-live sport.** Outcome settlement is MLB-only (WNBA/NBA/soccer never settle → accuracy is MLB-only). Documented as the #1 roadmap gap. ### Phase 4 — wired missing MLB stat types (a full vertical slice) - `batter_rbis → rbi` (unblocks a market already requested). - PropLine MLB MARKETS 6 → 12: +batter_runs, +batter_walks, +batter_doubles, +pitcher_earned_runs, +pitcher_hits_allowed, +pitcher_outs (same request, no extra quota; runs/walks/earned_runs/hits_allowed were already supported downstream — just never asked for). - `doubles` + `outs` (+`triples`) added to featureCache + outcomeService MLB_LOG_FIELD AND all three grade whitelists (analyze/scan/validation.py). ### Phase 6 — pipeline alerting + resilience - **`src/utils/opsNotify.js`** — ntfy.sh push (`vyndr-pipeline-kev2026`), never throws, auto-disabled under test / `PIPELINE_ALERTS=0`. Injectable `fetchImpl`. - `snapshotService.runSnapshot`: success alert ("✅ MLB snapshot: N graded, D deltas, X% accuracy"), stale alert (empty slate), failure alert; **retry-once** on a hard odds error/null (NOT on a legit empty slate — that's off-hours, not a failure). - **Missed-cron watchdog:** `snapshotScheduler.mostRecentExpectedSlot` + `isSnapshotOverdue` (pure, tested); the tick alerts once per missed slot; the status probe (`GET /api/internal/snapshot/status`) now returns `overdue`. ### Phase 7 — the master roadmap `specs/vyndr-roadmap.md` — priority-ordered Sessions 57–64 (WNBA/NBA settlement → ESPN features → soccer e2e → live calibration → NFL/NBA readiness), stat-type + sport coverage targets, operating invariants. ## Session 55 (2026-07-10) — SHIPPED ✅ SELF-LEARNING LOOP + REAL-TIME LAYER Backend 2255 (4 failing) → **2274 tests** (all green; +19 new, +4 fixed), 196 suites. Web build clean (exit 0). Spec: `specs/session55-self-learning.md`. ### Phase 2 — the self-learning loop (the crown jewel; nothing like it existed) - **`src/services/outcomeService.js`** — settles each locked snapshot grade against the REAL result (MLB Stats API game log — free, same source the grade pipeline uses) → hit/miss/push, aggregated by grade tier over a trailing 30-day window. Presence of a game-log row for the graded date ⇒ FINAL. Fully injectable → unit-tested with zero network. Idempotent (dedupe by `nameKey|stat|line|side|date`). NBA/WNBA degrade to `pending` (offline stats), never throw. - **Redis:** `outcomes:{sport}:log` (settled, cap 1000), `accuracy:{sport}`, `accuracy:overall` ({ overall, byGrade } over 30d; pct excludes pushes). - **Routes:** `GET /api/accuracy` (public, cached 5m), `GET /api/ledger/accuracy` (buckets — fills the pre-existing Next proxy that had no writer), internal `POST /api/internal/outcomes/:sport|/all`. Cron: `settleAllOutcomes()` runs on the snapshot scheduler tick BEFORE grading (settle yesterday, grade today). - **UI:** `AccuracyBadge` (dashboard header + scan result + landing) — honest by construction: below MIN_SAMPLE (8) it reads "MODEL · LEARNING" (amber) instead of faking a number; above it, "A-RATED · 68% HIT · 30D" (green). Settled outcome chips (`✓ HIT (2)` / `✕ MISS`) on slate props via a snapshot-route overlay + `StatStrip.OutcomeChip`. ### Phase 1 — the real-time layer (make it feel ALIVE) - **Slate auto-refresh:** silent 60s poll (no skeleton flash, never wipes a good view on a transient blip) + a "SIGNAL LIVE · N PROPS GRADED · M LIVE · UPDATED Xs ago" strip with a ticking freshness clock (`nowTick`, 15s). - **Ticker:** anchored `LIVE` badge (pulsing dot) that flashes green when a fresh event slides in (breaking-news feel). ### Phase 3 — landing top-signals preview - **`TopSignals.tsx`** — pulls tonight's REAL top-3 A-rated grades from `/api/snapshot/{mlb,nba,wnba}` as mini grade cards (archetype + grade + line) + the live `AccuracyBadge`. Self-hides off-hours. The product shown, not described. ### Founder pricing (the real cause of the "4 stripe failures") `FOUNDER_CODE_EXPIRY` default was `2026-06-30` — lapsed as of the current date (2026-07-10), silently disabling every founder code (and the ClaimMeter scarcity pitch). Extended the default to `2026-12-31` (operators still override via env). That restored founder pricing AND turned the 4 failing tests green. ### Honest scope (deferred — NOT built this session) The prompt's Phases 4–8 (dashboard card redesign, scan reveal polish, Parlay Lab derivatives-desk polish, nav/mobile/onboarding/settings/team-hub polish) are real follow-on work. This session invested in the two TRANSFORMATIVE, differentiated systems (real-time + self-learning) end-to-end with tests + the highest-leverage first-visit surfaces (landing signals, dashboard accuracy). Live calibration adjustment (spec 2.3, feeding outcomes back into grade confidence) is scaffolded by the accuracy record but not yet wired into the engine. ## Session 54 (2026-06-19) — SHIPPED ✅ AUDIT CLEANUP Backend 2246 → **2255 tests** (+9), 194 suites. Web build clean (exit 0). ### Phase 1 — name edge cases (BOTH playerName.js copies, kept identical) - **Hyphens** stripped in `normalizeName` display+key (`.replace(/-/g,' ')`) so "Jung-hoo Lee" === "Jung Hoo Lee". - **Middle single-letter tokens** stripped in `nameKey` (keep first + last) so "Josh H Smith" === "Josh Smith". Guarded: real middle names ("Juan Carlos Smith") and collapsed first initials ("JC Escarra") are untouched. - **`richie: 'richard'`** added to NICKNAMES. ### Phase 2 — polish - **Team Hub names normalized** at the source: `teamService.getTeamHub` now maps every roster player (MLB + snapshot-fallback) through `normalizeName().display` → "J.C. Escarra" renders as "JC Escarra", matching the dashboard. - **Accent-keeping dedup** (`snapshotService`): when collapsing variant grades, the GRADE picked is still highest-confidence, but the DISPLAY now prefers the accented variant ("José" over "Jose") so prop rows match the pitcher line. - **Parlay copy**: same-game (different-team) warning now names the game — "⚠ 2 legs from the same game (NYY @ BOS) — correlated". ## Session 53 (2026-06-19) — SHIPPED ✅ SOCIAL PREVIEW FIX ## Session 53 (2026-06-19) — SHIPPED ✅ SOCIAL PREVIEW FIX Backend 2239 → **2246 tests** (+7), 193 suites. Web build clean (exit 0). - **OG/Twitter copy de-jargoned** (`layout.tsx`): the openGraph description had "Bayesian intelligence / kill conditions"; the main description had "xG regression / penalty taker". Replaced with "Pre-graded player props with proprietary archetypes. Correlation-aware Parlay Lab. Real-time line movement tracking. Built in Detroit." Title → "VYNDR — The edge the books don't want you to have". NOTE: did NOT write "45 archetypes" (the spec said 45, but the codebase has 41 — used "proprietary archetypes" to stay accurate). - **Dynamic OG image** (`app/opengraph-image.tsx` + `twitter-image.tsx` re-export): 1200x630, #06060B bg, VYNDR wordmark (green R), "The books have every advantage. / We built this to give it back." + feature row. Node runtime (NOT edge — this is a self-hosted standalone build). Both routes prerender to a real PNG at build. - The stale static `/og-image.png` reference was removed from metadata so the file-based convention owns the image (no duplicate/conflicting og:image tag). ## Session 52 (2026-06-19) — SHIPPED ✅ TEASER + INFRA VERIFICATION Backend 2234 → **2239 tests** (+5), 192 suites. Web build clean (exit 0). ### Phase 1 — Push-to-Book teaser (feature NOT live; teaser only) - `StatStrip`: a `BookItTeaser` ("BOOK IT ⟶", hover tooltip "Push-to-Book coming soon — connect your sportsbook") after the parlay "+" on every graded prop. - `GradeResultCard`: a "PUSH-TO-BOOK · COMING SOON" footer section. ### Phase 2 — infrastructure verification - `snapshotScheduler` now logs BOTH states: armed (`[snapshotScheduler] armed — SNAPSHOT_CRON=1, hours=…`) and disarmed (so container logs disambiguate off-vs-crashed). Test asserts the armed log. - NEW `GET /api/internal/snapshot/status` (internal-key gated): `{ cron_armed, cron_hours_utc, last_snapshot:{sport:{updated_at,gradeCount,deltaCount}}, redis_keys:{…:bool}, ticker_count }`. The single probe to verify the pipeline post-deploy. - REDIS PERSISTENCE FINDING: the app uses `REDIS_URL` (ioredis) — AOF/RDB persistence is a server-side (Coolify Redis) config the app can't set/verify. If snapshot keys vanish on restart, enable persistence on the Redis instance. ### Phase 3 — delta pipeline (verified sound; no fix needed) `runSnapshot` already reads `:latest` as prev → `computeLineDeltas(enriched, prev.grades)` → writes old→`:previous`, new→`:latest`. So deltas populate on the 2nd+ run. Added an opt-in debug log (`SNAPSHOT_DEBUG=1`) + a trace test asserting `:previous` is preserved verbatim and the delta math is correct. ## Session 51 (2026-06-19) — SHIPPED ✅ TEAM HUB ## Session 51 (2026-06-19) — SHIPPED ✅ TEAM HUB Backend 2215 → **2234 tests** (+19), 190 suites. Web build clean (exit 0). New routes: `/team/[abbr]`, `/api/team/[abbr]`. ### Phase 1 — team data API - `mlbStatsAdapter` gained `getTeams()` / `resolveTeam(abbr)` (statsapi `/teams?sportId=1`, cached 24h, abbr→id) + `getTeamRoster(teamId)` (active roster, cached 6h). - `src/services/teamService.js getTeamHub(sport, abbr)` assembles the hub: resolve team → roster → per-player season stats (bounded concurrency 8, reuses playerIntelService mappers) → archetype (from the snapshot grade, else classify) → tonight's graded props (from `grades:{sport}`). Whole result cached 15 min. MLB = real; NBA/WNBA = a snapshot-built partial roster (graceful note). - `GET /api/team/:abbr` (public, 404 on unknown MLB team) + Next proxy. ### Phase 2 — Team Hub page `team/[abbr]/page.tsx` (server, `generateMetadata`) + `TeamHub.tsx` (client): team header + sport badge, sort (archetype / graded / A–Z), archetype filter chips, roster rows (archetype badge + player link + position + horizontal stats + graded props with grade badges + parlay "+"), "No active props" greyed state, loading/error states. ### Phase 3 — game card team links + back nav `vyndr/GameCard` team abbreviations are now `TeamLink`s → `/team/:abbr?sport=` (green hover, stops propagation from the open-game handler). Team Hub has "← Back to Slate". ## Session 50 (2026-06-19) — SHIPPED ✅ PARLAY LAB ## Session 50 (2026-06-19) — SHIPPED ✅ PARLAY LAB Backend 2185 → **2215 tests** (+30), 187 suites. Web build clean (exit 0). ### Phase 1 — correlation-score model (parlayService.js, ADDED to S28 funcs) Numeric, GAME-AWARE model: `correlationScore(l1,l2)` → 0.7 same-player/game / 0.4 same-team/game / 0.2 same-game-diff-team / 0.0 diff-game. `combinedGrade` = leg-grade avg penalized by `avgCorrelation * 0.5`. `estimatedPayout` = product of per-grade fair odds × (1 − avgCorrelation) discount. `correlationWarning` → "⚠ N legs from {TEAM}…". `gradeParlay` bundles it all. ### Phase 2 — POST /api/parlay/grade Returns `{ combined, correlation, payout, legs }` (2–6 legs, public/stateless). The Next `/api/parlay/grade` proxy was forwarding to the wrong upstream (`/api/scan/parlay`) — fixed to the new route. ### Phase 3 — ParlayContext extended `ParlayLeg` gained team/game/archetype; MAX_LEGS now 6 with a tier-aware `maxLegs` (set by the panel). Auto-grades the slip (debounced) via the endpoint when legs ≥ 2 → live `combined`/`correlation`/`payout`. + `hasLeg`/`legKey`/`atCap`. ### Phase 4 — "+" buttons `StatStrip` renders a "+"/"✓" per graded prop (toggles add/remove); `vyndr/GameCard` wires it via `useParlay` (builds a leg with team + game id). `GradeResultCard`'s existing "Add to Parlay" feeds the same context from the scan page. ### Phase 5 — ParlayPanel (replaces legacy ParlayTray in the layout) Bottom slide-up: legs (archetype + grade + remove), correlation warning, combined grade, est. payout, CLEAR ALL + a floating leg-count badge (bottom-right) when closed. Tier-gated: free 2 legs (payout blurred → Desk upgrade), Analyst 4, Desk 6. ## Session 49 (2026-06-19) — SHIPPED ✅ ONBOARDING FLOW ## Session 49 (2026-06-19) — SHIPPED ✅ ONBOARDING FLOW Backend 2156 → **2185 tests** (+29), 184 suites. Web build clean (exit 0). New routes: `/onboarding`, `/api/preferences`. ### Phase 1 — name micro-fixes (closes the normalization arc) `playerName.js` (both copies): collapse adjacent single-letter words ("J C Escarra" → "JC Escarra", display + key) + added `mickey: 'michael'`. ### Phases 2–5 — onboarding, end-to-end - **Storage:** Supabase `user_metadata.preferences` — NO migration. Shape: `{ sports[], books[], weekly_limit, onboarding_complete }`. - **API:** `src/routes/preferences.js` GET/POST (requireAuth; service-client admin getUserById/updateUserById; POST is a PARTIAL merge + sanitized) + `/api/preferences` Next proxy (forwards the bearer). - **Page:** `web/src/app/onboarding/page.tsx` — 3 self-contained steps (sports [≥1 required] → books [skip] → weekly bankroll [presets + custom + skip]) → "SIGNAL ACTIVE / You're locked in" → POST `onboarding_complete:true` → 2s → `/dashboard`. Redirects to /login when unauthenticated. - **Redirect:** the dashboard fetches `/api/preferences` (fresh — avoids stale session metadata); if `onboarding_complete !== true` AND the user is NEW (`created_at >= 2026-06-19 cutoff`), → `/onboarding`. Never fires while auth is loading (would bounce unauthenticated users). Existing users are exempt. - **Personalization:** the Slate defaults its tab to `prefs.sports[0]`; preferred books are highlighted (green glow) in each game card's lines grid (`isPreferredBook` in `lib/books.js`, threaded dashboard → Slate → GameCard). - **Settings:** a PREFERENCES section loads (GET) + edits + saves (POST) sports, books, and weekly limit. ## Session 48 (2026-06-19) — SHIPPED ✅ NORMALIZATION AT EVERY LAYER ## Session 48 (2026-06-19) — SHIPPED ✅ NORMALIZATION AT EVERY LAYER The normalizer functions were correct (S47) but raw names still flowed through paths that skipped them. Traced top-down, fixed each. Backend 2149 → **2156 tests** (+7), 181 suites. Web build clean (exit 0). ### The trace (3 leaking paths) 1. **Snapshot grades** stored RAW player names + no dedup → Explore/leaders/ profile/GameCard-overlay all saw variants. 2. **Game-card strips** merged the player but pushed EVERY variant prop → "Ks 5.5 AND Ks 3.5" duplicate rows. 3. **Scan player grid** (`tonightsPlayers`) grouped by raw `p.player` → variant tiles. ### Fixes - **2a (source chokepoint):** `snapshotService.runSnapshot` now normalizes each grade's player to the de-dotted display AND dedupes to ONE grade per normalized player+stat (highest confidence) BEFORE writing `grades:{sport}` + `snapshot:{sport}:latest`. Every consumer now gets clean, merged names for free. - **2b:** `buildPlayerStripsFromProps` dedupes a player's props by stat (prefers the graded over an awaiting row) → one row per stat. - **2c:** scan `tonightsPlayers` groups by `nameKey`, displays `normalizeName().display`. - **3 (profile usage/rest):** the "+0%"/"—" was the PLAYER PROFILE's `buildIntel` (separate from the grade card's `buildIntelFields`, which already works — the full feature merge carries ab_per_game/rest_days, NOT filtered by FEATURE_NAMES which is meta-only). `resolvePlayerStats` (MLB) now attaches real `usage` (AB/G) + `rest` (B2B/Xd) and `buildIntel` renders them; REST default is now "—" not "+0%". ## Session 47 (2026-06-19) — SHIPPED ✅ NAME NORM + INTEL + TICKER POLISH ## Session 47 (2026-06-19) — SHIPPED ✅ NAME NORM + INTEL + TICKER POLISH Backend 2122 → **2149 tests** (+27), 179 suites. Web build clean (exit 0). ### Phase 1 — complete name normalization `playerName.js` (both copies) gained: parenthetical-tag strip ("Jose Fermin (STL)" → "Jose Fermin"), a ~50-entry NICKNAMES table resolved in `nameKey` ("Matt"↔ "Matthew", "Mike"↔"Michael"), and the existing accent-fold now verified for Iván/Ivan, José/Jose. The slate strip now DISPLAYS the normalized de-dotted name (`buildPlayerStripsFromProps` uses `normalizeName().display`) — "A.J. Ewing" shows as "AJ Ewing", not the raw PropLine string. ### Phase 2 — complete VYNDR INTELLIGENCE for MLB The audit showed only Form. `mlbGameLogFeatures` now also derives `rest_days` (days off between the two most recent game-log dates; 0 = B2B, matching the NBA convention) and `ab_per_game` (the MLB "usage" equivalent). `buildIntelFields` renders `usage` as "X AB/G", `rest` as "B2B"/"Xd rest", and a matchup grade from `bvp_advantage` when no opp rank. So MLB grade cards now show Form + Usage + Rest (+ Matchup when available). ### Phase 3 — ticker SCAN dedup `pushTickerItems` keeps only the LATEST SCAN per sport (drops prior SCAN events for any sport with a fresh scan; parses the sport from a `sport` field or the text prefix for legacy items). MOVE/GRADE events preserved; cap stays 50. ### Phase 4 — archetype threshold BOMBER's HR thresholds prorated for mid-season (`hr>=15` strong / `hr>=10` moderate, was `>=20`/`>=15`), so June sluggers (Schwarber/Harper ~17-18 HR) classify as BOMBER instead of FLEX/DRIVER. `computeLineDeltas` re-verified structurally sound (deltas populate on the 2nd+ snapshot). ## Session 46 (2026-06-18) — SHIPPED ✅ P1 FIXES ## Session 46 (2026-06-18) — SHIPPED ✅ P1 FIXES Backend 2100 → **2122 tests** (+22), 176 suites. Web build clean (exit 0). ### Phase 1 — grade card intel (ROOT CAUSE) The STAT CONTEXT + VYNDR INTELLIGENCE sections were empty for MLB because `gameLogService.getGameLogs` is NBA/WNBA-only (offline Python service) — MLB props NEVER got `l5_avg`/`l20_avg`, so `buildIntelFields` always returned `{}`. FIX: `featureCache.gameLogFeatures` now has an MLB branch that derives l5/l10/l20 averages from `mlbStatsAdapter.getPlayerStats` (free statsapi.mlb.com) via the pure `mlbGameLogFeatures` + an MLB stat_type→game-log-field map. `buildIntelFields(features, opts)` also gained `playerStats`/`projection` fallbacks so partial intel renders (Sessions 43/44 had the wiring; the engine just never produced the values for MLB). ### Phase 2 — player name normalization `src/utils/playerName.js` + `web/src/lib/playerName.js` (identical; cross-checked by a test): `normalizeName(raw)` → `{ display, key }`. Strips periods, de-dots suffixes, accent-folds the key. Applied in `snapshotService` (grouping/deltas), `slateAdapter` (grade index + `buildPlayerStripsFromProps` merges variants, displays the longest), and `playerIntelService.sanitizePlayerName`/`normName`. "A.J. Ewing"/"AJ Ewing" and "Jazz Chisholm"/"Jazz Chisholm Jr." now merge. ### Phase 3 — MLB starting pitchers The ESPN schedule lacks probable pitchers. NEW `GET /api/schedule/:sport/pitchers` (MLB) → `probablePitchers` service wrapping `mlbStatsAdapter.getScheduleWithPitchers` + best-effort season ERA. The Slate fetches it, builds a team→pitcher map (`slateAdapter.buildPitcherMap`/`pitchersForGameTeams`, matched by full name + mascot), and attaches `pitchers` to MLB GameCardData. + Next proxy. ### Phase 4 — verify `computeLineDeltas` confirmed structurally sound (S45 tests); `deltas: 0` in the audit was just the first snapshot (no previous to diff). BACKEND_HANDOFF.md updated. ## Session 45 (2026-06-18) — SHIPPED ✅ SNAPSHOT PIPELINE ## Session 45 (2026-06-18) — SHIPPED ✅ SNAPSHOT PIPELINE The on-demand grade model is retired. Backend 2061 → **2100 tests** (+39), 173 suites. Web build clean (exit 0). ### Phase 1 — snapshotService (orchestration of existing services) `src/services/snapshotService.js` `runSnapshot(sport)`: getOdds (PropLine rotation) → gradeAndCacheSlate (captured via injected cacheSet) → classify each player's archetype (resolvePlayerStats + archetypeService, pure math) → attach `gradedAt {line, odds, timestamp}` (LOCKED) → compute line deltas vs the previous snapshot → write `snapshot:{sport}:latest|previous` + `grades:{sport}` → emit ticker events. `runAllSnapshots()` loops mlb/nba/wnba/soccer. Everything injectable → fully unit-tested with zero network. Deltas: `toward` = market confirming our side, `away` = opposing; noise filtered <0.5. ### Phase 2 — internal API + cron + ticker API - `POST /api/internal/snapshot/:sport` + `/snapshot/all` (existing `requireInternalAuth`; `/all` registered first so it isn't captured as a sport). - `GET /api/ticker` (public, cache-only, merges `TICKER_MANUAL` pins) + Next proxy. - In-process cron `src/snapshotScheduler.js` (gated `SNAPSHOT_CRON=1`, UTC hours 14,19,22,1,3 = 10AM/3PM/6PM/9PM/11PM ET), armed in server.js. No new dep — 1-min unref'd interval, fires once per slot. Prefer external n8n cron hitting the internal endpoint for multi-replica deploys. ### Phase 3 — GameCard swap (THE product shift) The live Slate now renders `vyndr/GameCard` (legacy kept for TYPES only). Reads `GET /api/snapshot/:sport` (cache-only, public; falls back to `grades:{sport}`), overlays the locked grades onto each game's odds-derived props via `slateAdapter.buildPlayerStripsFromProps` → player name ONCE + archetype badge + locked grade + "Graded Xh ago at -115 · Current 2.5 · ▲ TOWARD +1.0" line delta. Ungraded props → "Awaiting next scan" (NO Read button). The on-demand onGrade flow was deleted. ### Phase 4 — live ticker `vyndr/Ticker` now polls `/api/ticker` every 30s (graceful: keeps the passed hardcoded items as initial/fallback, never blanks). Tag colors: A+/A/SCAN green, MOVE/CASCADE amber, ALERT white. ### Phase 5 — NBA/WNBA ESPN fallback `espnStatsAdapter.getSeasonAverages(name, sport)` — free public ESPN stats, DEFENSIVE (`parseAthleteStats` returns null on any unrecognized shape → caller degrades to found:false; never a wrong-but-confident number). Wired as the NBA/WNBA fallback in `resolvePlayerStats` after the offline Python service. Note: the live ESPN athlete-stats shape may need tuning against production — the parser is tolerant and tested against a representative payload. ### Env vars (set in Coolify) - `PROPLINE_API_KEY_1/2/3` — PropLine props rotation (snapshot odds source). - `VYNDR_INTERNAL_KEY` — the internal-auth secret (header `x-internal-key`) for the snapshot trigger. (This is the existing key, NOT a new INTERNAL_AUTH_TOKEN.) - `SNAPSHOT_CRON=1` — arm the in-process scheduler (omit if using n8n). - `TICKER_MANUAL` — JSON array of editorial pins, e.g. `[{"tag":"ALERT","text":"VYNDR 2.0 is live."}]`. ## Session 44 (2026-06-18) — SHIPPED ✅ MAKE IT VISIBLE ## Session 44 (2026-06-18) — SHIPPED ✅ MAKE IT VISIBLE Wired existing backend work into the pages users see + renamed archetypes to VYNDR Originals. Backend 2045 → **2061 tests** (+16), 167 suites. Web build clean (exit 0). `BACKEND_HANDOFF.md` added as the canonical data contract. ### VYNDR Original archetype rename (proprietary names) All 41 archetypes renamed in `archetypeService.js` + `lib/archetypes.js` + `ArchetypeBadge`, each keeping `legacyName`/`legacy` (never displayed; resolves for stale data). NBA: TORCH, CONDUCTOR, FORTRESS, ARTILLERY, SURGE, DUAL THREAT, CONNECTOR, FASTBREAK, PAINT BOSS, LOCKDOWN, SWITCHBOARD, ARCHITECT, PISTON, SENTINEL, IGNITER. WNBA-unique: DISTRIBUTOR, SHIELD, RANGE, SPARK, ANCHOR. MLB: BOMBER, BRUSH, DRIVER, ALPHA, WHIFF, GHOST, HYBRID, FLEX, WORKHORSE, CATALYST, MIRROR, HAMMER, SINKER, BRIDGE, SWITCH. Soccer: FINISHER, MAESTRO, TOWER, MOTOR, BLADE, WALL. **MLB note:** the old taxonomy had two power hitters (POWER PULL/POWER SLUGGER) but the new set has one power name (BOMBER), so BOMBER now fires for any high-HR bat (Judge → BOMBER per the audit checklist) and the freed slot became a real WHIFF strikeout-artist pitcher (improves pitcher coverage). `getArchetype`/`archetypeInfo` resolve legacy names → VYNDR Originals. ### Phase 2 — grade-card intel now populates (the real bug) The chain engine→tierGating→/api/scan proxy already PRESERVED the intel fields (all spread `...result`/`...data`). The ONE broken link: `scan/page.tsx` called `mapScanToGradeResult` with a hardcoded field subset and DROPPED season_avg/ form/usage/matchup_grade/etc. Now forwards them (+ extended `ScanResponse`), so STAT CONTEXT + VYNDR INTELLIGENCE sections light up on a real MLB grade. ### Phase 3 — stale-game filtering `slateAdapter.isRelevantGame(game, now)`: upcoming/live always show; a COMPLETED game is dropped once >24h old (no more 5-day-old FINALs). Applied in `Slate`'s `filteredGames`. Schedule TTL was already 60s (≤30min) — no change. ### Phase 4 — landing copy `Features.tsx` rewritten: no more "Point-biserial", "Zone 14", "ABS intelligence", "Auto-calibrating", "Phi-coefficient". User-facing benefit copy (Player DNA archetypes / Self-improving model / Lineup intel before tip-off / Deep pitcher-batter matchups). ### Phase 5 — depth chart proxies (404 fix) Added the missing Next proxies `/api/stats/lineup/[team]`, `/depth/[team]`, `/cascade/[player]` → return JSON, not 404. ### Phase 1 — GameCard swap DEFERRED (decision: Kev) The live Slate keeps the legacy "Read"/on-demand grade card as a **temporary bridge**. The vyndr/GameCard swap lands WITH the snapshot pipeline (next session): the on-demand "Read" model is being retired for a pre-graded snapshot model (full slate graded at scheduled intervals, grades locked to the line), and the new card is designed for that. Swapping now would remove grading + show blank cards (grades cache unpopulated). BookChip brand colors already render in the legacy card (S43). ## Session 43 (2026-06-18) — SHIPPED ✅ DATA PIPELINE + AUDIT FIXES ## Session 43 (2026-06-18) — SHIPPED ✅ DATA PIPELINE + AUDIT FIXES Post-S42 Chrome audit: architecture renders but no real data flowed (every player `found:false`, leaders empty, grade intel hidden) + a P0 dropdown bug. Backend 2011 → **2045 tests** (+34), 163 suites. Web build clean (exit 0). ### Phase 1 — P0 bug fixes - **Avatar/More dropdown z-index** — the `