Commit Graph

469 Commits

Author SHA1 Message Date
builtbykev 125919f86a Scanner reskin: amber → blue boundary channel + build never-built S6/S7 states
Semantic COLOR fix, not cosmetic. The S6/S7 build predated the current Scanner
States spec: it used AMBER (the quarantine / model-suppressed channel) for the
"no market / line not priced" case, telling users "model suppressed" when the
truth is "the board never priced this." Corrected to the HANDOFF Session-3
blue-boundary law: BLUE (--priced-out #8FB2DE) = no-market boundary; amber stays
QUARANTINE; red stays REFUSAL.

Phase 1 (reskin): NoMarketState → dashed BLUE void box + blue header/copy; the
S7 rows → spec format (o 27.5 · BK −114 · ◆ −105 · OPEN READ ▸) at 44px,
390-legible. Input-area surfacer pills reskinned to the blue channel too.

Phase 2 (never-built states, only those Phase 0 confirmed against live data):
- GREEN CTA with LIVE player count ("PLAYER · N PRICED PROPS ▸"), degrading
  honestly to the board path ("N PROPS LIVE · TONIGHT'S BOARD ▸") at 0 — count
  from the SAME fresh index as the rows (pricedCountForPlayer), can't disagree.
- CASE A none-priced DEFAULT: "WE PRICE THESE FOR [player]" — the player's other
  priced stats (pricedStatsForPlayer, filter by nameKey).
- Typed-line-mismatch blue fact line ("o X ISN'T PRICED · NEAREST ↓").

FLAGGED / not built (no shells): Case C off-slate quiet-stop needs schedule/
roster membership the pricedLines index doesn't carry (out of the presentation
fence). Spec CONTRADICTION: Case B says "fair previews amber," but the law
reserves amber for quarantine — fair renders NEUTRAL ◆ (blue-dim), not amber, to
avoid blurring the channel.

Free-tier gate VERIFIED before rendering FAIR: fair_odds is the de-vigged MARKET
price (valueState: "never hide the honest fair number"), NOT the gated
model_odds — no paid leak. Carried through indexPricedLines (additive; keying/
refresh/onPick/stale-tap all unchanged — the proven S7 data path is untouched).
Scan A byte-identical; PRICED_NUDGE_ENABLED still the kill switch; reversible.
Build exit 0; priced + parity suites green (67).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
2026-07-22 21:52:41 -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 3440738d9e Landing hero → bundle visual system; claims filtered to earned only
Migrate the Landing hero (Landing-only component; LiveHeroProp/triplet
untouched) to the design bundle's visual language: mono "SPORTS
INTELLIGENCE TERMINAL" eyebrow, tightened 56px/800 headline "Every line,
graded before you bet it.", dual mono CTAs (OPEN THE TERMINAL / SEE THE
PUBLIC RECORD), proof chips.

Claims audited against the held list — the bundle's marketing copy carries
claims we have not earned; those did NOT ship:
- "CLV-VERIFIED" chip + "verified against closing lines" — HELD (C4 broken)
- "312 props / 47 games" count — fabricated demo → ABSENT (no count)
Shipped copy is earned only: pre-graded, edge-ranked, settled in public,
misses included; PUBLIC LEDGER / MISSES INCLUDED / 5 FREE READS chips.

HELD + FLAGGED for a design pass (fabrication-backed, no honest designed
state — check-don't-freelance): the edge-board demo (A+/A don't emit), the
"grades calibrate · A+ hit 80%" claim (calibration unmeasured), and the
tier-record band (real data is B/C-only, no ROI). Not built with fake data.

Tokens via var(--g-a/--void/--border/…) with bundle-hex fallbacks
(documented-intentional pattern). Build exit 0; parity QA green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
2026-07-22 19:28:02 -04:00
builtbykev d879a10dd3 Design bundle (2): current source of truth — Scanner States + blue-boundary law
Reference material only, ZERO product code. Adds Vyndr Scanner States.dc.html
(S6/S7 spec) + HANDOFF Session 3 blue-boundary-channel law (#8FB2DE = the honesty
channel: priced-out / no-market / line-not-priced). This is the diff baseline for
the design-migration arc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
2026-07-22 19:10:54 -04:00
builtbykev 9a91837f63 STATE: Session 80 — S7 nudge freshness completed; premise corrected report-first
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
2026-07-22 12:20:49 -04:00
builtbykev 4f3f433aae Complete S7 priced-line nudge: freshness on a long-open page + reversible gate
REPORT-FIRST correction. This order's premise — "S7 is a shell that doesn't
update per selection" — is not what the code does. pricedForSelection is a
useMemo on [pricedIndex, selectedPlayer, stat] and setSelectedPlayer/setStat
fire on every user pick, so the chips already update per selection, and the
prior session's verification of that stands. The genuine gap was FRESHNESS: the
snapshot fetch depended on [sport] only, so pricedIndex was fetched once per
sport-change and never refreshed. The pricing cron re-prices at five UTC hours,
so a scanner left open across a cron boundary surfaced hour-stale priced lines.
That is the real defect, and the only one fixed.

