Commit Graph

156 Commits

Author SHA1 Message Date
builtbykev 8c764c22a4 Structural hardening: unknown-is-not-zero + takeability-is-book-identity
Both guards are ADDITIVE. The full suite (4,111 -> 4,126 tests, 331 suites)
passes unchanged through the migration, which is the evidence that no
currently-correct output moved: served path, champion, reference ruler and
the four accruing challengers are byte-identical.

GUARD 1 -- src/utils/known.js. Number(null)===0 has produced at least SIX
separate defects here, including one in a module written the same week its
author documented the trap. Per-module vigilance has demonstrably failed,
so the rule lives in one place and SEVEN sites now delegate: platoonSplits,
projectionChallenger, challengerProjection, contactChallenger,
statcastAggregateService, consensusRuler, gradeRanking -- plus
compoundTotalBases moved onto knownRate.

Two functions, deliberately: knownNumber (any finite number -- a REAL 0 is
a fact and must survive) and knownRate (non-negative, rejects booleans --
for counts/rates where `true` or -1 is broken, not thin). Collapsing them
is how the next variant gets in. firstKnown() exists because `a || b`
discards a measured 0 and `a ?? b` does not.

MY OWN GUARD HAD THE BUG IT EXISTS TO PREVENT, and its own test caught it:
Number([]) === 0, so an empty array coerced to a measured ZERO. Same trap
wearing a different type. Both helpers now reject objects outright.

GUARD 2 -- src/config/takeability.js. Takeability is BOOK IDENTITY and
never price shape. Baseball prop markets are genuinely thin, juiced and
one-sided, and all three are NORMAL structure: betrivers and hardrockbet
legitimately quote one side only (5 such rows surfaced in yesterday's
re-stamp), and a hits-over at -300 is a real placeable bet. A rule that
inferred un-takeability from price extremity or one-sidedness would throw
those away while still admitting a DFS book at an ordinary -119 -- exactly
backwards, because the -119 is the fake one.

THE DISTINCTION THAT MUST NOT COLLAPSE, now enforced by test:
  isTakeableMarket(book)  -- CAN it be bet?     (identity)
  isWithinPriceBand(odds) -- SHOULD we promote? (policy band, floor -160)
A -300 DraftKings prop is takeable AND out of band; a PrizePicks -119 is in
band AND not takeable. Independent axes.

FLAGGED, NOT SILENTLY CHANGED: the ledger's `takeable` column is the
PRICE-BAND answer, and its name predates this distinction. Four challengers
and the ranking gate read it, so renaming or redefining it is its own
order -- doing it here would have changed correct current behaviour under
cover of a hardening change.

Fixtures are REAL prod rows from the 2026-08-02 re-stamp, not invented.

Gates: 4,126 tests / 331 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-02 17:25:40 -04:00
builtbykev 5de464330c URGENT: anchor the ledger price/book/takeable to TAKEABLE books
Ships before tonight's settle. Served path, champion, ranking and the
reference ruler are untouched.

TWO leaks, not one. The audit found ledgerService.indexProps; tracing the
lock price found that snapshotService.indexOdds has the SAME defect -- it
also indexed the full props list, so gradedAt.odds (the price a grade is
locked at) could itself be a DFS or exchange price. Fixing only the ledger
would have left the contamination flowing in through the lock.

Both now gate on TAKEABLE_BOOKS -- deliberately NOT MODEL_BOOKS. pinnacle
is model-eligible and correctly not takeable, so a MODEL gate would
re-break this the moment pinnacle's feed recovers. A test asserts pinnacle
cannot anchor a price.

TWO INDEXES, TWO ROLES, because the row needs two different things from a
prop and they have different correctness rules:
  PRICE / BOOK / TAKEABLE -- takeable books only.
  GAME FACTS (game_time, game_date, team/opponent) -- book-INDEPENDENT.
    First pitch is first pitch whichever book listed it, so these still
    come from any book. Gating them too would drop otherwise-valid rows
    for no gain.
Collapsing those roles into one index is precisely the bug.

No takeable quote leaves the key ABSENT and the price null. An honest
missing price beats a price from a book you cannot bet -- and it keeps the
takeable flag from being computed off a DFS number, which is what made it
wrong on its own terms rather than merely mislabelled.

Gates: 4,111 tests / 330 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-02 14:17:51 -04:00
builtbykev eabf3b5bcf tb-v1: model total_bases as a compound outcome (challenger)
Current ladder (proj_p_over_line) and champion p_win are BYTE-IDENTICAL.
tb-v1 writes alongside them, on total_bases props only.

STEP 0 -- components confirmed on real data, not assumed. statsapi has no
singles field, but hits - doubles - triples - homeRuns reproduces stored
totalBases EXACTLY on a real 10-game log. So the decomposition is exact,
not an approximation.

THE MODEL. Each component gets its own per-game Poisson rate; TB is their
weighted sum, and the PMF is built by exact convolution rather than
simulated (TB support is small). It inherits the SAME combined multiplier
proj-v1.1 computes, so the two models differ only in STRUCTURE.

Why this is the fix: with identical mean TB of 1.0, a pure-HR hitter and a
pure-singles hitter get P(TB>=4) of 0.221 vs 0.019 -- a 12x difference an NB
on TB alone cannot express, because it treats one home run as four events.
A test asserts that separation, and asserts P(TB>=4) for a pure-HR hitter
equals P(at least one HR) exactly.

INDEPENDENCE IS AN APPROXIMATION AND IS LABELLED AS ONE: a plate appearance
that becomes a double cannot also become a single, so the components are
weakly negatively correlated and independent Poissons slightly overstate
the tail. Closer to the truth than what it replaces; not a solved problem.

HONEST-ABSENT throughout: fewer than 3 usable games, or no derivable
component, returns null and the prop keeps the current ladder value. An
inconsistent row (hits < extra-base hits) is SKIPPED rather than clamped to
zero -- clamping would invent a plausible line out of a broken one.

I HIT THE Number(null)===0 TRAP IN MY OWN CODE and a test caught it: a null
rate passed a naive finite check and was treated as a measured zero, which
is the difference between "this player never triples" and "we do not know
his triple rate". Both tbPmf and tbMean now reject null/''/boolean strictly.

Holdout committed: TB ROWS ONLY (49 of 437 settled -- averaging into other
stats would hide the effect) and DIRECTION-ALIGNED, since the unaligned
comparison is the artifact that accounted for 41% of the ladder's apparent
loss. If tb-v1 does NOT improve, the family-mismatch hypothesis is wrong
and the mean/similarity branch reopens -- recorded in the query header.

Migration applied: proj_tb_p_over + proj_tb_meta, NULL-meaningful.

Gates: 4,104 tests / 329 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-02 03:29:23 -04:00
builtbykev 9ebd77b68e Build the matchup/platoon axis: three joins fixed, axis now FIRES
The axis was already wired and firing on 0/634 prod rows. Three separate
absences kept it silent, and all three are now joined:

1. oppPitcherByTeam 0 -> the self-origin /api/schedule/mlb/pitchers route
   returned nothing in prod. Added the statsapi probable-pitcher hydrate as
   a fallback, mirroring the one the schedule step already uses. 29/30
   team-sides, one free request.
2. handById 0 -> follows from (1); the batched people call now has ids.
3. bats 0/120 -> batter hand rode ONLY on statcast aggregate rows, which do
   not cover the slate. The season player list we ALREADY fetch and cache
   carries batSide on 1342/1342, so this is a join, not a fetch.
   Switch-hitters ('S') are preserved as-is; platoonSplits decides what to
   do with them, not the map.

Verified end-to-end against the live API: opp_declared 29,
pitchers_with_hand 29, batters_with_hand 1342, and a real read --
multiplier 0.966, L vs R, 287 observed PA, weight 0.324 -- composing
alongside environment in one challenger.

FALLBACK LADDER, and a deliberate deviation from the order. Shipped tier:
`batter_own_split` (the hitter's OWN vs-L/vs-R line, regressed toward HIS
OWN overall rate), labelled on every adjustment.

`league_generic` is deliberately NOT implemented. platoonSplits already
handles thin evidence by regressing toward the hitter's own rate, which
covers the thin case per-player; its own doc-comment argues a hitter with
no split evidence should get NO adjustment. A league split applied to such
a hitter models the LEAGUE, not the player -- the doctrine breach the order
itself names in the same step. Adding it would have produced more firing
rows and a weaker signal.

`archetype_x_archetype` is scoped, not built: it needs the opposing
starter classified per game, which is real work and a separate order. The
tier vocabulary is in place for it.

Honest-absent on every join: no starter, no pitcher hand, or no batter hand
-> NO matchup adjustment, never a fabricated neutral. A neutral multiplier
produces no adjustment row at all.

Holdout committed (scripts/matchup-axis-holdout.sql), filtered to
matchup-carrying rows, and it keeps MATCHUP'S OWN nudge visible rather than
only the combined challenger -- arch-v1 composes four axes into one
p_win_challenger, so a combined-only view could not tell which axis earned
the movement, or which one is dragging.

Champion p_win, ranking, calibration, the armed invariant and the two
accruing verdicts are untouched.

Gates: 4,093 tests / 328 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-02 01:11:05 -04:00
builtbykev 9fc17a4689 Fix the team resolve properly: backfill the name BEFORE confirmation
My first attempt did not work in prod -- team stayed 0/323 after deploy.
I resolved the team name AFTER the hint-confirmation check, but the check
itself reads hit.currentTeam.name, which is undefined because
/sports/1/players returns { id, link }. With a FULL-NAME hint (what
snapshotService passes) neither branch of teamRecordMatchesHint could
match: the name branch had no name, and the abbr branch cannot resolve a
full name to an abbr. Confirmation failed, the team was nulled, and my
later backfill ran on an already-null value.

withTeamName() now backfills the name from the cached /teams list BEFORE
any comparison, and is used at all three confirmation sites plus the
return. Verified against the live API on all four cases: no hint, FULL-NAME
hint, abbr hint -> "Philadelphia Phillies"; WRONG hint -> null.

That last case matters most: a wrong hint must still REFUSE. The
confirmation exists so a namesake collision cannot tag a player to a team
he is not on, which would fabricate opponents downstream. Making the match
succeed must not make it succeed wrongly, and a test locks it.

Gates: 4,087 tests / 327 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 23:34:16 -04:00
builtbykev 03efdda33c Arm the S59 invariant by fixing its input; matchup sourcing = BUILDABLE
PART 1 -- PREMISE CORRECTION, then the real fix.

