Commit Graph

10 Commits

Author SHA1 Message Date
builtbykev 43f65d30cb Wire the three proven hits factors pre-grade: transmission proven, gain
inconclusive

THE BUG THIS NEARLY SHIPPED AS A FINDING. The first audit reported 0
factors fired on all 1,140 rows. Not a result -- my paging helper ordered
by `id`, and batter_spray, team_defense, platoon_splits and
statcast_aggregates have composite primary keys with NO id column. The
query errored, the loop broke on error, and four fully-populated tables
read as empty. hitsFactorContext.js -- the PRODUCTION loader -- had the
identical defect, so live wiring would have loaded nothing and served
unadjusted while logging success. Third occurrence of this class in one
session. Both loaders now order by a real column and THROW rather than
degrade. The Phase 2 gate is what caught it: no resolution number was
quoted until transmission was proved.

PHASE 1 — pipeline is now base -> FACTORS -> CALIBRATE -> GRADE. Context
built in snapshotService BEFORE gradeAndCacheSlate (was line 640+, grade
at 454), threaded per prop, applied to p_over before p_win is set with
p_win_prefactor and a full trace retained. Hits only. Coverage 859/1140
rows (75%): 474 with all three factors, 256 two, 129 one, 281 none.

PHASE 2 — TRANSMISSION PROVEN, 12/12 sign-correct, 4/4 per factor, each
applied IN ISOLATION. My first table compared each factor's expected sign
against the COMPOSITE change and showed 3 false failures -- with three
factors firing the net can oppose any single member; that was a flaw in
the test, not the wiring. Two under-side rows confirm the flip is handled:
a factor raising p(over) correctly lowers p_win. Switch hitters (Bailey,
Bell, Rocchio) took no spray adjustment while their other factors fired
normally -- the refusal is selective, not a blanket skip.

PHASE 3/4 — both maps refit on the factor-adjusted forecast; the
shadow-duel baseline is VOID and restarts, since it accumulated against a
different forecast. Point-in-time, 765 held-out rows:

  reliability 0.00795 -> 0.00828
  RESOLUTION  0.00229 -> 0.00345   (variance explained 0.93% -> 1.39%)
  Brier       0.25398 -> 0.25305   delta -0.00093  CI [-0.00225,+0.00002]

Resolution rose 51% relative. The CI TOUCHES ZERO on 4 eval dates, so the
composition does NOT earn a proven keep -- three isolated passes did not
grant a composed pass. INCONCLUSIVE, reported as such. The gain is far
below the sum of the isolated effects, which is expected: all three run
through the same pitcher-batter confrontation and share signal.

PHASE 5 — 1.39% of variance is still far below what band separation
needs. The pivot was correct and incomplete: the plumbing defect was real
and is fixed, three proven factors reach the served number for the first
time, and transmission alone did not buy grade separation. Next arc is
factor STRENGTH and BREADTH, not more plumbing.

PHASE 6 — rbi anomaly logged, not chased: 14.51% variance explained vs
hits 1.03%, on the stat we do not serve corrected and which has no proven
factors. Either the biggest lever on the board or a mirage; it deserves
its own order.

The byte-identical invariant INVERTED for hits by design. All 13 frozen
non-hits modules verified unchanged, probabilityEstimator included -- the
factors ride outside it. No new Bonferroni slot; the composed OOS claim is
reported with its CI and not claimed as a pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-07 02:53:48 -04:00
builtbykev 843c8c6d4b Build the pitcher engine, and find the cap was eating the whole board
Strikeouts are NOT proven -- n=57 against a bar of 500. But the finding that
matters is not a correlation.

THE CAP. Measured on the live slate via the refusal diagnostic: 1,244 unique
gradeable props exist, the 500 cap graded about 334, and because dedupeProps
takes first-row-wins in FEED ORDER, what survives is decided by feed position
rather than value. Pitchers are 2.6% of a batter-dominated feed, so we were
grading SIX strikeout props a slate against 32 available -- putting n>=500
three months away for every pitcher stat. Pitcher props were never being
refused (graded 5, refused 0, suppressed 0); it was truncation.

Raised 500 -> 1500 on measured cost: 721ms per prop at concurrency 5 is about
179 seconds for the full board, against a cron that runs five times a day and
a fire-and-forget caller that never holds an HTTP response. statsapi is free
and unlimited. Concurrency stays at 5 -- one variable at a time. This unblocks
every n-blocked stat in the programme, not just pitchers.