FRESHNESS. The fetch is now a refreshPriced callback re-run when the held
snapshot is older than PRICED_STALE_MS (30s, matching the /api/snapshot cache)
at the moment of use — on selection change and on window focus — so a long-open
page never shows a stale line. Sport change still clears the index first, so the
old sport's lines never flash.

STALE-TAP was already safe and is unchanged: the scan submit re-fetches the live
snapshot server-side, so a chip that's gone stale between render and tap either
lands on a real triplet (still priced) or degrades to the honest empty state
(rotated away) — proven in the prior session and re-confirmed here (an
off-snapshot line returns no market and shows the empty state).

REVERSIBLE GATE. The whole nudge sits behind one PRICED_NUDGE_ENABLED flag: false
empties the surfaced set, so the scanner falls back to S6's link-only empty
state with the chips gone. Shipping enabled only after the cases are proven this
session; the flag is the instant revert lever.

DISPLAY-LAYER ONLY. Only scan/page.tsx changed. GradeResultCard, PriceTriplet,
gradeAdapter, valueState, both scan routes and the pure pricedLines helper are
byte-identical — Scan A and the scan-submit resolution are untouched, and the
change is independently revertible.

Tests 3765 passed / 303 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-22 12:09:33 -04:00
builtbykev 33d72e38f9 STATE: Session 79 — read-card no-market empty state + priced-line surfacer, live
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
2026-07-22 11:24:39 -04:00
builtbykev e311f53738 Read-card scan: honest no-market empty state + surface real priced lines
The Session-78 diagnosis stands: the join works, and a marketless scan rightly
shows no triplet. This makes that absence legible and points the user at what IS
priced, without fabricating a market.

PHASE 0 gate — design-check, reachability, timing, all clear. Design-check: the
bundle has the triplet's own REFUSAL language ("we'd rather show nothing than a
number we can't stand behind") as the honesty precedent, and a designed
EmptyState component whose actions give a path forward — so the empty state is
built in the established visual language, not freelanced. Reachability: the
scanner already fetches games/odds/search per selection; the snapshot is one
more public, 30s-cached fetch per sport, re-run when the sport changes.
Staleness: the snapshot rotates 5x/day and every scan re-validates the market
server-side at submit time, so a surfaced line that goes stale degrades to the
empty state on tap rather than a vanishing triplet — the stale-tap guard is
inherent, not bolted on.

Reversibility was the design constraint. The working card, price triplet, grade
adapter, valueState and the scan route are BYTE-IDENTICAL — a test asserts none
of them even reference the new empty state. Everything new lives in two added
files (lib/pricedLines.js, components/vyndr/NoMarketState.tsx) and additive
blocks in the scan page. Removing them leaves the Scan-A path untouched.

Non-fabricating by construction: indexPricedLines only keeps snapshot rows that
carry a real book price, keyed by exact player+stat via nameKey. A different
stat priced for the same player surfaces nothing for the picked stat; an
off-slate player surfaces nothing; nothing is suggested, interpolated, or
rounded to a nearest line. The empty state shows no market numbers of its own —
only real priced lines as one-tap chips, or a link to the live board when there
are none.

Framing is help, not restriction: a "PRICED TONIGHT" chip row sits under the
free-typed line input, and the scanner still accepts any player, stat and line.
Tapping a chip pre-fills the priced line and re-scans it — the market is
re-resolved server-side, so the tap either yields a real triplet or degrades to
the honest empty state.

Path forward, not a wall: a marketless scan no longer dead-ends in blank space.
It states truthfully that the board didn't price that line, keeps the grade, and
routes the user to the priced lines for that exact player+stat or to tonight's
board.

Tests 3760 passed / 303 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-22 11:21:29 -04:00
builtbykev 4c9707ffbb Platoon polarity: VERIFIED correct, pinned by a standing test
Report-only verification of the two unproven claims from the Session-77 wiring,
which fired platoon on synthetic split-less hitters where the K=600 regression
zeroed the multiplier and masked both direction and resolution. No product code
changed — this adds one standing regression test.

FIXTURE — Yordan Alvarez (LHB), real 2026 splits, deep both hands: 115 PA vs LHP
at .529 slg, 327 PA vs RHP at .695, overall .652. The regression leaves a
material multiplier both ways (0.97 vs LHP, 1.023 vs RHP), so unlike the last
test this fixture can actually reveal direction.