The order said the invariant's blocker was removed because "team is now
populated 416/416". It is not: what became 416/416 is home_team/away_team.
`team` (the PLAYER'S roster team) is still 0/416. Arming the guard off
home_team would compare the prop's game to itself -- always a match, a
permanent no-op that LOOKS armed. That would be worse than leaving it
disarmed, because it would read as a working guard.

The guard is also ALREADY fail-safe by construction (`if (knownTeam &&
gameTeams && ...)`), so Part 1's requirement was met in code all along.
What was missing was the data.

ROOT CAUSE: /sports/1/players returns currentTeam as { id, link } with NO
name, so searchPlayer's `hit.currentTeam?.name` was ALWAYS undefined and
every resolve returned team: null. The id is present on 1342/1342 and the
/teams list (already cached 24h) maps id -> name, so resolving it costs no
new request. Verified: Schwarber -> Philadelphia Phillies, Ohtani -> Los
Angeles Dodgers, Judge -> New York Yankees.

Five tests lock the fail-safe: drops only on a positive not-in-game;
abstains on unknown player team; abstains on unknown game participants;
and a row carrying only home_team/away_team does NOT satisfy the guard --
so the tautology can never be reintroduced.

PART 2 -- MATCHUP SOURCING: BUILDABLE. Measured on tonight's real board
against the free feeds, by VALUE not endpoint presence (the environment
trap: wired and null 634/634):

  opposing starter   29/30 team-sides (home 14/15, away 15/15)
  pitcher hand       1342/1342 (pitchHand.code)
  batter hand        1342/1342 (batSide.code; L 416 / R 848 / S 78)

SHARED DEPENDENCY, and it is the finding: /sports/1/players -- a list we
ALREADY fetch and cache -- carries currentTeam.id, batSide AND pitchHand.
One join unlocks the invariant's input and two of the three matchup inputs
at once. The third (probable starter) comes from the schedule hydrate that
already exists.

So matchup is BUILDABLE and is the next order; SOURCE-LINEUPS-first is NOT
needed. Archetype-level reach on the opposing starter is available too
(the SP resolves to a player id, so the existing classifier applies) --
noted, not built.

Champion p_win, ranking, calibration and both accruing verdicts untouched.

Gates: 4,082 tests / 327 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 23:29:00 -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 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 212c08b11f Fix the Step 0 probe: it was measuring itself, not the pipeline
The first run reported 0% coverage for EVERY feature including l5_avg --
which projectionFor requires, on a pipeline that had just graded 365
props. That is impossible, so the probe was wrong, not the pipeline.

Two bugs, both in my probe: featureCache.getFeatures takes camelCase
(playerName/statType) and I passed the prop's snake_case shape, and it
returns { features: {...} } while I read the top level. Either alone
yields all-zeros.

Now calls computeFeaturesForProp -- the grader's own entry point -- so it
measures what the grade path actually sees. Same class as the earlier
harness that returned a silent false: a measurement that makes working
code look broken is more dangerous than no measurement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
2026-08-01 02:36:00 -04:00
builtbykev 3e78217678 Step 0 input check: read-only feature-coverage probe
Before wiring any layer into the grade, measure whether its inputs are
actually populated on real props. A layer wired onto sparse inputs does not
degrade gracefully by default -- Number(null) === 0 turns a missing
opportunity into 'zero opportunity', a fabricated input rather than an
absent one.

Reports population per feature, SPLIT BY stat_type, because a feature can
be 100% present for batters and 0% for pitchers and a pooled number would
hide exactly that. Also reports whether ab_per_game varies across a
player's own props -- a per-player constant can only move all of a
player's props together, which is a very different thing from a per-prop
opportunity signal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
2026-08-01 02:34:33 -04:00
builtbykev 8d052131c5 Add a bisect hook (?limit=) to the internal snapshot trigger
The cap raise 25 -> 500 made an induced snapshot 502 at 13.4s and the run
did not complete in background either, while a 25-prop run had completed
in 16.3s. That rules out a simple duration timeout and means the cause has
to be measured, not guessed. ?limit= bounds one run so the regression can
be bisected without a prod env change; omitted, the real DEFAULT_LIMIT
applies.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
2026-08-01 02:12:07 -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 d18a19f6aa Part 1 diagnostic: read-only refusal categoriser (25-cap + 72% refusal)
READ-ONLY. Runs the REAL grade path over a REAL slate and categorises
every refusal; writes nothing. Reproduces gradeSlateService.dedupeProps
exactly (MODEL_BOOKS, first-row-wins) and calls analyzeViaEngine1 the same
way, so it measures what the pipeline does rather than a re-implementation.

Adds a FIFTH bucket the order did not anticipate, and it is likely to
change how the 72% is read: (e) POLICY-SUPPRESSION. The 2026-07-19
betting-logic audit deliberately refuses rare-event 0.5 markets (doubles/
triples/HR/SB) on the juiced under, plus any over-juiced price -- and it
sets the SAME insufficient_data flag as a genuine data gap. Counting those
as a data problem would send us hunting for data that is not missing, and
"fixing" them would re-introduce bets we removed on purpose.

Separates (b) FETCHABLE-GAP from (d) GENUINE-ABSENCE by asking the stats
layer directly whether the player has ANY game log, rather than assuming:
no log -> genuine absence, keep refusing; a log that exists while the grade
path found no projection -> a wiring gap with something to fix.

Also measures per-grade latency (mean/median/p90/max, serial and at
concurrency) so Part 2 can decide the cap on cost rather than on taste.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
2026-08-01 02:02:53 -04:00
builtbykev 86d123945c Rank on p_win: challenger instrument + retire edge from decisions
MEASURED BASIS (n=200 settled MLB rows): corr(p_win, outcome) = +0.26;
corr(edge, outcome) = -0.010 incumbent ruler / -0.022 consensus ruler.
Subtracting the market destroys the signal under BOTH rulers, so a
quantity that does not predict must not rank, gate or decide.

CHALLENGER-FIRST -- live ordering is byte-identical. rankGrades (the
incumbent, grade-first with edge as its 4th key) is untouched and tested
as untouched.

NEW: rankByForecast -- takeable-gated p_win -> grade -> confidence -> stable
order, with NO edge term anywhere. p_win LEADS and the letter follows,
deliberately: the letter measured r ~ 0.005 and is inverted (B 52.4% <
C 56.9%) while p_win measures +0.26, so leading with the letter would sort
by the weaker signal and use the stronger one only to break ties.

Recorded in the code: isotonic calibration is a MONOTONE transform, so
ranking on raw vs calibrated p_win gives the SAME ORDER. Calibration
matters when p_win is displayed or thresholded; it cannot change a
ranking. Nothing here needs the calibrated value.

rankingDelta + GET /api/internal/ranking-delta measure how far the board
would move before any flip. The endpoint reports p_win coverage alongside
the delta -- if p_win is absent the challenger degrades to grade order and
the delta UNDERSTATES, which is worth saying rather than reporting a clean
zero.

forecast_rank is stamped on snapshot grades BEFORE stripModelPrice, so
every tier gets the correct order without the paid values (the
topGradedService precedent -- an ordinal can travel where the magnitude
cannot). Additive only: nothing sorts by it yet.

RETIRED AS DECISIONS (not rankings, so done now):
- altLineScanner.compareToBookImplied no longer returns value_detected:
  edge > 0. Edge is still COMPUTED and returned -- losing the record would
  be worse than mis-using it -- but the verdict is an honest null with
  value_basis: 'retired:edge_does_not_predict'.
- scanAltLines no longer filters to edge>0 or calls the survivor "optimal".
  The whole ladder is returned ranked and labelled
  'price_gap_diagnostic_unvalidated'. The module has ZERO callers (verified)
  -- unwired like mlbGrader.js, left in place and made honest.

An honest asymmetry recorded there: ranking props AGAINST EACH OTHER must
not use edge, but choosing between RUNGS OF THE SAME PROP is inherently
price-relative -- ranking rungs by model probability alone would always
pick the lowest line, since P(over 0.5) > P(over 2.5) by construction. So
the gap stays the rung key, explicitly labelled unvalidated.

Two superseded tests updated to stronger properties.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
2026-08-01 01:24:55 -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 1372e6bcf7 Order Zero Phases 1-3: keyed verification, ruler_version boundary, report
PHASE 1 (measured on the live prod feed with the real key):

- WNBA is NOT thin at the feed -- 4.21 books/prop vs MLB's 3.61. It was
  allow-list-starved exactly as MLB was. This removes one candidate
  explanation for its anti-predictive result; it does not explain it, and
  WNBA stays abstaining.
- We cannot see 64.8% of the MLB slate at all (zero admitted books).
- Exchanges are real (smarkets 27%, novig 22%, kalshi 15% on MLB) but
  pinnacle, matchbook and polymarket measured 0% on BOTH sports. There is
  no sharp anchor for player props. The consensus is a MARKET consensus,
  not a SHARP one -- recorded as a permanent limitation, not a milestone.
- DFS is the trap, quantified: prizepicks covers 82% of MLB props, the
  highest in the feed. Admitting it "for breadth" would have looked like
  the biggest available win. Permanently excluded.
- Endpoints: /context WORKS and is FREE (umpire, roof, pitcher handedness,
  lineup confirmation -- richer than what we hand-built). /odds/closing and
  /movement are REDACTED (full structure, zero prices). /results and
  /exports/resolved-props are 403.
- The $19/mo question is answered: soccer IS graded, ~15 competitions in 30
  days (MLS 41k, Liga MX 15k, Brasileirao 12k, UCL/Europa/Conference). Our
  "soccer grades into a void" is a Pro-tier problem, not a data problem.
  NBA is absent because it is July -- seasonal, not inferable either way.

PHASE 2 delta, corrected: MLB mean +1.50 pts, median 0, p90 +10.0, 17.0%
of comparable props move >=5 pts, one-directional (the incumbent prices
the over below the exchange-inclusive consensus). WNBA symmetric and
tight. The median prop does not move -- the change is a right-skewed
minority. That the rulers DIFFER is established; that the new one is
BETTER is not, and that is the re-run.

PHASE 2 item 6: ledger_entries.ruler_version applied to prod, 1,384
existing rows backfilled to v1_first_book (a statement of fact -- every
row to date was produced by the first-book rule). ledgerService stamps
CURRENT_RULER_VERSION on new rows. Never pool edge or CLV across it.

Repo migration numbering lags prod; 025_ledger_ruler_version.sql records
the DDL for review.

PHASE 3: MLB isotonic p_win remains PROVISIONAL -- calibrated against
v1_first_book, does not promote until re-run on the consensus ruler.

NOT LIVE, deliberately: ALLOWED_BOOKS unchanged, served slate
byte-identical, CURRENT_RULER_VERSION still v1_first_book, no live path
calls consensusRuler.

Gates: 4,022 tests passed / 322 suites; next build exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
2026-07-31 23:52:28 -04:00
builtbykev c38db1ad65 Fix: the incumbent ruler respects the allow-list (correcting my own model)
My first delta run modelled the incumbent as first-row-wins over the RAW
feed and reported that an EXCLUDED book was "the market" on 69% of MLB
prop-lines, with prizepicks alone at 47%. That is WRONG and I caught it
before it went anywhere.

normalizeProps applies ALLOWED_BOOKS BEFORE gradeSlateService.dedupeProps
runs, so DFS books never reach the incumbent. The allow-list, for all the
coverage it costs, does keep DFS out of the ruler.

incumbentFairProb now takes the allow-list (defaulting to the live
ALLOWED_BOOKS) and reproduces the real chain. Two tests lock it, including
that a prop with no admitted book has NO incumbent -- it is never graded
at all, which is the real loss and is already measured as invisible_props.

Overstating the incumbent's badness would have been as dishonest as
understating it, and more persuasive.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
2026-07-31 23:47:13 -04:00
builtbykev a55dd2a6a0 Order Zero Phase 2: three-way book split + challenger consensus ruler
CHALLENGER-FIRST. The live ruler is byte-identical: CURRENT_RULER_VERSION
is still v1_first_book, nothing here writes a cache, a grade or a ledger
row, and no live code path calls consensusRuler yet.

bookRoles.js splits one allow-list into three, because it was answering
two different questions -- "can we show this?" and "can we price against
this?" -- with the same list, which is what bent the ruler.

  TAKEABLE  the user can actually bet here (drives best price / shopping)
  REFERENCE may price the fair-prob ruler; never surfaced as a place to bet
  EXCLUDED  DFS pick'em + offshore, permanently barred from all pricing

Two deliberate calls, both evidence-based:

- The six PropLine-phantom books (caesars/fanatics/bet365/hardrockbet/
  pointsbet/thescore) are KEPT despite the order saying remove. They
  returned zero PropLine quotes, but PropLine is not our only provider and
  the odds-api backup path may carry them. A book that never appears is
  never matched, which costs nothing; deleting them risks silently
  dropping real books on the backup with no upside. Recorded in
  PHANTOM_ON_PROPLINE rather than enacted as a deletion.

- REFERENCE = exchanges + pinnacle + bovada + the four US majors, chosen
  off the measured coverage curve rather than theory. exchange_only is
  cleanest (order-book, ~zero vig) but covers 14.3% of MLB and 5.6% of
  WNBA; adding the US majors gives 28.1% / 46.3%. pinnacle, matchbook and
  polymarket measured 0% on both sports and add nothing. The honest
  limitation is recorded in the config: this is a MARKET consensus, not a
  SHARP one.

consensusRuler.js: median de-vigged fair_prob across >=2 reference books
posting BOTH sides at the SAME line. Median so one stale exchange cannot
drag it. Different lines are never averaged, one-sided quotes never rule,
and n<2 falls back to single-book LABELLED as such with the v1 stamp --
never silently mixed, because a column holding both is two rulers wearing
one name.

The challenger delta runs over the live feed and reports incumbent_book_
roles, which is the real headline: the incumbent is literally first-row-
wins, so it reports what KIND of book has been acting as "the market".
DFS pick'em has the highest coverage in the feed, so a DFS book can be it.

18 ruler tests + 37 total in the two new suites. Full suite 4021 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
2026-07-31 23:44:37 -04:00
builtbykev c3bcfaba94 Order Zero Phase 1c: return the aggregate-only bodies in full
/sports and /markets/resolution-summary carry no per-prop data and no
credentials, and the shape summary alone cannot answer the question they
exist to answer -- whether PropLine actually GRADES the sports we cannot
settle. A shape is not a number. Both bodies are scrubbed on the way out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
2026-07-31 23:39:38 -04:00
builtbykev 3c466d79cb Order Zero Phase 1b: redaction detection + reference-policy curve
Two corrections to the first pass, both of which would have produced a
false positive.

1) A non-empty body is NOT proof of access. PropLine's free tier returns
   the full STRUCTURE of tier-gated endpoints with values stripped plus an
   upgrade_url -- and the first pass classified /odds/closing and /movement
   as "works" on structure alone. detectRedaction() now counts actual
   prices and downgrades works -> partial when a body advertises an upgrade
   or carries outcomes with zero prices. Same class as the harness that
   returned a silent false, inverted.

