Commit Graph

466 Commits

Author SHA1 Message Date
builtbykev 6b17f79367 Per-archetype re-audit: no slot reaches 500, and the replication unit
decided everything

The premise does not hold. prove-hit-factors.js has no date filter
anywhere in it and pages the full table -- there was never a window to
widen. Full clean history is 1,266 rows, not 2,715. platoon was not
"proved" last session, it was explicitly held on 4.5%-median-contaminated
season-to-date splits, and pitcher_contact_profile was demoted. The
proven set going in was one factor, not three.

STEP 1: no archetype slot reaches n>=500 on full history. Best is BOMBER
at 408, and BOMBER is the most common archetype on the board. GHOST 173,
BRUSH 64, DRIVER 43, CATALYST 16. These are confirmed genuinely short,
not artifacts.

STEP 2 is where the real finding is. park_hits initially PROVED at 619
rows across 45 games -- but those games only ever visited 14 distinct
park values. A park effect is replicated across parks, and unmodelled
park heterogeneity is confounded with the thing being estimated. Each
factor is now clustered on the coarser of the game and the entity its
treatment rides on.

That flipped two verdicts and confirms Kev's causal-correctness thesis
from a new direction: defense_by_direction has 442 hitter-team units of
replication where crude team defense has 26. The correct atom is not just
more accurate, it is the only one measurable at all. park_hits (14) and
defense (26) can never be validated however long the ledger runs -- the
same ceiling as park dimensions, reached independently.

Also fixed a bar I got wrong last session: I transplanted the 500-row
floor onto clusters, which refused a factor with 1,059 rows over 85 games
while answering neither question. Two floors now -- rows>=500 for a stable
estimate, clusters>=40 for a trustworthy interval. Not a lowered bar:
park_hits and defense are still refused.

PROVEN: defense_by_direction only, pooled, [-0.0054,-0.0012] at 99 tests.
It stays POOLED-ONLY -- no per-archetype reasoning wired, nothing
grandfathered. The card must not say "GHOST: defence matchup strong"
because we have not earned that sentence. The predicted fingerprint did
not appear either: BOMBER -0.0036 vs GHOST -0.0024, the opposite
direction, both noise-dominated. Recorded so it is not claimed later.

RESCALE: NOT READY. One proven factor worth -0.0031 Brier. Rescaling on
that is relabelling.

Counter and frozen clusters byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-05 19:58:37 -04:00
builtbykev b818626870 BUILD-STATE: under-querying vs out-of-data session
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-05 19:30:56 -04:00
builtbykev 7b85934dc3 Under-querying vs out of data: the answer depends on the unit
The platoon test's n=452 described how much of the JOIN survived, not how
much data exists. There are 1,266 clean settled hits rows and zero
quarantined ones. platoon_splits had been ingested from tonight's lineups
only (315 players), so any hitter who settled a prop without appearing in
an ingest-day lineup was silently absent from every test.

Backfilled all 380 hitters (81 fetched, 0 unresolved). Re-ran on 1,059
rows, up from 452.

THE DEMOTION IS THE HEADLINE. pitcher_contact_profile, the strongest
proven factor in the programme (-0.0064, CI [-0.0113,-0.0014]), roughly
halved to -0.0034 on more than double the sample and its corrected
interval now spans zero. The Bonferroni denominator also rose to 55,
which widens every interval -- but a denominator cannot move a point
estimate, and that halved on its own.

platoon and platoon_severity now clear the bar and are NOT promoted.
Upper bound -0.0001, on season-to-date splits that contain the games they
predict: measured contamination is 4.5% median, 12.4% at p90, 137% worst.
I had assumed ~1%. They stay CANDIDATE pending point-in-time splits.