THE ENGINE. pitcherEngine.js is its own engine, not the batter engine pointed
at pitchers: the batter model asks whether contact becomes a hit and reads
contact quality, the pitcher model asks whether the plate appearance ends
without contact at all and reads stuff. Archetypes are FLAME (whiff-led),
SCALPEL (chase-led), SINKER (pitches to contact) and DEFAULT, and a test
asserts the weight keys are not the batter engine's. The projection is K% by
log5 against THIS lineup, times batters faced, through a binomial. An
unclassifiable arm gets the balanced map, never a guessed archetype.

THE MEASUREMENT, at n=57 and contaminated. Four solo features clear the 0.15
effect bar and fail only on sample: arm angle at -0.250 -- the largest
correlation measured anywhere in this programme -- then whiff +0.213, k rate
+0.206, chase +0.195. The batter cluster's best was 0.135. Head to head,
pitch-v1 resolves 0.1285 against the counter's -0.0639, delta +0.192 with a CI
spanning zero.

That negative is the interesting number. The counter is ANTI-PREDICTIVE on
strikeouts: counting a pitcher's recent Ks is worse than useless, because his
recent totals track which lineups he drew and how long he was left in rather
than his skill. It is the one stat where the incumbent has no defensible edge.

A bug caught on the way. resolveTeam wants an abbreviation and the game log
supplies full team names, so the roster join silently resolved nothing and the
first run reported 0% lineup coverage -- the theorized stuff x lineup carrier
was never being tested, not failing. Fixed; coverage is now 94.7%. The carrier
still shows no incremental signal over whiff alone, and adding the lineup term
lowered head-to-head resolution, which is recorded rather than dropped.

Calibration was not reached: nothing passed the first bar. The batter model
and the counter are byte-identical, verified by diff.

4,221 tests green (335 suites); web build exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-03 18:43:32 -04:00
builtbykev 4435856f46 Audit finds env/matchup axes DEAD in prod; fix the environment join
STEP 0 AUDIT -- the "already partly live" premise was half true: the CODE
is wired, the axes are NOT firing. Across 634 graded prod rows the
environment and matchup axes fired on ZERO rows, while 13 archetype axes
fired normally (power 80, swing_miss 69, contact 56, launch 51,
line_drive 43, ...) plus opportunity 142. Ledger confirms it from the
other side: env_multiplier, env_park_base, env_weather_mod, wx_forecast
and env_weather_state are ALL null on 634/634.

ROOT CAUSE, located rather than inferred. A drop-off audit against the
live snapshot: with_team_field 0/120, with_bats 0/120, with_playerId
120/120, oppPitcherByTeam 0, handById 0. `team` is a KEY on every stored
grade and NULL on 416/416 -- so an environment resolver keyed off the
player's roster team could never find a venue, while buildContext sat
there with all 30 teams mapped and 14 weather forecasts resolved and
unused. Coors composes to 1.241 the moment it gets a key.

FIX -- and it is the more correct join, not just a workaround. The park
and the weather belong to the GAME, not to the player's roster team, and
the game rides on the prop from the odds feed. gradeBestSide now carries
home_team/away_team onto the graded row (the legacy grade shape dropped
them), and contextFor joins on the game first, keeping the roster team as
a fallback. This no longer depends on a stats-resolve that can
legitimately fail.

MATCHUP/PLATOON IS NOT FIXED HERE and is not claimed as fixed: it needs
the opposing starter and both hands, and the audit shows
oppPitcherByTeam=0, handById=0 and bats=0 on the slate -- three separate
absences. Per "one axis at a time" that is its own order with its own
diagnosis, not a second fix smuggled into this one.

Champion p_win, ranking, calibration and opportunity_drift's accruing
verdict are all untouched.

Gates: 4,077 tests / 326 suites green; next build exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
2026-08-01 04:05:43 -04:00
builtbykev a7d6cf8e36 Raise the grade cap 25 -> 500 on measured cost; refusals are correct
PART 1 (read-only, measured on a live prod slate, n=80) OVERTURNS THE
PREMISE. The refusal rate is not a data problem -- it is 98% correct
behaviour. The cap is the entire problem, and it is worse than "25 of 546".