RESOLUTION — verified on the live slate that the pitcher-hand attached to a
hitter is the OPPOSING team's probable, not his own. CLE (home) resolved to
Minnesota's away starter 696070; MIN (away) resolved to Cleveland's home starter
800048. The chain — hitter's team, the game, the other team, that team's
probable, that pitcher's hand — is correct, and it is pinned independently of
direction because a backwards resolution is invisible on a neutral hitter.

DIRECTION — deterministic L-vs-R on the frozen Alvarez fixture. Facing RHP nudges
UP (1.023) because he slugs .695 there, above his .652 overall — a favorable
opposite-hand matchup, exactly what platoon theory predicts for a left-handed
bat. Facing LHP nudges DOWN (0.97). The two move opposite directions, and
crucially the SPECIFIC sides are asserted, not merely "opposite" — a
mirrored-but-inverted implementation would put RHP below 1 and fails here. Both-
backwards is ruled out.

The test also pins a REVERSE-split hitter, Brandon Nimmo, who hits better vs LHP
than RHP. His multiplier goes up vs LHP, following his real numbers rather than a
hardcoded LHB-vs-RHP assumption — proof the sign is data-driven, which is the
correct design.

VERDICT: PASS. Resolution correct, direction correctly signed against both the
real split and platoon theory. Pinned by tests/unit/platoonPolarity.test.js so
the polarity cannot silently regress — the opp_rank_stat lesson applied.

Tests 3750 passed / 302 suites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
2026-07-21 23:08:11 -04:00
builtbykev 535a7b70ea STATE: Session 77 — dormant adjusters wired; env non-null proven, ledger row pending
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
2026-07-21 21:41:21 -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 2adf192d98 STATE: Session 76 — platoon regressed; plumbing gap now four orders old
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
2026-07-21 01:58:04 -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 9086951852 STATE: Session 75 — weather modulation composed onto park; venue gap still open
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:41 -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 5b4af67d93 STATE: Session 74 — public park base pluggable, game-context capture live
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
2026-07-21 01:23:28 -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 63e4bce858 STATE: Session 73 — park factors derived + composable; pipeline gap documented
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:49 -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 beac1816d2 STATE: Session 72 — Tier-1 live, Tier-2 harness forward-accrual (no historical OOS)
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:46 -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 56fee267c9 STATE: Session 71 — challenger deployed; verification gap documented honestly
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
2026-07-21 00:04:24 -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 80f7100fc3 STATE: Session 70 — measurement instrument wired; the baseline accrues forward
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
2026-07-20 23:12:27 -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 063e9fb3f7 STATE: Session 69 — multi-axis archetypes live, FLEX fabrication deleted
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
2026-07-20 22:40:26 -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 27aabd078f STATE: Session 68 — Layer 1 mechanism data live (1,354 rows, 100% join)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
2026-07-20 21:55:30 -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
builtbykev 528cb1a6d0 Layer 1: Statcast mechanism-data ingestion (backfill + nightly refresh)
The data foundation for the archetype and projection layers, built as the
pattern every sport inherits. Layers 2 and 3 are not touched.

PHASE 0 GATE — both match rates measured live, both 100%. Batters 40/40;
PITCHERS 66/66 across five real rosters (CLE, DET, MIN, NYY, LAD) joined by
MLBAM id against the 713-pitcher Savant feed. Zero honest-absent on identity,
because the join is an integer both systems use natively — and the snapshot
pipeline already stores it per graded row.

SOURCE — five Baseball Savant CSV leaderboards, free and public, pulled with
axios and the CSV parser savantAdapter already runs in prod. pybaseball is
deliberately NOT used: it is an MIT wrapper over these same URLs, and adding it
would reintroduce a Python runtime in a stack where the existing Python service
is already offline. min=1 on every feed, not Savant's default min=q, so the
long tail arrives and OUR minimum-sample gate decides what is thin — explicit
and testable rather than silently dropped upstream.

Measured: 1,354 rows per season (604 batters, 750 pitchers), all five feeds in
about five seconds. Pitcher mechanism includes arm angle, GB/FB/LD, chase and
whiff; batters get exit velo, launch angle, barrel and hard-hit, chase and
z-swing. Handedness rides in free on the movement feed (677 pitchers); batter
handedness stays absent pending a roster join rather than being guessed.

BACKFILL AND REFRESH ARE THE SAME CALL — a full re-pull upserted on
(sport, season, source_id). Idempotent and self-healing: a missed night
self-corrects on the next run, with no incremental who-played bookkeeping to
drift out of sync. At 1,354 rows the simple thing is also the robust one.

