Commit Graph

36 Commits

Author SHA1 Message Date
builtbykev 55b210cb95 Fix the dormant basketball window-bug before it ships; guard the class
PHASE 0 — audit. espnStatsAdapter's slice(0,20) was already fixed at
494c83c, so the ESPN branch of getStatRows inherits a full log and is
CORRECT. MLB's l20_avg is a real seasonTotal/games aggregate, verified,
also CORRECT. TWO basketball defects remained:

  DEFECTIVE  nbaGameLogFeatures   m20 = avg(vals.slice(0, 20))
             -- l20_avg is the season reference projectionFor reads, and
             slicing to 20 made it a twenty-game average wearing a season
             label. The MLB l20 bug, unfixed for basketball.
  DEFECTIVE  getStatRows python   getGameLogs(playerName, sp, 20)

PHASE 1 — both fixed via a named SEASON_LOG_DEPTH = 100, past an 82-game
season so a request can never truncate one. API COST: ZERO. The count is a
request parameter, so asking for a season is the same single call. No
extra request, no extra quota.

MEASUREMENT DEFERRED, EXPLICITLY: basketball is offline, there are no
settled basketball rows, and resolution before/after CANNOT be measured
now. This is a code fix, not a measured improvement -- exactly like the
deferred MLB sibling paths.

PHASE 2 — the window guard asserts the property on source: no fixed N may
stand in for a season. It immediately caught TWO MORE instances I had
missed in Phase 1 -- a hardcoded 20 at featureCache:377 and
gameLogService's own `count = 20` DEFAULT, which would have handed a
twenty-game window to any caller that omitted the argument. That is a
seventh path, found by the guard rather than by me.

Retro-confirmed: run unchanged against 981a05c it goes 3 failed / 6
passed, flagging the basketball slice, the game-log request and the
season-reference check.

The class in full, now six paths across two sports, every one of which
looked like ordinary code. `slice(0, 20)` is unremarkable; what made it a
defect was the QUESTION it answered -- "what is this player's season
rate?" -- and no test could see that mismatch because the value produced
was always a plausible number.

PHASE 3 — THIS CLOSES THE NON-ACCRUAL ARC. Everything buildable without
settled rows is built: push unblocked (it was the wrong remote, not the
firewall), grade surface honest and rendering, reachability guarded,
champion repaired across every path including dormant basketball, and the
window class guarded so it cannot return.

The program is now correctly IDLE on modeling. Today's count: 0 eligible
dates, all four re-audit items WAITING. FIRST TRIGGER: 10 eligible
calibration dates on the repaired champion, at which point the resumption
order is calibration re-fit, hits factor lift, prior verdict re-audit,
rbi lineup-slot gate.

No basketball measurement claimed. No NBA chain/archetype build. MLB
serving verified byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-07 15:00:24 -04:00
builtbykev 3591c7626e Total grade cutover + the ceiling stated as a position
PHASE 0 caught my own repeat of the failure I diagnosed one order ago.
91927a4 attached `served_grade` BESIDE the old letter and left `grade`
alone -- so the honest grade reached nobody, exactly as gradeBands had
been built-correct-and-unread. grep showed served_grade appearing in one
file (where I set it) and all 14+ consumers -- scan route, dashboard,
parlay, newsletter, desk, content templates, retention -- still reading
`.grade`, i.e. still the dishonest letter.

CUTOVER IS NOW TOTAL: legacy.grade IS the honest letter. Overwriting the
one field every consumer already reads cuts every surface over at once
instead of editing fourteen call sites and missing one. engine1's index is
preserved as `engine_grade` and verified read by ZERO serving code.

Confidence follows the letter: it came from a grade-band midpoint of the
OLD letter, so leaving it would have paired a served B+ with a C's
confidence. Both now derive from p_win, kept on the existing 0-100 scale.

MEASURED BLAST RADIUS before shipping: 303 of 47,991 non-refused
snapshots (0.6%) have a grade but no p_win, and now render NO READ instead
of a letter. That is correct -- their old letter came from the retired
index carrying 0.48% resolution, i.e. noise -- and NO READ is a rendered
state with a reason, so never-blank holds.

PHASE 1 — the ceiling is now a STATED POSITION, not a confusing absence.
servedGrade.SCALE_LEGEND plus web GradeScaleLegend.tsx say it plainly: we
do not issue A grades, no band has hit at a rate that would justify one,
our honest ceiling is a strong B+ (~66% realized vs ~60% baseline), and if
the model earns an A the legend changes and we say why. The
separates_from_base_rate flag renders per band -- C+/C/C- are labelled
"we cannot separate this from the baseline", which is most of any slate.

PHASE 3 hand-verified across every state: B+ with 3 factors (basis
forecast_plus_matchup_factors), B+ with none (forecast_only), C flagged
not-separable, F, and three refusal states rendering NO READ with reasons.
never-blank PASS, no-manufactured-A PASS.

Test fallout was real and is documented rather than papered over: engine
BEHAVIOUR assertions moved to engine_grade, suppression assertions stayed
on grade (a suppressed prop has no letter either way), and the confidence
78 -> 95 change is the grade-band midpoint being replaced by p_win.

No A-threshold loosening. No calibrated number leaks (deployed set empty).
p_win never mutated. Ten frozen modules verified unchanged including
engine1 and probabilityEstimator. No Bonferroni slot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-07 14:07:02 -04:00
builtbykev 91927a4a8a Serve an honest grade: the letter was carrying 1/6 the information of the
number beside it

PHASE 0 corrects the order's premise. A grade letter has been served all
along -- engine1.gradeProp builds it from an additive factor index,
computed INDEPENDENTLY of p_win. gradeBands is orphaned for a different
reason than assumed: it defines what a letter MEANS from realized
outcomes, and every band collapses to base-rate at current resolution.

The measurement that changed this order, on 3,417 settled props:

  grade  n      realized  mean p_win
  A         8    0.500      0.647     <- the TOP grade did WORST
  B       985    0.640      0.700
  C     1,695    0.602      0.676
  D       303    0.558      0.604
  F       426    0.535      0.588

  letter resolution 0.00116 (0.48% of variance)
  p_win  resolution 0.00715 (2.98%)
  -> the letter carried 0.16x the information of the number beside it