Composition: GRADED 44 (55.0%) | POLICY-SUPPRESSION 35 (43.8%) |
FETCHABLE-GAP 1 (1.3%) | FALSE-THRESHOLD 0 | ARCHETYPE-GAP 0 |
GENUINE-ABSENCE 0.

THE FIFTH BUCKET the order did not anticipate: all 35 "refusals" are
rare_event_over_below_line -- the 2026-07-19 betting-logic audit
deliberately refusing 0.5-line rare events, setting the SAME
insufficient_data flag as a real data gap, which is why they read as one.
They are entirely doubles (18) and stolen_bases (17), while hits (19/19),
rbi (19/19) and total_bases (5/5) grade at ~100%. Had we "fixed" this we
would have re-introduced exactly the bets a previous audit removed, and the
count would have looked like progress.

THE CAP: 585 unique gradeable props, cap 25 -> 560 discarded (95.7%).
Traced to Session 32 (f0c8b4f), commented "bound the herd" -- a guard
written before anyone measured what a grade costs. So I measured it:
721ms mean / 666ms median / 1024ms p90 per grade => ~72s for 500 props at
concurrency 5. Both callers tolerate that: the cron runs 5x/day and
recordDownstream is fire-and-forget.

PART 2 -- item 3 ONLY, because that is what the diagnosis supports.
DEFAULT_LIMIT 25 -> 500, env-tunable via GRADE_SLATE_LIMIT. Concurrency
stays 5 deliberately: the cap raise already multiplies load ~20x, and
concurrency decides how hard we hit statsapi at once. One variable at a
time.

Items 4/5/6 have nothing to act on and I am not manufacturing work for
them: 0 false thresholds to loosen (loosening would be manufacturing
grades); /context wiring is worth doing for grade QUALITY but would not
have graded one extra prop here, so it is not claimed as a coverage win;
archetypes are display-side and do not gate grading at all.

THE REFUSAL RATE DOES NOT DROP, AND THAT IS CORRECT. No threshold lowered,
no grade forced. The board grows because the cap stops discarding 95.7% of
the slate.

Flagged in advance rather than discovered later: snapshot payload and
ledger volume both scale with the same multiple. If the response gets
unwieldy the fix is a response-side cap on what the BOARD returns, never a
re-cap on what gets graded -- grading everything and serving a slice is
honest; grading a slice and calling it the slate is what this fixes.

Gates: 4,052 tests / 324 suites green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
2026-08-01 02:06:25 -04:00
builtbykev f0543b57a4 Product identity + widen books for DISPLAY, model input byte-identical
IDENTITY (CLAUDE.md top + MASTER-PLAN header). VYNDR is a PREDICTIVE MODEL:
it projects what a player will DO and picks accurately. Market edge is a
BYPRODUCT of a good prediction, never the success criterion. Success =
the forecast is honest about its own confidence AND still ranks --
calibration and resolution, both. No edge/CLV term belongs in a pass/fail
gate; they are diagnostics we report, not thresholds a model must clear.
A model tuned to beat a closing line has been fitted to the market instead
of to the game.

Per-sport doctrine (Phillips 2022, classify by what players DO not by
position): each sport is its own model -- own variables, archetypes,
conditions, calibration, honest ceiling. Shared across sports: ONLY the
Bayesian inference math.

Truth Law: no fabricated data; honest-absent over invented; label
limitations in-band; provisional stays provisional until re-run;
documented is not verified.

PHASE 2 -- AGGREGATOR WIDENING (live). normalizeProps now emits every
DISPLAY book instead of 5 of 18. Before this we discarded 13 books of our
own accord and 64.8% of the MLB slate was invisible to users. Every prop
carries book_role (both/takeable/reference/dfs/offshore) so the display
layer can say WHAT a price is -- a fixed-payout DFS number and a two-way
sportsbook price are not interchangeable objects. Unknown books are still
dropped.

PHASE 3 -- MODEL GATE (the model does not move). bookRoles splits
MODEL_BOOKS (the legacy allow-list, character for character) from
DISPLAY_BOOKS. Both model paths re-filter before they pick a line:
gradeSlateService.dedupeProps (before first-row-wins AND before the limit)
and intradayRefreshService.indexOddsProps (which RE-GRADES at the current
line -- without the gate, widening would have silently moved locked lines
onto books the model has never been calibrated against). A test asserts
the graded set is byte-identical through the widening.