HONESTY RULES, each with a test: a metric the feed did not carry is null and
never 0; a thin sample is STORED and flagged rather than dropped or inflated,
because thin and missing are different claims; an unjoined player is stored
with a null player_key and joins later; and if every feed comes back empty the
job REFUSES to write, so a bad night can never blank a good table.

Freshness is treated as a truth property. updated_at on every row, and the
scheduler pages on a failed run AND on silent staleness — a job that stops
being scheduled never produces a failure, so staleness has to alarm on its own.
Never-built is deliberately not stale: different condition, different fix, and
paging on a fresh install teaches the operator to ignore the alarm.

Nightly at STATCAST_HOUR_UTC (default 11 UTC, after every game is final), kill
switch STATCAST=0, and induce-able at POST /api/internal/statcast/refresh with
a freshness probe at /statcast/status — we verify a refresh by running it, not
by waiting for the slot.

Migration 030 applied. Promoted columns for the classification-critical metrics
plus a metrics JSONB carrying every raw field, so Layer 2 can reach something we
did not promote without a re-ingest. Raw per-pitch stays out of Postgres on
purpose: one season is ~0.85 GB against a 500 MB plan ceiling, and it is
re-pullable from the free source if Layer 3 ever needs it.

Pattern documented in docs/MECHANISM-DATA.md for NBA tracking and NFL Next Gen.

Tests 3581 passed / 292 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 21:37:30 -04:00
builtbykev 0264486bf9 STATE: Session 67 — price layer gated at two doors, read card wired
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
2026-07-20 19:59:33 -04:00
builtbykev 4e2f488341 Forward model_price_locked to the hero — a paywall was wearing poison's copy
Live induction on the deployed landing page caught this; markup review and the
unit suite both passed it. With the model leg stripped for anonymous visitors,
LiveHeroProp forwarded book/fair/model/ev/quarantine to PriceTriplet but NOT
model_price_locked, so deriveValueState fell through to the missing-model-price
branch and the card rendered "MODEL READ WITHHELD" — the quarantine state,
whose copy says we suppressed our own price because a leg is poisoned. Nothing
was poisoned. The real reason was the paywall, and the two must never share a
face: one says our data is untrustworthy, the other says you don't have this
tier.

Now forwarded, with a test asserting both the separation in deriveValueState
and the forwarding at the call site.

Tests 3559 passed / 291 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 19:56:21 -04:00
builtbykev aaa41134d4 Close the second leak path: /api/hero-prop bypassed the snapshot gate
The landing hero reads the snapshot from Redis DIRECTLY via heroPropService,
so it never passed through routes/snapshot.js and was still serving model_odds,
ev_pct, value and takeable to anonymous visitors after the first fix. Same
strip, same tier resolution, same private-cache rule for authenticated callers;
the Next proxy now forwards the bearer token.

PRODUCT CONSEQUENCE, FLAGGED RATHER THAN BURIED: the landing hero is served to
anonymous visitors, so it now renders BOOK and FAIR with the model leg LOCKED
instead of the full triplet it showed this morning. That follows the stated
free-tier rule exactly, but it does trade a strong marketing moment (VALUE
+21.1% VS FAIR on the shop window) for consistency of the gate. Reversing is
one line — add 'model_price' to the free tier in src/config/tiers.js, or
special-case the hero route — and is a product call, not a correctness one.

Tests 3557 passed / 291 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 19:52:00 -04:00
builtbykev fbcb00b7b1 Close the public model-price leak; wire the read card's price layer
PHASE 0.5 GATE — the three checks, and one correction.

`fairLine` does not exist. Zero hits across src/ and web/src. Option A as
written had no referent, but it resolves better than feared: `fair_odds` is
already a real de-vigged American price on every graded snapshot row, so there
is nothing to derive.

  Gate 1 (is it a price): PASS. fair_odds is American odds from
  impliedProbToAmerican inside devigTwoWay; fair_prob is the probability. Both
  distinct from `line`, the stat threshold.

  Gate 2 (numeric match): PASS, 8/8 exact. Recomputed fair_odds and fair_prob
  independently from the stored raw over/under prices; every value matched the
  stored one to the integer and to 3dp. Same de-vig, same numbers the component
  was proven against.

  Gate 3 (poison independence): PASS, and proven on the quarantined cohort
  itself. devigTwoWay's inputs are (over_odds, under_odds) — market prices
  only, no model term is reachable. The 8 rows recomputed above are all
  wrong_opponent_grade rows, and their fair prices reproduce exactly from the
  market. The poison is in the grade, not the price. Quarantine therefore
  suppresses the MODEL leg only; the fair leg stands, as designed.

THE LEAK WAS REAL AND ALREADY LIVE. GET /api/snapshot/:sport is public and
unauthenticated, and it was serving model_odds, p_win, ev_pct, value and
takeable to anonymous callers on every graded row — 25 of 25 on the live wnba
board. The Session-66 gate on /api/analyze was bypassed entirely by this
endpoint.