Concretely, from the hand-verify: Christian Encarnacion's 0.95 over
graded C and his 0.05 under ALSO graded C -- same hitter, opposite
forecasts, same letter. The gap was never that grades don't ship; it is
that the weaker of two available signals shipped as the headline.

PHASE 1 — model/servedGrade.js derives the letter from p_win with bands
anchored on MEASURED realized rates (B+ 0.663 / B 0.646 / C+ 0.615 /
C 0.589 / C- 0.548 / D 0.512 / F 0.447, base 0.6005).

NO MANUFACTURED A, structurally: A+/A/A- are UNISSUABLE, not rare. The
realized rate plateaus at 0.65-0.68 above p_win 0.70, so no band has
earned a top letter; a test sweeps every p_win 0..1 and asserts none
produces one. Even 0.99 tops out at B+ with its realized 0.663 attached.
Raising that ceiling later is a deliberate, visible act.

Bands that cannot separate SAY so -- C+/C/C- carry
separates_from_base_rate false and copy naming it, which is the honest
description of a forecast explaining 3% of variance. Every grade states
its basis (forecast_only vs forecast_plus_matchup_factors, naming which
factors fired) and calibrated:false. engine1.grade is preserved as
engine_grade so nothing downstream breaks.

PHASE 2 — refusals render real states: insufficient_data -> "not enough
history to call this one"; juiced_no_edge -> "the book has priced the vig
past any edge on this side". 1,870 refused snapshots carry exactly those
two reasons and both now surface.

PHASE 3 — hand-verified on 12 real served props. Freeman/Rice/Encarnacion
0.95 overs now B+ (was B, C, B); the 0.05 unders now F (was C). Refused
doubles render NO READ with their reason. never-blank PASS,
no-manufactured-A PASS.

Serving change; nine frozen model modules unchanged including engine1;
p_win never mutated; no calibrated number leaks (deployed set empty); no
Bonferroni slot.

STILL TRUE: the forecast explains ~3% of outcome variance. This order did
not make the model better. It made the letter stop overstating it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-07 04:53:02 -04:00
builtbykev 2391574f00 Live-surface integrity on the repaired champion; fix the label my own
repair falsified

PHASE 0 — four checks PASS, one defect found and fixed.

PASS  gradeBands is required by NO serving code -- built across several
      orders, never wired. No stale band derived from the retired
      ten-game champion can reach a user, because none reaches a user
      at all.
PASS  CALIBRATION_DEPLOYED is [] and the calibrate loop iterates it, so
      calibrate() is never called and p_win_calibrated is never set. The
      only assignment site sits inside that empty loop. No withdrawn map
      can leak.
PASS  projectionFor reads l20_avg, which mlbGameLogFeatures now builds
      from fullLog -- so refusals are computed on the repaired
      full-window reference, not the retired ten-game one.
PASS  factors still fire with correct sign across the repaired base
      range (0.35/0.50/0.65/0.80): defense lowers, pitcher-contact
      raises, platoon raises at every point. Mechanical firing check
      only -- NOT a lift re-measurement, which waits for accrual.

DEFECT FIXED — my own repair falsified a user-facing sentence. The grade
card rendered "Last 20 games average: X" from l20_avg, and l20_avg is now
a FULL SEASON average. The number changed and the label did not, so the
surface was stating something the data no longer supported. Copy now reads
"Season average"; trapDetection's L20 explanations likewise. The field
name is kept -- it is read in many places -- but no rendered sentence
claims a window that isn't there.

That is the same class as everything else tonight, one layer out: a
correct-looking string describing data that moved underneath it.

Serving change; frozen model modules unchanged; p_win never mutated; no
Bonferroni slot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-07 03:49:54 -04:00
builtbykev 494c83cf76 Hunt the window-bug class: three more paths, and the forward re-audit rule
in code

PHASE 0 — getStatRows is the single base-rate path, so every branch is
audited, plus the feature builders since l20_avg is the season reference
projectionFor reads:

  getStatRows MLB -> estimator base    fullLog            CORRECT (929fd81)
  mlbGameLogFeatures l5/l10/l20        last10 = 10        DEFECTIVE
  espnStatsAdapter.parseGameLog        slice(0,20)        DEFECTIVE
  getStatRows NBA/WNBA ESPN branch     inherits 20-cap    DEFECTIVE via source
  getStatRows NBA/WNBA python branch   getGameLogs(...,20) dormant (offline)
  pitcherEngine / skillProjection      statcast profiles  N/A
  pitcher props via getStatRows MLB    fullLog            CORRECT
  settleSource                         full log (S64)     CORRECT

THE PITCHER ANSWER IS GOOD NEWS: pitcher props run through the same
getStatRows MLB branch, so 929fd81 repaired them too. There is no separate
defective pitcher base-rate path.

THE ONE HIDING IN PLAIN SIGHT: mlbGameLogFeatures carries the comment
"l20 = all available (the season per-game reference projectionFor needs)"
while building from last10 -- so l20_avg was a TEN-GAME AVERAGE WEARING A
SEASON LABEL, feeding both the consistency pull inside the estimator and
projectionFor, which decides refusals. It survived the previous repair
because that fix touched only getStatRows.

PHASE 1 — mlbGameLogFeatures now reads fullLog; espnStatsAdapter drops its
slice(0,20) cap. ZERO new API calls on both: each widens data already
fetched and then discarded, the same shape as the original repair. The
python branch is left alone -- the service is offline in prod and fixing it
would be speculative.

Their before/after resolution is NOT measured, deliberately: the only way
to measure today is to reconstruct the repaired forecast over old rows,
which is the reconstruction-vs-served trap this order refuses. Code fix
now, measurement at accrual.

PHASE 2 — MODEL_VERSION bumped to engine1@2026-08-07-fullwindow, so every
forward snapshot is self-identifying (retentionService already stamps it;
no new plumbing). model/reAuditEligibility.js encodes the rule: isEligible
accepts only the repaired marker, assess counts eligible DATES not rows,
and ACCRUAL is frozen at calibration 10 / hits-lift 10 / verdict-reaudit
14 / rbi-gate 14. A test locks the invisible case -- a MIXED table of 330
rows with 30 repaired returns eligible_dates 3, not 330 rows of false
confidence. Once both generations share a table a naive count would fit a
map on a blend of two forecasters.