GAME-LEVEL IS A DIFFERENT PROBLEM. game_context held zero weather rows
ever -- not because the fetcher was wrong (it correctly targets
Open-Meteo's archive) but because ledger_entries keys a game as
mlb:2026-08-03:Away@Home and game_context keys it as mlb:823437. Every
lookup missed and NULL columns read as honest absence. Third occurrence
of that class.

Fixed the join: 96/101 settled games now carry actual archived weather,
park dimensions backfilled 15 -> 30 venues.

But 928 total_bases rows sit on 47 games at 17.6 rows per game. Park and
weather assign one value per game, so resampling rows would have
manufactured a pass. factorGate now resamples clusters when rows carry
one and judges sample against effective_n; unclustered rows keep the
original path byte-for-byte. Verdict: 47 clusters < 500, and the point
estimate is +0.0011 -- worse, not merely unproven.

Weather needs ~57 more days. Park dimensions need never: there are 30
ballparks in MLB, so a venue-constant factor can never reach 500
independent units. That bar was built for player-level factors and does
not transfer.

Wind is refused. We have speed and bearing for all 96 games; we lack park
orientation, and 220 degrees is blowing out at one park and in at
another. Using speed alone would assert an effect while discarding the
sign that decides what it is.

Counter and frozen clusters untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-05 19:30:17 -04:00
builtbykev 6452926732 Retain raw weather, and record platoon severity 48 rows short
Two fixes in the weather path, and the second was hiding behind the first. The
scalar weather_mod cannot express a hit-TYPE conversion at all -- wind out and
warm turning fly balls into extra bases, and cold heavy air turning them into
outs, collapse to the same number once multiplied -- so the raw temperature,
wind speed and wind direction are now retained alongside it.

And the old guard only kept the environment when the multiplier was not 1,
which silently discarded the forecast for every ordinary night. That is the
majority of games, and precisely the rows a hit-type model would need in order
to learn what ordinary looks like.

Platoon severity is built and measured at n=452, which is 48 rows short of the
gate: CANDIDATE_PENDING, neither proven nor theatre. It moves less than flat
platoon (0.021 against 0.026), consistent with the pattern, and its Brier point
estimate is favourable but the corrected interval still spans zero.

Worth naming: the refusal costs sample, and that is the design working. Flat
platoon scores 741 rows because it will happily apply a boost to anyone;
severity scores 452 because the other 289 are hitters whose split we cannot
actually read at 60 plate appearances on the short side. Buying those rows back
by shrinking instead of refusing would have produced a number indistinguishable
from a measured league-average split, which is a different claim from the one
the data supports.

Park dimensions are ingested and verified in production across fifteen venues,
joined by the venue the game is actually at rather than inferred from the home
team -- neutral-site and international games break that assumption without
surfacing an error.

The park-and-weather-to-hit-type atom is NOT built. Its inputs landed this
session and carry a single as_of date, so testing it on total_bases would be
scoring games with inputs that postdate them. Building it now would produce
something plausible rather than something proven.

Proven factors for hits remain pitcher_contact_profile and
defense_by_direction. 4,307 tests green (344 suites); web build exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-04 20:40:56 -04:00
builtbykev de0077f6f9 Causally-correct platoon + park-dimensions ingest
Applying the method that worked for defence to the two factors the code flagged
as still crude.

PLATOON. The flat version is 'lefty versus righty, add a boost', and it failed
the two-part gate for the same reason team-average defence did: it is not the
unit the causal story runs through. The advantage is only worth what THIS
hitter's split is actually worth -- measured on a real hitter, .284 against
left-handed pitching versus .221 against right-handed, a 63-point split, where
the flat factor applied the same six percent to him and to a hitter with none.

Most of the work is sample discipline, and the second rule matters more than
the first. Severity shrinks toward the league split weighted by the SMALLER
side's plate appearances, because a 500-against-40 split is a 40-PA read. And
below a floor it REFUSES outright rather than shrinking, because a
heavily-shrunk severity is indistinguishable from a measured league-average one
and those are different claims -- without the refusal the atom would quietly
assert a league-typical split about every September call-up in the league.

Switch hitters turn out to be the easy case misread as the hard one. He bats
opposite by choice so the direction is never in doubt, but the per-side value of
his swing is a different question and one this sample cannot answer, so he is
unreadable rather than credited with an automatic edge.

PARK DIMENSIONS. Free from statsapi's venue endpoint, which carries fence
distances, roof, turf and elevation outright -- Wrigley returns 355 down the
left line, 400 to centre, 353 to right, at 595 feet. parkFactors holds run
COEFFICIENTS, which structurally cannot express a park that turns outs into hits
without scoring, and that is why the crude park factor failed.

The park join is by the venue the game is ACTUALLY at, carried from the schedule
feed, never inferred from the home team -- neutral-site and international games
break that assumption and they break it silently. A venue with no geometry at
all is absent rather than a park with zero dimensions.

Both tables dated in the primary key. Venue geometry changes rarely but it does
change, and by now that is the default rather than a lesson.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-04 20:30:57 -04:00
builtbykev 20c45cbcd1 The causally-correct defence atom proves where the crude one did not
Kev's insight holds, and the data says so cleanly. defense_by_direction PROVES
on hits -- n=528, Brier -0.0034, interval [-0.0059, -0.0009] at the 99.9% level
the cumulative correction now demands -- while team-average defence remains not
proven, its interval still spanning zero. Same signal, same rows, different
unit.

The detail worth keeping is that the causally-correct atom moves the number
LESS THAN HALF as much as the crude one, 0.013 against 0.030, and is the one
that is reliably right. The team average was moving more and knowing less. Big
movement is not evidence of a good factor; it is frequently the tell.

Both halves turned out to be free, as the order expected. Savant's batted-ball
leaderboard carries pull/straight/oppo crossed with ground/air for 609 hitters
-- the statcast leaderboard we already pull does not, it has nineteen columns
and no direction at all -- and the OAA feed already carries each fielder's
position, so per-position defence is a regrouping of last week's ingest rather
than a new source. Verified in production: 609 spray profiles, 31 teams.

Handedness is what joins them and getting it backwards would have been
invisible. Pull for a right-handed hitter is the left side; for a left-handed
hitter it is the right side. A model that ignored `bats` would send half the
league's grounders to the wrong infielders and still look like it was reading
defence, and nothing downstream would have caught it. Switch hitters bat
opposite the pitcher, which this does not resolve, so they are unreadable
rather than guessed.

Unmeasured zones are renormalised away rather than contributing a zero, since a
zero asserts an exactly-average fielder standing there, and coverage states
honestly what share of a hitter's contact we could actually read.

ATOM 2 is input-blocked rather than sample-blocked, and the distinction matters
because waiting will not fix it. The weather free-source check passes --
Open-Meteo is already wired and exposes temperature, wind speed, wind direction
and precipitation -- but those raw fields are collapsed into a single scalar
modifier and wx_forecast is empty on all 1,119 settled rows. Park DIMENSIONS
are not ingested at all; parkFactors holds coefficients, not wall heights or
fence distances. A park-and-weather-to-hit-type conversion needs both, so it is
scoped rather than half-built: retaining the raw weather fields is the cheap
half, dimensions are the missing one.

Proven factors for hits are now pitcher_contact_profile and
defense_by_direction, both pooled; every per-archetype slot remains
sample-blocked.

4,297 tests green (342 suites); web build exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-04 20:01:09 -04:00
builtbykev 405180e791 Build the causally-correct defence atom: spray x positional OAA
Team-average defence failed the two-part gate for hits, and the reason was the
unit rather than the signal. A left-handed pull-ground hitter meets the first
baseman and the second baseman and almost nobody else, so a team total averages
in five fielders who will never touch his ball.

Both halves were already free on the host we pull from. Statcast publishes
spray x trajectory per hitter -- pull/straight/oppo crossed with ground/air,
608 hitters -- and the OAA feed already carries each fielder's position, so
per-position defence is a regrouping of data ingested last week rather than a
new source. Zero new sourcing, as the order expected.

Handedness is what joins them and getting it backwards would be invisible: pull
for a right-handed hitter is the left side, pull for a left-handed hitter is the
right side, so a model ignoring bats would send half the league's grounders to
the wrong infielders and still look like it was reading defence. A switch hitter
bats opposite the pitcher, which this does not resolve, so he is unreadable
rather than guessed.

Two properties the crude version could not express, both locked by test: two
teams with the SAME total defence read differently for a pull hitter, and a
ground-ball hitter and an air hitter read the same team in opposite directions.

Unmeasured zones are renormalised away rather than contributing a zero, which
would assert an exactly-average fielder standing there, and  states
honestly what share of a hitter's contact we could actually read. Nothing
readable at all returns null, so the caller falls back to the base rate instead
of to an invented 1.0 that looks measured.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-04 19:53:07 -04:00
builtbykev a9ee55550b Build the two-part factor gate: one factor proves, and zero are theatre
The question was whether the hit grade reads tonight's game or just says he is
due. Answering it needed a gate that correlation cannot provide, because
correlation cannot separate the two ways a factor looks alive: it reads the
game, or it moves the number and reads nothing. The second is what a product
ships by accident -- arch-v1 moved 76% of rows by 2.5 points, changed
resolution by 0.0000, and was live for months, and no user could have told.

So a factor must now clear both conditions: move the prediction off the
player's own leave-one-out base rate, AND improve out-of-sample Brier. Brier
rather than correlation, because correlation asks whether the ordering improved
and this asks whether the NUMBER got closer to what happened -- and for a graded
probability the number is the product.

The correction applies to the interval itself, which turned out to matter more
than expected. A plain 95% CI is the right bar for one test; at fifty
cumulative tests roughly two or three intervals exclude zero by chance alone.
Widening to 1 - 0.05/tests, currently 99.9%, flipped both defence and platoon
out of "proves". A 95% interval would have shipped two unproven factors into
the grade, with reasoning text explaining them to users.

That forced a distinction I had initially collapsed. Defence and platoon have
FAVOURABLE point estimates whose corrected intervals merely span zero, and
calling that THEATER would repeat the error this codebase keeps correcting:
insufficient evidence is not evidence of absence. THEATER is now reserved for
its one real meaning -- moves the number, reads nothing -- and
NOT_PROVEN_AT_CORRECTED_BAR names a real candidate held to a bar that rises with
every hypothesis the programme tests.

Result on 741 settled hits rows: pitcher_contact_profile PROVES, improving
Brier by 0.0066 with a 99.9% interval of [-0.0114, -0.0016]. Defence (-0.0043)
and platoon (-0.0039) are not proven at the corrected bar. Park is
sample-blocked at n=405. Zero factors are theatre, which is the genuinely good
news: nothing decorative is being wired. Per-archetype every slot is
sample-blocked (BOMBER 252-294, GHOST 67-125).

Two spec gaps worth recording. The approach identities the order names -- SPRAY,
DAMAGE-DEALER, COUNT-WORKER -- do not exist in the registry; the MLB batter
archetypes are BOMBER, GHOST, TORCH, BRUSH, DRIVER, FLEX, ALPHA, HYBRID and
CATALYST. And parkFactors maps hits to run_base, so there is no hits-specific
park factor at all: a park that turns outs into hits without producing runs is
invisible to the input we have.

The grade rescale is NOT run. It was explicitly gated on the factor proving,
and one pooled factor worth 0.0066 of Brier is not a factor-informed
distribution -- rescaling on it would dress a base-rate model as a matchup
model, which is the exact thing this gate was built to prevent.

4,286 tests green (340 suites); web build exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-04 19:30:18 -04:00
builtbykev 4d1803f6d7 Calibrate hits point-in-time: partial pass, and an honest ceiling of 0.667
Fitted the isotonic map on game_date < 2026-08-02 (n=589) and evaluated it on
everything from that date forward (n=383). The map never saw the evaluation
rows, which is the only thing that makes the result mean anything -- fitting
and evaluating on the same rows always looks perfectly calibrated, because the
map is reciting the answers it was built from.

It works, on most of the distribution. Held-out after correction: 0.477 comes
back 0.506, 0.587 comes back 0.580, 0.667 comes back 0.603 -- against raw
errors of +0.191, +0.279 and +0.246 in the same bins. Ordering survived, and
that was verified pairwise rather than assumed, because a broken map would
silently destroy the one thing this model does well.

Two findings matter more than the pass.

First, the honest ceiling is 0.667. Once the numbers are truthful this model
has no 80%-plus hit reads at all -- the top of its range was miscalibration,
not confidence. A four-leg ticket at the ceiling is 0.198, where the raw
numbers implied 0.686. The high-floor parlay is a two-thirds-per-leg
proposition, and that is the number to say out loud.

Second, calibration is certified BY BAND rather than by a blanket flag.
Held-out error was -0.029 and +0.007 through the middle but -0.167 at the
bottom and +0.063 at the top: the model is trustworthy over most of its mass
and untrustworthy at both edges. A single true/false would either throw away
the 72% that works or ship the edges that do not. Only a probability inside a
certified band is marked stackable, and that flag is what chainAcross requires
before it will compound anything. The certified band is 0.40 to 0.60, n=276.

A methodological catch on the way: my first pass condition demanded honest bins
at 0.70 and above -- but honest calibration REMOVES those bins, since the
ceiling drops to 0.667. The gate would have failed the repair for succeeding.
It now tests the highest remaining band instead of a fixed threshold.

Wired forward with the same discipline: calibrationService fits strictly before
today, splits by time rather than at random, and returns null on thin history
so that "no calibrator" means nothing is stackable rather than "trust the raw
numbers". p_win is never mutated -- the calibrated value rides beside it as
p_win_calibrated, because a calibration map is a correction to a forecast, not
a different forecast, and the counter stays byte-identical.

4,275 tests green (339 suites); web build exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-04 17:51:15 -04:00
builtbykev 9c5b968351 chaining-v1: the portable chain, and the gate that blocks the parlay surface
The order's own prerequisite for the hit-parlay surface was to verify the hit
probability is calibrated. It is not, and the failure is exactly the shape that
destroys a parlay.

Measured on 972 settled hits props: the model is monotonically over-confident
at the top and flat above 0.70. Predicted 0.911 comes back 0.630. Predicted
0.844 comes back 0.630. Predicted 0.747 comes back 0.605. There is no
discrimination at all in the range a parlay is built from, and the error runs
in the flattering direction. Four "91%" legs are 0.686 by the model and 0.157
in fact -- a 4.4x overstatement that compounds with every leg added.

Single props survive a calibration error of that size. A parlay multiplies it.
So chainAcross REFUSES to compound atoms not marked calibrated, and refusing is
the feature rather than a limitation: a ticket built on these numbers would be
confidently wrong in the direction the user pays for.

calibration.js provides the reliability table, the gate (tolerance 0.05,
weighted to the high end because that is where tickets live) and an isotonic
fit. Isotonic is the honest repair here because it is monotone: the model's
ordering survives untouched while the numbers move to what actually happened.
The fitted map says 0.65 -> 0.594, 0.85 -> 0.639, 0.91 -> 0.639.

chain.js is the portable core -- base events plus context, through a chain
function, into a PLUGGABLE aggregator: across players for a compound ticket, up
to the team for expected scoring. The sport-specific parts are inputs rather
than code paths, so basketball plugs in as content. The archetype
redistribution hook is there now, dormant in baseball because a nine-run lead
does not change who bats next, and live in basketball where a blowout fades the
star and feeds the bench.

Two judgement calls worth naming. Treating same-game legs as independent errs
in the FLATTERING direction, since they share pitcher, park and weather -- so
correlation shifts the compound toward the weakest leg, bounded, and is labelled
an approximation rather than a joint distribution. And market divergence does
NOT downgrade confidence: it flags a contested script whose props are either the
best or the worst on the board, and which one is unknown until settled.
Internal inconsistency does downgrade it, because per-entity reads failing to
sum to the team read means one of them is wrong and we do not know which.

Not built: the independent game-script projection. It needs proven team-level
atoms and out-of-sample validation against actual margins, and no atom has
passed the gate yet. Building it now would produce something plausible rather
than something proven, which is the failure mode this whole programme exists to
avoid.

4,269 tests green (339 suites); web build exit 0; counter and frozen clusters
byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-04 17:17:14 -04:00
builtbykev a80a775fa0 Record the lineup-context ingest: prod-verified, 153 lineups / 149 opportunity
Verified in production via the new on-demand endpoint: 153 batting-order rows
across 10 games (orders 1-9), and 149 hitter-opportunity rows with RISP shares
ranging 0.170 to 0.528.

The data passes its own coherence check on arrival: the highest RISP-share
hitters all bat fourth and fifth, which is exactly where the mechanism says the
RBI opportunity lives. Nothing was fitted to produce that -- it is the two
tables joining and agreeing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-04 16:19:18 -04:00
builtbykev 7f69fef14c Add an on-demand endpoint for the lineup-context ingest
The first prod run wrote zero rows while the parser demonstrably works locally
(144 rows, 10 games with lineups posted), so the zero was wiring rather than
absence -- but diagnosing that required a full snapshot, which now takes about
three minutes and 524s at the edge.

Same reasoning as the statcast refresh endpoint: a job is proven by running it
and reading the result, never by waiting for the slot it rides in. This makes
the ingest verifiable in seconds, so 'zero rows' can be told apart from 'no
lineups posted yet' immediately -- which is the exact confusion the defence
ingest hit when a doubled path 404'd and read as 'Statcast has no fielding
data'.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-04 16:13:53 -04:00
builtbykev 08276c0880 Ingest lineup + baserunner context: the input RBI and runs always needed
RBI is power TIMES opportunity. The same swing drives in one run or three
depending on who is on base, and a hitter batting with the bases empty cannot
drive anyone in however hard he hits it. Every context-free model of RBI here
has failed, and the failure kept being read as 'skill inputs don't work for
RBI' when the truth was that we were modelling half the stat.

Both halves are free from statsapi.mlb.com, which we already call for game
logs, schedules and probable pitchers. No new provider, no key, no quota.

RUNG 1, batting order: schedule?hydrate=lineups returns homePlayers and
awayPlayers as ORDERED arrays of nine, and the order IS the batting order --
index 0 is the leadoff hitter. That single fact gives CATALYST its identity
and supplies lineup-position context for every context-dependent stat.

RUNG 2 turned out cheap, which the cheapest-first rule did not expect. It
looked like it would need play-by-play reconstruction across a season; statsapi
serves situational splits directly, so 'how often does this hitter bat with
runners to drive in' is ONE call per player rather than one per game. Measured
on a real hitter: 87 plate appearances with runners in scoring position
producing 25 RBI, against 302 with the bases empty producing 17. That ratio is
the opportunity half of the stat and it is the thing no amount of exit velocity
can tell you.

Both tables are dated in the primary key. statcast_aggregates was built
upsert-in-place and that silently made every backtest leak the games it was
predicting; a lineup is worse still, because it is a PRE-GAME fact that changes
by the hour, so an in-place table would overwrite what we knew at grade time
with what turned out to be true.

Absent stays absent throughout: no lineup posted is an empty slate rather than
a guessed order, a short lineup records fewer slots rather than padding to
nine, and a hitter with no splits is null rather than a zero RISP share --
which would assert he never bats with runners on, a strong claim and usually a
false one.

Wired into the snapshot best-effort, so a context failure can never break the
pipeline it rides in. The three pre-registered theories are now marked
input-ready rather than input-blocked: DRIVER's power x runners-on and power x
lineup-position, and CATALYST's speed x on-base x power-behind. They are
sample-blocked from here, and the proofs run under native cumulative
correction as sample accumulates -- ingesting is not proving.

Counter and frozen clusters byte-identical. 4,250 tests green (338 suites);
web build exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-04 16:07:13 -04:00
builtbykev 52a3142c5f Split infield defence out, and pre-register the DRIVER/CATALYST/SINKER batch
Every slot in this batch is far below the gate -- DRIVER x hits 39, CATALYST
under 22, SINKER not yet gradeable at all -- so per the order's own sample rule
these are CANDIDATE-pending-accumulation, not tested-and-failed. Testing them
now would produce noise and burn cumulative-correction budget on it.

What IS deliverable is the input SINKER's theory needs, and it turned out to be
free. The OAA feed already carries each fielder's position, so infield-only
defence is derivable from data ingested yesterday: 1B/2B/3B/SS summed
separately from the outfield. Team-total OAA is the wrong unit for a
ground-ball pitcher -- he lives on the infield converting grounders and his
outfielders are close to irrelevant to him, so averaging them in dilutes
exactly the signal. On a real team the split shows a +15 infield inside a +2
team total, which is the dilution made visible. Under three measured fielders
in a unit is absent rather than zero, same rule as everywhere else.

The three theories are now PRE-REGISTERED in the registry with their mechanism
and the skill each would validate, marked CANDIDATE. That is the point of
writing them down before the sample exists: the claim is on the record with a
date and cannot be quietly reshaped into whatever the numbers turn out to
support once they arrive.

Two of them are input-blocked rather than sample-blocked, and the distinction
matters because waiting will not fix them. DRIVER's RBI theory needs
baserunner state and CATALYST's runs theory needs both baserunner state and
batting order; we ingest neither, and player_role_profiles is empty. So RBI
does not unblock on DRIVER -- it unblocks on ingesting lineup context, which
is a sourcing question, not an accumulation one. SINKER is the only one of the
three whose inputs are now ready.

Premise note: no registry re-adjudication demoted anything last session. The
proven set was empty, zero features were demoted, nothing was recalibrated,
and nothing is published -- node scripts/proven-status.js confirms it in one
command.

Counter and frozen clusters byte-identical. 4,238 tests green (337 suites);
web build exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-04 15:28:16 -04:00
builtbykev ff037e40c2 Re-adjudicate: nothing to demote, and close the hole that would have mattered
There is nothing to re-adjudicate. The proven set is empty and always has
been -- verified three ways: proven-status reports EMPTY, validatedSkills()
returns {} for every archetype, and zero conditioning entries have ever
reached PROVEN. The one PROVEN feature is recent_frequency_prior, which is the
incumbent counter itself, proven by the S78 ablation as ~100% of the
champion's resolution. It is the baseline every challenger is measured
against, not a conditioning interaction, and demoting it would leave the model
with nothing to grade from.

A correction to the premise: the cumulative gate did NOT catch a false
positive last session. It caught nothing, because there was nothing in the
proven set to catch. What it did was tighten alpha from 0.0026 to 0.0013
within one session, which demonstrated the mechanism working rather than a
demotion. So steps 3 and 4 -- demote, recalibrate -- are vacuous here, and
readjudicateAll says so plainly rather than glossing a no-op.

But the worry behind the order was well founded, and the audit found the real
exposure: promote() did not require the cumulative denominator. It checked n,
lift and CI, and nothing stopped a future session from testing eight
hypotheses, correcting by eight, and promoting on a p-value that would not
survive the programme's real denominator. That is precisely the hole that
makes a retroactive re-adjudication pass necessary later, so it is closed at
promotion time instead. isSufficient now refuses evidence carrying no
correction, evidence corrected against fewer tests than the cumulative count,
and any p-value that does not clear 0.05 over its own test count. The same
rule guards a PROVEN conditioning entry.

The second audit found two of four analysis scripts still correcting
per-session; pitcher-prove-k and tb-solo-and-interactions now use the
cumulative ledger, so the correction is native on every path.

reAblation.js is the standing second line: pure and injectable, so the
decision rule cannot drift from the gate's, and every verdict records both
p-values and both test counts so a demotion is re-derivable by anyone. A
feature promoted at alpha 0.05/20 can demote on the same p-value once the bar
is 0.05/60 -- correct, because the bar rose only after the programme had more
chances to get lucky. No fresh measurement is PENDING_RETEST and never a
demotion: absence of a re-test is not evidence, and demoting on it would
punish whichever stat happens to be off-season.

Net effect on the proven set is zero. No demotions, no recalibrations, and no
public ledger event -- announcing "recalibrated after re-adjudication" when
nothing changed would itself be a false signal of rigour.

4,238 tests green (337 suites); web build exit 0; counter byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-04 15:13:39 -04:00
builtbykev ece2b9f5f9 Ingest defence, and make Bonferroni cumulative across the programme
Two things shipped that stand regardless of sample.

DEFENCE. Statcast Outs Above Average is free on the host we already pull six
feeds from, so there was nothing to decide. 514 fielders, aggregated to team
level -- the unit a batter's prop actually needs, the defence behind the
pitcher he faces -- and persisted as 31 team rows. Verified in production.
Cubs +56 best, Mariners -29 worst.

Unknown is not zero, and it bites unusually hard here: an OAA of 0 is a REAL
reading meaning exactly average, so coercing absence to 0 would assert that
every unmeasured fielder is league-average, which is the commonest defensive
profile there is. team_defense also carries as_of_date in its primary key from
the first row -- statcast_aggregates was built upsert-in-place and that
silently made every backtest leak the games it predicted, so point-in-time is
available here before it is needed rather than after a wrong answer.

A bug worth recording as a class: BASE already ends in /leaderboard, so the
new feed built a doubled path and 404'd. Because a failing feed degrades to an
empty index by design -- correct, so one broken source cannot fail the whole
pull -- it surfaced as "fielding_oaa: 0 rows", which reads exactly like
"Statcast has no fielding data". Graceful degradation makes a wiring bug look
like an honest absence.

CUMULATIVE CORRECTION. Bonferroni had been applied per session throughout: a
run testing eight features corrected by eight. Across a programme's lifetime
that is wrong in the dangerous direction, because every order gets a fresh
generous alpha and the false-positive rate compounds quietly. Correcting by 8
when sixty have been tried is how a noise result eventually gets recorded as
PROVEN with a p-value to point at. The denominator is now distinct hypotheses
ever tested, persisted, and it moved 19 -> 38 within this session alone, alpha
0.0026 -> 0.0013. Re-tests deliberately do not inflate it: re-asking the same
question on more data is not a new shot on goal, and counting it would punish
the discipline of waiting for sample.

THE MEASUREMENT. The differential the theory predicted is present: defence
correlates with the counter's residual at +0.130 for GHOST, the contact and
speed archetype, and -0.018 for BOMBER, the power archetype. A GHOST's hits
depend on whether anyone can range to the ball; a BOMBER's barrels clear the
defence entirely. So a flat BOMBER result is the theory working rather than
the test failing.

It is not a result. GHOST is n=104 against a 500 bar, with p=0.188 against a
corrected alpha of 0.0013 -- three orders of magnitude short. Both are
recorded as CANDIDATE with their measured lift, tagged contact-skill, so the
re-run at full sample compares against a recorded baseline.

Nothing proved, so nothing was recalibrated and nothing shipped.

4,228 tests green (336 suites); web build exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-03 22:20:30 -04:00
builtbykev 25e36c0257 Fix the doubled /leaderboard path — the fielding feed 404'd silently
BASE already ends in /leaderboard, so the new feed built
.../leaderboard/leaderboard/outs_above_average and 404'd. Because a failing
feed degrades to an EMPTY index by design -- correct behaviour, so one broken
source cannot fail the whole mechanism pull -- it surfaced as 'fielding_oaa: 0
rows' rather than as an error, which reads exactly like 'Statcast has no
fielding data'. Worth noting as a class: graceful degradation makes a wiring
bug look like an honest absence.

Verified: 514 fielders, 31 teams. Cubs +56 OAA, Mariners -29.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-03 22:07:40 -04:00
builtbykev 010a876b3c Ingest free Statcast fielding (OAA) as team defence, dated from day one
Defence was the one conditioning category with no derivable proxy: nothing we
ingest measures fielding, and a team's pitchers' hits-allowed conflates
pitching with defence and would validate the wrong skill. Statcast publishes
Outs Above Average free on the same host as the six feeds already pulled --
verified live at 513 fielders -- so there was nothing to decide.

Added as a seventh feed, indexed per fielder and aggregated to team level,
which is the unit a batter's prop actually needs: the defence behind the
pitcher he faces. Summed OAA is the team's outs converted above average; the
mean rides along because a team with more measured fielders would otherwise
look better merely for being measured more, and a team with under three
measured fielders is absent rather than thin.

Unknown is not zero, and it bites unusually hard here: an OAA of 0 is a REAL
reading meaning exactly average, so coercing absence to 0 would assert that
every unmeasured fielder is league-average -- the most common defensive
profile there is, and a fabricated fact rather than a neutral default.

team_defense carries as_of_date in its primary key from the first row.
statcast_aggregates was built upsert-in-place with a single as_of date, which
silently made every backtest leak the games it was predicting and cost a
session to find; this makes point-in-time available before it is needed
instead of after a wrong answer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-03 22:03:26 -04:00
builtbykev ac1361486e Build the conditioning registry, and a probe so "proven" stops drifting
The order opens with "two proven clusters live". They are not proven -- the
proven set is empty -- and this is the fourth consecutive order to start from
a stronger claim than the measurements support. Correcting that in prose four
times has not worked, so this session adds scripts/proven-status.js, which
recomputes the answer from the ledger: hits LOSES (-0.096, CI excluding zero),
total_bases INCONCLUSIVE (+0.004), strikeouts INCONCLUSIVE (+0.259 at n=57).
It deliberately reports sample readiness separately from recorded verdicts, so
"n>=500" can never again be read as "passed".

A counting error worth recording. The first read of the top-volume archetype
said BOMBER x hits was 641 rows -- gate-ready. It is 287. model_snapshots
holds one row per prop PER SNAPSHOT CYCLE, so joining it to ledger_entries
counts each ledger row once per cycle it appeared in. Deduping on the ledger
row id gives the true figure, and my own status script had the same bug until
it was fixed. That is the difference between running the gate and being short
by 213.

So no archetype x stat combination reaches the gate. BOMBER x hits at 287 is
the closest; pitcher archetypes are untestable at 58 settled strikeout rows
across all of them, so the pitcher half of this order could not be run.

The registry is built: recordConditioning keys archetype x underlying-skill x
interaction x status with measured lift, and the skill tag is MANDATORY and
enforced -- untagged entries are refused, and PROVEN without sufficient
evidence is refused. validatedSkills() returns the coherent profile as it
stands, which is {} for every archetype, by design.

BOMBER x hits conditioning was tested across the order's categories and every
result is underpowered: arsenal (barrel x breaking share) incremental +0.043,
batted-ball (launch x pitcher GB) +0.001, contact quality -0.020 and -0.015,
K x K -0.063. Within BOMBER the counter still leads on hits, 0.218 to 0.160,
consistent with the closed pooled negative.

One bug fixed mid-run: fromStatcastRow maps percentage and raw fields only and
does not carry pitch_mix, so the arsenal category first reported n=0 for every
row -- it was measuring nothing rather than failing. Without catching it,
"arsenal doesn't matter" would have been recorded from a column that was never
populated.

On defense: I looked for a derivable proxy before calling it unsourceable, and
there isn't one. We ingest no fielding data at all, and opposing pitchers'
hits-allowed conflates pitching with defense, so it would validate the wrong
skill. It needs Savant's fielding endpoint -- free, same host as the five
feeds already ingested -- and it is not sourced here, because sourcing it to
test at n=282 would answer nothing.

Nothing proved, so nothing was recalibrated and nothing shipped.

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-03 21:49:51 -04:00
builtbykev 9538e11198 Derive the lineup K-rate free, and fingerprint the cap fix
Two premise corrections first. Pitcher stuff features have NOT proven solo
through the gate -- every one was refused on sample (n=57 against 500). Four
exceed the effect-size bar (arm angle -0.250, whiff +0.213, k rate +0.206,
chase +0.195), which is why they are worth pursuing, but clearing one of three
thresholds is not passing. And the carrier was not blocked only on the lineup
input: that input was built and measured last session at 94.7% coverage. What
blocks it is n, and n was being throttled by the grading cap.

RUNG 1 IS DERIVED AND COSTS NOTHING. Opposing-team K-rate comes from joining
the opposing roster to the batter k_pct values already in statcast_aggregates
-- no new feed. The improvement this session is that it is PA-WEIGHTED: an
unweighted roster mean counts a 12-PA callup the same as an everyday starter,
which is not the lineup a pitcher faces.

That change alone reversed the term's sign. Unweighted, the lineup term HURT
the model (0.1738 -> 0.1285). PA-weighted, it HELPS (0.1738 -> 0.1953). Same
hypothesis, same data -- the derivation was the problem, not the signal, which
is the entire argument for deriving the best honest version before sourcing
anything. Head-to-head is now +0.2592 with a CI of [-0.0167, +0.5645], very
nearly excluding zero, at n=57.

Within archetype, the two strata come out with OPPOSITE signs -- FLAME
incremental -0.152, non-FLAME +0.145 -- and the pooled value (+0.077) sits
between them, which is the shape a conditional effect makes and is invisible
when pooled. That is what stratifying was for. But n is 20 and 24, the
standard error on a correlation there is about 0.22, and the direction
contradicts the theory that predicted a stronger effect for finesse arms. It
is recorded as a structure to re-test, not as a finding.

Rungs 2 and 3 are NOT triggered. A rung fails only once it has been fairly
tested, and Rung 1 is n-blocked rather than failed. Sourcing confirmed lineups
now would be paying for precision on top of a proxy we have not yet measured.

THE RESULT THAT DECIDES THE TIMELINE: yesterday's cap raise is fingerprinted
in production at 907 grades per snapshot, up from 334, with strikeouts going 6
to 17. That puts n>=500 for pitcher Ks about a week out instead of three
months. Operational note: the manual internal snapshot endpoint now 524s at
the Cloudflare edge because grading the full board exceeds 100s -- the run
still completes server-side (this very snapshot was written by a 524'd
request) and the cron is in-process, so a 524 there is not a failure.

Nothing proven, nothing calibrated, nothing shipped. The counter remains
anti-predictive on strikeouts at -0.064 and the skill model leads it by 0.26.

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-03 18:43:32 -04:00
builtbykev c0621e7aa2 Measure the batter cluster: the proven set is empty, and hits is closed
PREMISE CORRECTION FIRST, because it defines the bar. total_bases has not
passed BAR 1. Its head-to-head is inconclusive at parity -- delta +0.004 to
+0.007 with a CI spanning zero -- and it is contaminated, and no feature of
its passed the gate. It was described last session as the first challenger
that did not LOSE, which is not the same as proven. If it is installed as the
frozen proven reference and every other stat is held to "the identical bar
total_bases cleared", the bar becomes "be inconclusive at parity" and the
whole cluster passes on a null result. The proven set is EMPTY.

HITS IS NOW A FINAL ANSWER. At n=803 it clears the gate's sample requirement,
so its features were properly TESTED rather than refused: every one fails on
effect size (max marginal |r| 0.053 against a 0.15 bar), every interaction's
incremental contribution collapses to about zero, and the model loses
head-to-head by 0.096 with a CI excluding zero. That is a well-powered
negative and hits should be closed rather than retried.

The rest are n-blocked: total_bases 383, rbi 391, home_runs 228, runs 188,
against a bar of 500. Two leads are worth carrying. home_runs barrel rate has
a marginal r of -0.135, and the sign matters -- higher barrel rate goes with
the counter OVER-predicting, which would be a correction rather than a new
predictor. And runs batterK x pitcherK has the largest incremental in the
cluster at +0.132, with a clean mechanism: strikeouts destroy plate
appearances, and a PA that never happens cannot score.

RBI deserves a caveat rather than a verdict. It is power times OPPORTUNITY,
and we ingest no baserunner state at all, so half its mechanism is missing. A
weak RBI result is evidence that we are modelling half the stat.

total_bases was held frozen: git diff on skillProjection against the prior
commit is empty. The counter is untouched.

Also fixed and verified in production: the point-in-time retention shipped
after yesterday's refresh had already run, so statcast_history was empty, and
its first run then failed on a hand-enumerated schema that had already drifted
from its source ("could not find the 'swing_pct' column"). The refresh itself
still succeeded and wrote all 1,387 aggregate rows, which confirmed the
best-effort guard in prod. The table now mirrors the source via LIKE and the
writer passes rows through whole. Verified live: 1,387 rows retained at as_of
2026-08-03. A usable point-in-time window starts 2026-08-04.

Stage B has nothing to calibrate. Everything now waits on a point-in-time
window and on sample -- both waiting problems, not building problems.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-03 16:58:57 -04:00
builtbykev c2d6e8ee7d Mirror statcast_history on its source so retention cannot drift
The first production run of the point-in-time retention failed with
"Could not find the 'swing_pct' column of 'statcast_history'" -- the
hand-enumerated column list had already drifted from the table it was copying.
The refresh itself still succeeded and wrote all 1,387 aggregate rows, which
verified the best-effort guard in prod: a retention failure does not fail the
refresh.

The table is now created with LIKE statcast_aggregates, and the writer passes
the row through whole instead of hand-stripping columns, so there is no drift
surface left. Recreating was safe -- nothing had been retained.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-03 16:49:15 -04:00
builtbykev 4aab18096f Prove both on total bases -- and find that my own fix destroyed the backtest
Nothing passed. Nothing promoted. Counter byte-identical.

THE BLOCKER, which is the real finding. statcast_aggregates is upserted in
place and holds exactly one as-of date. Yesterday's skill backtest was honest
only by accident: the nightly refresh was unreachable code, so the profiles
sat frozen at 2026-07-21 -- before the settled window. Repairing that cron was
right for production and it refreshed them to today, destroying every prior
version. Scoring a 2026-07-25 game now uses a season aggregate that contains
that game. Point-in-time validation is structurally impossible from that
table, so every number in this run is contaminated and directional, and none
of it is a gate verdict.

Fixed forward: statcast_history retains a dated snapshot on every refresh, so
point-in-time becomes "as_of_date < game_date, most recent". Retention is
best-effort and cannot fail the refresh; both properties are unit-tested. It
has one day of data, which is not yet a window.

SOLO BASELINE, n=383, Bonferroni across 12 tests (alpha 0.00417): nothing
passes. hard_hit_pct is closest at marginal r 0.135 with p 0.0080, failing
both the 0.15 effect bar and the corrected alpha. And it drifted DOWN from
0.153 at n=295 -- an estimate regressing as noise averages out, not an effect
firming up. I called that number encouraging yesterday; on 88 more rows it is
fading, and it should not keep being quoted at its best value.

INTERACTIONS, each scored by partial correlation against the counter residual
controlling for both of its own components: none pass. Only barrel x power
archetype has an incremental exceeding its parts (-0.101 against 0.019) at
n=260 -- the shape Discipline 2 predicts, but a lead, not a finding.

A methodological catch worth keeping. The archetype conditioner was first
built as barrel_pct over league barrel -- a monotone transform of one of its
own components -- so the "interaction" was barrel squared, measuring
nonlinearity in barrel rate rather than any archetype effect, and it produced
this run's only positive result. A Gauss-Jordan pivot test does not catch that,
because the two columns differ by a scale factor. Fixed with a scale-free
collinearity check plus real archetype labels joined from model_snapshots.
Without it this document would have reported a fabricated interaction as the
session's finding.

COMBINED vs COUNTER on total bases: 0.2718 against 0.2647, delta +0.0071, CI
[-0.065, +0.079] -- inconclusive, and the first time a challenger has not
lost. The same engine on hits was -0.116 with a CI excluding zero. That
contrast is the whole argument for total bases, and it is what the physics
said: contact quality governs extra bases, not whether a grounder finds a hole.

Also built: the compound TB projection. skillProjection no longer refuses
total bases -- a deterministic bases-per-hit multiplier had made P(TB>=2)
exactly P(hits>=1), a relabelled hits curve. It is now a convolution over
per-PA base outcomes with hit-type shares shifted by skill. Non-degeneracy is
locked by test.

4,204 tests green (334 suites); web build exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-03 16:27:11 -04:00
builtbykev c7cc8f5e52 Build the gate, run it, and find we were proving things on the wrong stat
PREMISE CORRECTION FIRST. statModel.js and correlateValidator.js do not exist
in this repository. The validation spec's only prior form is
src/services/python/blueprints/unconventional.py -- a Flask blueprint in the
Python service that is offline in production, scoring NBA factors against a
warehouse that was never populated -- and tests/unit/supplementSystems.test.js
requires only fs and path while defining its own validateFactor inline at line
368. Those tests assert a re-implementation of the thresholds, not an
implementation, which is exactly why they passed for months while nothing was
connected. The diagnosis behind the order is right -- every challenger was
measured without a gate -- but the cause is that there was no gate on the Node
side to import. So it is built, to the exact spec.

correlateValidator: n>=500, |r|>=0.15, p<0.05, Bonferroni across the sweep.
The p-value is exact rather than approximated (t-transform through a
regularized incomplete beta) and is verified in the suite against known
values, because scipy is not available here. Pairs with an unknown side are
dropped, never zero-filled -- a zero-fill inside a correlation does not add
noise, it invents a point at the origin.

THE RUN, hits, n=570, Bonferroni-8: every skill feature fails, and not
narrowly. The strongest marginal correlation against the counter's residual is
0.062 against a 0.15 bar. That is an effect-size failure at a sample that
would have found a real effect comfortably -- a clean, well-powered negative.
The head-to-head agrees: value engine 0.0499 against the counter's 0.166,
delta -0.116 with CI [-0.189, -0.043]. Not promoted.

THE RUN, total bases, n=295: cannot be tested, and that is the finding.
hard_hit_pct shows a marginal r of 0.153 -- above the threshold -- and exit
velo 0.124, refused solely because n is 205 short of 500. It is the most
encouraging number this work has produced, and it is what the physics
predicts: contact quality governs extra bases, not whether a grounder finds a
hole. We have been testing skill inputs on the one stat where they should not
matter much.

Two things the run forced. Feature verdicts are now PER STAT, because marking
these DEAD sport-wide on hits evidence would have killed, for total bases, the
features that look most alive there -- per-sport doctrine one level deeper.
And the gate now reports r and p even when underpowered, because "not enough
data yet" and "nothing here" demand opposite decisions and a bare refusal was
hiding the best signal on the board.

Next: build the compound TB projection (skillProjection still refuses total
bases by design, since a deterministic bases-per-hit made P(TB>=2) identical
to P(hits>=1)), accrue to n>=500, re-run this gate. Leave hits alone.

4,200 tests green (334 suites); web build exit 0; counter byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-03 02:34:02 -04:00
builtbykev 258d8a6655 The skill engine: built, gated by construction, and Stage A honestly lost
Built src/services/model/ -- the forward, archetype-selected, skill-based
projection, as a challenger. The champion is untouched.

featureRegistry makes "earn its place or it's out" structural rather than
aspirational: CANDIDATE / PROVEN / DEAD per feature per sport, liveFeatures()
returns PROVEN only, promotion requires n>=200 with positive lift and a CI
excluding zero, and there is deliberately no override argument. It ships with
exactly ONE proven feature -- the incumbent counter, because it is the only
one with a measurement. A test asserts that with only PROVEN features allowed
the projection returns null, so an unproven model cannot reach a user by
accident. The three champion adjustment layers are registered DEAD with their
reasons so they cannot be silently rebuilt.

skillProjection is a PA outcome tree: K and BB combined by log5 odds-ratio
against league (both identities unit-tested), then archetype-weighted contact
quality against contact allowed, then Binomial(PA, p_hit) mixed over a PA
distribution. Archetype is a FEATURE SELECTOR, not a nudge -- BOMBER reads
barrels at 0.50 and ground-ball speed at 0.00, GHOST inverts it -- and a test
locks that the same hitter read two ways moves more than 0.15.

STAGE A: IT LOSES. Out-of-sample on 570 settled hits props with 91.9%
opposing-pitcher coverage, resolution 0.0499 against the champion's 0.166,
delta -0.116 with CI [-0.189, -0.043]. It is not selective either: its eight
most confident picks hit 50%, a lift of -0.065. Not promoted. The gate did its
job on its first real test, which is the point of having built it that way.

Two false starts, both recorded because they nearly produced a wrong verdict:
statcast_aggregates stores PERCENTAGES, so raw rows made bip = 1-29.6-17.1 and
refused 568 of 576 -- the honest-absent guards made a units bug loud instead of
silent, and the conversion now lives at one chokepoint. And the first run
resolved an opposing pitcher for 1 of 570 rows, because ledger team/opponent
are NULL, so it would have reported "skill-v1 loses" while measuring a
batter-only model with no matchup in it at all. The verdict above is from the
corrected run.

The loss is real but partial: park was passed as 1.0, handedness and
opportunity_drift never fired, PA is season-PA over a constant, and the skill
profiles carry no recency at all while the champion has a last-5 term.

Also fixed: the Statcast nightly refresh was unreachable code. It sat inside
tick() below "if (!HOURS_UTC.includes(h)) return" while testing h === 11, so
it had never run once; the aggregates were 13 days stale and both of its
alerts were in the same dead branch. It now runs on its own tick, and the test
that passed happily throughout -- it only checked the string existed -- is
replaced by one that asserts it is not behind the guard.

4,182 tests green (333 suites); web build exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-03 02:20:40 -04:00
builtbykev c551bf0340 Reality assessment: the forward model exists, wired to the wrong side of the pipe
READ-ONLY. src/ and web/ untouched.

Inventoried every forward-model component against the real objective -- a
forward matchup projection, not an edge number. The finding is that all of it
already exists and is already loaded in production, and 100% of it sits
DOWNSTREAM of the grade in challenger columns nothing serves. The served p_win
reads three features and a game log; it has never seen a pitcher.

Inputs are HAVE, not missing: statcast_aggregates carries 1,354 rows (750
pitchers, 604 batters) with exit velo, launch angle, barrel, hard-hit, whiff,
chase, pitch mix, GB/FB, arm angle, and handedness complete on every row. Real
gaps are team defense and catcher/umpire. So Stage A is a plumbing-and-
modelling job, not a data-acquisition job.

Found along the way: the Statcast nightly refresh is unreachable code. tick()
returns for any hour not in HOURS_UTC (14,19,22,1,3) and the refresh block
then tests h === 11, which that guard can never admit. The mechanism data has
been frozen at its 2026-07-21 backfill for 13 days, and the block's own
failure alert sits in the same dead branch -- the identical silently-guarded-
out shape as the settlement outage.

Design shows the counter: every factor label the SIGNAL BREAKDOWN renders is a
restatement of recent frequency (l5_hot_vs_line, l20_over_line, back_to_back,
home_game) plus several structurally-NBA labels (referees, coach pace,
starters out) inside a baseball product. Not one names a pitcher, pitch type,
handedness or park. The card's forward-read slots already exist and go
unfilled -- the surface needs feeding, not redesigning.

On what changes: the prior measurements were outcome-accuracy, not edge, so
the metric was right and the question was narrow. proj-v1.1 and hits-v1 stay
correctly refuted as DISTRIBUTION swaps on thin inputs -- neither tested a
matchup-fed projection. arch-v1 is a market-relative nudge by construction and
is the one component genuinely measured on the wrong axis. AT CEILING is
provisional: measured only against features the champion already reads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-03 00:53:56 -04:00
builtbykev d8bf7765db Decompose the champion: its whole edge is a hit-rate counter
READ-ONLY. src/ and web/ untouched; 4,159 tests still green.

WHAT THE CHAMPION IS. probabilityEstimator is five lines of arithmetic: the
empirical frequency of (stat > THIS line) over the game log, blended 0.6/0.4
with the last-5 frequency, then +/-0.03 opponent, +/-0.015 home/away, a
cv>0.40 pull toward 0.50, and a clamp to [0.10, 0.95]. It reads three
features. featureCache retains a dozen more that p_win never touches.

THE ABLATION IS EXACT, NOT A REFIT. Every adjustment is closed-form from
stored features and the consistency step is linear, so each layer subtracts
algebraically out of the stored p_win -- no re-estimation, no re-fetch, no
lookahead possible. Per stat, paired bootstrap:

  removing ALL THREE adjustments changes resolution by NOTHING on every stat
  hits -0.0059  total_bases -0.0015  rbi +0.0106  runs +0.0130  walks +0.0008

and rbi's home/away is mildly HARMFUL (+0.0053, CI excludes zero). So ~100% of
the champion's resolution is base+recency: how often this player has cleared
this number lately. Everything else is decoration.

A CORRECTION. Pooled, the champion resolves 0.46; per stat it is 0.196 (hits)
to 0.499 (rbi). Pooling stats with different base rates inflates correlation,
so 0.46 should not be quoted as the champion's resolution. Last session's
paired differences remain valid; only the absolute level was inflated.

THE BIGGEST LOSS IS NOT A MISSING FEATURE -- IT IS THE CLAMP. 358 of 1,741
settled rows (20.6%) sit on the boundary, so the model emits a constant there
and cannot rank a fifth of the book at all. And that constant hides two
opposite failures: 0.900 covers home_runs-under truly winning 99.5% (9.5pts
under-confident) next to hits-under truly winning 51.9% (38.1pts over-
confident). PROB_CEIL=0.95 makes the 99.5% case inexpressible. Global
over-prediction is +3.5pts, +7.6 on total_bases. None of this needs new data.

ONE REAL MISSING-WEIGHTING LEAD: opportunity_drift, residual corr +0.156 on
hits and +0.145 on total_bases -- it REPEATS across independent stats, unlike
the weather hits on TB which sit inside the expected false-positive count (70
tests at alpha .05 expects 3-4). And we already compute it: arch-v1's
opportunity axis uses it and extracts nothing (delta +0.0001). Wrong
implementation, not a missing feature -- opportunity must scale the rate, not
nudge the probability.

ARCHETYPE IS UNMEASURABLE, NOT REFUTED. Only 2 of 41 labels (BOMBER, GHOST)
reach n>=40 settled rows and every mean residual straddles zero. That is "we
have not measured it", and it does not license acting in either direction.

Why every challenger has failed is now legible: the ladder and hits-v1 REPLACE
the frequency question with a fitted distribution; the environment axis adds
inputs the champion ignores. Asking the frequency question at the traded line
is the thing that works.

Flagged, not fixed: model_snapshots.outcome is NULL on all 22,032 rows -- the
retention table built for exactly this replay was never settled, so labels had
to be joined from ledger_entries.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-02 23:54:51 -04:00
builtbykev f897c7ec06 hits-v1 fingerprint PASSED: 72/72 written in prod, 45 outside the band modelled anyway
The prod-write fingerprint that was blocked by the odds outage has landed on the
first snapshot after deploy. hits-v1 records exactly as the live-board
verification predicted, and the takeable axis behaves as specified -- scope is
book identity, never price shape.

The verdict is unchanged: hits-v1 is REFUTED and stays unpromoted. This confirms
only that it is recording, so the forward accrual can judge the backtest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-02 22:08:12 -04:00
builtbykev 3ba3dd28f3 Scoreboard every challenger; diagnose the 429 as odds-api, not PropLine
PROMOTE-THE-EARNED. Nothing was promoted, because nothing earned it -- not
because the bar was held high. Measured on the same bar that refuted hits-v1:
own rows only, direction-aligned, paired bootstrap, promote only on a CI
excluding zero.

  arch-v1        n=1741  delta 0.0000  CI[-0.0050,+0.0054]  inconclusive
  contact-v1     n=1055  delta +0.0008 CI[-0.0052,+0.0069]  inconclusive
  proj-v1.1      n=1664  delta -0.0301 CI[-0.0543,-0.0060]  reliably WORSE
  matchup/tb-v1/hits-v1  n=0  genuinely pending (rows dated 08-02+)

arch-v1 is the interesting one: it MOVED 76% of rows by 2.5 points on average
and resolution is identical to the champion to four decimals, on the moved
rows too. That is active movement carrying no information -- a finding, not a
pending verdict.

These are true prospective holdouts: arch-v1 and contact-v1 wrote p_win at
grade time into their own columns before the game. Nothing recomputed.

THE 429, read-only. The premise was that we re-pull the full picture every
slot and blow the quota. Measured: PropLine is at 5 calls of 3,000/day --
0.17%. One snapshot is ONE PropLine call per sport, all markets comma-joined.
There is no request-pattern problem, so a change-based pull cannot fix it and
no tier upgrade is needed.

The 429 is odds-api: 478/500 MONTHLY, blocked at 95%. oddsService falls
through silently when PropLine returns empty, and the backup's quota gate
throws the error -- so an empty slate is indistinguishable from an outage and
the message names the wrong provider. Flagged for its own order.

Could NOT verify PropLine movement endpoints: docs are auth-gated and the keys
are production-only. Not asserted either way. The movement-as-data argument
stands on its own merits and should be justified that way, not as a quota fix
it isn't.

Book-breadth invariant written down: we never discard books. All are kept and
shown (DISPLAY_BOOKS = MODEL + REFERENCE + DFS); DFS pick'em is excluded from
PRICING only, because a fixed-payout shaded number is not a market price.
Verified this is already what bookRoles.js does.

Champion byte-identical; every challenger stays wired.
4,159 tests green (332 suites); web build exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-02 22:07:43 -04:00
builtbykev b06a84af80 Settlement has been dead since 2026-08-01: a 500-id filter overflowed the URL
The self-learning loop stopped two days ago and reported success the whole
time. 1,444 ledger rows from 2026-08-01 sit unsettled with settle_attempts=0
-- never even attempted -- and every accruing challenger has been starved of
settled sample as a result.

ROOT CAUSE. settleLedger fetched open ids, then REFETCHED the full rows with
.in('id', ids). PostgREST puts filters in the URL, so 500 UUIDs became an
18,499-character request that the fetch layer rejects with "TypeError: fetch
failed". The result was destructured as `const { data: rows } = ...` with NO
error binding, so rows came back null, the loop body never executed, and the
function returned {settled:0, voided:0, unrecoverable:0, pending:0} --
byte-identical to a clean "nothing to settle". Reproduced against prod before
changing anything.

WHY IT HID FOR TWO DAYS. It is volume-triggered. Daily volume ran 20-260 rows
and settled perfectly for weeks; 2026-08-01 was the first day past the 500-row
fetch limit. And the zero-settle ops alarm reads these very return values, so
pending:0 told the watchdog the backlog was empty -- the alarm built to catch
exactly this could not see it.

THE FIX. The refetch existed only to add game_date/settle_attempts/
dclv_computed_at. Selecting them in the first query removes the id list
entirely, so there is no URL to overflow at any volume. A failed fetch now
surfaces its error instead of being reported as an empty backlog.

captureClosing carried the same shape one level down -- .in('id', g.ids) on an
UPDATE, which fails identically once a single line|odds group gets large on a
big slate. Its id filters are now chunked at 100 (~3.7 KB).

Tests: the regression is locked by asserting settlement issues NO id-list
filter at 500 rows, and that a failed fetch is never reported as an empty
backlog -- the two properties that would have caught this. Two existing
suites asserted the old two-query shape and were updated to the real one.

4,159 tests green (332 suites); web build exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-02 21:49:30 -04:00
builtbykev 2394fb04a1 Record the hits-v1 fingerprint as PENDING, and why
The prod-write fingerprint did not land: the odds provider is returning 429
(quota exhausted), so the snapshot refuses with gradeCount 0 and the MLB board
has been frozen since 07:30 UTC. The 14/19/22 UTC cron slots failed the same
way, all before this change deployed -- hits-v1 sits inside the snapshot's
existing try/catch, is purely additive, and had zero grades to attach to.

Firing is already verified against the real production snapshot through the
real attachProjection path (158/159). What is pending is only confirmation
that the deployed process writes the columns, which needs a slate the pipeline
can fetch. The exact fingerprint query is recorded in the spec.

The odds quota exhaustion is a live outage of the whole grading pipeline and
is flagged for its own order, not folded into this one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-02 19:07:58 -04:00
builtbykev 07626de3de hits-v1: built on the right structure, measured honestly, REFUTED
Hits was diagnosed as a family mismatch: 84% of hits rows trade at 0.5, so
the stat rides on P(0), and a negative binomial has unbounded support and no
notion of opportunity at all. hits-v1 models it as the bounded conversion it
is -- N ~ the player's empirical at-bat distribution, hits|N ~ Binomial(N,q),
with the multiplier scaling q (conversion) and never N (opportunity).

STEP 0 confirmed the inputs before the model existed: 30/30 real ledger
players, 100% combined-input coverage. Every read goes through knownRate --
a row with no atBats is dropped, never counted as a 0-at-bat game.

It FIRES: 158/159 hits props (99.4%) on the live production snapshot, through
the real attachProjection path. Scoping by book IDENTITY rather than price
shape kept 94 out-of-promotion-band props on the board, 93 of them modelled --
59% that a price rule would have deleted.

And it LOST. Point-in-time replay (game log truncated strictly before each
row's game_date, real grade-time multiplier), hits-only, direction-aligned,
n=242: resolution champion 0.195 / ladder 0.048 / hits-v1 0.026. Paired
bootstrap on the same rows: hits-v1 - ladder = -0.022, CI95 excluding zero.
Not promoted.

The value is in what it eliminates. The family was wrong AND the mean was not
the constraint -- hits-v1 moved the line-0.5 mean 0.554 -> 0.581 toward a
0.598 base rate while resolution fell. What is left is per-prop
discrimination: the ladder's inputs, not its distribution.

The pre-registered fallback is recorded as WRONG rather than deleted. It said
hits might be genuinely low-resolution for anyone; the champion scores 0.276
on the identical 189 rows, so there is real signal and the ceiling claim was
the comfortable reading, not the honest one. Its own control refuted it, and
that control was already in hand when the branch was written.

hits-v1 stays wired as a challenger writing its own ledger columns so the
forward accrual can confirm the backtest. Champion, ladder, ranking,
calibration, reference ruler and the four accruing verdicts are byte-identical
-- the diff has zero deleted lines.

Tests 4,156 green (332 suites); web build exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-02 19:04:08 -04:00
builtbykev d103ecf4c3 Disambiguate takeable: THREE questions shared one word, now three names
BYTE-IDENTICAL. The audit found no consumer getting the wrong axis, so this
is a disambiguation, not a bug fix. 4,131 tests / 331 suites green.

STEP 1 AUDIT -- and the order's premise was wrong in a useful way:

  the four accruing challengers   read the flag ZERO times (not four)
  the ranking gate                wants PROMOTION, gets promotion  [correct]
  the ledger column               holds the LEDGER band, consumed as such
  the UI (LiveHeroProp)           TYPES a `takeable` field it never renders

THERE ARE THREE DEFINITIONS, NOT TWO -- and I only found the third by
tracing the ranking gate:

  1. IDENTITY    can it be bet?        book identity (takeability)
  2. LEDGER BAND worth recording?      odds >= -160, UNCAPPED plus
  3. PROMOTION   worth crowning?       -160..+200, i.e. band PLUS a ceiling

(2) and (3) genuinely disagree, and I measured it rather than asserting it:
439 rows -- 28.2% of all takeable=true ledger rows -- carry prices above
+200, up to +1300. A +1300 longshot is a real bet worth RECORDING and not
one worth CROWNING. Both are correct for their own purpose.

THE DANGER WAS NEVER THE LOGIC. It was that three questions shared one
word, so a reader could not tell which answer they held -- and hits, which
must model thin/juiced/one-sided REAL markets, would have been the next
reader to guess wrong.

RESOLUTION: all three now have distinct names in config/takeability.js;
gradeRanking calls isWithinPromotionBand so its intent is self-evident (a
test pins it byte-identical to the old valueEngine call across the whole
price range); the ledger dual-writes within_price_band with `takeable`
kept as a documented DEPRECATED MIRROR so nothing breaks. Column comments
in the database now say what each column actually holds.

I did NOT redefine `takeable` in place. Four readers and a ranking gate
sit on it, and silently changing its meaning under cover of a naming
change is exactly the class of move this session keeps removing.

Gates: 4,131 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 18:19:17 -04:00
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 f67245e1e5 Re-stamp A: 862 rows recovered by honest join (not 936 -- see deviation)
Database only; no application code changed, so the served path, champion
and reference ruler are byte-identical.

RESULT: 862 rows re-stamped from the takeable LOCK-TIME price in
lock_lines, 862/862 now anchored to takeable books, tagged
price_source='archive_restamp', quarantine lifted. 812 pending clean rows
recovered into the accruing verdicts. Holdout verification: 2,792 rows,
862 re-stamped included, 0 re-stamped rows non-takeable, 144 still
excluded, 0 quarantined rows leaked, and 0 NON-TAKEABLE rows remain in the
holdout population since 2026-08-01.

DEVIATION, stated rather than buried: the order authorised 936. That
figure came from a takeable book posting the same LINE. Requiring what a
re-stamp actually needs -- that book's price for the GRADED SIDE at LOCK
TIME -- resolves 862. Of the other 74, 73 have a takeable side-price only
OUTSIDE the lock window and 5 are genuinely one-sided markets.

I did not widen the window to reach 936. A takeable price captured hours
after the grade is a later market moment, not a lock price; substituting it
is precisely the reconstruct-vs-join line this order was fenced against,
and it would have been invisible in the totals -- showing only as a
cleaner-looking 936.

Those 74 were also RE-TAGGED, because their old label had become a lie:
recoverable_same_line -> no_takeable_lock_price_for_side. A future attempt
reading the old tag would have been invited to widen the window and call it
recovery.

takeable was RECOMPUTED from the recovered price rather than carried over
-- the old flag was computed FROM the contaminated price and was wrong on
its own terms. 101 rows had their flag change, which is the direct measure
of how wrong it was.

Provenance travels with the data (price_source), on the same principle as
is_proxy: a value recovered by a later join is not identical in kind to one
captured natively at grade time, even when it is the same number.

EVIDENCE FOR THE NEXT ORDER'S INVARIANT: 5 of the excluded rows are
one-sided TAKEABLE markets, and betrivers/hardrockbet legitimately quote
one side only. A guard that inferred takeability from price shape would
throw away real markets while still admitting a DFS book at -119 --
takeability is book IDENTITY, never price extremity or one-sidedness.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
2026-08-02 16:22:06 -04:00
builtbykev e29ab6fd6a Takeable enforcement: verified on real rows, 1,006 tagged, re-stamp call ready
PART 1 verified by inducing the REAL rowsFromSnapshot over REAL lock_lines
rows from prod. Three cases, 0 non-takeable anchors:
  Narvaez  (dabble/kalshi/prizepicks/smarkets, NO takeable book)
           -> book=null, price=null, takeable=null  [honest absent]
  Schwarber(bovada/dabble/novig/PINNACLE before draftkings)
           -> draftkings +102  [pinnacle SKIPPED, proving TAKEABLE not MODEL]
  Ohtani   (dabble/onexbet before draftkings) -> draftkings -266
Narvaez is the case that matters: pre-fix he was stamped dabble +104
takeable=true; he is now honestly absent.

A HARNESS BUG RECORDED: my first verification pulled live /api/odds/mlb,
which returned {"error":"Odds data temporarily unavailable"}. The script
read that as 0 props and printed "all from takeable books? true" -- a
VACUOUSLY TRUE pass. I caught it only because I also printed the book list
and it was empty. Same family as the silent-false traps: a probe that finds
nothing looks identical to a probe that finds nothing wrong.

PART 2: 1,006 rows tagged via the purpose-built quarantine_reason at ROW
level with three sub-cases (recoverable_same_line 936, no_takeable_quote
49, takeable_line_differs 21). getModelAggregate ALREADY excluded
quarantined rows, so the public record and the n>=20 gate were clean
automatically; all five committed holdout scripts now carry the exclusion
explicitly.

PART 3 -- the re-stamp call is now fact-based. The takeable LOCK-TIME price
is recoverable for 936/1,006 (93.0%) from lock_lines, the correct
instrument. Only 431 appear in closing_captures, which is the wrong timing
for a lock price anyway.

LINE CONTAMINATION ANSWERED (previously unverified): the stored line
MATCHES a takeable book's line on 936 (93.0%), DIFFERS on 21 (2.1%), and is
unverifiable on 49 (4.9%) where no takeable book quoted the prop at all.

That makes it cleanly row-level: re-stamp the 936 as an honest JOIN and
recover 886 pending rows for the holdouts, or leave all 1,006 excluded.
Either way the 21 + 49 stay out -- re-stamping those would invent a lock
price, or a line, we never captured. Nothing re-stamped; Kev's call.

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:24:28 -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 08e5c908e6 Takeable audit: the ledger is contaminated, and I caused it
READ-ONLY. Nothing enforced or fixed; the five challengers untouched.

VERDICT: gaps exist, and one is LIVE CONTAMINATION of the ledger -- the
exact table every accruing holdout resolves against. book, locked_odds and
the takeable flag ITSELF are being stamped from books you cannot bet: DFS
dabble (707 rows, 24% of all rows), offshore bovada (214), onexbet (42),
exchange kalshi (7, mean |odds| 1120).

0% before 2026-08-01. 47.9% on 08-01. 42.5% on 08-02. It began the day I
widened the books for display.

LEAK LOCATED, not inferred: recordPipelineGrades indexes byKey over the
FULL display-widened props list, then prefers that prop -- book:
(prop && prop.book) || g.book, and locked_odds/takeable both fall back to
oddsForSide(prop). The grade is computed on a MODEL book and the ledger row
is then re-stamped from whatever book indexed first. The takeable flag is
therefore not merely mislabelled: it is computed FROM the contaminated
price, so it is wrong on its own terms.

The served grade path is clean TODAY (428 grades, 100% MODEL books), so
dedupeProps' gate works. But MODEL_BOOKS is NOT a subset of TAKEABLE_BOOKS
-- pinnacle is model-eligible and correctly not takeable -- so the
projection may anchor to a reference line by design. Harmless while
pinnacle returns nothing; live again when it recovers.

BLAST RADIUS bounded but growing: 47 contaminated rows have already
settled (21% of settled rows since 08-01) and ~700 are still pending and
will settle into the holdouts. The damage is mostly ahead of us, which is
what makes this urgent rather than historical.

NOT VERIFIED and not claimed either way: whether the stored `line` is also
contaminated. It traces to the graded prop, but I did not check it
end-to-end; the enforcement order should.

The prediction-vs-reference distinction HOLDS and must not be collapsed:
the prediction target must be takeable, while fair_prob / consensus / edge
stay reference. The bug is not the three-way split -- it is that one write
path ignores it.

Stack sequenced in the plan: (a) takeable enforcement, (b) structural
Number(null)===0 guard (hits will re-trigger it -- its 0.5 lines make P(0)
the whole game), (c) hits. Carry-forward: tb-v1 verdict, the third
pre-registered branch, and the 100s Cloudflare timeout vs a ~115s snapshot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
2026-08-02 14:09:14 -04:00
builtbykev aa1228ec42 tb-v1 report + plan: diagnosis on trial, branch pre-registered
Firing verified on a real prod snapshot: 10/10 total_bases props carry
proj_tb_p_over. The snapshot HTTP call returned 524 (Cloudflare's 100s
origin timeout vs a ~115s snapshot) but the work completed server-side --
confirmed from the ledger rather than assumed.

Face validity is good and diagnostic: means agree almost exactly with the
ladder (1.813 vs 1.833), so this is a SHAPE-ONLY intervention, which is
what was intended. Component rates are plausible, and Carroll's triples
rate (0.112, far above his peers) is a clean check -- he is a speed player
and the model sees it.

AN OBSERVATION I AM NOT RESOLVING BY EYE: tb-v1 reads systematically LOWER
than the ladder (0.424 vs 0.540 at the same mean). That is the expected
DIRECTION, since the NB overstates P(>=2) by treating a home run as four
accumulating events -- but whether 0.424 is right or an overcorrection is
not knowable from face validity. A ~1.8-TB hitter clearing 1.5 empirically
sits nearer 45-50%, between the two. I am not claiming tb-v1 is better; the
holdout decides.

BRANCH PRE-REGISTERED, before the result, so the verdict cannot be
reinterpreted afterward: improves -> family-mismatch HOLDS, similarity
stays off the critical path, hits is next; does not improve -> hypothesis
WRONG and the mean-weakness/similarity branch REOPENS.

Also recorded: I hit Number(null)===0 in my own new module -- a null
component rate treated as a measured zero, the difference between "never
triples" and "we don't know his triple rate". A test caught it. Sixth
appearance of this trap in this codebase, and it caught the person writing
the warnings about it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
2026-08-02 03:34:14 -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 48706210fe Diagnose proj-v1.1: concentrated mean failure, NOT a similarity problem
READ-ONLY. Nothing built or fixed; the four challengers untouched.

41% OF THE REPORTED GAP WAS A MEASUREMENT ARTIFACT. p_win is P(graded
side); proj_p_over_line is P(over); 31.4% of settled rows are UNDER-graded,
so comparing them raw measures the ladder backwards on a third of the
sample. Matched + direction-aligned (n=437): 0.252 vs champion 0.352, not
0.108 vs 0.331. The PRODUCT is not making this mistake -- I checked;
projectionChallenger normalises both to the over basis deliberately. The
error was in the measurement.

THE LOSS IS CONCENTRATED. hits (n=245, res 0.060) and total_bases (n=49,
res 0.009) are 67% of rows and carry essentially no signal. Everything else
is fine or better: walks 0.519 vs champion 0.544, runs mean 0.345 vs 0.392,
and on DOUBLES the ladder's mean BEATS the champion's (0.207 vs -0.062).

IT IS THE MEAN, NOT THE SHAPE. On the two failing families the mean itself
carries no signal (0.052, -0.019) against the champion's 0.158 and 0.085.
Where the mean is good the probability is good -- shape follows mean.

A HYPOTHESIS I TESTED AND DISPROVED: prediction compression. I expected
P(>=1 hit) to sit in a narrow band and fail to rank. It does not -- spread
ratio 0.94 overall, 0.80 for hits, 0.94 for total_bases. The ladder has
comparable spread; it is spread in a direction uncorrelated with outcomes.
Recorded because it was a plausible story the data refused.

PRIORS AND PLUMBING CLEAN. proj_factors carries form_rate,
combined_multiplier and breakdown on every row; proj_point 100% populated
with sane centres (hits 0.830 vs line 0.578). Not the environment-style
silent-null failure.

NAMED CAUSE (structural, flagged as hypothesis not finding): the count
model mismatches those two stats. total_bases is a WEIGHTED SUM (1B..HR =
1..4), so an NB treats one home run as four events and mis-states variance
-- and TB has the worst result in the table. hits is BOUNDED BY AT-BATS and
mostly traded at 0.5, so almost everything rides on P(0), the region where
the wrong family hurts most. walks/runs/doubles ARE genuine low-rate counts
and are exactly the ones that work.

FIX BRANCH: targeted per-stat fix for hits and total_bases. THIS REMOVES
THE MLB SIMILARITY BUILD FROM THE CRITICAL PATH -- that branch assumed a
GLOBAL mean weakness, and the mean is fine or better on three of six stat
families. Similarity may be worth building later, on evidence, not on this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
2026-08-02 02:50:13 -04:00
builtbykev f5997778a2 Dormant-layer audit: nothing to connect; proj-v1.1 is live and losing
READ-ONLY. Nothing connected, built or wired; the accruing challengers were
not touched. "Dormant" meant three different things and in no case is the
answer "connect it".

DISTRIBUTION LADDER IS NOT DORMANT. projection/distribution.js is consumed
by projectionChallenger (proj-v1.1), live on every snapshot at 94.2%
coverage (276/293) with 437 settled rows since 2026-07-23. It is a FOURTH
accruing challenger, and it is LOSING: resolution 0.108 vs the champion's
0.331. That verdict is no longer thin.

It is also PER-STAT and doctrine-correct -- nine distinct league priors
(hits 0.90, total_bases 1.45, home_runs 0.15, ...) each feeding a
gamma-Poisson posterior into a negative binomial. Correcting the plan:
§10.3's "single additive index across hits/Ks/TB" is engine1's GRADE, not
this ladder, which made a solved problem look open.

SIMILARITY IS WRONG-SPORT. Zero callers, and its weights are NBA
vocabulary: pace 0.15, referee_tendency 0.06, lineup_context 0.12,
score_state_context 0.05, travel_fatigue 0.08. MLB has no pace and no
referees. Connecting it would be the sport-stubbed-in-on-another-sport's-
template breach, and it would fail QUIETLY -- missing factors are skipped,
so the score would silently collapse onto whatever few dimensions happened
to exist. CONSTRUCT, not connect.

BAYESIAN WOULD REGRESS THE MODEL. Zero callers, and DISTRIBUTION_SHAPES
keys on rbis / runs_scored / strikeouts_batter / outs_recorded /
pitcher_strikeouts / walks_allowed / pitches_thrown -- NONE of which are
live stat keys (S41: they are rbi / runs / outs / strikeouts).
getDistributionShape defaults to 'normal' on an unknown key, so wiring it
as-is would model COUNT stats as Gaussian, silently, on most MLB props. It
is also superseded by distribution.js. Do not connect; retire or rewrite.

DEPENDENCY, inverted: a better mean would help the ladder, but the ladder
is already connected and both would-be foundations are unusable -- so this
is not "connect similarity first", it is "the ladder is live and
underperforming, and strengthening its mean requires BUILDING an MLB
similarity layer that does not exist".

Next-order pointer moved to diagnosing proj-v1.1: the only candidate
already carrying settled evidence, and its diagnosis decides whether the
similarity build is worth doing at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
2026-08-02 01:51:00 -04:00
builtbykev ec815b0e37 Matchup axis report + plan reconciled: three challengers now accruing
Records the verification that matters: firing measured on a real prod
snapshot rather than inferred. environment 248/293 (84.6%) -- also its
FIRST confirmed ledger write, which the previous session could only infer
-- and matchup 243/293 (82.9%) on tier batter_own_split. Both were 0/634.

Collinearity guard passed at n=243: r = -0.003 vs the projection, +0.074 vs
p_win, +0.003 vs line, -0.068 vs environment, -0.150 vs opportunity. The
axis is not re-encoding recent form. The nudge distribution is also the
right SHAPE -- mean +0.0007, 123 positive / 120 negative -- a balanced
two-sided signal; a one-sided distribution would have suggested a sign or
baseline error.

Plan reconciled in place: arch-v1 condition axes marked firing, three
challengers listed with coverage and their own holdout queries, and the
next-order pointer moved to connecting the still-dormant layers
(similarity, Bayesian, distribution ladder) with archetype_x_archetype as
the named alternative.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
2026-08-02 01:15:51 -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 cfda597fb5 Reconcile MASTER-PLAN to true state; next order = matchup axis
Reconciled in place, not regenerated. Next-order pointer now MATCHUP AXIS
with its verified sourcing table, and an explicit note that
SOURCE-LINEUPS-first is NOT needed.

Marked DONE with their evidence: p_win ranking + edge retirement,
calibration DECIDED, MLB isotonic DECIDED (provisional label retracted),
grade cap 25->500 (board 7->365+), book widening, S59 invariant armed,
environment axis repaired.

Records the honest shape of Phase 1: it is further along than the phase
table implied, but mostly because the work turned out to be CONNECTION AND
REPAIR rather than construction -- the ladder question dissolved, the cap
was discarding 95.7% of the slate, and two condition axes were wired but
firing on zero rows.

Carried forward without softening: WNBA is NOT BUILT rather than failed,
and the ruler is MARKET-not-SHARP with PENDING-RECOVERY status until
PropLine answers the Pinnacle question -- not to be enshrined as permanent.

Remaining ~19 orders, ~9 unblocked. The two accruing verdicts are time,
not code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
2026-08-01 23:30:03 -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 0d43fb7db8 arch-v1 axis audit: env/matchup were dead; environment fixed
Report for the audit + fix already committed. Records the two things worth
carrying forward:

1. The environment axis has NOT yet been observed writing to the ledger,
   and I am not claiming it has. recordPipelineGrades upserts with
   ignoreDuplicates and dedupes on (user_id, player_key, stat, line, side,
   game_id) -- correctly, so a re-run never overwrites the original lock.
   Today's 429 rows predate the fix, so the axis cannot backfill onto them;
   first ledger observation is tomorrow's slate. What IS directly verified
   is the resolver (105/120) and the join key (416/416) -- the two things
   that were actually broken.

2. Matchup is not fixed and is not claimed as fixed. It needs the opposing
   starter and BOTH hands, and the audit shows three separate absences:
   oppPitcherByTeam 0, handById 0, bats 0/120. Fixing the pitcher feed
   without the hands, or the hands without the feed, still produces an axis
   that fires on zero rows.

Also noted: the S59 slate JOIN INVARIANT keys off the same null `team`
field, so it is currently inert too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
2026-08-01 04:12:28 -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