2) One hard-coded reference set forces a yes/no on a question that is
   really a curve. reference_policy_curve reports strict eligibility
   (>=2 books, both sides, same line) under exchange_only /
   exchange_plus_sharp / exchange_plus_us / takeable_only, so the ruler
   decision is made on coverage-vs-quality rather than on a guess. DFS is
   absent from every policy by construction and a test asserts it.

Also probes /markets/resolution-summary: /exports/resolved-props being 403
tells us we cannot PULL settlements; resolution-summary tells us whether
they EXIST to be bought. Different questions.

19 unit tests, still hermetic.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
2026-07-31 23:37:23 -04:00
builtbykev 2071b79456 Order Zero Phase 1: keyed read-only PropLine verification endpoint
Adds GET /api/internal/propline-verify (internal-key gated, read-only) so
Phase 1 can run WHERE THE KEY LIVES. Touches no cache, no ledger, no
grade; the live adapter and the live ruler are untouched. Breadth reuses
proplineAdapter.fetchRaw -- the exact live request -- so what it measures
is what the pipeline actually receives.

Reports per sport (never pooled): books/prop from the feed vs after our
own ALLOWED_BOOKS, props made INVISIBLE by that filter, reference-book
presence, DFS presence reported separately, and consensus eligibility.

Consensus eligibility is deliberately strict: >=2 REFERENCE books posting
BOTH sides at the SAME line. A one-sided quote cannot be de-vigged, and
two books at different lines are not the same market -- counting either
would overstate how much of the slate can carry a real ruler.

Probes the documented-but-unverified endpoints (/sports, /context,
/odds/closing, /movement, /results, /exports/resolved-props for four sport
keys) and classifies works/partial/no, with 403 = tier-gated and 200-but-
empty = partial rather than works.

Key safety is the other locked property: the key goes via axios params,
never string-interpolated, and every emitted string passes scrubKeys()
which removes the literal key AND any surviving apiKey= query value. A
test asserts a thrown transport error carrying the key cannot escape.

13 unit tests, hermetic (no network, no key).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
2026-07-31 23:34:03 -04:00
builtbykev bedbb8c008 Build 2 Phase B: checkout claims atomically, webhook finalizes, bypass retired
Stripe wired to the Phase-A mechanism. Live prices verified READ-ONLY; no
Stripe object was created and no payment was run.

B1 PRICE KEY -> ID + BOOT ASSERTION (src/config/stripePrices.js). claim_founder_slot
returns a price KEY; this module is the only place a key becomes a Stripe id, and
it reads env (legacy STRIPE_PRICE_ANALYST/DESK accepted as fallbacks so an existing
deploy keeps working). assertPricesConfigured() is wired into server.js and FAILS
BOOT when any of the four is unset — verified by deleting one: it throws
"BOOT FAILED - unset Stripe price env for: desk_founder". A blank price can no
longer sell at the wrong rate or 503 a customer at checkout.

B2 CHECKOUT CLAIMS BEFORE CREATING THE SESSION. resolveCheckoutPrice previously
called founderSeatsAvailable() — a COUNT read, which WAS the race (two checkouts
at seat 99 both read 99, both got founder). It now calls claim_founder_slot and
uses the returned key. The promo-code bypass is retired: founderCode no longer
influences price or metadata, and getPriceId THROWS if handed a code rather than
silently granting a founder rate. metadata.is_founder is renamed is_founder_audit
and the webhook no longer reads it — caller-supplied metadata must never decide
who pays the lifetime founder price.

TRANSIENT-FAILURE POLICY (a real design call, not a default): if the claim RPC
errors we now fail RETRYABLY (503 claim_failed) instead of silently selling at
standing. Both silent options are irreversible — standing permanently overcharges
someone who was entitled to founder, and granting founder without a slot pushes
past the 100 cap at permanent prices. A full cap is NOT an error and still returns
standing normally, per "never error to the customer": a full cap is a real answer,
a DB blip is not.

B3 WEBHOOK FINALIZES THROUGH THE SINGLE WRITER. checkout.session.completed calls
finalize_founder_slot, which flips user_profiles.founder_pricing (canonical) and
mirrors users.founder_status in the SAME txn, so they cannot drift again (they
already had, 1 vs 0). Verify-after-write re-reads the profile and logs the end
state. If finalize errors, the tier is still set so a PAID customer is never left
unentitled, but no founder flag is guessed.

B4 SIGNATURE VERIFICATION was already present (constructEvent with
STRIPE_WEBHOOK_SECRET + express.raw). The live endpoint exists and is enabled:
https://api.vyndr.app/api/stripe/webhook subscribing checkout.session.completed,
customer.subscription.created/updated/deleted, invoice.payment_succeeded/failed.

VERIFICATION: V1 boot assertion proven by simulation. V2 all four prices retrieved
live and confirmed active with correct amounts and monthly recurrence (14.99 /
24.99 / 44.99 / 59.99) — read-only, nothing created. V3 no code path grants founder
except the claim (greps clean; the legacy helper now throws). V4 the handler reads
customer/subscription/metadata.user_id and calls finalize with signature
verification in place. V5 reset to a pristine 100 free / 0 claimed baseline with
both founder flags at 0.

Secrets live only in .env (0600, gitignored, untracked). A pre-commit scan
confirmed NO tracked file contains the key material.

Floor: 320 suites / 3984 passed, 3 skipped (superseded founder-code tests), web
build exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
2026-07-31 18:42:44 -04:00
builtbykev c2c7abbb65 Edge-shading challenger: built + measured. Flooding NOT fixed — input scale is the bug
Challenger only. Champion grade byte-identical (verified by diff). Nothing
promoted, no live grade re-lettered, no ledger row deleted or re-settled.

BUILT src/services/challengers/efficiencyShading.js (measured-never-served):
  adjusted_edge = raw_edge * f(efficiency); grade = band(adjusted_edge) against
  ONE fixed bar (A+>=10, A>=5, B>=3, C>=1, D>=0, F<0) that never moves.
  f(e) = E_SOFTEST/e bounded to (0,1] — soft markets intact (never amplified),
  sharp shaded toward but not past zero, unscored -> f=1 and FLAGGED.
  A fence test asserts no production grade path imports it.
  Cross-market behaviour is unit-proven: the same raw 6% edge grades A in soft
  mlb:total_bases and B in sharp nba:points.

MEASURED on 1250 live ledger rows — Phase 2.5's answer is NO, the flooding is
not gone: challenger 79.0% A and 80.9% A/B (MLB 93.4% A) vs champion 0.2% A.

TWO findings explain why, and they are the point of the order:

1. The shading is a NO-OP on the live board: rows_actually_shaded = 0 of 1250.
   96.5% of rows are UNSCORED (f=1), and the one scored market present
   (mlb:total_bases) is the anchor so its f is 1.0 by construction.
   mlb:strikeouts and nba:points do not appear in the ledger at all (our
   basketball is wnba, not nba). Challenger vs baseline: 0 rows changed.

2. Placement was never the bug — the INPUT SCALE is. Against a fixed 5% bar the
   RAW edge already clears A on 100% of MLB doubles, 89.6% of hits, before any
   shading. MLB median raw edge is 60%, twelve times the bar. Decisive test:
   apply the sharpest score in the spec (f=0.647) to EVERY row — the maximum
   the design permits — and 75.8% still clear A (MLB 91.7%). Since f is bounded
   <= 1, no achievable shading can close a 12x overshoot. Moving the multiply
   from the threshold to the edge does not change the outcome.

This is edge_pct behaving as the 2026-07-29 diagnosis described: a price-free
(proj-line)/line gap whose scale is a function of line size. It is not a
betting edge, so no fixed betting-edge bar is meaningful against it.

2.6 efficient-market over-suppression: CANNOT DETERMINE — zero live rows are
shaded, so there is no efficient market in the data to over-suppress.

Phase 3: takeable tagging was completed in the previous order (migration 034,
1246/1254 rows) and is not repeated. The model-version boundary is again NOT
applied: nothing promoted, so no boundary exists.

Unblocking needs the input replaced, not the multiply moved: p_win vs
fair_prob (both already computed) instead of edge_pct, plus scores FIT from our
own record for the markets we actually grade.

Floor: 313 suites / 3899 tests green (9 new), web build exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
2026-07-31 00:39:13 -04:00
builtbykev 2bfaeff572 Ledger takeable tagging (deferred C2); efficiency challenger BLOCKED
Champion grade UNCHANGED. Push scoring untouched. Additive tags only — nothing
deleted, nothing re-settled.

PART A — THE EFFICIENCY CHALLENGER: BLOCKED, NOT BUILT.
Review Zero came back ABSENT on all three inputs:
  0.1 efficiency scores DO NOT EXIST (zero occurrences of market_efficiency /
      marketEfficiency / efficiency_score in src/ or web/src/).
  0.2 base thresholds DO NOT EXIST (engine1.js has zero `edge` references — the
      grade is not an edge-vs-threshold comparison; grade_thresholds.json holds
      PROBABILITY bands).
  0.3 the +/-0.05 additive efficiency nudge DOES NOT EXIST. The only 0.05s on
      the grade path are featureCache.teammate_absence_bump, a bvp_advantage
      cutoff, and p*0.9+0.05 inside probabilityEstimator (the 0.5*0.1 term of
      the shrink-toward-0.5). There is no additive scaling to replace.

So a challenger differing from the champion in EXACTLY ONE thing cannot be
constructed: there is no additive scaling to swap, no base threshold to
multiply, and engine1.js has zero `sport` references so market cannot reach the
grade. A threshold must exist first — that is R1 of
specs/full-output-grade-mapping.md, an explicitly held separate order. Shipping
R1+R4 together would make the Phase-3 delta report misleading: the re-letter
would be driven mostly by switching to probability grading while being
presented as the efficiency fix.

0.4 coverage: the spec names 5 scores; the live ledger has 11 markets and only
MLB total_bases maps to one. 9 of 11 have no score, so "all scored markets"
cannot be satisfied without inventing 9 numbers.

PART B — LEDGER TAKEABLE TAGGING: BUILT (the deferred C2).
New src/config/takeableStandard.js: floor on the minus side, UNCAPPED plus.
Deliberately NOT valueEngine.isTakeable (the -160..+200 PROMOTION band) — a
+400 prop is not promotable but IS takeable; a test asserts the two diverge on
the plus side and agree at the floor so they can never quietly merge. Absent
price returns null, never false (Number(null) === 0 would tag a missing price
takeable). The floor is POLICY not derived (C1 could not derive one) and is
labelled so; each row records takeable_floor so a re-derivation can re-tag.

Migration 034 (applied + tracked): ledger_entries.takeable boolean +
takeable_floor numeric, nullable, partial index. Forward tagging in
ledgerService at row build; backfill in one statement.
Result: 1254 rows, 1246 tagged (781 takeable / 465 below floor), 8 NULL with
null_despite_price = 0 (the NULLs are genuinely priceless rows). Settled 1163
and graded 1254 unchanged.

PART C — the model-version boundary tag is DELIBERATELY NOT APPLIED: no scaling
change shipped, so no boundary exists, and stamping one would mark a model
transition that never happened. modelEras.js is its home when a real one lands.

Floor: 312 suites / 3890 tests green (8 new), web build exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
2026-07-31 00:19:13 -04:00
builtbykev 72a14dc4cd Build /api/props/top-graded server selector: rank with p_win, serve without it
New READ endpoint. No grade, ledger row, lock_line, or scoring write. Push
scoring untouched.

REVIEW ZERO CORRECTED THE PREMISE: the handler NEVER EXISTED in any commit
(searched git rev-list --all for a /top-graded definition in src/ — zero hits).
Not "removed" — the three axios callers (cheatsheetGenerator, gradeOfTheDay,
widget) and the Next proxy were written against a phantom endpoint, so those
three content generators have silently received [] for their entire life.
Contract recovered from the four consumers, not guessed: {props:[...]},
?sport=UPPERCASE (absent = all sports, which gradeOfTheDay relies on) + ?limit,
rows carrying player/stat/line/direction/sport/grade/confidence? plus the
player_name/stat_type aliases and game_id.

POPULATED-PATH RISK FOUND: the board's populated branch had never run in prod,
and dashboard/page.tsx:463 calls g.stat.replace(/_/g,' ') UNGUARDED (g.player
also feeds the row key, /scan URL and heading; sport must be UPPERCASE for
SportPill). toRow requires non-empty string player+stat and a finite line,
uppercases sport, and DROPS unrenderable rows — a shorter board beats a broken
one.

THE LEAK BOUNDARY (why this is server-side): the browser cannot rank on p_win
for all tiers because stripModelPrice deliberately withholds it from unentitled
tiers. Order of operations is
  read cache -> RANK with p_win (every tier) -> map rows incl. model fields
    -> stripModelPrice(rows, tier) -> serialize
so a free caller receives the paid RANKING without the paid VALUES. Tier comes
from resolveTierFromRequest, which FAILS CLOSED to 'free'. Cache-Control is
private under a bearer token, public otherwise (the /api/snapshot precedent).

ONE SHARED DEFINITION, no drift: new src/utils/gradeRanking.js
(takeablePWin/descNullsLast/rankGrades). heroPropService now imports
takeablePWin instead of its inline copy (behaviour unchanged — it was that
logic verbatim); the selector imports rankGrades; web/src/lib/slateAdapter
keeps its mirror (the browser cannot import src/, S25) and a test cross-checks
the two on identical fixtures (playerName.js precedent). Board is grade-first
("top GRADES"), hero is p_win-first ("top read") — they differ BY DESIGN and
agree within the leading tier.

HONEST LIMIT: the Next proxy (cachedBackendJson) sends no Authorization header
and caches under a shared key, so via the dashboard every viewer gets the
free-tier payload — correct order, no paid values. That is the SAFE behaviour;
forwarding auth into a shared cache is exactly how a paid payload leaks to
anonymous viewers. Per-tier delivery through the proxy needs a tier-keyed cache
and is not done here.

Verified on real prod snapshot data (anonymous path): MLB 8 props, WNBA 10,
0 paid-field leaks, render-contract safe on every row, sport uppercase.

Floor: 311 suites / 3882 tests green (18 new — leak test uses POPULATED p_win,
not today's nulls: entitled gets p_win and it drove the order, unentitled gets
a byte-identical order with all five MODEL_FIELDS absent and no trace in
JSON.stringify, while book/fair market facts survive). Web build exit 0.
Dashboard visual is auth-gated -> tagged for the Chrome audit, not faked.

Held: edge_pct rescale/retirement (Order B); board columns/contract unchanged;
tier-keyed proxy caching.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
2026-07-29 22:05:30 -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 41b86e3874 Hero ranking fix: rank by champion p_win among takeable, honest empty state
Review Zero found the hero's ACTUAL behavior was worse than "unknown": it ranks
on ev_pct (heroPropService v2), but ev_pct is NULL on served grades and
Number(null)===0 made Number.isFinite(Number(null)) TRUE — so every prop tied at
EV 0 and the "top read" was really the FIRST takeable A/B prop in cache order
(arbitrary, dressed as ranked).

v3: rank by the CHAMPION's p_win (the only signal with a promising, not proven,
edge — its takeable-MLB-over CLV survived the skew audit) among A/B, TAKEABLE-
priced reads (isTakeable band -160..+200, same as the proof/audit). Strict
null guard kills the Number(null)=0 bug. Takeable filter is mandatory (raw p_win
crowns -300 chalk). NO backfill: nothing qualifies → honest empty state
(available:false, reason:'no_qualifying_read'), never a weak recent read.

p_win is RANKING-ONLY, server-side — toHero never exposes it and the route strips
it. Framing unchanged in substance (model number vs book number, grade,
timestamp) — no proven-edge / +EV / best-bet claim, no CLV/ROI/edge number.
Display-only: reads snapshot caches, writes to nothing (no grade/ledger/lock_lines).
Full suite 3852 green, web build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VsztNChZ7vEvSR61AuMhD1
2026-07-29 04:43:21 -04:00
builtbykev c7067c80c4 Persist lock-time multi-book lines to lock_lines (unblocks the staleness audit)
The over-side skew audit's confirming check — was our locked line stale-high vs
consensus AT LOCK — was BLOCKED because multi-book lines at lock were never
persisted (bookprices is Redis current-only). This persists them.

- migration 033: lock_lines table (tracked + applied to prod). One row per
  (graded prop × book) with both odds + a lock timestamp. RLS enabled, NO
  policies -> service-role only (fence). UNIQUE key -> idempotent re-runs.
- lockLineCapture.js: buildLockRows (pure, graded-props only, honest-absent
  single-book) + idempotent upsert persist. Built from the in-memory props at
  the LOCK moment (ts) -> no Redis re-read, no TTL race.
- snapshotService: persist right after `enriched` (the lock moment; gradedAt
  uses the same ts). Best-effort + fenced.

FENCE (measurement-only): lock_lines is read by NOTHING on the grade path
(gradeSlateService, snapshot dedup/indexOdds, challengers, selector, ledger) —
a grep test asserts it, and RLS locks it to the service role. Grade byte-
identical proven: runSnapshot grades are identical with persist on/off (test).