CURRENT_RULER_VERSION stays v1_first_book. The gate lifts only when the
MLB calibration is re-run on the consensus ruler and v2 is promoted.

HONEST FRAMING, recorded in the plan: this is an AGGREGATOR win and it
does NOT fix the model. WNBA still abstains -- a model problem, not a
coverage problem; it is better covered than MLB. MLB isotonic still
provisional. The consensus is MARKET, not SHARP: pinnacle, matchbook and
polymarket are 0% on both sports, so no sharp anchor exists in our feed.

Two superseded tests updated to stronger properties rather than deleted:
roleOf now names the KIND of book, and the normalizer test asserts the
display set widens WHILE the model set does not.

Gates: 4,027 tests / 322 suites green; next build exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
2026-08-01 00:50:54 -04:00
builtbykev 6415751f2e Grading binds opponent features to the REAL game, not ESPN's "today"
Order 1.6 Phase 1. This is a MODEL-OUTPUT fix, not bookkeeping.

computeFeatures.lookupTodayGame called the ESPN scoreboard with NO date
param and took whatever ESPN calls "today". Renamed to lookupGameOnDate
and now sends ?dates=YYYYMMDD from the prop's BOUND game — the same game
the ledger, retention and settlement use, so all four finally agree.

PROVEN against live ESPN (before/after, same instant):
  dateless "today"      CLE->PIT  NYY->LAD  LAD->NYY   (Jul 19 card)
  bound to 2026-07-20   CLE->MIN  NYY->PIT  LAD->PHI   (the real games)
  bound to 2026-07-19   CLE->PIT  NYY->LAD  LAD->NYY   (reproduces OLD)
Every opponent was wrong. opponentAbbr feeds opp_rank_stat (a +/-1.0
factor) and isHome feeds home_away (+0.5), so late-slot grades were
scored against the wrong matchup.

Note the window is WIDER than the 01:00/03:00 UTC slots: this ran at
07:5x UTC = 03:5x ET and ESPN's dateless scoreboard was STILL returning
the previous day's card.

HONEST DEGRADATION: with no bound game date the grader does NOT fall back
to a dateless lookup — it records 'no_bound_game_date' and leaves
opponentAbbr/isHome/gameId null, so engine1 simply omits the opponent and
home/away factors rather than scoring a wrong matchup. Tests assert both
directions.

Same class of bug fixed alongside: the Tank01 augmentation used TODAY's
UTC date for its cache key; it now uses the bound game date.

gradeSlateService threads game_date/game_time/home_team/away_team into the
grader so the binding reaches computeFeatures at all.

Audited the rest of the feature path for dateless/"today" lookups — none
remain (weather is current-conditions by venue, park/pace are static).

Suite 282/3383 green, build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
2026-07-20 03:50:07 -04:00
builtbykev d3ffa1b8c2 Retention: model_snapshots live + base64 SSH key support
RETENTION (Phase 2, priority zero). History starts compounding tonight.

migration 025 model_snapshots — APPLIED to prod. Append-only, one row per
graded prop PER SIDE PER CYCLE, with a unique index on
(snapshot_id, player_key, stat, line, side) so a retried cycle cannot
duplicate. RLS on, service-role writes only.

What it captures that the ledger never did:
- features jsonb — the model's INPUTS. Without these a backtest can only
  grade our own homework; with them any future model can be replayed
  against the exact conditions this one faced.
- REFUSALS (refused + refusal_reason). The ledger drops them, so a gate
  refusing props that would have WON is invisible — unmeasurable lost
  edge. Captured via a new onGraded hook in gradeSlateService that fires
  with BOTH sides before any filtering.
- grade_11, the pre-collapse grade. The 4-letter map throws away the
  entire live C-/C/C+/B- range.
- model_version + code_sha on every row. ledger_entries mixes pre/post-fix
  grades with no marker and cannot be separated retroactively.
- p_win / ev_pct / fair_odds / takeable / value — none of which any
  permanent store held.

Wiring: analyzeViaEngine1 attaches _features/_grade_11 (underscore =
internal); gradeSlateService fires onGraded then STRIPS them so they never
reach a cache or API payload; snapshotService builds rows and persists
best-effort. Retention reuses the LEDGER's dateET/gameIdFor helpers so
rows share the ledger's natural key exactly — otherwise the settle pass
could never join outcomes onto them. Rows are written BEFORE the empty-
slate early return: a slate that refused everything is exactly the case
worth recording.

