# VYNDR — Build State ## Last Updated 2026-08-03 ## 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 `