PHASE 3 — the board, each consequence labelled: calibration WITHDRAWN
(refits at 10 dates, never on reconstructions); factor verdicts SUSPECT
(all measured against a champion worse than a frequency table, direction
UNKNOWN, not pre-priced, 14 dates); hits factor lift UN-REMEASURABLE (10
dates, factors still wired and transmitting); rbi lineup-slot RE-QUEUED
(14 dates). Pre-registered order: calibration, hits lift, verdict
re-audit, rbi gate.

Then STOP and accrue. Nothing further can be honestly measured until the
board fills with rows the repaired champion produced.

Serving-path changes by design for the MLB feature path and NBA/WNBA logs;
eleven frozen model modules verified unchanged. p_win never mutated. No
Bonferroni slot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-07 03:40:20 -04:00
builtbykev 929fd81940 Repair the champion: it was reading ten games, not a season
PHASE 0 — the defect is real past the peek. Against a FAIR point-in-time
baseline (each player's rate over games strictly before that date, >=10
prior games, box scores back to 05-01), the served champion LOSES on all
four stats, three of four CIs excluding zero:

  hits  0.00251 vs 0.00774  CI [-0.0074,-0.0011]
  TB    0.00393 vs 0.00619  CI [-0.0055,-0.0003]
  rbi   0.02481 vs 0.03133  CI [-0.0153,-0.0005]
  runs  0.00181 vs 0.00683  CI [-0.0114,+0.0008]

PHASE 1 — the cause is the WINDOW, not the weights. estimateProbability
builds its base rate as the frequency over every row it is handed, and
featureCache.getStatRows handed it res.last10. So the "season rate" was a
TEN-GAME rate, and 0.4 of the forecast was the last five OF THOSE TEN. The
0.40 recency weight costs resolution on all four stats (-0.00086,
-0.00107, -0.00562, -0.00365). Nudges are mixed and small -- harmful on
hits and rbi, marginally helpful on TB and runs -- so they are left alone.

PHASE 2 — two lines, no new data, no extra API call, because fullLog was
already fetched by the same adapter call that produced last10:
getStatRows now reads fullLog, and RECENCY_WEIGHT goes 0.40 -> 0.20.

  hits  0.00251 -> 0.00817  (tripled; now above the fair baseline)
  TB    0.00393 -> 0.00734  (above baseline; vs old CI [0.0020,0.0067])
  rbi   0.02481 -> 0.02727  (still below baseline, CI includes zero)
  runs  0.00181 -> 0.00436  (still below baseline, CI includes zero)

Gate stated exactly: hits and TB now exceed the fair baseline on the point
estimate; rbi and runs remain below but EVERY CI now includes zero, so no
stat reliably loses to a frequency table. That is a tie on rbi/runs, not a
win, and it is reported as one. Only TB's improvement over the old
champion is CI-confirmed; the rest are directional.

STALE-FIT GATE: CALIBRATION_DEPLOYED is now EMPTY. The low-param maps were
fitted on the retired forecast and fromLedger cannot rescue them -- settled
ledger rows still carry OLD p_win, so refitting today would refit the
retired forecast. Nothing is served calibrated until dates settle under
the repaired champion, and the favourite-longshot bias must be re-measured
rather than assumed to survive. The shadow duel is void.

PHASE 3 — the hits factor lift is NOT re-measured, and cannot be yet: it
needs settled rows produced BY the repaired champion, which ships in this
commit. Replaying would score the factors against a reconstruction rather
than the served forecast. Deferred, explicitly. The factors remain wired
and transmitting; only their lift is unquantified on the new baseline.

PHASE 4 — standing flag, and it is large: EVERY factor verdict in this
programme, every null and every THEATER, was measured against a champion
worse than a frequency table. Signal added to noise reads as noise. Prior
verdicts may deserve re-audit. Logged, not re-run.

Re-queued not built: rbi lineup-slot / RISP opportunity through the
two-part gate, now landing on a repaired champion.

Serving-path change by design; the byte-identical invariant inverted and
all four stats move. Nine frozen model modules verified unchanged. No
Bonferroni slot -- resolution accounting on the champion's own knobs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-07 03:28:33 -04:00
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 092f8f09cd Build opportunity_drift axis on challengerProjection (arch-v1)
Champion p_win and the live grade path are BYTE-IDENTICAL: the axis writes
only to p_win_challenger / challenger_adjustments in the ledger.

STEP 1 -- MAP THE INPUT. MLB_LOG_FIELD now maps at_bats -> 'atBats'.
Deliberately NOT added to outcomeService's map or liveTracking's
LIVE_BOX_FIELD: those exist to SETTLE and TRACK graded props, and nothing
grades at-bats, so adding it there would imply a settlement path for a
market we do not carry. A test asserts the settle map still lacks it.

STEP 2 -- DRIFT, NOT LEVEL. opportunity_drift = mean(last-5 atBats) /
(season atBats / games). The LEVEL is collinear with l20_avg (same
games denominator; hits/game ~= (hits/AB) x (AB/game)), so the projection
already embeds it multiplicatively and adding it would double-count. A
deviation from the player's own baseline is the part the projection does
not contain.

HONEST ABSENCE throughout: fewer than 3 at-bat rows, no at-bats in the
logs, or no season baseline all leave drift UNDEFINED -- never 1.0 by
default and never 0. Number(null) === 0 here would read as "zero at-bats",
the strongest possible fade, invented from missing data. Four tests cover
the absent paths.