CONTRACT HELD: retention is injectable and every path is caught. persist()
returns errors, never throws; a missing Supabase client is SKIPPED, not an
error. A retention failure can never break a snapshot.

BACKUP: backup-db.sh now accepts BACKUP_SSH_KEY as base64 (recommended —
survives env-var newline mangling, which is how injected SSH keys usually
break silently) OR raw PEM, detected by decoding and looking for the PEM
header. Verified both forms detect correctly against a real generated key.

Suite 279/3325 green, build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
2026-07-19 23:01:12 -04:00
builtbykev d242b11b4b S1 (a1): feature-promise audit — no claim survives unverified
PROMISE-AUDIT.md: every /pricing claim → verified/built/reworded.
BUILT (was vapor): alt line ladder + edge ranking (same-features regrade
at shifted lines, Desk-gated at the API), quarter-Kelly (engine quantile
P(win) x real captured odds — either missing → no sizing), free-tier
kill-condition locked previews. FIXED (was false): analyst 15/day cap vs
the Founder 'Unlimited reads' promise → analyst unlimited; every '40+
factors' claim (real count: 22 named features) reworded truthfully in 7
files. VERIFIED: cascade alerts (real, wired), phi correlation,
leg history, cross-book comparison, WC soccer, real-time feed.
Locked by tests/unit/promiseAudit.test.js. Jest now ignores
.claude/worktrees (parallel agents' suites no longer leak into runs).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 14:24:41 -04:00
builtbykev d296e40cb6 Session 58: Phase 1 — Truth Infrastructure (2327 tests)
ledger_entries is live (migration 019 applied to prod, RLS + NULLS NOT
DISTINCT dedupe verified against the real database). Every grade now
persists, settles against the real result, and carries closing-line value.

- ledgerService: pipeline pre-grade upserts (public model record, user_id
  null, idempotent), closing capture on every snapshot (last write before
  game start = the close), settlement with SIGNED CLV (over = locked -
  closing; beat/faded/flat), 30d model aggregate with the hard n>=20 rule.
- Write paths: snapshotService -> ledger (priority path); Next /api/scan ->
  ledger for authenticated users only (anon never touches the public
  record). Refused reads write nothing and don't burn a scan.
- Honest refusal (work-order 1.5): no projection => insufficient_data,
  grade null, "INSUFFICIENT DATA - no read" UI. The web gradeAdapter no
  longer displays the line as the model projection (the audit's
  model==line / +0% edge degenerate); the card renders absent states.
  projectionFor is sport-aware (l5 -> l20 -> {stat}_per_90 -> xG).
- /ledger: MY READS | MODEL tabs; model header shows hit% + beat-close%
  only at n>=20, else RECORD BUILDING + live pending count. ModelRecord
  deferred-render strip on landing + player hero. CLV + outcome chips,
  revised_from_grade strikethrough (Phase 2.5 ready).
- SYNC (Task 5): thresholds vs SNAPSHOT_EXPECTED_INTERVAL (normal <1.5x,
  amber >=1.5x, STALE red >=3x) via /api/snapshot/summary.
- Phase 2.5 logged in specs/vyndr-roadmap.md (build after Phase 3).
- Data-semantics hardening: strict null-safe numeric parsing everywhere a
  market value is handled (Number(null)===0 would have fabricated lines).

Backend 2309 -> 2327 tests (201 suites), web build exit 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 21:34:26 -04:00
builtbykev f0c8b4f29b Session 32: Grades pipeline + NFL/NHL wiring + rate limiting + audit cleanup (1718 tests)
- gradeSlateService writes grades:{sport} cache (closes content pipeline →
  dataLevel full); fire-and-forget from oddsService.recordDownstream, gated
  by shouldGradeSlate (off in test, GRADE_SLATE_ON_FETCH override)
- NFL/NHL wired: oddsService SPORT_KEYS/SPORT_MARKETS (correct the-odds-api
  keys americanfootball_nfl/icehockey_nhl), proplineAdapter MARKETS, NHL
  MARKET_MAP keys to avoid silent-zero
- rate limiting mounted on 8 public cached routers (odds/parlay 30/min,
  rest 60/min)
- jsonlLogger writes to temp under test (no more dirtied tracked artifact);
  5MB pipeline test given 20s timeout

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 18:21:32 -04:00