Volume ~1.5-3k rows/day (graded props x books x 5 snapshots); weeks retained,
no pruning needed short-term. Does NOT retroactively fix the existing 62 rows —
future accrual only; confirmation still needs weeks of settled rows. Full suite
3842 green, web build exit 0. No grade/locked_odds/outcome/served surface changed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VsztNChZ7vEvSR61AuMhD1
2026-07-29 02:47:47 -04:00
builtbykev 6552281661 CLV instrument repair: fix attachClosingProb read + recoverable market_unavailable
The closing_prob funnel collapsed 100k priced captures -> 59 usable. Root cause
(VERIFIED against prod, join key is PERFECT with 0 mismatches):
- attachClosingProb read closing_captures with .limit(50000) and NO ORDER BY on
  a 730k-row table that is 86% refusal rows -> saw ~7% for MLB, missed most
  priced closes and declared 200+ rows closeless that HAD a capture.
- market_unavailable_reason was write-once/terminal, so a row wrongly declared
  (truncated read / premature declaration before the capture was visible) could
  never recover even once its genuine capture existed. 298 rows (204 MLB + 94
  WNBA) were stuck this way.

Fix (CLV computation only — no grade/locked_odds/outcome touched):
- Read ONLY priced captures (missed_reason IS NULL, both odds NOT NULL), scoped
  to the candidate rows' game_dates -> small AND complete, no arbitrary truncation.
- Drop the market_unavailable exclusion from candidates; make it a re-checkable
  absence: a genuine close now UPGRADES the row (writes closing_prob, clears the
  verdict). closing_prob stays write-once (first true close wins). No capture +
  past game -> still declared absent (honest). No churn on already-absent rows.
- New internal trigger POST /api/internal/ledger/attach-closing[/:sport] for
  backfill + verification (scheduler already runs attach per tick).

Recovers ~312 usable closes (59 -> ~371), MLB included. Capture itself was
healthy all along (94.9% MLB / 95.8% WNBA per-prop coverage). Full suite 3835
green (17/17 instrument tests incl. 2 new recovery cases), web build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VsztNChZ7vEvSR61AuMhD1
2026-07-28 18:59:12 -04:00
builtbykev 6bc18d823c Honesty pass: remove every live fabrication (REMOVE/HIDE only, no feature cut)
Six live untruths corrected — no grade/snapshot/scorer/pipeline touched:
1. /compare — hardcoded Jokic A+/Wembanyama A + fake VERDICT replaced with an
   honest in-development state; removed from Nav + BottomTabBar (route still
   resolves, never the sample). Real two-player build is later.
2. Pricing — founder Desk $34.99→$44.99 (matches lib/checkout.js), Analyst
   $14.99; removed the struck $19.99/$44.99 "regular" numbers and DeskShowcase's
   stale $34.99. First-100 counter is real (ClaimMeter → Stripe countFounderSeats);
   no fake "first 50" desk claim added (no such counter exists).
3. FAQ "NexaPay" → Stripe (verified: live checkout is Next→Express→checkout.stripe.com).
4. FAQ + Features "Brier/CLV published from day one" removed (not surfaced yet) —
   returns when real. Backend Brier compute untouched.
5. MobileEdgeBoard removed from the Slate — its edge% feed was a miscalibrated
   placeholder (masked >40% as "—"); phones now show the real game cards.
6. Price triplet — never-computed model/EV now derives NO_MODEL (honest absent,
   MODEL "—" / "NOT PRICED", no verdict) instead of QUARANTINE's false "we
   suppressed our price / a leg is poisoned" copy. Fixes grade card + LiveHeroProp.

Full suite 3833 green, web build exit 0. Tests updated to the new honest contracts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VsztNChZ7vEvSR61AuMhD1
2026-07-27 06:04:00 -04:00
builtbykev e81c9b8c51 Book Comparison Phase 1-3(backend): fenced per-book store + honest gated crown
Per-book prices existed only transiently (odds cache, ~1h, raw names, grade-path
input); every grade-path persistence point collapses to one book. The
/api/books feature was built+mounted but non-functional (fed FLAT rows to a
GROUPED comparator -> always empty).

Phase 1: bookPriceStore captures per-book prices from `props` BEFORE dedupeProps,
keyed nameKey|stat, into bookprices:{sport} (SNAP_TTL) in snapshotService. Fenced:
reads props, writes its own key, read by nothing on the grade path. Grade proven
byte-identical (test + no-grade-path-reference grep test).

Phase 2: scripts/measure-book-spread.js reports same-line best-vs-worst spread
(cents + implied-prob pts), per sport, never pooled. Pre-registered crown
threshold: median >=8c OR >=2pp. Runs post-deploy on real data.

Phase 3 (backend): compareProp is honest-absent (single-book/flat -> no crown)
and the crown is gated (BOOK_CROWN_ENABLED, default OFF until Phase 2 clears).
/api/books repointed to the snapshot-locked store (fallback odds cache),
nameKey-matched; `source` field is the deploy fingerprint.

HELD unchanged: dedupeProps, snapshot dedup, selector, grade, champion,
challengers, ranking, edge_pct/ev_pct. UI routing of BookComparison + crown
treatment deferred to post-measurement (gated on Phase 2). Full suite 3834 green,
web build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VsztNChZ7vEvSR61AuMhD1
2026-07-27 00:45:01 -04:00
builtbykev 914a057611 proj-v1 book-implied: raw odds → de-vigged FAIR (fix self-flattering basis)
proj_book_implied derived from raw book_odds — VIG-INCLUSIVE. A -110/-110 market
implies 52.4%/side (104.8% sum); fair is 50%. Comparing our P against raw book
overstates the book on both sides, biasing the handicapper test IN OUR FAVOR; on
juiced longshots (the Judge HR -18.5pt case) much of that "edge" was vig, not
disagreement.

Fix (fenced to proj-v1's stored comparison basis): proj_book_implied now derives
from DE-VIGGED FAIR via the grade's g.fair_prob — the SAME multiplicative de-vig
the triplet uses (utils/devig.js), so the basis matches the product's shown fair.
Expressed on the OVER basis (under props → 1 - fair) to match our stored P(≥rung);
traded-rung ladder book_implied likewise. HONEST-NULL where fair is uncomputable
(one-sided market, ~14%) — NEVER a raw-book fallback (that would recreate the vig
bias on a subset and mix two bases in one ledger). proj_factors records
book_implied_basis ('fair_multiplicative'|'none').

Phase 0 (prod-verified): fair reachable at store point (g.fair_prob on the grade,
no threading); 86% batting coverage; method = multiplicative/proportional.
Phase 2 FLAG: multiplicative de-vig mis-splits vig on juiced longshots (favorite-
longshot bias), so a longshot fair still carries known method bias — flagged
per-row (longshot_devig_caveat); a better de-vig (Shin/power) is a separate item.
Phase 3: version bumped proj-v1 → proj-v1.1 so pre-fix (raw-book) and post-fix
(fair) rows never silently mix — the projection model is byte-identical, only the
basis changed; pre-fix rows can't be recomputed (only the graded side's odds were
stored). Champion + arch-v1 + contact-v1 + proj-v1's other columns untouched.
proj suites 26/26.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
2026-07-25 19:09:02 -04:00
builtbykev e96b0dbb6d proj-v1: book-implied from book_odds (grades carry odds, not fair_prob)
The live fingerprint showed proj_book_implied null on every real row: grades
carry book_odds/locked_odds (e.g. -264) but NOT a de-vigged fair_prob, so keying
the book comparison off fair_prob yielded null. The book ODDS are exactly "the
book's implied probability" the handicapper test needs. Now proj_book_implied +
the traded rung's book_implied derive from americanToImplied(book_odds),
expressed on the OVER basis (under props → 1 - implied) so it's directly
comparable to our P(≥rung). Vigged (a known offset the ledger measures both
sides of). proj-v1 suites 24/24.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
2026-07-23 04:14:04 -04:00
builtbykev 316b79733e proj-v1 sanity fixes (caught in the real-data induction)
1. matchupRead fly-ball signal: the batter metrics `gb_pct_bb`/`fb_ld_pct` are
   MISLABELED — they're exit velocities by batted-ball type (Judge fb_ld_pct =
   100.3 mph, not a rate), not ground/fly RATES. Switched fly-ball lean to
   avg_launch_angle (league p10/p50/p90 = 7.1/13.9/20.1°), the correct signal.
2. Absolute rate now fits the FULL season (recency-weighted), not a 20-game
   window: the window under-sampled rare stats — Judge HR projected 0.11 vs his
   0.28 season rate (a fake -32pt edge). Now point=0.27 (matches season); the
   last-5-2x recency lean is preserved.

Post-fix induction (real statsapi logs + real statcast): Judge HR 0.27 (P>=1
0.235 vs book 0.42 -> flags the juiced over), Judge TB P>=2 0.548 vs 0.48
(+6.8pt), thin-hot 3-game P>=1 0.726 / P>=3 0.164 (credible low, thin high),
.300 hitter != 3.0. proj-v1 suites 23/23.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
2026-07-23 04:05:23 -04:00
builtbykev 6386e737b9 proj-v1: absolute matchup projection challenger (distribution + full ladder)
A THIRD challenger (after arch-v1, contact-v1), MLB batting v1. Champion is
market-relative P(stat>LINE); proj-v1 is ABSOLUTE — what the hitter will DO —
emitted as a full distribution from which the WHOLE LADDER (P≥1,P≥2,P≥3) derives.
Champion untouched; nothing claimed; the ledger decides per rung, per stat.

- projection/distribution.js — Bayesian Gamma-Poisson → negative-binomial
  predictive. Admits over-dispersion; under-dispersion → Poisson approx
  (conservative, documented). Uncertainty scales with sample by construction
  (r=α): thin → WIDE (real mass on P≥1, honestly thin P≥3), thick → tight.
  NEVER abstains — width carries the honesty.
- projection/matchupRead.js — the input the book doesn't use. HONEST FIDELITY:
  pitcher repertoire is rich (97% pitch-mix) but hitters have NO pitch-type
  performance, so TRUE repertoire-vs-profile is impossible today. This is the
  COARSE version (arsenal buckets fastball/sinker/breaking + whiff/hard-hit
  tendency × hitter whiff/chase/gb-fb/hard-hit) — beats generic L/R, derived +
  documented + TESTED two-sided. A hitter pitch-type feed unlocks the true form.