The strip covers more than model_odds, because model_odds is not the only way
to read the model price: p_win IS the price in another base, and ev_pct is
INVERTIBLE — ev is a function of p_win and book_odds, and book_odds is public,
so leaving ev behind hands the price over. All five model-derived fields go.
book_odds, fair_odds, fair_prob, overround and devig_method stay on every tier:
the fair leg is never the paywall. Rows that keep a book+fair pair are stamped
model_price_locked so a gated price is never mistaken for a missing one.

Tier comes from resolveTierFromRequest, which reads a bearer token when one is
present and otherwise returns 'free'. It FAILS CLOSED on every error path, so a
resolution failure can only ever withhold the price. The response now varies by
entitlement, so the /:sport handler downgrades Cache-Control to private for
authenticated callers and the browser proxy forwards the bearer token —
otherwise a CDN could hand a paid payload to an anonymous viewer, or every
request would look anonymous and paid users would lose the leg.

READ CARD — a manual scan carries no market. The request is {player, stat,
line, direction}, so the engine has no over/under prices to de-vig and
book_odds/fair_odds are legitimately absent from its response; that is why the
triplet was hidden there. lookupSnapshotPrices recovers them from the
pre-graded snapshot via the same cache-only read this route already performs
for locked odds and team. The join is exact on player + stat + line + side
(fair_odds is side-specific), and returns nothing unless book and fair are BOTH
present — a user-chosen line the board never graded has no market attached, so
the triplet stays hidden rather than borrowing another line's price.

FAIR-LEG ABSENCE, measured before shipping: 636 graded rows, 636 with book,
636 with fair, 0 one-sided. Absence rate 0.0%. The hero number is not a
sometimes-number on current data.

Tests 3556 passed / 291 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 19:47:42 -04:00
builtbykev 16697a4b90 STATE: Session 66 — price-layer tokens + the triplet, with induction proof
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
2026-07-20 18:33:02 -04:00
builtbykev 8cf9cbfc26 Price-layer token foundation + the price triplet, built natively on it
PHASE 0 finding, reported before building: the token layer this order asked
me to establish ALREADY EXISTS and already matches HANDOFF.md exactly.
web/src/app/globals.css :root carries the design's surfaces, borders, text
ramp, fonts and grade colours byte-for-byte (aligned 2026-07-16), and
lib/colorContract.js already encodes the green-is-edge-only and
glow-is-A-tier-only laws with a test enforcing them. The stack is Tailwind v4
CSS-first (no config file) with components styled by inline style={{}} reading
var(--x) — 1,916 such reads — so CSS custom properties are the only vehicle
the stack natively consumes. Creating a second parallel layer would have meant
two competing sources of truth, so this EXTENDS the existing one.