STEP 3 -- THE AXIS. opportunityNudge composes in the same log-odds space
as park and platoon (log of a ratio), with two guards the measured axes do
not need: a +/-10% DEADBAND (a rest day or a blowout can move a 5-game
window without any role change) and a tighter cap (0.15 vs the
environment's 0.30) so a noisy PROXY cannot outvote measured signals.
Every adjustment carries is_proxy: true and
proxy_for: 'confirmed_batting_order' so nothing downstream can mistake it
for a lineup feed.

The axis can stand ALONE -- without it the early return would gate
opportunity off on exactly the thin-classification rows it is most likely
to help.

Zero extra I/O: analyzeViaEngine1 attaches drift from the feature vector
it has already built, and attachChallenger reads it off the grade. Nothing
re-fetches in a loop that runs over hundreds of props.

COLLINEARITY GUARD added to the coverage probe: Pearson r of drift against
l20_avg / l5_avg / ab_per_game, returning null under n=8 rather than
reporting a correlation on a handful of rows. If drift just re-encodes the
projection, the axis is dead signal and gets shelved.

Gates: 4,073 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 03:15:47 -04:00
builtbykev b85b351993 Grade-board sort: signed signal, takeable-gated p_win, missing sorts LAST
Display ORDERING only. No grade, ledger row, lock_line, scoring, or edge_pct
scale/display change. Push scoring untouched.

Two defects removed from selectTopGrades (wrong at ANY scale, independent of
edge_pct's separate retirement):
  1. edge: Math.abs(numOr(g.edge, -Infinity)) — abs() on an already-
     direction-signed value ranked the model's strongest DISAGREEMENTS level
     with its strongest agreements (177 public ledger rows carry a negative
     edge; positive = the model AGREES with the graded side).
  2. Math.abs(-Infinity) === Infinity, so a row with NO edge sorted FIRST —
     absent data presented as the top pick (the Number(null) class).

New key: grade -> confidence -> takeable-gated p_win (nulls LAST) -> SIGNED
edge (nulls LAST) -> input order. Scales are never mixed in one comparator.
Takeable band = web valueState.isTakeable, asserted byte-equal to the hero's
config/valueEngine.isTakeable (-160..+200) incl. strict-null.

Alt-line ladder (analyzeViaEngine1:506) no longer sorts by edge_pct: ordered
highest-p_win-first derived analytically at zero added compute — P(stat >= k)
is monotone non-increasing in k, so p_win-desc is line-ASC for an over and
line-DESC for an under. base stays marked; no consumer depends on
alt_lines[0]; deskShowcaseService.rungsOf already re-sorted by line.

THREE PREMISE BREAKS found report-first, before code:
  - /api/props/top-graded 404s in prod (absent from src/) so the dashboard
    board renders receipts/empty — the edge sort orders nothing there today.
    The prior order's "97.3% of rows tie" was a LEDGER measurement wrongly
    extrapolated to that board. Fix is correct-in-itself and lands when the
    feed is restored.
  - p_win cannot be a client-side key for all tiers: snapshotGating strips it
    for unentitled tiers ("shipping p_win is shipping the model price").
    Verified live: prod /api/snapshot carries p_win on 0/8 MLB, 0/25 WNBA.
  - Ladder rungs carry no per-rung price, so the takeable gate is inapplicable.

Verified on real data, both sports, both paths: unentitled — WNBA (n=25)
ordering CHANGED, MLB (n=8) unchanged, signed edge non-increasing in every
(grade,confidence) tie group (20 pairs, 0 violations); entitled — 40 real
ledger rows with p_win+locked_odds, p_win-descending, untakeable chalk NOT
promoted (Trea Turner .757 @-275 does not beat Rhyne Howard .745 @-120)
(36 pairs, 0 violations).

Hero consistency, stated honestly: same signal + same gate, different
precedence BY CONTRACT (board = grade-tier-first "top GRADES"; hero =
p_win-first "top read"). Identical within the leading tier (verified); across
tiers the board may lead with an A the hero doesn't pick. Not a contradiction.

Floor: 310 suites / 3864 tests green, web build exit 0. Dashboard + Desk
visuals are auth/feed-gated -> tagged for the Chrome audit, no visual faked.

Held: edge_pct rescale/display retirement (Order B); building the missing
/api/props/top-graded selector; exposing p_win to unentitled tiers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
2026-07-29 21:13:51 -04:00
builtbykev 83e9da3663 Consistency classifier: CV → index of dispersion for low-mean counts
The A/D investigation found CV (std/mean) is scale-broken on count data —
for a Poisson-ish stat cv ≈ 1/sqrt(mean), so EVERY stat with mean < 4 blew
past the boom_bust cutoff regardless of behavior. The S63 stopgap made those
return 'unknown', which silently ate a real +1.0 consistency signal on every
MLB batting prop — steady low-mean hitters never got their earned factor.

Fix, fenced to the low-mean branch of consistencyScore (the only branch that
was returning 'unknown'): classify with the index of dispersion (variance/mean,
Poisson baseline 1.0) — the scale-appropriate, UNBIASED statistic for counts.
mean ≥ 4 keeps the NBA-calibrated CV path BYTE-IDENTICAL (zero NBA blast
radius). This is a bug CORRECTION, not threshold loosening: the CV thresholds
and the engine1 ±1.0 delta are unchanged.

Bands (asymmetric around Poisson 1.0, since counts are naturally mildly
over-dispersed): iod<0.60 elite / <0.85 reliable (+1.0) / ≤1.30 volatile
(neutral) / >1.30 boom_bust (−1.0). Sample floor MIN_GAMES_FOR_IOD=8 so a
thin sample abstains ('unknown') — no small-sample guess.

Validated on real 10-game logs (two-sided): Kwan hits 0.67 / Alonso hits
0.78 → reliable (RECOVERED); Alonso TB 2.57 / Henderson hits 1.33 → boom_bust
(no false consistency); HR mean 0.1 → 1.0 → neutral. Direct engine1 proof: a
strong steady prop that grades B+ today reaches A- once the +1.0 fires; a
boom-bust bat stays B (no inflation). A- now emerges NATURALLY from a real
recovered factor. Standing two-sided test pins all three directions.

Forward-only (settled grades are locked in the ledger, never re-graded).
Emitting A- ≠ proving A- — the A-tier record accrues from emission, still
measurement-gated. Full unit suite green (4 pre-existing redis/timing flakes
pass in isolation); web build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
2026-07-22 21:05:16 -04:00
builtbykev f5156dd16d Un-claim CLV on the public profile; un-fabricate player-page FORM
Two Truth-Law fixes found by auditing the product logged-out.

FIX 1 — /u/[handle] claimed a "CLV-verified record" with "closing-line
value included" while ZERO closing-line value renders there. Verified
live: GET /api/profiles/vyndr returns beat_close_pct null (gated behind
CLV_CAPTURE_RELIABLE, unset while C4 is open). Eight instances found —
two of them (the OG + portrait "CLV-VERIFIED RECORD · 30D" eyebrows)
only by the post-removal residual sweep; two more printed the claim in
exactly the no-record branch.

Copy now describes what the page shows. The gated CLV-VERIFIED badge and
the BEAT CLOSE figure are removed from the public profile, OG card and
portrait card. DISPLAY ONLY: beat_close_pct, clvCaptureReliable() and
the whole CLV data path are untouched, and the earned directional badge
stays Analyst+Desk. The claim returns when CLV genuinely renders here.

Also fixes the doubled "· VYNDR · VYNDR" title (layout's '%s · VYNDR'
template already supplies the suffix); verified on composed output by
serving the build and reading the real HTML, not on source.

FIX 2 — the player page's FORM was `70 + 4 × (count of tonight's graded
props)`. Nothing on the HTTP path ever sets stats.form, so that fallback
WAS the live number: Josh Bell's "74" is 70 + 4×1 prop, confirmed
against his live payload. MATCHUP was gradeFromForm(that number), with a
hardcoded 'B' on the no-archetype branch — both fabricated letters with
no opponent input on the path. Systemic: buildIntel is the unconditional
path for every player and sport.

FORM and MATCHUP now render "—" (kind 'plain', so no bar width or colour
is computed off a null). gradeFromForm is deleted and the prop count is
no longer passed into buildIntel. computeFormScore's hardcoded 75 now
returns undefined. Induced across MLB/NBA/WNBA: all render cleanly, and
real values (USAGE 3.6 AB/G, REST B2B) still render.

Neither form value feeds the grade — engine1 reads raw l5_avg/l20_avg
against the line and never a form key; buildIntelFields decorates the
already-graded object. Grade inputs are byte-identical.

Held (needs a per-sport headline-stat design call): a real player-level
form metric + label disambiguation.

Tests 3491 passed / 289 suites, web build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
2026-07-20 17:05:04 -04:00
builtbykev 63302d194e Wire MLB opp_rank_stat into live features (consumption path verified)
featureCache.teamFeatures now derives MLB opp_rank_stat from statsapi team
pitching splits when the ESPN path yields nothing — which for MLB is
always, because ESPN's MLB team endpoint carries no defensive metric at
all. mlbStatsAdapter.getTeamPitchingStats fetches all 30 teams in one free
unauthenticated call, cached at the season TTL.

CONSUMPTION PATH VERIFIED before wiring, not assumed:
  featureCache.teamFeatures sets out.opp_rank_stat (line 338)
    -> engine1.computeFactors READS features.opp_rank_stat (lines 96-102)
    -> fires weak_opponent_defense (>=0.70) / top_opponent_defense (<=0.30)
So teamFeatures is the correct insertion point: the grader reads exactly
the field we populate. A value written anywhere else would have been a
dead end — computed, retained, and still not affecting the grade.

Contract preserved: the derived value goes into the SAME field with the
SAME 0-1 scale and the SAME high=weak polarity WNBA uses, so engine1 reads
one field with one meaning across sports. Isolated and best-effort — a
derivation failure leaves the field ABSENT (honest null), never a guessed
rank. Only fills when the ESPN path produced nothing, so WNBA behaviour is
untouched.

Suite 285/3435 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 12:49:13 -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 26b276fbfb Fix the ESPN team-stats parser + report: opponent rank is still underivable
Ran the manual regrade with the internal key (thanks). Results are mixed
and the honest half matters more.

CONFIRMED WORKING — the probability layer is fully alive in production.
After POST /api/internal/snapshot/{mlb,wnba}: p_win, ev_pct, model_odds
and value are present on 32/32 live grades (mlb 7/7, wnba 25/25), up from
0/8 before. That fix is done.

NOT WORKING — the grade-range half did not land, and I am not going to
claim it did. The live distribution is unchanged (wnba B17/C8 before AND
after; mlb B4/C3), no A, no D, same four confidence values. Diagnosis:
matchup_grade is 0/25 on the live board, i.e. opp_rank_stat is still
null, so engine1's +/-1.0 opponent factor still never fires and the
ceiling is still +3.0 against the +4.5 an A requires.

Two distinct causes, both verified against the live ESPN feed:
1. refreshTeamStats CRASHED on every team — "buckets is not iterable",
   captured 0 / errored 15. ESPN's current shape is results.stats =
   an OBJECT with categories[], not an array. The old parser did for...of
   on it. This was invisible until S63 gave the function its first
   production caller. FIXED here (now captured 15 / errored 0) with a
   regression test covering the current shape, the legacy array shape,
   and empty payloads.
2. Even parsed correctly, the endpoint does not carry a
   defensive-strength metric at all: defensive_rating, opponent_ppg,
   pace and opponent_fg_pct all normalize to null — it returns only a
   team's OWN stats. So defensive_rank_normalized cannot be computed and
   opp_rank_stat remains underivable from this source. A test documents
   the gap and will fail if that ever changes.

Consequence: A STILL DOES NOT EMIT, so the A-RATED marketing hold STAYS.
Reviving the opponent factor needs a different derivation (opponent
points allowed from scoreboard/schedule, or a different ESPN endpoint) —
logged as the concrete next item, not hand-waved as done.

Suite 278/3305 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
2026-07-19 20:31:15 -04:00
builtbykev 1a94ef5fcf Revive the dead probability layer + restore grade range ON MERIT
Folds re-sequenced steps 1+2 into one change (Kev's call): same bug
family — features wired to sources that return null.

THE PROBABILITY LAYER WAS DEAD IN PRODUCTION. p_win/ev_pct/kelly/
model_odds/value were absent on 0/8 live grades because
gameLogService.getGameLogs returns null for MLB by construction and
depends on the offline Python service for NBA/WNBA, so meta.gameLogs was
[] for every sport. This was the S46 bug in a second location — that fix
gave featureCache an MLB branch (why grades still worked) but never the
estimator. featureCache.getStatRows now supplies normalized rows
([{date,[statType]:v}], most-recent-first) for every sport, feeding the
estimator AND consistency AND game_count_in_7d from one fetch.
VERIFIED on real props: p_win 25/25 WNBA, 8/8 MLB (was 0).

GRADE RANGE, ON MERIT — never by rescaling (permanent founder ruling:
minting A's without new information is a relabelled B sold as an A and
corrupts an append-only ledger).
- refreshTeamStats wired into runSnapshot — it had ZERO production
  callers, so opp_rank_stat was permanently null and a +/-1.0 factor
  could never fire. Test-env no-op (opsNotify precedent).
- L20 made SYMMETRIC: both branches were delta +1.0, so the season
  baseline could only ever ADD. No negative path was a structural reason
  D was unreachable. New l20_contradicts_* carries -1.0.
- game_count_in_7d derived from real logged dates (heavy_workload_7d).
- NOT wired, deliberately, with reasons inline: teamId (no team_id
  column; getFeatures reads it top-level; factor also needs a starter-id
  list) and season_type (ESPN 2 = REGULAR season; threading it raw would
  fire veteran_in_playoffs in July). Dead code dressed as a fix is the
  thing we are removing, not adding.

CALIBRATION GUARD (found by verifying, not assuming): consistency CV is
NBA-tuned; for a Poisson-ish stat cv ~ 1/sqrt(mean), so any stat with
mean < 4 auto-classifies boom_bust. First verification run showed 8/8 MLB
props boom_bust — a blanket -1.0 that dropped the board to all-C. Floored
at CONSISTENCY_MIN_MEAN=4 -> 'unknown' below. Absent beats wrong. MLB
low-count stats therefore still get no consistency factor: honest, not
fixed. Scale-free index-of-dispersion classifier is the open follow-up.

CONFIDENCE IS NOT A PROBABILITY: payloads carry confidence_basis:
'grade_band'. Corrected mlb-grade-degradation.md — its "25/25
grade<->confidence agreement" is a TAUTOLOGY (confidence is derived FROM
the letter, so it would report 25/25 even if every grade were wrong), not
a validation. Removed dead mlbGrader.js (referenced only by its own test)
and the stale computeFeatures comment claiming a penalty that never ran.

VERIFICATION (scripts/verify-grade-range.js, real props/logs/engine):
WNBA 25 props B 68%->32%, C 32%->64%, D 0->1 (4%); 11-step spread went
from 2 steps to 5 (C/C+/B-/D). The D is earned: Angel Reese assists o2.5,
p_win 0.365. Nothing flooded — grades got HARDER. A did not emit locally
because opp_rank_stat needs the Redis cache only prod populates (local
ceiling +3.0 vs the +4.5 A needs); reachability is proven arithmetically
and locked in tests. Prod A-emission is the outstanding fingerprint.

MARKETING HOLD: "A-RATED" (AccuracyBadge, TopSignals) is unsupported
until that fingerprint. Confirmed honest fallbacks render today —
/api/ledger/accuracy has B and C buckets only, so the badge shows
"MODEL · 63% HIT" and TopSignals self-hides. Nothing fabricated ships.

Suite 276/3286 green, web 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 18:54:51 -04:00
builtbykev 7a925f43eb Model Train arc 1 (engine): de-vig + EV + takeable/value gates + hero v2 + triplet
Steps 1-6 — make "real opportunities at takeable prices" the engine, not a filter.

1. DE-VIG (src/utils/devig.js): two-way multiplicative de-vig strips the vig and
   returns fair prob + fair price per side + the overround. One side missing →
   fair UNAVAILABLE (null), never faked. Method noted in code + the `devig_method`
   field.
2. EV (devig.evPct): ev_pct = model prob × decimal − 1 at the graded side's
   ACTUAL price. This is the ranking signal now, replacing raw |model−consensus|.
3. TAKEABLE gate (src/config/valueEngine.js, TAKEABLE_ODDS_CEILING −160 .. +200,
   env-tunable): promoted surfaces only (hero/featured/alerts). The full board
   still shows everything; Parlay Lab exempt; JUICE_ODDS_FLOOR (−400) stays the
   absolute backstop underneath. Strict null-guard (Number(null)===0 would have
   made a missing price "takeable").
4. VALUE flag: passes BOTH gates (takeable AND ev_pct ≥ VALUE_EV_THRESHOLD).
   Grade = read quality; value = the price pays you. Shipped in payloads.
5. HERO v2 (heroPropService): highest ev_pct among takeable A/B reads — a huge
   gap on a −900 line is trivia, not an opportunity.
6. VALUE TRIPLET: book_odds · fair_odds · model_odds on every read (snapshot,
   hero, scan — they all spread the grade). Handoff documents the fields; the
   rendering is Session-2 Design's job.

All wired in analyzeViaEngine1's existing p_win/kelly block (real quantile
probability × real book odds, or nothing). 33 new tests; suite 276/3306 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 02:43:30 -04:00
builtbykev 348a82b4a0 Generalize the no-edge guard: suppress by the BOOK'S PRICE, not a stat whitelist
Follow-up to the rare-event under fix — the whitelist (doubles/triples/HR/SB)
was fragile: the same juiced-under problem exists for steals, blocks, and any
other low-frequency market, and a new stat would slip through.

The real signal is the book's own price. The doubles unders were priced -625 to
-1100 — laying 6-11x to win 1x on an ~82% event, with no value the model could
recover. So the PRIMARY guard is now stat/sport-agnostic: analyzeViaEngine1
refuses any read whose graded-side odds are past the juice floor
(JUICE_ODDS_FLOOR, default -400, env-tunable). That catches every version of
this — steals, blocks, anything — and it also keeps the public record honest
(those -800 "wins" hit ~82% of the time and would inflate the hit rate, the same
class as the projection-0 degradation).

The structural rare-event rules stay as the BACKUP for props with no odds
(list also expanded cross-sport: + steals, blocks). Normal + longshot prices
(-110, -250, +600) are preserved. 16 tests cover both layers.

Reported: the doubles projection was REAL per-player (not a fallback); the fix
is the price guard, not a bigger list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 02:09:11 -04:00
builtbykev f72f063e6f Suppress rare-event 0.5 unders (juiced, no-edge) — config-driven grade + board fix
Betting-logic audit: the CONSENSUS-vs-MODEL board flooded with fake reads like
"DOUBLES u0.5 · MODEL 0.2 · +edge" — the juiced under side of rare counting-stat
markets (doubles/triples/HR/SB), which is never a takeable edge and violates the
no-unders-default doctrine.

Report finding (item 3/4): the doubles projection is REAL per-player, not a flat
fallback — 'doubles' maps to a real game-log field (MLB_LOG_FIELD doubles→
doubles) and the live values varied (0.03/0.16/0.2/0.22). So no projection-gate
refusal for fakeness; the problem is purely structural (a rare event's real
projection always sits below a 0.5 line, so the under always "wins").

Fix (config-driven — src/config/rareEventMarkets.js, tunable stat list + line
threshold):
- Grade layer (analyzeViaEngine1): a rare-event UNDER at ≤0.5 is always REFUSED
  (grade null + suppressed flag/reason). A rare-event OVER at ≤0.5 is refused
  UNLESS the model genuinely projects the event above the line — because a
  0.2-over-0.5 carries the SAME |edge| as the suppressed under and would just
  take its rank on the board. The over grades normally once projection > line.
- Board layer (marketBreadth.collectBreadth): drops null-model rows so a
  suppressed/ungraded prop can't rank a "MODEL —" placeholder onto the board.

10 suppression tests + config locks; also fixed a settingsPage book assertion
left over from the ESPN→theScore swap. Suite 274/3289 green, web build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 00:19:23 -04:00
builtbykev 888d103f95 Fix MLB grade degradation: projection>0 gate, edge semantics, letter=confidence
The #1 board item — three grading bugs the phone audit surfaced, all in the
live Node grade path (engine1 + analyzeViaEngine1), fixed at the source.

1. PROJECTION=0 NOW REFUSES. projectionFor returned l5_avg even when it was 0
   (finite, so the `== null` gate passed it) — 9/25 live grades graded on a
   zero projection, producing a degenerate edge and a hollow grade. Now a
   non-positive reference is not a projection: projectionFor skips it and falls
   through to the next POSITIVE reference (l5 -> l20 -> per_90 -> xg); when none
   is positive it returns null and the read REFUSES (insufficient_data). The
   gate also gained an explicit `> 0` guard so the invariant is structural — a
   grade can never be emitted with a non-positive projection. Fewer graded
   props, honest.

2. EDGE_PCT. The formula was already (model - line) / line signed by direction
   — Kev's intended semantics. The broken {20,60,100,140} cluster was the
   proj=0 degeneracy ((line - 0)/line = 100%); with #1 those refuse, so the
   fabricated 100s vanish and real edges flow. The main-line edge now reuses
   the VALIDATED projection (edgePctFor accepts an optional ref) so edge and
   the persisted projection can never diverge. Frontend |edge|>40 guard stays
   as a safety net.

3. LETTER == THRESHOLD_TABLE(CONFIDENCE). engine1's hand-rolled
   GRADE_TO_CONFIDENCE drifted a full sub-tier low (B -> 0.55, which the
   canonical grade_thresholds.json calls B-) — the "B at 45%" the audit caught.
   Now confidence is DERIVED from each grade's band MIDPOINT in
   grade_thresholds.json (one source of truth, shared with the Python engine),
   so applying the threshold table to any grade's displayed confidence resolves
   back to the same letter. Proven for all 11 grades.

Regression locks: tests/unit/mlbGradeDegradation.test.js (14 tests). Backend
suite 269/3253 green, web build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 03:06:12 -04:00
builtbykev 287c1c047a Wave 0: NBA/WNBA grade unlock — free ESPN per-athlete gamelog source
The Python nba_api service (gameLogService) is offline in prod, so
featureCache's non-MLB branch produced no l5/l20 averages →
projectionFor returned null → the ENTIRE NBA/WNBA slate refused
(insufficient_data). Only MLB actually graded.

Fix (free, no-auth, verified live):
- espnStatsAdapter.getPlayerGameLog(name, sport) — resolves name→ESPN
  numeric athlete id via the v2 search (the v3 /search now returns
  count:0; the v2 uid carries a:<id>, defaultLeagueSlug disambiguates
  league), fetches the per-athlete gamelog, and parses per-game rows
  keyed by VYNDR stat names (points/rebounds/assists/threes/steals/
  blocks/turnovers + computed pra). Columns are indexed by the
  response's own names[] array (NBA and WNBA orders DIFFER), never
  positionally. Most-recent first, defensive (null on unrecognized
  shape, never throws), cached (espngamelog:{sport}:{id} 4h + memory).
- featureCache.gameLogFeatures — falls back to the ESPN gamelog for
  nba/wnba when the Python source returns null/empty, producing
  l5/l10/l20 + rest_days + minutes_per_game via a new local
  NBA_LOG_FIELD map + pure nbaGameLogFeatures (S11 three-map-split:
  separate from MLB_LOG_FIELD).

Grade gates already whitelist all 8 NBA/WNBA stat types in both Node
paths (analyze.js + scan.js); no gate change needed.

Tests (hermetic, no network): espnGameLog.test.js (parser/resolver/
adapter) + featureCacheNba.test.js (the UNLOCK proof — empty features
refuse, ESPN-derived features grade). 3098 tests green; next build
exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 18:44:57 -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 2ae8a5697e Session 56: Full audit — PropLine + boxscore + pipeline + sport coverage (2289 tests)
Research (verified against live MLB Stats / ESPN / The Odds APIs):
- specs/propline-audit.md — every stat_type mapped against our 4-layer pipeline;
  real MLB boxscore fields; sport coverage status; pipeline gap analysis.
- specs/vyndr-roadmap.md — priority-ordered Sessions 57–64 + coverage targets.
- scripts/propline-audit.js + specs/audit-data/ (raw capture).

Headline bug: oddsNormalizer mapped batter_rbis → 'rbis' while the whole
grade/feature/outcome chain keys on 'rbi' — every PropLine RBI prop silently
failed to grade AND settle. Fixed (+ regression test).

Phase 4 — wired missing MLB stats end-to-end:
- PropLine MLB markets 6 → 12 (+runs, walks, doubles, earned_runs, hits_allowed,
  outs — same request, no extra quota).
- doubles/outs/triples added to featureCache + outcomeService MLB_LOG_FIELD and
  all three grade whitelists (analyze/scan/validation.py).

Phase 6 — pipeline resilience:
- opsNotify.js: ntfy alerts (never throws, test-disabled). Snapshot success/
  stale/failure alerts; retry-once on hard odds error (not on empty slate).
- Missed-cron watchdog (mostRecentExpectedSlot/isSnapshotOverdue); status probe
  now returns `overdue`.

Coverage truth: MLB is the only end-to-end-live sport; outcome settlement is
MLB-only (WNBA/NBA/soccer never settle) — documented as the #1 roadmap gap.

Backend 2276 → 2289 tests (+13). Web build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 17:00:29 -04:00
builtbykev 78db55d499 Session 47: Name normalization + grade intel + ticker polish (2149 tests)
- Name normalization completed: NICKNAMES table (Matt↔Matthew, Mike↔Michael...)
  resolved in nameKey, parenthetical team-tag strip "(STL)", verified accent-fold
  (Iván/Ivan, José/Jose). Slate strip now DISPLAYS the normalized de-dotted name
  ("AJ Ewing" not "A.J. Ewing") via buildPlayerStripsFromProps.
- Complete MLB VYNDR INTELLIGENCE: mlbGameLogFeatures derives rest_days (days off
  between latest games; 0=B2B) + ab_per_game (usage). buildIntelFields renders
  usage as "X AB/G", rest as B2B/Xd, matchup from bvp_advantage fallback.
- Ticker SCAN dedup: pushTickerItems keeps one SCAN per sport (sport field or
  text-prefix parse for legacy); MOVE/GRADE preserved; cap 50.
- BOMBER threshold prorated for mid-season (hr>=15 strong / >=10 mod) so June
  sluggers classify BOMBER not FLEX/DRIVER.

Backend 2122 -> 2149 tests (+27), 179 suites. Web build clean (exit 0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 01:45:36 -04:00
builtbykev c8fc9f577e Session 46: Grade card intel + name normalization + pitchers (2122 tests)
Three focused P1 fixes on the Session-45 snapshot model.

- Grade card intel ROOT CAUSE: gameLogService is NBA/WNBA-only (offline Python),
  so MLB props never got l5_avg/l20_avg and buildIntelFields returned {}. Wired
  MLB game logs into featureCache.gameLogFeatures via mlbStatsAdapter.getPlayerStats
  (pure mlbGameLogFeatures + MLB stat_type->field map). buildIntelFields gained
  playerStats/projection fallbacks for partial intel.
- Player name normalization: src/utils/playerName.js (+ web/src/lib copy):
  normalizeName -> {display,key}. Strips periods, de-dots suffix, accent-folds
  the key. Applied in snapshotService grouping, slateAdapter grade index +
  player-strip merge (variants collapse, longest name shown), and
  playerIntelService. "A.J. Ewing"/"AJ Ewing" + "Jazz Chisholm"/"Jr." now merge.
- MLB starting pitchers: new GET /api/schedule/:sport/pitchers (probablePitchers
  service wrapping mlbStatsAdapter.getScheduleWithPitchers + best-effort ERA).
  Slate fetches it, builds a team->pitcher map (full name + mascot match),
  attaches pitchers to MLB GameCardData. + Next proxy.

Backend 2100 -> 2122 tests (+22), 176 suites. Web build clean (exit 0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 23:56:26 -04:00
builtbykev 80683e71b4 Session 43: Data pipeline + audit fixes + depth chart foundation (2045 tests)
P0 fixes + wiring real data into the S42 Player Intelligence architecture.

- P0 dropdown z-index: the nav's backdrop-filter stacking context let the
  Ticker/HeartbeatBar paint over the avatar/More dropdowns and eat clicks.
  nav now position:relative zIndex:2; menus zIndex:100. Avatar Settings ->
  /settings.
- Real MLB stats: mlbStatsAdapter.searchPlayer + getPlayerStats (name->id->
  season+gamelog). playerIntelService.resolvePlayerStats normalizes into the
  archetype classifier; getPlayerIntel returns found:true + real season +
  archetype classified from real stats. NBA via nbaStatsClient (degrades).
- Game cards: slateAdapter.groupPropsByPlayer (playerStrips, name once) +
  mapPitchers (MLB probables), folded into mapScheduleToGameCards. Legacy
  GameCard line grid renders BookChip (brand colors) not grey text.
- Grade card intel: analyzeViaEngine1.buildIntelFields computes stat-context +
  form/usage/matchup/rest from the existing feature vector (zero extra I/O);
  gradeAdapter lights up the card sections. Archetype deferred (needs season
  line at grade time).
- Depth chart foundation: depthChartService (getLineup/getDepthChart/
  getCascadeProjection) + /api/stats/lineup|depth|cascade, graceful + injectable.
- Mobile: player hero name overflow-wrap + 24px on <=640px (was clipping).

Backend 2011 -> 2045 tests (+34), 163 suites. Web build clean (exit 0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:38:06 -04:00
builtbykev 73b65a0248 Session 16: Live hero prop, sport-specific markets fix, soccer weather, Sentry CSP (1429 tests) 2026-06-11 18:15:25 -04:00
builtbykev 167996d99a Session 15: Intelligence hardening — park factors, weather, Tank01 prefetch, pace factors, signal audit, founder pricing fix (1405 tests)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-11 16:21:18 -04:00
builtbykev f5d79cf70d Session 14: Africa checkout, Tank01 NBA/MLB wiring, WNBA+MLB odds proxies, OAuth icons, loading skeletons (1330 tests) 2026-06-11 10:06:49 -04:00
builtbykev b55dcbd614 Session 9: api-football + FootApi + Tank01 adapters, grace period middleware, cookie consent, /pricing page, OOM fix documented (1240 tests) 2026-06-10 19:41:37 -04:00
builtbykev ad5ea8d5a8 Session 7j: Soccer intelligence - 9 leagues, 11 signals, 6 traps, poller, prefetch, 131 new tests (1173 total) 2026-06-10 14:50:13 -04:00
builtbykev 4815ceac03 Sessions 7e+7f: Grade adapter, normalize consolidation, computeFeatures, analyzeViaEngine1, scan/parlay migrated to engine1 2026-06-10 09:28:30 -04:00
builtbykev 012c0ef47e Session 7e: Grade adapter, normalize consolidation, ARCH-2 banners 2026-06-10 03:37:07 -04:00
builtbykev 6f4a353de9 Session 7d: Audit fixes - rate limiting, error leak, parallel parlays, analyze cache, bundle analyzer 2026-06-10 03:12:20 -04:00
builtbykev 1fa04dc776 Sessions 5-7a: 955 tests, deployment ready 2026-06-08 18:35:13 -04:00