- projectionChallenger.js — park RELATIVE to the player's own log exposure
  (isHome→own park, away→opp park; Phase B's raw-multiply bug solved), recency-
  weighted fit, per-factor breakdown (form/park/weather/platoon/matchup — show
  your work), full rung set + book-implied per rung. Combined non-form
  multiplier bounded.
- Wired after contact-v1, own try, flag PROJ_V1_ENABLED, reusing arch-v1's
  already-computed park/weather/platoon (no duplicate env I/O). Own ledger
  columns (migration 032, applied to prod): distribution, ladder, point, line,
  our-P, book-implied, factor breakdown — measurable per rung/stat after settle.

Phase 0 (prod-verified): venue join via isHome; NB family; uncertainty-as-width;
coarse matchup honest fidelity; no lineup-slot (per-game rate, volume implicit).
Sanity: thin-hot → wide (credible low rung, thin high rung); .300 hitter ≠ 3.0;
matchup two-sided; champion byte-identical. proj-v1 suites 23/23; snapshot/
ledger/siblings 74 green. Forward-only, version-stamped, PROJ_V1_ENABLED kill.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
2026-07-23 03:59:36 -04:00
builtbykev b6f12daa98 Contact-quality challenger (contact-v1) — nominate, don't swap
Phase A #2: the champion grade (l5/l20 result-based form) is a HYPOTHESIS that
contact quality predicts better — unmeasured on our props, with zero settled
p_win yet. Swapping l5/l20 (the champion's two heaviest ±1.0 factors) blind
could degrade the core grade undetectably for weeks. So this NOMINATES contact
quality as a second challenger, records what it WOULD project per prop, and lets
the settled ledger decide. Nothing users see changes; the champion is untouched.

- src/services/contactChallenger.js — pure, mirrors challengerProjection. Log-
  odds lean (capped, never a re-forecast) from SEASON contact quality vs league
  percentiles. Metric→prop mapping is the whole game: barrel_pct→HR,
  hard_hit_pct→TB/doubles, k_pct-INVERSE→hits (singles resolve on contact
  frequency, not barrels), k_pct→batter K. rbi/runs/walks ABSTAIN (opportunity/
  discipline — no clean contact predictor). Honest-absent: thin (<50 PA)/absent/
  unmapped/non-batter → p_win_contact NULL (no projection), never a fallback;
  "measured but unremarkable" is distinct (equals champion, delta 0).
- Wired in snapshotService AFTER arch-v1, reusing the already-loaded statcast
  rows; its own try so a second challenger can't break the pipeline. Reads
  g.p_win, never writes it.
- Retained SEPARATELY on the ledger (p_win_contact/contact_delta/
  contact_adjustments/contact_version='contact-v1') so each challenger's marginal
  contribution is measured independently; ledger_entries.stat gives per-prop-type
  segmentation. Migration 031 (applied to prod).

Phase 0 (prod-verified): statcast_aggregates is SEASON cumulative (not rolling),
48h stale now but season-scoped so ~8 PA/600 is negligible; 100% of graded
hitters covered, 92% at ≥50 PA; no xBA/xwOBA in the feed. Forward-only,
version-stamped (contact_version null on pre-nomination rows). Promotion is a
LATER decision on settled evidence, per prop type — never asserted here.

contactChallenger 14/14; snapshot/ledger/arch-v1 suites 80 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
2026-07-22 22:42:55 -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 7b25d97891 Wire the four dormant adjusters live — pure input-wiring
Verified state going in: parkBase, weatherMod and platoonSplits were called by
nothing, and env_multiplier was non-null on zero rows across four orders. The
adjusters were correct in isolation and starved of inputs. This gives them their
inputs and changes none of their internal logic — the five adjuster files are
byte-identical after this commit.

PHASE 0 GATE — all three inputs are available at snapshot build, and the two
join keys already existed. Venue: always, on every schedule game object.
First-pitch: always, gameTime on the same object. Opposing-pitcher hand:
present once the probable is declared, via the pitchers endpoint's pitcherId
joined to statsapi handedness — 15 of 15 games declared this afternoon, though
morning locks precede declaration and those props honest-absent on platoon,
correctly. The batter-handedness join (statcast bats) and the MLBAM id were
already on each grade from earlier sessions.

environmentContext.js is the wiring, kept separate from the adjusters so they
stay pure. It fetches once per snapshot: the schedule (team to venue, gameTime),
probable pitchers (team to opposing pitcher id), one batched handedness call,
one Open-Meteo forecast per home park, and batter splits per graded hitter. Park
coordinates for 30 parks live here as public geometry, the same class as the
dome list and centre-field bearings already in weatherMod, rather than inside an
adjuster. Everything is best-effort: a missing venue drops park and weather, an
undeclared pitcher drops platoon, and any fetch failure degrades that prop to
archetype-only rather than breaking the pipeline the adjusters are measured
inside.

attachChallenger becomes async and takes a per-grade contextFor that returns the
environment coefficient (park_base x weather_mod, composed) and the matchup
(platoon). Point-in-time holds: the weather is a forecast for first pitch fetched
now, and the split is the hitter's line entering the game — neither reads a
settle-time value.

Attribution is independent. env_multiplier, env_park_base, env_weather_mod and
env_weather_state land in their own ledger columns, and challenger_adjustments
keeps every axis — archetype, environment, matchup — as a separate entry, so
when volume accrues each of the four can be measured for its own marginal
contribution rather than as one blended delta.

The combined move stays bounded, tested on the worst case: a Coors slugger with
wind out and a favourable platoon, all at once, still moves under 12 percent,
because every layer is capped and the total nudge is clamped. Stacking leans, it
does not compound into a re-forecast.

Non-MLB honest-absents entirely — park, weather and platoon are MLB-only today,
so a WNBA prop gets no environment and no matchup.

The champion is untouched throughout: p_win is read, never written, the served
snapshot payload is still the enriched object, and a test confirms p_win passes
through byte-for-byte while the challenger moves.

Tests 3741 passed / 301 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-21 21:38:51 -04:00
builtbykev 6dd6f59481 Layer 3 Step 6: platoon splits, regressed hard
The highest-value adjuster and the thinnest sample in baseball. The regression
is not a refinement here, it is the entire feature: applying raw splits would
adjust projections on noise, which is worse than not building it.

PHASE 0 — both gates clear, and one was already closed. Splits are a statsapi
pull, one call per hitter (statSplits with sitCodes vl,vr). The batter-handedness
join that Session 69 recorded as pending is in fact DONE: statcast_aggregates
carries bats for 604 of 604 batters, 210 left, 327 right, 67 switch. STATE said
pending; the data says otherwise, and the note is corrected. Point-in-time holds
as long as the split is fetched before first pitch, since a season split queried
this afternoon cannot contain tonight — but a historical backtest would use
season-final numbers and leak, so clean measurement is forward-accruing.

THE SPINE — regressed = (PA x observed + K x prior) / (PA + K), with K = 600 PA
and the prior being the hitter's OWN blended rate rather than the league's. The
question a platoon adjustment answers is whether he is DIFFERENT against this
hand than he normally is, so his own line is the correct null and a hitter with
no evidence of a split correctly gets nothing. K is deliberately conservative:
platoon skill is famously slow to stabilise, with the half-signal point for
right-handed batters near a thousand PA.

THE MAKE-OR-BREAK TEST, both halves. A .310 average against left-handed pitching
on 30 PA gets 4.8% weight and moves the projection by 0.003 — essentially
nothing, which is the correct answer rather than a limitation. The SAME .310 on
400 PA gets 40% weight and moves it by 0.023, eight times as far. A test asserts
that ratio stays above five, so if the regression ever breaks the suite says so
instead of the projections quietly drifting onto noise.

Real data behaves exactly as the mechanism predicts and is worth recording:
Josh Bell hits .259 against lefties and .248 against righties, which looks like
a platoon split until the sample speaks — 126 PA earns 17% weight and the
adjustment lands at 1.005. Aaron Judge, 76 PA against lefties, comes out at
0.999. Neither is material. Most hitters will get nothing from this adjuster,
and that is the honest output, not a failure.

Honest-absent has five distinct routes, all returning exactly 1.0: no batter
handedness, no pitcher handedness, no splits, a stat platoon says nothing about,
and a missing side falling back to the prior rather than to zero.

INDEPENDENT of the environment. Park and weather compose into one coefficient
because they both describe the stadium; platoon describes this hitter against
this pitcher's hand, so it rides its own slot with its own label. Entangling
them would make both harder to attribute when the instrument scores them.
Directional, mirrored on the under, capped at 15%, and inverted for strikeouts
where a higher rate means a higher prop rather than a better hitter.

Tests 3729 passed / 300 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-21 01:57:33 -04:00
builtbykev 9f60ceba10 Layer 3 Step 5: weather modulation composed onto the park base
Completes the coupled environment: effective = park_base x weather_mod. Weather
tilts the park, it never overrides it — a wind-out night at Oracle Park is still
Oracle Park.

PHASE 0 — both feeds are free and keyless. statsapi /venues gives every park's
coordinates in one call; Open-Meteo returns hourly temperature, wind speed and
wind direction for those coordinates hours before first pitch, which is when we
project. Verified live.

THE SPINE — two weather values, two purposes, never crossed. The FORECAST we
held at projection time drives the live adjustment AND is what the instrument
measures, because it is what we actually knew. It lands on the ledger row beside
p_win. The ACTUAL goes only to game_context as raw material for future
self-derived weather factors, and is read by nothing that scores a projection.
Using the actual to measure tonight would be scoring ourselves on information we
did not have. The actual is also pulled from Open-Meteo's ARCHIVE endpoint
rather than the forecast endpoint, because asking a forecaster after the fact
returns a re-forecast, not what happened.

WIND IS PARK-ORIENTATION CONDITIONED. Wind direction is meteorological — the
direction it comes FROM — so blowing out to centre means arriving from the
opposite bearing. Getting that backwards would invert every wind adjustment in
the system, so the 180-degree rotation is commented at the site and pinned by a
test on all three cases: straight out, straight in, and crosswind. Centre-field
bearings are public geometry, in the same class as the dome list; a park missing
from the table gets no wind effect at all rather than a guessed one, and keeps
its temperature effect.

THREE HONEST DO-NOTHING STATES, all multiplier 1.0, none fabricating an effect.
Dome: weather does not apply, and the PARK factor still does — verified that a
domed venue keeps its sub-1.0 park base while weather stands down. Forecast
absent: none available for this park and time. Sub-threshold: a real forecast
below a meaningful bar, because manufacturing a 0.3% nudge on a light breeze is
false precision. Weather also says nothing about a strikeout prop and returns
not-applicable rather than a neutral it might later be tempted to fill.

Conservative and ledger-tunable: every magnitude is an env var, the total is
capped at 12%, and nothing here is asserted. This is a nominated challenger that
earns its place on the instrument or is cut.

Induced at Wrigley, whose centre field bears 32 degrees: wind from 212 at 15 mph
computes as 15 mph straight out, weather 1.12 composed with park 1.06 for an
effective 1.187 and a +0.043 nudge; the under mirrors exactly; the pitcher's
home-runs-allowed prop moves with the hitter's, since both are P(over) on a ball
leaving the park. Wind in drops the coefficient to 0.955. A calm 72-degree
evening, a dome, and a missing forecast all return 1.0 by three different
honest routes, with the park base still applying in each.

One correction to the order worth recording: it describes a wind-out night as
helping the hitter and hurting "the pitcher there's HR-allowed" as opposite
sides. In prop terms both go the same way — the HR-allowed OVER is more likely
too. The sign lives in the stat, exactly as established for park factors, and
the implementation follows that rather than the phrasing.

Migration 036. Tests 3707 passed / 299 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-21 01:43:13 -04:00
builtbykev f33091ddb8 Layer 3 Step 4b: public park base, source-pluggable, plus game-level capture
PHASE 0 — the settle path sees a player's game-log line, not the game. It knows
date and teams, never venue or final totals. But the grain is far cheaper than
per-prop or even per-game: ONE statsapi schedule call per game DATE returns
every game that day with venue, linescore and scoring plays. Fifteen games, one
call, verified live.

PUBLIC BASE — the ingestion was already done. The static FanGraphs table from
Session 15 is the public base; this converts its 100-indexed values into the
multipliers the composable architecture wants (Coors 128 becomes 1.28) rather
than ingesting a second copy of a number we already hold.

It is labelled COMMODITY in the code, not just in a comment. Every resolution
carries a provenance record, and the public one reads proprietary: false with
the note "Commodity: a public number. Not a VYNDR derivation." The proprietary
label exists but belongs only to the self-derived version, and only once it
beats this base on the instrument. A surface rendering a park effect can state
which it is rather than implying the flattering one.

Honest-absent where even the PUBLIC number is thin: a relocated club in a
temporary venue gets no factor, because a public number for a park with one
season behind it is no more trustworthy than ours would be.

SOURCE-PLUGGABLE is the architectural point. resolveParkBase() is the only
accessor, public and derived return identical shapes, and callers never branch
on source — so when self-derived factors clear their floor they swap into the
same slot with nothing downstream to rewrite. A derived source with no factor
available returns absent rather than silently falling back to public, because a
silent fallback would make a proprietary claim out of a commodity number.

GAME-LEVEL CAPTURE starts now because it cannot start retroactively. Game grain,
deduped on game_id, never copied onto prop rows — a game's totals belong to the
game, and duplicating them per prop is how one fact starts disagreeing with
itself. Every field is tied to a named future derivation: venue for park
factors, runs for the run environment, HR totals for HR factors. Nothing else is
stored. Only Final games are captured, since an in-progress total is not a
result, and a game with no scoring plays reports HR as absent rather than zero.
HR totals come from scoring plays, which is complete because every home run
scores at least the batter.

The accrual target is stated rather than promised: 150 home games per venue at
roughly 81 per season means about two seasons before a self-derived factor can
be nominated, and accrualStatus() reports live progress per venue so the wait is
measurable.

Induced: Coors home runs +0.061 for the hitter and identically +0.061 for the
pitcher's home-runs-allowed at the same park, mirrored on the under; San
Francisco negative; Tampa flagged weather-N/A with its factor still applying;
the Athletics' temporary venue absent; strikeouts untouched. A real 2025-07-19
capture produced 15 games across 15 venues, 12 with HR totals, zero duplicate
game ids.

Migration 035. Induce with POST /api/internal/gamectx/:date, progress at
/gamectx/accrual. Tests 3688 passed / 298 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-21 01:20:29 -04:00
builtbykev 3ac91c3d96 Layer 3 Step 4: derived park factors, composable for weather
PHASE 0 GATE — the answer is BOTH, and the important half was already here.
A STATIC FanGraphs park-factor table has existed since Session 15
(src/data/parkFactors.js) and computeFeatures already consumes it, so park is
not a new idea in this codebase. What was missing is OUR derivation. I nearly
built a second source of truth before finding it; the new service lives at
src/services/parkFactors.js and the two are deliberately distinct.

That discovery changes the point of this order rather than just its scope. If
the champion already sees a park factor, adding one to the challenger risks
double-counting — which is exactly the redundancy the Session-72 harness exists
to catch. So park ships as a NOMINATED CHALLENGER whose job is to be tested for
marginal contribution, not as an assumed improvement. Checked and worth noting:
the static table reaches computeFeatures but NOT probabilityEstimator, so it
does not currently touch p_win at all.

DERIVATION, not ingestion. statsapi gives every game with venue, linescore and
scoringPlays in one call per date range — and since every home run scores at
least the batter, HR totals are fully recoverable from scoring plays. Derived
from 5,055 real games across 2022-2025: Coors tops the run environment at
1.099, Dodger Stadium tops home runs at 1.106, Oracle Park and PNC suppress
them at 0.923 and 0.917. Eighteen parks cleared the floor, eighteen did not and
are honestly absent.

COMPOSABLE BY CONSTRUCTION — the architectural point. Park emits a multiplier
around 1.0, never an additive nudge, because weather has to modulate it next
order: effective = park_base x weather_mod. Additive terms do not compose
correctly (a 5% park and an 8% wind are 1.05 x 1.08, not +13%), and the
challenger converts the multiplier to log-odds so stacking stays correct. A
test multiplies a placeholder weather term onto the park base to prove the shape
composes with no rearchitecting.

DIRECTIONAL BY PROP-OWNER: home_runs and home_runs_allowed both key off hr_base
in the same direction, because the sign lives in the STAT, not the park. Coors
inflates the hitter's home run prop and the pitcher's home-runs-allowed prop
identically.

THREE HONEST STATES, deliberately distinct. Absent (thin sample, adjust
nothing), present (adjust), and weather_na for domes — where the park factor
STILL APPLIES because a dome has a real run environment, and the flag exists so
next order's weather modulation correctly does nothing there. N/A is not absent;
conflating them would either drop a valid park factor or apply wind indoors.

Structural breaks: a season deviating past the threshold starts a new regime
only if the FOLLOWING season confirms it — one odd year is noise, two
consecutive years on the same side is a rebuilt park. Only post-break seasons
are used, so a humidor or moved wall cannot be diluted by the stadium that
preceded it. Factors regress toward neutral by sample size, so a two-season park
cannot assert a Coors-sized coefficient, and fine conditioning stays unavailable
until its own larger floor.

Tests 3669 passed / 297 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-21 01:06:17 -04:00
builtbykev 927e867a23 Layer 3 Step 3: Tier-1 mappings live; Tier-2 nomination harness
PHASE 0 GATE — historical out-of-sample testing is NOT available, and the reason
matters. statcast_aggregates is overwritten nightly by design (Layer 1 is a full
re-pull upsert), so it holds season-TO-DATE numbers with no point-in-time
history. Classifying a player for a 15 July game using today's aggregate would
feed the model games from 15-21 July — look-ahead leakage, and the resulting
"out-of-sample" verdict would be worthless. The harness therefore reads the
archetype vector RETAINED at grade time (Session 70's instrument) and runs
FORWARD-ACCRUAL, not historical. Reported rather than worked around.

CANONICAL NAMES ASSERTED. Every mapping references the axis keys the classifier
actually emits, and a test walks both maps against BATTER_AXES / PITCHER_AXES.
A key that does not exist would look active and never fire — a mapping that
appears wired while silently doing nothing is the exact failure this guards.

TIER 1 IS LIVE, tautological and directional: PUNCHOUT/WHIFF raises strikeouts;
SINKER/SEAM lowers home runs allowed and FLY BALL/ELEVATOR raises them (a ball
on the ground cannot leave the park); SURGEON ARM/PINPOINT lowers walks allowed;
SLUGGER/BOMBER raises total bases and home runs; TECHNICIAN/SURGEON raises hits
and lowers strikeouts; GRINDER/SNIPER raises walks. Each adjusts only its named
stat, mirrors exactly on the under side, and leaves an average player untouched.

SPEED IS HONESTLY ABSENT. BURNER/stolen-bases has no axis to key on — SB is a
statsapi field that never reached the aggregate store, so Layer 2 shelved it.
The mapping is an empty object rather than an invented one.

THE TIER-2 HARNESS tests MARGINAL CONTRIBUTION, not correlation. A ground-ball
arm obviously correlates with fewer home runs; the question is whether the
archetype explains the PROJECTION'S RESIDUAL (outcome minus p_win). If the
projection already knows it, the residual carries no signal and the mapping is
rejected as redundant — that hurdle is what catches double-counting. The split
is by DATE, never random, because rows from one game share a pitcher, a park and
a lineup and would leak across a random split. Direction is validated from the
held-out data and a contradicted sign is REJECTED, never silently flipped to
whatever the data says, which would be fitting noise.

LIFECYCLE ENCODED — nominated, live, claimed. A mapping that survives runs live
and is measured; only the quantified public claim waits for the ledger. Nothing
sits dark.

One fixture bug worth recording: my first synthetic generator aliased the
carrier selector against the outcome draw and manufactured a 0.038 effect where
the generator had put zero. The harness rejected it correctly — it just gave the
sign reason instead of the redundancy reason, which is how I found it. The draw
now uses a coprime modulus.

Real candidate run end to end, GROUND-BALL to hits-allowed: INSUFFICIENT, 0 of
200 settled rows, because no settled row carries p_win yet (Session 70's
instrument starts recording at the next new lock). That is the correct verdict
and the expected one.

Tests 3654 passed / 296 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-21 00:30:16 -04:00
builtbykev f2da9dd7e8 Layer 3 Step 2: archetype-aware CHALLENGER, measured not claimed
The champion (probabilityEstimator -> p_win) keeps serving and grading users,
completely unchanged. The challenger is a second probability computed from the
same inputs at the same instant, landing on the same ledger row so it joins to
the same outcome and the same close. Identical conditions, one difference —
the only clean A/B.

NOTHING IS CLAIMED. Running a challenger is honest beta; asserting it is better
before the settled ledger says so is not. Promotion stays a later decision gated
on Brier and calibration over sufficient segmented volume.

INTERPRETABLE, NOT A RE-ESTIMATION. The challenger is the champion's probability
adjusted by the Layer-2 axes, applied in log-odds space so a nudge cannot push
past 0 or 1 and means the same thing at p=0.5 as at p=0.9. Every deviation is
attributable to a named axis and a signed nudge, stored as
challenger_adjustments, and the total is capped at 0.45 log-odds — a lean on a
real signal, never a re-forecast. Only mechanically obvious stat/axis
relationships are mapped; a speculative mapping would be the same guessing this
layer exists to replace.

IDENTICAL WHERE THERE IS NO SIGNAL, by construction. An unremarkable player, a
thin sample, an unmapped stat or a missing classification all return the
champion's probability byte-for-byte with an empty adjustment list and a stated
reason. The experiment therefore differs only where archetype-awareness could
possibly help or hurt, with no dilution from rows the treatment never touched.

Induced on real players. Judge home runs over: 0.42 -> 0.447, via BOMBER +0.22
and WHIFF RISK -0.11 — two real opposing signals netting positive. The same prop
under mirrors it exactly to -0.027. Judge strikeouts: delta exactly 0, because
WHIFF RISK and GRINDER cancel — an honest "no lean" with both signals still
recorded. Skubal strikeouts over: 0.60 -> 0.702 via WHIFF, TRAPDOOR and CANNON
all aligned; his hits-allowed goes the other way, 0.50 -> 0.392, because a
strikeout arm makes hits less likely. Josh Bell and a 12-PA sample are
untouched.

Isolation is structural: adjust() is pure, the champion field is read and never
written, the served snapshot payload is still the untouched champion object, and
a challenger failure is caught so it can never break the pipeline it is measured
inside. Statcast aggregates load once per snapshot run rather than per prop, so
grade-time I/O stays at zero.

Migration 034. Tests 3634 passed / 295 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 23:53:32 -04:00
builtbykev 474ebc5d3a Fix the close-attach: de-vig raw prices, not a column that does not exist
Caught by inducing on real rows. The first attach ran and marked 642 rows
market-unavailable while attaching ZERO closes — because it selected a
`fair_prob` column from closing_captures, which has none. That table stores
over_odds and under_odds deliberately (Session 64) so the de-vig can run later
against the same engine the grade-time fair price uses; asking it for a
probability returns nothing and makes every row look closeless.

The de-vig now runs here, via devigTwoWay, which is what makes lock and close
comparable at all. A one-sided capture yields no fair probability and is
correctly not a close.

Repair checked rather than assumed: the 642 markings turn out to be CORRECT —
every one is a game from before closing capture existed on 2026-07-20, so those
rows genuinely have no close and the absence is true. Zero capture-era rows were
wrongly marked. The bug would have mis-marked every future row, which is what
the fix prevents.

Two tests added: the de-vig path with real prices, and a source assertion that
the query never again asks closing_captures for a column it does not have.

Tests 3616 passed / 294 suites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
2026-07-20 23:11:52 -04:00
builtbykev c5580f333e Layer 3 Step 1: wire the measurement instrument
Step 0 found we have been flying without one. p_win lives only in
model_snapshots, which has 1,000 rows and ZERO settled outcomes; the closing
line lives only in closing_captures, which carries no link to a result; and
ledger_entries, the row that actually settles, carries no probability at all.
So "is the projection calibrated" and "does it beat the market" have never been
answerable — the entire measurable universe was 35 rows recovered by a lossy
in-memory join.

PHASE 0 — closing coverage verified BEFORE reuse, because an instrument built
on a partial close measures a biased subset. closing_captures holds 70,254 rows
of which 13,364 are usable, and the 56,890 refusals are candidates we never
graded plus one-sided prices — not refusals of our props. Coverage on graded
props since capture started is 83/83, 100%. Safe to reuse, with the honest
caveat that capture only began 2026-07-20.

THE FOUR-TUPLE NOW LANDS ON ONE ROW. ledger_entries gains p_win, fair_prob_lock,
archetype_vector and projection_locked_at at LOCK time, and closing_prob plus
closing_captured_at from the append-only capture store. The join is the whole
point: calibration is p_win against outcome, market-comparison is p_win against
the close, and both become plain SQL on one record instead of a join that
silently drops 90% of the rows.

p_win and the archetype vector are IMMUTABLE — written once at lock via the
existing ignoreDuplicates upsert, never re-derived at settle. A re-derivation
would measure a projection we never made.

The archetype is stored as the VECTOR, not the label. "Did archetype-awareness
help?" can only be answered against the axes that were live at grade time, and
a single text column cannot express a blend. A grade with no archetype stores
null rather than an empty object.

HONEST-ABSENT BOTH WAYS. A past game with no usable capture is marked
market_unavailable_reason and never given an imputed line; calibration still
scores on those rows, only market-comparison is absent. And a game that has not
started yet is NOT declared closeless — a close can still arrive, and premature
absence is as dishonest as imputation in the other direction.

One bug caught before it shipped: the scheduler hook iterated a SPORTS
identifier that does not exist in that scope. Inside its try/catch it would have
thrown ReferenceError every tick and silently never run — the instrument would
have looked wired and captured nothing. Now iterates cadence.ALL_SPORTS.

The baseline accrues FORWARD. Historical p_win and closes are gone, discarded
before this existed. Calibration and market-comparison stay honest-absent until
volume accrues.

Tests 3614 passed / 294 suites, web build exit 0. Migration 033 applied.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
2026-07-20 22:58:30 -04:00
builtbykev 7ac6aa73e3 Layer 2: multi-axis archetype classifier; the FLEX fallback is gone
A player is a blend across independent axes, not one label. Skubal is a STARTER
and a strikeout arm and a ground-ball arm and a control arm — four true things
at once, and single-label classification threw three of them away.

AXIS INDEPENDENCE WAS MEASURED, NOT ASSUMED. Correlations over the live store
(467 batters, 531 pitchers); anything |r| >= 0.70 is one underlying trait and
was collapsed so we never show one trait as two archetypes. Batter k% ~ whiff%
+0.89, hard-hit% ~ exit velo +0.88, chase% ~ swing% +0.87, chase% ~ bb% -0.72;
pitcher k% ~ whiff% +0.76, gb% ~ fb% -0.73 — all collapsed.

The survivors are genuinely orthogonal, and one result is worth stating: pitcher
velocity correlates +0.14 with K%, +0.07 with whiff% and +0.07 with GB%.
Velocity is NOT a proxy for missing bats — a hard thrower who misses no bats is
a real distinct type, so CANNON earns its own axis rather than being folded into
STRIKEOUT. Pitcher K% ~ GB% is -0.10, so PUNCHOUT and SINKER are independent,
which is exactly the multi-axis thesis.

Cut-lines are the measured p75 (distinctive) and p90 (elite), per role where the
tails differ even when the medians agree: reliever GB% p90 is 54.1 against a
starter's 48.9, both with a median of 42.5.

THE FALLBACK IS DELETED. classify() used to return FLEX (mlb) / SHIELD (wnba) /
CONNECTOR (nba) at weight 1.0 when nothing scored — "could not classify"
rendered as a fully-confident classification of a real archetype, with
descriptive education copy attached. 8 of 18 MLB players carried it, and FLEX
could never be earned because its only scoring input had zero writers. Every
sport now does what MMA already did: unclassified is absent.

Induced on real players. Skubal: STARTER, throws L, WHIFF + SEAM + PINPOINT, all
elite. Judge: BOMBER + GRINDER + WHIFF RISK — elite power, patient, strikes out,
three true things. Kwan: SURGEON + SNIPER + SLASH with NO power claimed (0.4
barrel% is absent, not "low power"). Josh Bell, who used to classify as DRIVER:
empty blend, "No standout profile — league-average across every measured axis."
Alan Roden, who was FLEX at weight 1.0 on 21 PA: every axis absent, "Not enough
plate appearances yet — no profile claimed."

Per-axis honest-absence holds: a velo-less pitcher keeps every other axis, and
NO DATA is distinguishable from LEAGUE-AVERAGE rather than collapsing into one
shrug. The full vector is stored for Layer 3; only the top three distinctive
traits surface.

Three existing tests asserted the fallback and were updated to assert absence.
One of them surfaced a real robustness gap: classify(sport, null) threw, because
an explicit null does not trigger a default parameter and every scorer
dereferences its argument. Guarded.

Every baseball name is accounted for in docs/ARCHETYPE-AXES.md — built, alias,
tier, or shelved with its unlock condition. Zero orphans; cross-sport names left
for their sport.

Tests 3601 passed / 293 suites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
2026-07-20 22:38:32 -04:00
builtbykev 1265c23305 Tier-A joins: handedness, true role, and the velo fix
Three Layer-2 prerequisites, each one free call.

HANDEDNESS — statsapi /sports/1/players carries batSide, pitchHand AND
primaryPosition for every player: 1,316/1,316 in the live probe. Batter
handedness was 100% absent, which made every platoon or switch-flavour
archetype unbuildable; it is now populated from the same call that gives
pitchers theirs, with statsapi as the authority and the movement feed as the
fallback.

ROLE — statsapi season pitching with playerPool=ALL returns 751 rows (the
default returns only the ~57 qualified). Real usage: gamesStarted, gamesPitched,
gamesFinished, saves, holds. roleDetail derives starter/closer/setup/reliever
from that instead of the season-IP proxy, which drifts all year as innings
accumulate and left 32 pitchers in a 60-80 IP trough.

VELO — recovered from 53% to 99% (721/729 pitchers). The movement feed carries
only each pitcher's PRIMARY pitch, so matching by position could never do
better than one pitch each. The wide pitch-arsenals feed has one column per
pitch type, matched BY TYPE: Skubal now has velo on all 5 of his pitches. Velo
archetypes are therefore buildable rather than shelved.

Migration 032 applied.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
2026-07-20 22:30:12 -04:00
builtbykev a49959867d Statcast: take the full arsenal, not each pitcher's primary pitch
Caught by spot-checking a real row after the backfill landed: Skubal stored
with one pitch. The pitch-movement endpoint with an empty pitch_type returns
ONE row per pitcher — their primary offering — so 677 rows for ~700 pitchers,
and a five-pitch arsenal was being recorded as a one-pitch one. Not a
fabrication, but a silent under-representation of the single most important
pitcher-mechanism field, which is worse than useless for Layer 2: it would have
classified every pitcher as a one-pitch arm.

Mix now comes from pitch-arsenal-stats (3,205 rows = pitcher x pitch type)
carrying usage%, whiff%, K%, put-away% and run value per 100 for every pitch.
Movement still supplies velo, break and handedness, folded onto the primary
pitch; a pitcher present only in the movement feed keeps his handedness and his
one measured pitch rather than being dropped. Velo on non-primary pitches is
null — absent, not guessed.

Skubal now stores 5 pitches, throws L, FF first by usage with velo 96.7.

Tests 3583 passed / 292 suites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
2026-07-20 21:53:32 -04:00
builtbykev a011ae79fe Statcast: role belongs in the key (two-way players)
Found by inducing the real job on the server, not by review: the first chunk
wrote, the second failed with 'ON CONFLICT DO UPDATE command cannot affect row
a second time'. A player can legitimately appear in BOTH the batter and the
pitcher feeds — two-way players, position players who pitch, pitchers who bat —
so (sport, season, source_id) collapsed two real profiles into one key and a
single batch hit the same row twice.

Ohtani has a real batter profile and a real pitcher profile. Merging them would
invent one player out of two genuinely different sets of measurements, so role
goes in the primary key rather than one profile winning. Migration 031 applied;
conflict target updated; a two-way case is now a test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
2026-07-20 21:49:53 -04:00