PHASE 1 — additive only. globals.css gains one colour the system did not have,
the priced-out blue (#8fb2de + tints), plus a tokenized A-tier glow and the
fair-leg tints. The block writes the LAWS into the token layer itself — green
= takeable edge only, glow = A-tier only, amber = caution + the fair leg, red
= miss/negative only, blue = edge priced out, JetBrains Mono = all data — and
a test asserts every newly-declared name is new (zero collisions, zero
overrides). No existing hardcoded style was touched and no live surface was
migrated: the diff over existing files is 149 insertions, 0 deletions.

PHASE 2 — lib/valueState.js is the single verdict function; the component
renders what it returns and never re-derives one. VALUE fires only on
ev >= 2 AND a takeable price, mirroring src/config/valueEngine.js with a test
that cross-checks both files and fails on drift. Five states: VALUE (green),
EDGE-NOT-TAKEABLE (blue), NO EDGE (grey, stated at full voice), QUARANTINE
(model leg withheld, book+fair stand), REFUSAL (nothing rendered). Free tier
is gated at the wire — tierGating strips model_odds and sets
model_price_locked, so the lock is real rather than a blur over data already
sent; book and fair pass through on every tier because the fair leg is never
the paywall. Wired into the landing hero (data was already on /api/hero-prop)
and the read card, where the projection block reads first and the triplet sits
beside it, not in place of it. Ledger and public profile are out of scope —
no fair-odds columns exist there.

PHASE 3 — induced all six states in a real browser and read back computed
styles, not just markup. Green resolves on VALUE alone: rgb(0,212,160) on the
model leg and verdict; the +11.7%-EV-at-210 row renders rgb(143,178,222) blue
and a white model leg; quarantine shows MODEL "—" with book and fair intact;
refusal renders no legs at all; free tier renders a lock bar with book -120 and
fair -104 still honest. Landing hero on live data: book -153, fair -129, model
-343, VALUE +21.1% vs fair at +28.1% EV. At a real 390px column the three legs
hold at 117px each with no horizontal overflow and fair no smaller than its
neighbours.

Induction caught a real bug that markup review would not have: the "VS FAIR"
figure compared BOOK to fair, printing "VALUE · -6.5% VS FAIR" — a
contradiction on screen. The design's own two worked examples pin the formula
as MODEL minus FAIR in implied-probability percentage points; modelVsFair now
reproduces both exactly (+2.9 and -1.8) and a test locks them. The figure is
shown only when its sign agrees with the verdict, so a row that clears the EV
bar on the book price while our price sits level with fair leads with the EV
instead of a number that reads as a contradiction.

Tests 3539 passed / 290 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 18:22:00 -04:00
builtbykev f549422758 Design bundle: Price Triplet (ACT 01) + Offseason/Intelligence + 83 glyphs
Drops the updated claude.ai/design bundle into specs/design-reference/ as the
build reference. New since the Jul-18 export: Vyndr Price Triplet.dc.html (the
reality-corrected ACT 01 — five honesty states incl. EDGE-NOT-TAKEABLE, and the
projection-then-price read-card hierarchy), Vyndr Offseason.dc.html,
Vyndr Intelligence.dc.html, PNG export masters, and 83 archetype glyphs
(74 display + 9 classifier-legacy).

Reference material only. No product code touched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
2026-07-20 17:53:23 -04:00
builtbykev 38ee83d0be STATE: Session 65 — CLV un-claim + FORM un-fabricate, with live fingerprint
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
2026-07-20 17:09:01 -04:00
builtbykev f5156dd16d Un-claim CLV on the public profile; un-fabricate player-page FORM
Two Truth-Law fixes found by auditing the product logged-out.

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
2026-07-20 17:05:04 -04:00
builtbykev ca9ca34cbb Remove legacy C4 CLV from FIVE surfaces; install directional badge on ledger
TRUTH FIX FIRST. row.clv/clv_result derive from the overwritable
closing_line — 637/699 rows had closing == locked — so of the 578 rendered
chips, 522 "flat"s encoded a CAPTURE FAILURE as a held line. That number
was live on FIVE surfaces, not the one the order assumed:

  1. ledger card            ClvChip@319            (authed)
  2. public profile /u/     duplicate chip@260     (PUBLIC, shareable)
  3. dashboard              "· CLV BEAT"@508       (authed)
  4. board / Slate          "CLV BEAT/FADED"@422   (FREE surface)
  5. board / Slate          "✓ HIT · CLV BEAT"@517 (FREE surface)

Removing it from the ledger alone would have left the lie live on three
surfaces including two public ones, defeating the stated PRIMARY GOAL, so
the removal covers all five. That is a deliberate extension beyond the
"scoped to ledger card" guardrail and is flagged as such — the guardrail
protected against feature creep, and this is the same defect at four more
addresses.

DIRECTIONAL BADGE installed in the old ledger slot. Three time states now
read distinctly on the row: entry (locked_odds, at-grade) · close (badge,
at-close) · outcome (hit/miss, final). The RESULT stays the row hero.
The PUBLIC profile deliberately gets NO badge — it never receives dclv
data (Analyst+Desk, server-gated), so that surface now shows the settled
result alone.

HONEST ABSENCE, tested: the 578 formerly-chipped rows now render NOTHING —
not a "flat", which is the old chip's lie in subtler form. Rows settled
before capture existed will never get dclv, and permanent silence is the
correct output. All six states verified in place; a 40-row dense ledger
stays legible (27 badges, max 40 chars, one line, consistent slot after
OutcomeChip); no CLV sort or filter exists.

FIELD REMOVAL DEFERRED as a separate scoped cleanup: /api/ledger/mine and
/api/profiles still SEND clv/clv_result, and dashboard/Slate still type
them. Display removal is local and safe; stripping the fields mid-swap
could break a response consumer.

Suite 289/3485 green, build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
2026-07-20 13:58:47 -04:00
builtbykev da8bfdf1db Render directional-CLV badge — Analyst+Desk, server-gated, receipt-bearing
CARD BADGE ONLY. Ticker-CLV explicitly DEFERRED (named, not lost).

PHASE 1 — SERVER-SIDE GATE AT THE DATA LAYER. A Free request never
RECEIVES dclv data: the CLV columns are appended to the SELECT only behind
canAccess(tier,'clv_badge') (new capability, analyst+desk), and responses
are ALSO stripped as defence in depth so a future SELECT change cannot
quietly leak. No CSS/client gate — data that reaches the browser has left
the building. dclv_fair_lock/fair_close are de-vig internals and are never
sent at all.

SURFACE AUDIT, all six channels, each test-locked to contain no CLV:
public profile (share link), snapshot/card feed, ticker feed, share
card/OG, embeddable widget, newsletter. A test also asserts no
ledger_entries read uses select('*') — a star would auto-leak every new
column, which is exactly how a gate becomes theatre.

PHASE 2 — IMMUTABLE ONCE COMPUTED. A settle can re-run (stat correction,
protested game) and a badge that flips positive->negative AFTER a user saw
or screenshotted it is a credibility failure. First computation wins: dclv
is only computed when dclv_computed_at is null, so a re-settle can never
rewrite a shown badge. Same discipline as the locked grade.

PHASE 3 — RENDER, test-first, ABSENCE IS HONEST. unknown / flat / null /
missing-receipt all render NOTHING — no element, no placeholder, no
"pending". Proven on an ALL-NULL board (today: 0 badges) and a MIXED board
(tomorrow: 1 of 4 badged, badge-less cards clean). Binary states only:
  positive -> MOVED TOWARD US  "graded -110 · closed -145"
  negative -> MOVED AWAY       "graded -110 · closed +120"
The RECEIPT is the persuasive part, so a badge with no numbers is
suppressed rather than shown as a bare claim. Negative is neutral context
and NEVER touches the locked grade — no back-door re-grading.

NO aggregate, count or rollup exists by construction: the module exports
exactly {clvBadge, fmtPrice} and a badge payload carries exactly
{tone,label,receipt} — asserted by test, because an on-screen tally would
be the held aggregate claim through the side door.

Build gotcha hit and fixed: clvBadge is CommonJS (allowJs) with no TS
types, so the .tsx needed an explicit cast at the call site — the build
worker exits 1 on type errors even though compilation "succeeds".

Suite 288/3473 green, build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
2026-07-20 13:37:52 -04:00
builtbykev dcdad60896 Directional CLV — per-read signal, side-bound, with a real compute trigger
PER-READ ONLY. No aggregate CLV stat, no CLV marketing un-held.

PHASE 0 FINDING THAT SHAPED THE BUILD: the LOCK end must come from
model_snapshots, NOT ledger_entries. The ledger stores only the graded
side's locked_odds (694 rows, single-side) which CANNOT be de-vigged.
model_snapshots retains BOTH side prices on 520/520 graded rows AND an
already-de-vigged fair_prob on 520/520 — produced by the same
devig.devigTwoWay the close uses, so "same method both ends" holds by
construction rather than by convention.

THE COMPUTE TRIGGER is the SETTLE PASS (ledgerService.settleLedger). At
settle the game is final, so the close has landed and the read is final —
the only moment both ends of the comparison exist. Grade and locked prices
are written hours earlier and the close at lock, so without this trigger a
correct CLV function would simply never populate.

JOIN INHERITS THE PROVEN KEY: (sport, player_key, stat, side, game_date),
WITHOUT line — a close that moved off the graded line is the entire point.
Verified clean earlier: 164 identity groups, zero ambiguity. Rows whose
capture refused (missed/ambiguous/one-sided) are UNKNOWN for CLV, matching
the capture layer's own honesty.

SIGN IS SIDE-BOUND and proven by test before the logic existed — the
badge-inverting trap. Same market move:
  OVER-graded  -> positive  clv +0.0800  (fair .500 -> .580)
  UNDER-graded -> negative  clv -0.0800  (fair .500 -> .420)
exact mirrors. FLAT is a PROBABILITY-space threshold always (1.5pp): a
40-cent price move on a deep favourite reads flat, correctly, because
price space lies about magnitude.

UNKNOWN is a first-class state, never 0 — zero asserts "the market did not
move", which is a claim; a missing close asserts nothing. describe()
returns null for unknown so a badge can never render for it.

migration 030 adds dclv/dclv_state/dclv_fair_lock/dclv_fair_close/
dclv_computed_at as NEW columns rather than reusing the C4 clv fields —
conflating a verified per-read signal with a known-broken one would be the
worst kind of quiet lie.

Caught pre-deploy: the trigger call passed `deps`, which is not in scope in
settleLedger (it uses `opts`) — a ReferenceError at the call site, outside
the helper's try/catch, which broke two settlement suites.

Suite 286/3447 green, build exit 0.

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

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

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

Suite 285/3435 green, build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
2026-07-20 12:49:13 -04:00
builtbykev 55157b3288 Close-capture retry (lock-walled) + MLB opp_rank_stat derivation
PHASE 1 — CLOSE-CAPTURE RETRY, test-first. The closing capture gets a
retry the snapshot path deliberately does not: a snapshot re-runs at the
next slot, but a MISSED CLOSE IS PERMANENT, and the feed flaked once on a
dry induce. Three hard rules, each driven by a test written before the
logic:
  - BOUNDED attempts (default 3) with short backoff so every attempt fits
    inside the window. Never infinite.
  - HARD LOCK-WALL: inside lockWallMinutes of first pitch (or past it) it
    stops and records missed_close. A price captured AT or AFTER lock is
    NOT a close; storing one would fabricate the CLV baseline.
  - NO BOUND LOCK TIME -> refuse immediately, never burn retries on a prop
    whose close cannot be timed.
On exhaustion it records missed_close with NO price — never a stale,
mid-day or post-lock line.

PHASE 3 — MLB opp_rank_stat DERIVED, contract-locked. MLB previously had
no opponent metric at all (ESPN's MLB team endpoint carries none), so
engine1's +/-1.0 opponent factor never fired for the sport carrying most
of our volume. Derived from data we already ingest: statsapi team pitching
splits, all 30 teams in ONE free unauthenticated call.

THE SHARED CONTRACT is documented and TESTED, not assumed: 0-1 scale,
HIGH (>=0.70) = WEAK opponent, LOW (<=0.30) = TOUGH — identical to WNBA's
live semantics. Polarity is the highest-risk part: backwards polarity does
not fail loudly, it silently adjusts every MLB grade the wrong way. A test
asserts MLB polarity EQUALS WNBA polarity using engine1's own thresholds.

PROVEN AGAINST THE LIVE FEED:
  Colorado Rockies  BAA .286 -> opp_rank 0.983  (weak, fires weak_opponent)
  LA Dodgers        BAA .215 -> opp_rank 0.017  (tough, fires top_opponent)
  POLARITY HOLDS: true

HONEST NULLS, tested: thin league baseline, thin opponent sample, unmapped
stat, unknown opponent, or a missing field all return NULL with a reason —
we are FIXING a silent null, so it is never replaced by a confident guess
off three games. opponentStrengthHealth pages on an empty source AND on
derived-null-for-a-sport-we-expect-to-derive.

Suite 285/3435 green, build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
2026-07-20 12:27:47 -04:00
builtbykev 2321267346 STATE: harness armed + closing capture started; reverted my own non-close writes 2026-07-20 12:09:12 -04:00
builtbykev 77a58e4113 Arm harness on our scheduler + start closing-line capture (capture only)
PART A — HARNESS ARMED ON OUR OWN INFRA. snapshotScheduler now runs the
nightly backtest at HARNESS_HOUR_UTC (default 14), appends to
harness_results, and pages via opsWatch.harnessStaleAlarm — a validator
that stops running looks exactly like one that keeps passing. No external
dependency: the join is plain SQL through the service client and the
harness is a pure function. POST /api/internal/harness/run induces the
same code path on demand, because a scheduled mechanism is verified by
inducing it, never by waiting for a slot.

PART B PHASE 0 — GATE PASSED for what is capturable:
- C4 diagnosed: closing_line is ONE overwritable field with no timestamp
  and no provenance. captureClosing writes the current line and, when a
  prop fails to match, silently leaves the earlier value (= the lock) in
  place — so "captured a real close" is indistinguishable from "never
  updated". It is 92% equal, not 100%: 56 rows DID record movement, so
  the defect is provenance, not the value.
- Feeds: normalized props already carry BOTH raw side prices per book,
  with game_time, and the intraday refresh polls every ~20 min during
  slate hours — so the last observable pre-lock line is available.
- SHARP close: pinnacle is in ALLOWED_BOOKS -> a no-vig reference is
  capturable ("beat the market").
- ODAWA: NOT capturable. 'odawa' exists only as a UI preference option in
  onboarding/settings; it is in no adapter, no ALLOWED_BOOKS, no feed. An
  un-capturable source is a finding, not a gap to paper over.
- JOIN: must drop `line` from the natural key, because a close that MOVED
  off the graded line is the entire point of CLV. Verified safe — all 164
  current identity groups have exactly ONE line per
  (sport, player_key, stat, side, game_date). Zero ambiguity.

PART B PHASE 1 — CAPTURE ONLY, built test-first. The refusal was proven
before the capture logic existed: unbound game_time, doubleheader
ambiguity, a missed pre-lock window, or a one-sided price all record
missed_reason with NO price. A stale or mid-day line substituted for a
close would manufacture a CLV proof from a number that was never the
close.

migration 029 closing_captures: append-only, never overwritten (that is
the provenance C4 lacked), BOTH raw side prices so the existing de-vig
engine can compute a fair closing probability later, sharp vs book line
types kept distinct. Wired into the intraday refresh with a capture-rate
alarm — a missed close is unrecoverable.

NO CLV metric built, as ordered. This starts the clock.

Suite 284/3417 green, build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
2026-07-20 12:05:16 -04:00