main
280 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
86d123945c |
Rank on p_win: challenger instrument + retire edge from decisions
MEASURED BASIS (n=200 settled MLB rows): corr(p_win, outcome) = +0.26; corr(edge, outcome) = -0.010 incumbent ruler / -0.022 consensus ruler. Subtracting the market destroys the signal under BOTH rulers, so a quantity that does not predict must not rank, gate or decide. CHALLENGER-FIRST -- live ordering is byte-identical. rankGrades (the incumbent, grade-first with edge as its 4th key) is untouched and tested as untouched. NEW: rankByForecast -- takeable-gated p_win -> grade -> confidence -> stable order, with NO edge term anywhere. p_win LEADS and the letter follows, deliberately: the letter measured r ~ 0.005 and is inverted (B 52.4% < C 56.9%) while p_win measures +0.26, so leading with the letter would sort by the weaker signal and use the stronger one only to break ties. Recorded in the code: isotonic calibration is a MONOTONE transform, so ranking on raw vs calibrated p_win gives the SAME ORDER. Calibration matters when p_win is displayed or thresholded; it cannot change a ranking. Nothing here needs the calibrated value. rankingDelta + GET /api/internal/ranking-delta measure how far the board would move before any flip. The endpoint reports p_win coverage alongside the delta -- if p_win is absent the challenger degrades to grade order and the delta UNDERSTATES, which is worth saying rather than reporting a clean zero. forecast_rank is stamped on snapshot grades BEFORE stripModelPrice, so every tier gets the correct order without the paid values (the topGradedService precedent -- an ordinal can travel where the magnitude cannot). Additive only: nothing sorts by it yet. RETIRED AS DECISIONS (not rankings, so done now): - altLineScanner.compareToBookImplied no longer returns value_detected: edge > 0. Edge is still COMPUTED and returned -- losing the record would be worse than mis-using it -- but the verdict is an honest null with value_basis: 'retired:edge_does_not_predict'. - scanAltLines no longer filters to edge>0 or calls the survivor "optimal". The whole ladder is returned ranked and labelled 'price_gap_diagnostic_unvalidated'. The module has ZERO callers (verified) -- unwired like mlbGrader.js, left in place and made honest. An honest asymmetry recorded there: ranking props AGAINST EACH OTHER must not use edge, but choosing between RUNGS OF THE SAME PROP is inherently price-relative -- ranking rungs by model probability alone would always pick the lowest line, since P(over 0.5) > P(over 2.5) by construction. So the gap stays the rung key, explicitly labelled unvalidated. Two superseded tests updated to stronger properties. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc |
||
|
|
68c5b65427 |
Thread book_role through the odds route grouping
The route regroups flat props into lines[] and was dropping the role tag, so the widened feed reached the browser untagged. That is not cosmetic: on a live prop, PrizePicks prices both sides at even money (+100/+100) while BetMGM has +450/-750. Rendered side by side without a tag, the pick'em row reads as a dramatically better price when it is a different product entirely -- exactly the confusion the three-way split exists to prevent. Consumers gate on book_role !== 'dfs' before treating a row as a market price. The ?book= filter now accepts any DISPLAY book, since shopping a real book against an exchange is the point of the widening. Grading still only ever consumes MODEL_BOOKS. One superseded integration test updated to a stronger pair: an unknown book still 400s, and a newly-visible one no longer does. Gates: 4,028 tests / 322 suites green; next build exit 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc |
||
|
|
f0543b57a4 |
Product identity + widen books for DISPLAY, model input byte-identical
IDENTITY (CLAUDE.md top + MASTER-PLAN header). VYNDR is a PREDICTIVE MODEL: it projects what a player will DO and picks accurately. Market edge is a BYPRODUCT of a good prediction, never the success criterion. Success = the forecast is honest about its own confidence AND still ranks -- calibration and resolution, both. No edge/CLV term belongs in a pass/fail gate; they are diagnostics we report, not thresholds a model must clear. A model tuned to beat a closing line has been fitted to the market instead of to the game. Per-sport doctrine (Phillips 2022, classify by what players DO not by position): each sport is its own model -- own variables, archetypes, conditions, calibration, honest ceiling. Shared across sports: ONLY the Bayesian inference math. Truth Law: no fabricated data; honest-absent over invented; label limitations in-band; provisional stays provisional until re-run; documented is not verified. PHASE 2 -- AGGREGATOR WIDENING (live). normalizeProps now emits every DISPLAY book instead of 5 of 18. Before this we discarded 13 books of our own accord and 64.8% of the MLB slate was invisible to users. Every prop carries book_role (both/takeable/reference/dfs/offshore) so the display layer can say WHAT a price is -- a fixed-payout DFS number and a two-way sportsbook price are not interchangeable objects. Unknown books are still dropped. PHASE 3 -- MODEL GATE (the model does not move). bookRoles splits MODEL_BOOKS (the legacy allow-list, character for character) from DISPLAY_BOOKS. Both model paths re-filter before they pick a line: gradeSlateService.dedupeProps (before first-row-wins AND before the limit) and intradayRefreshService.indexOddsProps (which RE-GRADES at the current line -- without the gate, widening would have silently moved locked lines onto books the model has never been calibrated against). A test asserts the graded set is byte-identical through the widening. CURRENT_RULER_VERSION stays v1_first_book. The gate lifts only when the MLB calibration is re-run on the consensus ruler and v2 is promoted. HONEST FRAMING, recorded in the plan: this is an AGGREGATOR win and it does NOT fix the model. WNBA still abstains -- a model problem, not a coverage problem; it is better covered than MLB. MLB isotonic still provisional. The consensus is MARKET, not SHARP: pinnacle, matchbook and polymarket are 0% on both sports, so no sharp anchor exists in our feed. Two superseded tests updated to stronger properties rather than deleted: roleOf now names the KIND of book, and the normalizer test asserts the display set widens WHILE the model set does not. Gates: 4,027 tests / 322 suites green; next build exit 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc |
||
|
|
c38db1ad65 |
Fix: the incumbent ruler respects the allow-list (correcting my own model)
My first delta run modelled the incumbent as first-row-wins over the RAW feed and reported that an EXCLUDED book was "the market" on 69% of MLB prop-lines, with prizepicks alone at 47%. That is WRONG and I caught it before it went anywhere. normalizeProps applies ALLOWED_BOOKS BEFORE gradeSlateService.dedupeProps runs, so DFS books never reach the incumbent. The allow-list, for all the coverage it costs, does keep DFS out of the ruler. incumbentFairProb now takes the allow-list (defaulting to the live ALLOWED_BOOKS) and reproduces the real chain. Two tests lock it, including that a prop with no admitted book has NO incumbent -- it is never graded at all, which is the real loss and is already measured as invisible_props. Overstating the incumbent's badness would have been as dishonest as understating it, and more persuasive. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc |
||
|
|
a55dd2a6a0 |
Order Zero Phase 2: three-way book split + challenger consensus ruler
CHALLENGER-FIRST. The live ruler is byte-identical: CURRENT_RULER_VERSION is still v1_first_book, nothing here writes a cache, a grade or a ledger row, and no live code path calls consensusRuler yet. bookRoles.js splits one allow-list into three, because it was answering two different questions -- "can we show this?" and "can we price against this?" -- with the same list, which is what bent the ruler. TAKEABLE the user can actually bet here (drives best price / shopping) REFERENCE may price the fair-prob ruler; never surfaced as a place to bet EXCLUDED DFS pick'em + offshore, permanently barred from all pricing Two deliberate calls, both evidence-based: - The six PropLine-phantom books (caesars/fanatics/bet365/hardrockbet/ pointsbet/thescore) are KEPT despite the order saying remove. They returned zero PropLine quotes, but PropLine is not our only provider and the odds-api backup path may carry them. A book that never appears is never matched, which costs nothing; deleting them risks silently dropping real books on the backup with no upside. Recorded in PHANTOM_ON_PROPLINE rather than enacted as a deletion. - REFERENCE = exchanges + pinnacle + bovada + the four US majors, chosen off the measured coverage curve rather than theory. exchange_only is cleanest (order-book, ~zero vig) but covers 14.3% of MLB and 5.6% of WNBA; adding the US majors gives 28.1% / 46.3%. pinnacle, matchbook and polymarket measured 0% on both sports and add nothing. The honest limitation is recorded in the config: this is a MARKET consensus, not a SHARP one. consensusRuler.js: median de-vigged fair_prob across >=2 reference books posting BOTH sides at the SAME line. Median so one stale exchange cannot drag it. Different lines are never averaged, one-sided quotes never rule, and n<2 falls back to single-book LABELLED as such with the v1 stamp -- never silently mixed, because a column holding both is two rulers wearing one name. The challenger delta runs over the live feed and reports incumbent_book_ roles, which is the real headline: the incumbent is literally first-row- wins, so it reports what KIND of book has been acting as "the market". DFS pick'em has the highest coverage in the feed, so a DFS book can be it. 18 ruler tests + 37 total in the two new suites. Full suite 4021 passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc |
||
|
|
3c466d79cb |
Order Zero Phase 1b: redaction detection + reference-policy curve
Two corrections to the first pass, both of which would have produced a false positive. 1) A non-empty body is NOT proof of access. PropLine's free tier returns the full STRUCTURE of tier-gated endpoints with values stripped plus an upgrade_url -- and the first pass classified /odds/closing and /movement as "works" on structure alone. detectRedaction() now counts actual prices and downgrades works -> partial when a body advertises an upgrade or carries outcomes with zero prices. Same class as the harness that returned a silent false, inverted. 2) One hard-coded reference set forces a yes/no on a question that is really a curve. reference_policy_curve reports strict eligibility (>=2 books, both sides, same line) under exchange_only / exchange_plus_sharp / exchange_plus_us / takeable_only, so the ruler decision is made on coverage-vs-quality rather than on a guess. DFS is absent from every policy by construction and a test asserts it. Also probes /markets/resolution-summary: /exports/resolved-props being 403 tells us we cannot PULL settlements; resolution-summary tells us whether they EXIST to be bought. Different questions. 19 unit tests, still hermetic. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc |
||
|
|
2071b79456 |
Order Zero Phase 1: keyed read-only PropLine verification endpoint
Adds GET /api/internal/propline-verify (internal-key gated, read-only) so Phase 1 can run WHERE THE KEY LIVES. Touches no cache, no ledger, no grade; the live adapter and the live ruler are untouched. Breadth reuses proplineAdapter.fetchRaw -- the exact live request -- so what it measures is what the pipeline actually receives. Reports per sport (never pooled): books/prop from the feed vs after our own ALLOWED_BOOKS, props made INVISIBLE by that filter, reference-book presence, DFS presence reported separately, and consensus eligibility. Consensus eligibility is deliberately strict: >=2 REFERENCE books posting BOTH sides at the SAME line. A one-sided quote cannot be de-vigged, and two books at different lines are not the same market -- counting either would overstate how much of the slate can carry a real ruler. Probes the documented-but-unverified endpoints (/sports, /context, /odds/closing, /movement, /results, /exports/resolved-props for four sport keys) and classifies works/partial/no, with 403 = tier-gated and 200-but- empty = partial rather than works. Key safety is the other locked property: the key goes via axios params, never string-interpolated, and every emitted string passes scrubKeys() which removes the literal key AND any surviving apiKey= query value. A test asserts a thrown transport error carrying the key cannot escape. 13 unit tests, hermetic (no network, no key). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc |
||
|
|
bedbb8c008 |
Build 2 Phase B: checkout claims atomically, webhook finalizes, bypass retired
Stripe wired to the Phase-A mechanism. Live prices verified READ-ONLY; no Stripe object was created and no payment was run. B1 PRICE KEY -> ID + BOOT ASSERTION (src/config/stripePrices.js). claim_founder_slot returns a price KEY; this module is the only place a key becomes a Stripe id, and it reads env (legacy STRIPE_PRICE_ANALYST/DESK accepted as fallbacks so an existing deploy keeps working). assertPricesConfigured() is wired into server.js and FAILS BOOT when any of the four is unset — verified by deleting one: it throws "BOOT FAILED - unset Stripe price env for: desk_founder". A blank price can no longer sell at the wrong rate or 503 a customer at checkout. B2 CHECKOUT CLAIMS BEFORE CREATING THE SESSION. resolveCheckoutPrice previously called founderSeatsAvailable() — a COUNT read, which WAS the race (two checkouts at seat 99 both read 99, both got founder). It now calls claim_founder_slot and uses the returned key. The promo-code bypass is retired: founderCode no longer influences price or metadata, and getPriceId THROWS if handed a code rather than silently granting a founder rate. metadata.is_founder is renamed is_founder_audit and the webhook no longer reads it — caller-supplied metadata must never decide who pays the lifetime founder price. TRANSIENT-FAILURE POLICY (a real design call, not a default): if the claim RPC errors we now fail RETRYABLY (503 claim_failed) instead of silently selling at standing. Both silent options are irreversible — standing permanently overcharges someone who was entitled to founder, and granting founder without a slot pushes past the 100 cap at permanent prices. A full cap is NOT an error and still returns standing normally, per "never error to the customer": a full cap is a real answer, a DB blip is not. B3 WEBHOOK FINALIZES THROUGH THE SINGLE WRITER. checkout.session.completed calls finalize_founder_slot, which flips user_profiles.founder_pricing (canonical) and mirrors users.founder_status in the SAME txn, so they cannot drift again (they already had, 1 vs 0). Verify-after-write re-reads the profile and logs the end state. If finalize errors, the tier is still set so a PAID customer is never left unentitled, but no founder flag is guessed. B4 SIGNATURE VERIFICATION was already present (constructEvent with STRIPE_WEBHOOK_SECRET + express.raw). The live endpoint exists and is enabled: https://api.vyndr.app/api/stripe/webhook subscribing checkout.session.completed, customer.subscription.created/updated/deleted, invoice.payment_succeeded/failed. VERIFICATION: V1 boot assertion proven by simulation. V2 all four prices retrieved live and confirmed active with correct amounts and monthly recurrence (14.99 / 24.99 / 44.99 / 59.99) — read-only, nothing created. V3 no code path grants founder except the claim (greps clean; the legacy helper now throws). V4 the handler reads customer/subscription/metadata.user_id and calls finalize with signature verification in place. V5 reset to a pristine 100 free / 0 claimed baseline with both founder flags at 0. Secrets live only in .env (0600, gitignored, untracked). A pre-commit scan confirmed NO tracked file contains the key material. Floor: 320 suites / 3984 passed, 3 skipped (superseded founder-code tests), web build exit 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc |
||
|
|
5930f18d81 |
Free proof surface: /record — tier-record-forward, honest CLV building panel
Presentation over existing endpoints. src/ untouched (git diff empty): no grade, model or ledger change. Pricing/migration are Builds 2/3. TIER-RECORD-FORWARD. /record reads the canonical public aggregates (/api/accuracy + /api/ledger/model) and prints them as-is. B 60% n512 and C 57% n413 ship with C honestly BELOW B; A (n2), D (n5) and F (n5) render HOLLOW with their real sample instead of a rate. Sport slicing (all/mlb/wnba) is client-side because the endpoints ignore ?sport= — mlb 526 @62%, wnba 411 @54% come from the sports map. THE LOAD-BEARING RULE, enforced in lib/proofRecord.js and locked by tests: where the source withholds a percentage it stays null. A is 1/2 and therefore 50% is derivable — a test asserts we do NOT derive it, because the API withheld it on purpose (n < 20). CLV IS AN HONEST ABSENCE, NOT A NUMBER. beat_close_pct is null because clvCaptureReliable() has not passed. The panel says "NOT PUBLISHED YET" and explains that any percentage printed today would be measuring our collection gaps as much as our edge; it surfaces the accruing sample (937) but no rate. Tests assert the panel never falls back to clv_beat/clv_sample (34/937 = 3.6%) and that the serialized panel contains no "3.6" — that number is computable and would be wrong, which is the exact fabrication this surface exists to refuse. The panel is built to receive a real number later without a redesign. HELD, and named on the page rather than faked: calibration and accuracy-over-time are absent because there is no honest source (no claimed-vs-actual endpoint; window_days fixed at 30 with no series). The page says so, and says it is not because they are unflattering. A page-level test asserts no hard-coded percentage exists in the markup, so no figure can drift from the aggregate, and that the page never touches /api/snapshot or itemized rows — the Build-1 gate holds and the exploit stays dead. Floor: 320 suites / 3986 tests green (16 new), web build exit 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc |
||
|
|
713f90183f |
Build 1 CORRECTED: itemized grades are PAID (live AND settled) — exploit killed
Serving/gating change only. src/services/ untouched: no grade, model or
settlement-logic change. Pricing = Build 2, migration = Build 3.
WHY THE PRIOR GATE WAS WRONG: freeing grades at resolution made the free tier a
ONE-DAY-DELAYED FEED OF THE WHOLE PRODUCT — settlement is nightly, so a bettor
watching one cycle behind got the entire method free. There is now NO
per-grade resolution flip: an itemized grade, tonight's or last week's, is
Analyst+.
FREE now gets, none of it itemizing the nightly slate:
1. the full data aggregator (unchanged — schedule, per-book lines, stats,
streaks, hubs)
2. the AGGREGATE track record, which ALREADY EXISTS and is public:
/api/accuracy (sample 937, byGrade tiers, per-sport mlb+wnba, min_sample 20)
and /api/ledger/accuracy (per-grade buckets). The honest-record laws are
already honored there — A/D/F return pct:null under the n>=20 threshold
rather than a fake percentage.
3. a CAPPED, day-rotated sample of resolved calls for texture: cap 3, stable
within a day, rotates across days, and only RESOLVED rows are eligible so a
live read can never be sampled. The cap is what kills the exploit — three
rotating past calls cannot reconstruct a nightly slate, whereas the full
settled list is the feed one cycle late.
4. the locked shell of tonight's reads: they exist, and their shape.
EVERY itemized grade for an unentitled tier now loses grade, confidence,
confidence_basis, reasoning, kill_conditions_triggered, projection, edge_pct,
matchup_grade, form, alt_lines and kelly, and is stamped locked. Free-side DATA
survives so the board still reads as real: player, market, line, book_odds,
fair_odds (the de-vigged fair number is the free hook and is never the paywall),
season/last10 stats, archetype — and `outcome`, because a RESULT is a fact
rather than a judgment.
The tease stays aggregate-only (live_locked {count, tiers}) computed from the
ungated rows and never joined back to one, and no gated row carries a grade, so
nobody can work out which prop is the A.
Floor: 319 suites / 3970 tests green, web build exit 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
|
||
|
|
6d36e05bfe |
Build 1: the settled/live gate — unresolved is paid, resolved is free
Serving/gating change only. src/services/ untouched (git diff empty): no grade,
model or settlement-logic change. Pricing and migration are Builds 2 and 3.
Push scoring untouched.
THE RULE: a grade is PAID while its outcome is unknown and becomes FREE the
moment it resolves.
Resolution is read ONLY from a written outcome — never from time, game status or
gradedAt. A game can be final long before the settle pass runs, so treating
"probably over" as settled is exactly how a live edge would leak; a test asserts
an hours-old gradedAt with no outcome is still LIVE. void and unrecoverable ARE
resolutions (terminal results, no live edge left). isResolved FAILS CLOSED:
null outcome, {} with no result, and empty-string result all read as LIVE, so a
settlement failure withholds content rather than exposing it — the same
direction resolveTierFromRequest fails.
FREE/ANON: settled grades pass through IN FULL, reasoning and kill conditions
included — settled reads are the proof product and cost nothing once the outcome
is known. That also converts the previously-unenforced board reasoning leak into
a deliberate rule rather than an oversight.
LIVE grades for unentitled tiers are reduced to a shell: every piece of model
JUDGMENT is dropped (grade, confidence, confidence_basis, reasoning,
kill_conditions_triggered, projection, edge_pct, matchup_grade, form, alt_lines,
kelly) and `locked: true` is stamped so the card renders the unlock prompt. The
free-side DATA stays so the tease is real rather than empty: player, market,
line, book_odds, fair_odds, season/last10 stats, archetype, gradedAt, history.
fair_odds deliberately survives — the de-vigged fair number is the free hook and
is never the paywall. A test asserts the serialized free row carries no trace of
the withheld judgment.
THE TEASE IS AGGREGATE ONLY: live_locked = {count, tiers} computed from the
ungated rows and never joined back to one, and no gated row carries a grade — so
a free viewer learns that N reads exist and their tier shape without being able
to work out WHICH prop is the A.
Gate order in the route: stripModelPrice (S67) first, then gateLiveGrades.
Entitled tiers get the array back by reference — zero cost, zero change.
Floor: 319 suites / 3971 tests green (10 new), web build exit 0.
One test note: the route-level supertest case was removed deliberately — it
needs a live Redis and hangs on ioredis' reconnect timer in a single-suite local
run (known behaviour, CLAUDE.md). The gate contract is fully covered by pure
tests; the wire is verified against prod anonymously in the fingerprint.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
|
||
|
|
085e8a3a63 |
D1 finish: row-hover rationale, IntersectionObserver reveal, team-gradient chips
Additive frontend. Backend untouched (git diff src/ = empty): no grade, model,
classifier or ledger change. Scope held to the row anatomy these three items
need — no System-artboard-wide rebuild. Push scoring untouched.
REVIEW ZERO — the two checks that decided whether these could be honest:
0.2 RATIONALE SOURCE — VERIFIED REAL. Live snapshot grades carry `reasoning`
and `kill_conditions_triggered`. The summary is built by analyzeViaEngine1
from the actual feature vector (l5/l20 averages, gap to the line, home/away,
opponent defensive rank, rest days) and kills carry real codes + reasons.
So the hover shows genuine grade truth, not a placeholder.
0.3 TEAM COLOURS — PARTIAL, and deliberately left partial. The System artboard
defines a colour pair for only 10 teams (BOS CHC CHI DEN LAD MIL MIN NYY PIT
SD), lifted verbatim; lib/teams.js holds ~80. The other ~70 are NOT invented
— a wrong team colour is a recognition error the user reads as fact. Unknown
teams get the honest-neutral chip (muted border, no colour claim), never a
guess and never a blank gap. Coverage is reported by coverage(), not hidden.
SHIPPED:
- web/src/lib/rowRationale.js — rationaleFor() returns real summary + kills, or
NULL. No generic fallback: an empty hover is honest, a manufactured "why" is a
fabricated model explanation. A locked/tier-gated reasoning is treated as
ABSENT rather than paraphrased or leaked, and a kill condition with no reason
explains nothing so it is dropped.
- web/src/lib/reveal.js — IntersectionObserver reveal that fires ONCE then
unobserves ("react to truth, then rest"), reuses D1-A's bootDelayMs for the
60ms stagger so there is ONE source of truth for the timing, and reveals
IMMEDIATELY when IntersectionObserver is absent (SSR/test) so a missing API can
never hide real content. Reduced motion is handled by the existing CSS, so the
row is visible either way.
- web/src/lib/teamChips.js — Rev-3 geometry (10px, 135deg, before the abbr,
inside the row) plus the ranked opacity ramp 1/.86/.64/.48 so chips dim with
their row. Swap-ready for licensed logos at the same size.
Floor: 318 suites / 3961 tests green (15 new), web build exit 0.
The three modules are pure and unit-locked; mounting them into the live row
components is a follow-up, and the visual result belongs in the Chrome audit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
|
||
|
|
3d1a3c7794 |
D1-A: combat glyphs, boundary-channel blue, reaction primitives, READ-FAB
Additive frontend/visual. Backend untouched (git diff src/ = empty): no grade,
model, classifier or ledger change. The 41->74 registry expansion is HELD for
D1-B. Push scoring untouched.
REVIEW ZERO — classifier coverage bounded the glyph wiring. Three buckets, and
the computation was redone three times before it was right (the frontend keys
GLYPHS by ARCHETYPE NAME while the backend keys `glyph:` by SHAPE NAME, and most
registry keys are unquoted identifiers — the first two passes mis-parsed both):
(a) classifier-backed, already wired: 38
(b) classifier-backed, package SVG exists, NOT wired -> WIRED HERE: 6
striker, grappler, pressure, counter, grinder, finisher — all MMA/combat
archetypes in archetypeService.js that were rendering EMOJI fallbacks
('*', 'x', '>', '<>') where the package ships real 24-grid duotone marks.
(c) package SVG with no classifier -> HELD for D1-B: 39 (wiring them would
render nothing)
(d) classifier-backed but NO package SVG: 2 ('dual threat', 'paint boss') —
a DESIGN gap, not a build gap; flagged for D1-B.
GLYPHS map 38 -> 44 keys, deliberately far short of the package's 83.
BOUNDARY CHANNEL — the blue tokens already existed (--priced-out set) and were
applied on NoMarketState and the scan void box, but PriceTriplet's NO_MODEL
("line not priced") still rendered in neutral text, so the channel was applied
inconsistently. NO_MODEL now renders in the channel, completing "every boundary
state or none". Token-only (no hex fallback and no hex in prose — PriceTriplet's
own test forbids literal hex, and it caught both).
REACTION PRIMITIVES — new web/src/lib/reactions.js + globals.css keyframes at the
exact HANDOFF timings: flash .75s ease-out, boot stagger 60ms steps, reactions
gated 1.5s, WIRE hold 6s. nudge() REFUSES a no-op (null/absent direction -> no
flash) so the primitive cannot be attached to an idle loop — a flash without a
new datum is the UI lying about the feed. Reduced-motion honoured.
READ-FAB — aligned to the exact package geometry: 50px circle, translateY(-14px),
6px void ring (was 46px, marginTop -16, 3px ring).
CARD TOKEN — audit correction: #0E0E14 was already tokenised as --bg-1/--card;
the audit's "1 file" was counting the raw hex, not the token. No change needed.
Floor: 317 suites / 3946 tests green (16 new), web build exit 0.
NOT DONE THIS ORDER (reported, not silently dropped): row-hover rationale and
IntersectionObserver reveal (Phase 3 item 8) and team-gradient chips (Phase 4
item 10) are not implemented — they need the System artboard's row anatomy,
which is a larger port than the rest of D1-A.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
|
||
|
|
bf7c0a3c08 |
Wave 3: /compare built (real head-to-head); resolution tail scoped, not shipped
No grade, ledger or scoring change. Push scoring untouched.
REVIEW ZERO 0.3/0.4 — THE RESOLUTION TAIL DOES NOT FIRE. The resolver is
POST /api/grading/resolve (routes/grading.js:208), and its fanout at :356-371
covers webPush, telegram and discord — but:
- share-card generation: SPEC'D-NOT-BUILT. Not in the fanout at all (grep
shareCard in grading.js = 0). shareCards/renderer.js exists with ZERO
callers, so the component is built but no step would ever invoke it.
- push notifications: BUILT-NOT-FIRING. In the fanout but gated on
webPush.configured() (VAPID). push_subscriptions = 0 rows and
user_notifications = 0 rows — nothing ever subscribed or delivered.
- Telegram result posts: BUILT-NOT-FIRING (gated on BOT_TOKEN + CHANNEL_ID).
- Discord result posts: BUILT-NOT-FIRING (gated on webhookFor('results')).
- recap (all-Final trigger): SPEC'D-NOT-BUILT. No recap file exists in src/.
AND THE WHOLE TAIL IS UNREACHABLE: nothing calls /api/grading/resolve — there is
no ESPN poller in the repo. The live settlement path is the scheduler's
settleAllOutcomes + settleAllLedgers, which fans out to opsNotify only (ops
alerts), with no user-facing output. So even the built channels have no trigger.
Per the order's own rule, ShareCard, /notifications, result posts and recap are
therefore ALL SCOPED, none shipped — no dead shells over a silent pipeline.
BUILT — /compare. Semantics (0.2): a same-market head-to-head, two players with
every row a measure BOTH sides are scored on, aligned via alignRows so the
numbers are comparable — deliberately not two disconnected graded props. Reads
the live /api/stats/player/:name?sport= aggregate. Honest-absent three ways: an
unresolved side reads NO DATA while the other still renders; a measure only one
side has renders a dash, never 0; if neither resolves the page refuses to
compare. NO VERDICT — it shows measures and says the reader draws the call.
Two pre-existing tests (vyndrPhaseE, vyndrParityQA) asserted the in-development
placeholder; both superseded rather than deleted — they now assert the stronger
properties against the real page (live fetch, no sample players, NO VERDICT,
NO DATA, "not a zero").
Floor: 316 suites / 3930 tests green (10 new), web build exit 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
|
||
|
|
ff7f5d8d2d |
Wave 1: wire /intelligence, /slip, /parlay + /marketplace honesty pass
Wiring + one copy pass. No grade, ledger, model or scoring change (diff empty
across intelligence/, ledgerService, outcomeService, gradeSlateService).
REVIEW ZERO — each surface proven with real data BEFORE wiring:
0.1 /intelligence vs /system are NOT duplicates. System.dc.html is a
multi-surface artboard (TERMINAL + INTELLIGENCE + WIRE sections), not the
design for a distinct /system route; its INTELLIGENCE section is already
realised as the live app/intelligence/page.tsx. No /system page exists and
none should be built as a second copy — the prod 404 is correct.
0.2 /intelligence renders live and gates SERVER-side, not by blur: the proxy
requires auth and limits by tier (desk 50 signals / non-desk 8), and
returns 401 to an anonymous caller (verified live). No leak.
0.3 /slip parses a real DraftKings slip end to end: 3/3 legs,
needs_review false, Aaron Judge total_bases over 1.5 @ -115. Honest limit
recorded: parsers are layout-rigid, an unsupported layout yields ZERO legs
rather than wrong ones (never-guess), so real-world OCR hit-rate across
layouts is CANNOT DETERMINE until user slips arrive.
0.4 /parlay direct route hits the real correlation builder on the same
ParlayContext the drawer uses.
0.5 /marketplace advertised four unbuilt things but made NO performance or
profit claim, and its capture was already real (/api/waitlist upserts to a
waitlist table). The gap was tense, not fabrication.
WIRED: Nav MORE gains Intelligence, Slip Reader and Marketplace; Parlay Lab
re-pointed from the drawer hash to /parlay (the drawer is unaffected —
ParlayPanel stays mounted with its floating badge).
GATING: /intelligence added to GATED_ROUTES because its feed 401s signed-out, so
an ungated link would land visitors on a permanently empty page. /parlay stays
OPEN deliberately — it is the free parlay funnel and gating it would be a
monetization regression.
/marketplace honesty pass: every item body now opens "Not built yet." /
"Not written yet." / "Not produced yet." with what is planned; the subhead states
it is not a purchase, not a pre-order and not a promise of a ship date; the
playbook item carries "No profit claim, no promised return". The capture stays
real — no fake button. Unit-locked.
Floor: 315 suites / 3920 tests green (12 new), web build exit 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
|
||
|
|
0997334f8b |
Order B: retire edge_pct display. Promotion gate NOT passed — no flip.
THE PROMOTION WAS NOT PERFORMED. Champion grade path byte-identical (diff empty
across intelligence/, gradeSlateService, snapshotService). Projection, p_win and
the CLV instrument untouched.
REVIEW ZERO IS A GATE AND THREE OF FOUR PREREQUISITES FAIL:
0.1 scores are ESTIMATED priors from the founding spec, not measured. The
premise's cited values are not in the code either — the module holds
nba:points .80 and mlb:total_bases .55; there is no NBA 0.72 and no WNBA
score at all.
0.2 VERSION-BOUNDARY TAG DID NOT LAND — config/modelEras.js has zero shading
references. It was deliberately not applied twice (nothing had been
promoted) and reported both times. The order's own rule says STOP.
0.3 NO ROLLBACK FLAG EXISTS — zero occurrences of SHADING_ENABLED /
EDGE_SHADING / shadingEnabled anywhere in src/.
0.4 takeable tags DID land (migration 034, 1246/1254 rows). PASS.
AND THE APPROVED DELTA DOES NOT MATCH THE MEASURED ONE. Approved: 43.6% of
grades re-letter, efficient markets tighten and soft hold. Measured on all 1250
live rows: 97.4% change (1217), 79.8% move UP, 17.6% down, resulting in 79.0%
A-family (MLB 93.4%) against the champion's 0.2%. And rows_actually_shaded = 0
of 1250 — 96.5% of markets are unscored (f=1) and the one scored market present
is the anchor (f=1.0 by construction). The entire re-letter comes from switching
to edge-vs-fixed-bar grading, NOT from efficiency shading, which is inert on
this board. That is an unapproved grading-basis change riding along, which the
order's own "no new scaling changes riding along" guardrail forbids.
Flipping would re-letter 97.4% of an append-only public record, move 79.8% of
grades UP and mint A's on 79% of the board, on a letter whose measured
correlation with outcomes is r ~ 0.005 — the exact scenario the permanent
founder ruling forbids.
SHIPPED — ORDER B (independent of the promotion, and a live falsehood):
edge_pct display retired from GradeResultCard (confidence strip, EDGE stat cell
now honest-absent, alt-ladder rung) and SoccerGradeResult. DeskShowcase kept
(already honest). Computation and the board's signed-edge sort fallback SURVIVE
— deleting them would re-break the sort fixed on 2026-07-29; a test asserts all
three survive and the sort still orders agrees -> disagrees -> absent.
Fixed two build-breakers the retirement caused (orphaned edgeColor import,
orphaned edge_pct destructure; edge_pct stays on the props contract). Two
pre-existing tests superseded rather than deleted: they asserted the edge figure
is sign-coloured, and now assert the stronger property that no edge percentage
renders at all.
Floor: 314 suites / 3908 tests green (9 new), web build exit 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
|
||
|
|
c2c7abbb65 |
Edge-shading challenger: built + measured. Flooding NOT fixed — input scale is the bug
Challenger only. Champion grade byte-identical (verified by diff). Nothing promoted, no live grade re-lettered, no ledger row deleted or re-settled. BUILT src/services/challengers/efficiencyShading.js (measured-never-served): adjusted_edge = raw_edge * f(efficiency); grade = band(adjusted_edge) against ONE fixed bar (A+>=10, A>=5, B>=3, C>=1, D>=0, F<0) that never moves. f(e) = E_SOFTEST/e bounded to (0,1] — soft markets intact (never amplified), sharp shaded toward but not past zero, unscored -> f=1 and FLAGGED. A fence test asserts no production grade path imports it. Cross-market behaviour is unit-proven: the same raw 6% edge grades A in soft mlb:total_bases and B in sharp nba:points. MEASURED on 1250 live ledger rows — Phase 2.5's answer is NO, the flooding is not gone: challenger 79.0% A and 80.9% A/B (MLB 93.4% A) vs champion 0.2% A. TWO findings explain why, and they are the point of the order: 1. The shading is a NO-OP on the live board: rows_actually_shaded = 0 of 1250. 96.5% of rows are UNSCORED (f=1), and the one scored market present (mlb:total_bases) is the anchor so its f is 1.0 by construction. mlb:strikeouts and nba:points do not appear in the ledger at all (our basketball is wnba, not nba). Challenger vs baseline: 0 rows changed. 2. Placement was never the bug — the INPUT SCALE is. Against a fixed 5% bar the RAW edge already clears A on 100% of MLB doubles, 89.6% of hits, before any shading. MLB median raw edge is 60%, twelve times the bar. Decisive test: apply the sharpest score in the spec (f=0.647) to EVERY row — the maximum the design permits — and 75.8% still clear A (MLB 91.7%). Since f is bounded <= 1, no achievable shading can close a 12x overshoot. Moving the multiply from the threshold to the edge does not change the outcome. This is edge_pct behaving as the 2026-07-29 diagnosis described: a price-free (proj-line)/line gap whose scale is a function of line size. It is not a betting edge, so no fixed betting-edge bar is meaningful against it. 2.6 efficient-market over-suppression: CANNOT DETERMINE — zero live rows are shaded, so there is no efficient market in the data to over-suppress. Phase 3: takeable tagging was completed in the previous order (migration 034, 1246/1254 rows) and is not repeated. The model-version boundary is again NOT applied: nothing promoted, so no boundary exists. Unblocking needs the input replaced, not the multiply moved: p_win vs fair_prob (both already computed) instead of edge_pct, plus scores FIT from our own record for the markets we actually grade. Floor: 313 suites / 3899 tests green (9 new), web build exit 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc |
||
|
|
2bfaeff572 |
Ledger takeable tagging (deferred C2); efficiency challenger BLOCKED
Champion grade UNCHANGED. Push scoring untouched. Additive tags only — nothing
deleted, nothing re-settled.
PART A — THE EFFICIENCY CHALLENGER: BLOCKED, NOT BUILT.
Review Zero came back ABSENT on all three inputs:
0.1 efficiency scores DO NOT EXIST (zero occurrences of market_efficiency /
marketEfficiency / efficiency_score in src/ or web/src/).
0.2 base thresholds DO NOT EXIST (engine1.js has zero `edge` references — the
grade is not an edge-vs-threshold comparison; grade_thresholds.json holds
PROBABILITY bands).
0.3 the +/-0.05 additive efficiency nudge DOES NOT EXIST. The only 0.05s on
the grade path are featureCache.teammate_absence_bump, a bvp_advantage
cutoff, and p*0.9+0.05 inside probabilityEstimator (the 0.5*0.1 term of
the shrink-toward-0.5). There is no additive scaling to replace.
So a challenger differing from the champion in EXACTLY ONE thing cannot be
constructed: there is no additive scaling to swap, no base threshold to
multiply, and engine1.js has zero `sport` references so market cannot reach the
grade. A threshold must exist first — that is R1 of
specs/full-output-grade-mapping.md, an explicitly held separate order. Shipping
R1+R4 together would make the Phase-3 delta report misleading: the re-letter
would be driven mostly by switching to probability grading while being
presented as the efficiency fix.
0.4 coverage: the spec names 5 scores; the live ledger has 11 markets and only
MLB total_bases maps to one. 9 of 11 have no score, so "all scored markets"
cannot be satisfied without inventing 9 numbers.
PART B — LEDGER TAKEABLE TAGGING: BUILT (the deferred C2).
New src/config/takeableStandard.js: floor on the minus side, UNCAPPED plus.
Deliberately NOT valueEngine.isTakeable (the -160..+200 PROMOTION band) — a
+400 prop is not promotable but IS takeable; a test asserts the two diverge on
the plus side and agree at the floor so they can never quietly merge. Absent
price returns null, never false (Number(null) === 0 would tag a missing price
takeable). The floor is POLICY not derived (C1 could not derive one) and is
labelled so; each row records takeable_floor so a re-derivation can re-tag.
Migration 034 (applied + tracked): ledger_entries.takeable boolean +
takeable_floor numeric, nullable, partial index. Forward tagging in
ledgerService at row build; backfill in one statement.
Result: 1254 rows, 1246 tagged (781 takeable / 465 below floor), 8 NULL with
null_despite_price = 0 (the NULLs are genuinely priceless rows). Settled 1163
and graded 1254 unchanged.
PART C — the model-version boundary tag is DELIBERATELY NOT APPLIED: no scaling
change shipped, so no boundary exists, and stamping one would mark a model
transition that never happened. modelEras.js is its home when a real one lands.
Floor: 312 suites / 3890 tests green (8 new), web build exit 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
|
||
|
|
72a14dc4cd |
Build /api/props/top-graded server selector: rank with p_win, serve without it
New READ endpoint. No grade, ledger row, lock_line, or scoring write. Push
scoring untouched.
REVIEW ZERO CORRECTED THE PREMISE: the handler NEVER EXISTED in any commit
(searched git rev-list --all for a /top-graded definition in src/ — zero hits).
Not "removed" — the three axios callers (cheatsheetGenerator, gradeOfTheDay,
widget) and the Next proxy were written against a phantom endpoint, so those
three content generators have silently received [] for their entire life.
Contract recovered from the four consumers, not guessed: {props:[...]},
?sport=UPPERCASE (absent = all sports, which gradeOfTheDay relies on) + ?limit,
rows carrying player/stat/line/direction/sport/grade/confidence? plus the
player_name/stat_type aliases and game_id.
POPULATED-PATH RISK FOUND: the board's populated branch had never run in prod,
and dashboard/page.tsx:463 calls g.stat.replace(/_/g,' ') UNGUARDED (g.player
also feeds the row key, /scan URL and heading; sport must be UPPERCASE for
SportPill). toRow requires non-empty string player+stat and a finite line,
uppercases sport, and DROPS unrenderable rows — a shorter board beats a broken
one.
THE LEAK BOUNDARY (why this is server-side): the browser cannot rank on p_win
for all tiers because stripModelPrice deliberately withholds it from unentitled
tiers. Order of operations is
read cache -> RANK with p_win (every tier) -> map rows incl. model fields
-> stripModelPrice(rows, tier) -> serialize
so a free caller receives the paid RANKING without the paid VALUES. Tier comes
from resolveTierFromRequest, which FAILS CLOSED to 'free'. Cache-Control is
private under a bearer token, public otherwise (the /api/snapshot precedent).
ONE SHARED DEFINITION, no drift: new src/utils/gradeRanking.js
(takeablePWin/descNullsLast/rankGrades). heroPropService now imports
takeablePWin instead of its inline copy (behaviour unchanged — it was that
logic verbatim); the selector imports rankGrades; web/src/lib/slateAdapter
keeps its mirror (the browser cannot import src/, S25) and a test cross-checks
the two on identical fixtures (playerName.js precedent). Board is grade-first
("top GRADES"), hero is p_win-first ("top read") — they differ BY DESIGN and
agree within the leading tier.
HONEST LIMIT: the Next proxy (cachedBackendJson) sends no Authorization header
and caches under a shared key, so via the dashboard every viewer gets the
free-tier payload — correct order, no paid values. That is the SAFE behaviour;
forwarding auth into a shared cache is exactly how a paid payload leaks to
anonymous viewers. Per-tier delivery through the proxy needs a tier-keyed cache
and is not done here.
Verified on real prod snapshot data (anonymous path): MLB 8 props, WNBA 10,
0 paid-field leaks, render-contract safe on every row, sport uppercase.
Floor: 311 suites / 3882 tests green (18 new — leak test uses POPULATED p_win,
not today's nulls: entitled gets p_win and it drove the order, unentitled gets
a byte-identical order with all five MODEL_FIELDS absent and no trace in
JSON.stringify, while book/fair market facts survive). Web build exit 0.
Dashboard visual is auth-gated -> tagged for the Chrome audit, not faked.
Held: edge_pct rescale/retirement (Order B); board columns/contract unchanged;
tier-keyed proxy caching.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
|
||
|
|
b85b351993 |
Grade-board sort: signed signal, takeable-gated p_win, missing sorts LAST
Display ORDERING only. No grade, ledger row, lock_line, scoring, or edge_pct
scale/display change. Push scoring untouched.
Two defects removed from selectTopGrades (wrong at ANY scale, independent of
edge_pct's separate retirement):
1. edge: Math.abs(numOr(g.edge, -Infinity)) — abs() on an already-
direction-signed value ranked the model's strongest DISAGREEMENTS level
with its strongest agreements (177 public ledger rows carry a negative
edge; positive = the model AGREES with the graded side).
2. Math.abs(-Infinity) === Infinity, so a row with NO edge sorted FIRST —
absent data presented as the top pick (the Number(null) class).
New key: grade -> confidence -> takeable-gated p_win (nulls LAST) -> SIGNED
edge (nulls LAST) -> input order. Scales are never mixed in one comparator.
Takeable band = web valueState.isTakeable, asserted byte-equal to the hero's
config/valueEngine.isTakeable (-160..+200) incl. strict-null.
Alt-line ladder (analyzeViaEngine1:506) no longer sorts by edge_pct: ordered
highest-p_win-first derived analytically at zero added compute — P(stat >= k)
is monotone non-increasing in k, so p_win-desc is line-ASC for an over and
line-DESC for an under. base stays marked; no consumer depends on
alt_lines[0]; deskShowcaseService.rungsOf already re-sorted by line.
THREE PREMISE BREAKS found report-first, before code:
- /api/props/top-graded 404s in prod (absent from src/) so the dashboard
board renders receipts/empty — the edge sort orders nothing there today.
The prior order's "97.3% of rows tie" was a LEDGER measurement wrongly
extrapolated to that board. Fix is correct-in-itself and lands when the
feed is restored.
- p_win cannot be a client-side key for all tiers: snapshotGating strips it
for unentitled tiers ("shipping p_win is shipping the model price").
Verified live: prod /api/snapshot carries p_win on 0/8 MLB, 0/25 WNBA.
- Ladder rungs carry no per-rung price, so the takeable gate is inapplicable.
Verified on real data, both sports, both paths: unentitled — WNBA (n=25)
ordering CHANGED, MLB (n=8) unchanged, signed edge non-increasing in every
(grade,confidence) tie group (20 pairs, 0 violations); entitled — 40 real
ledger rows with p_win+locked_odds, p_win-descending, untakeable chalk NOT
promoted (Trea Turner .757 @-275 does not beat Rhyne Howard .745 @-120)
(36 pairs, 0 violations).
Hero consistency, stated honestly: same signal + same gate, different
precedence BY CONTRACT (board = grade-tier-first "top GRADES"; hero =
p_win-first "top read"). Identical within the leading tier (verified); across
tiers the board may lead with an A the hero doesn't pick. Not a contradiction.
Floor: 310 suites / 3864 tests green, web build exit 0. Dashboard + Desk
visuals are auth/feed-gated -> tagged for the Chrome audit, no visual faked.
Held: edge_pct rescale/display retirement (Order B); building the missing
/api/props/top-graded selector; exposing p_win to unentitled tiers.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
|
||
|
|
41b86e3874 |
Hero ranking fix: rank by champion p_win among takeable, honest empty state
Review Zero found the hero's ACTUAL behavior was worse than "unknown": it ranks on ev_pct (heroPropService v2), but ev_pct is NULL on served grades and Number(null)===0 made Number.isFinite(Number(null)) TRUE — so every prop tied at EV 0 and the "top read" was really the FIRST takeable A/B prop in cache order (arbitrary, dressed as ranked). v3: rank by the CHAMPION's p_win (the only signal with a promising, not proven, edge — its takeable-MLB-over CLV survived the skew audit) among A/B, TAKEABLE- priced reads (isTakeable band -160..+200, same as the proof/audit). Strict null guard kills the Number(null)=0 bug. Takeable filter is mandatory (raw p_win crowns -300 chalk). NO backfill: nothing qualifies → honest empty state (available:false, reason:'no_qualifying_read'), never a weak recent read. p_win is RANKING-ONLY, server-side — toHero never exposes it and the route strips it. Framing unchanged in substance (model number vs book number, grade, timestamp) — no proven-edge / +EV / best-bet claim, no CLV/ROI/edge number. Display-only: reads snapshot caches, writes to nothing (no grade/ledger/lock_lines). Full suite 3852 green, web build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VsztNChZ7vEvSR61AuMhD1 |
||
|
|
2ab2eeaa7d |
Wire BookComparison to the prop card (display-only, honest states)
BookComparison.tsx was built but UNROUTED (dead). Route it to the GradeResultCard via a new self-fetching BookComparisonPanel that reads the live /api/books feed (source:'bookprices' — the snapshot-locked, fenced, byte-identical store). Contract fix (Review Zero 0.1): books frequently sit at DIFFERENT lines (WNBA DK 21.5 / FD 18.5; MLB 2/3), so BookComparison now renders EACH book's own line per-row — never one shared header line implying a false same-number comparison. Honest states: single-book (the common case for MLB) → one book, "One book posting this prop.", NO crown/second row; multi-book → all books' own line+price, NONE crowned (BOOK_CROWN_ENABLED=false — no best-price claim, verified live crowned:false); no books → renders NULL (panel self-hides), never a placeholder. No regression: only the always-empty inline d.books section was replaced; grade, projection, PropLine line, and PriceTriplet price are untouched (wiring test asserts them). Freshness (0.4): bookprices is written in the SAME snapshot that locks the grade (intraday refresh touches neither gradedAt.line nor bookprices) — same fresh, no stale-label needed. Web-only → grade byte-identical trivially. Full suite 3851 green, web build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VsztNChZ7vEvSR61AuMhD1 |
||
|
|
c7067c80c4 |
Persist lock-time multi-book lines to lock_lines (unblocks the staleness audit)
The over-side skew audit's confirming check — was our locked line stale-high vs consensus AT LOCK — was BLOCKED because multi-book lines at lock were never persisted (bookprices is Redis current-only). This persists them. - migration 033: lock_lines table (tracked + applied to prod). One row per (graded prop × book) with both odds + a lock timestamp. RLS enabled, NO policies -> service-role only (fence). UNIQUE key -> idempotent re-runs. - lockLineCapture.js: buildLockRows (pure, graded-props only, honest-absent single-book) + idempotent upsert persist. Built from the in-memory props at the LOCK moment (ts) -> no Redis re-read, no TTL race. - snapshotService: persist right after `enriched` (the lock moment; gradedAt uses the same ts). Best-effort + fenced. FENCE (measurement-only): lock_lines is read by NOTHING on the grade path (gradeSlateService, snapshot dedup/indexOdds, challengers, selector, ledger) — a grep test asserts it, and RLS locks it to the service role. Grade byte- identical proven: runSnapshot grades are identical with persist on/off (test). Volume ~1.5-3k rows/day (graded props x books x 5 snapshots); weeks retained, no pruning needed short-term. Does NOT retroactively fix the existing 62 rows — future accrual only; confirmation still needs weeks of settled rows. Full suite 3842 green, web build exit 0. No grade/locked_odds/outcome/served surface changed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VsztNChZ7vEvSR61AuMhD1 |
||
|
|
6552281661 |
CLV instrument repair: fix attachClosingProb read + recoverable market_unavailable
The closing_prob funnel collapsed 100k priced captures -> 59 usable. Root cause (VERIFIED against prod, join key is PERFECT with 0 mismatches): - attachClosingProb read closing_captures with .limit(50000) and NO ORDER BY on a 730k-row table that is 86% refusal rows -> saw ~7% for MLB, missed most priced closes and declared 200+ rows closeless that HAD a capture. - market_unavailable_reason was write-once/terminal, so a row wrongly declared (truncated read / premature declaration before the capture was visible) could never recover even once its genuine capture existed. 298 rows (204 MLB + 94 WNBA) were stuck this way. Fix (CLV computation only — no grade/locked_odds/outcome touched): - Read ONLY priced captures (missed_reason IS NULL, both odds NOT NULL), scoped to the candidate rows' game_dates -> small AND complete, no arbitrary truncation. - Drop the market_unavailable exclusion from candidates; make it a re-checkable absence: a genuine close now UPGRADES the row (writes closing_prob, clears the verdict). closing_prob stays write-once (first true close wins). No capture + past game -> still declared absent (honest). No churn on already-absent rows. - New internal trigger POST /api/internal/ledger/attach-closing[/:sport] for backfill + verification (scheduler already runs attach per tick). Recovers ~312 usable closes (59 -> ~371), MLB included. Capture itself was healthy all along (94.9% MLB / 95.8% WNBA per-prop coverage). Full suite 3835 green (17/17 instrument tests incl. 2 new recovery cases), web build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VsztNChZ7vEvSR61AuMhD1 |
||
|
|
6bc18d823c |
Honesty pass: remove every live fabrication (REMOVE/HIDE only, no feature cut)
Six live untruths corrected — no grade/snapshot/scorer/pipeline touched: 1. /compare — hardcoded Jokic A+/Wembanyama A + fake VERDICT replaced with an honest in-development state; removed from Nav + BottomTabBar (route still resolves, never the sample). Real two-player build is later. 2. Pricing — founder Desk $34.99→$44.99 (matches lib/checkout.js), Analyst $14.99; removed the struck $19.99/$44.99 "regular" numbers and DeskShowcase's stale $34.99. First-100 counter is real (ClaimMeter → Stripe countFounderSeats); no fake "first 50" desk claim added (no such counter exists). 3. FAQ "NexaPay" → Stripe (verified: live checkout is Next→Express→checkout.stripe.com). 4. FAQ + Features "Brier/CLV published from day one" removed (not surfaced yet) — returns when real. Backend Brier compute untouched. 5. MobileEdgeBoard removed from the Slate — its edge% feed was a miscalibrated placeholder (masked >40% as "—"); phones now show the real game cards. 6. Price triplet — never-computed model/EV now derives NO_MODEL (honest absent, MODEL "—" / "NOT PRICED", no verdict) instead of QUARANTINE's false "we suppressed our price / a leg is poisoned" copy. Fixes grade card + LiveHeroProp. Full suite 3833 green, web build exit 0. Tests updated to the new honest contracts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VsztNChZ7vEvSR61AuMhD1 |
||
|
|
e81c9b8c51 |
Book Comparison Phase 1-3(backend): fenced per-book store + honest gated crown
Per-book prices existed only transiently (odds cache, ~1h, raw names, grade-path
input); every grade-path persistence point collapses to one book. The
/api/books feature was built+mounted but non-functional (fed FLAT rows to a
GROUPED comparator -> always empty).
Phase 1: bookPriceStore captures per-book prices from `props` BEFORE dedupeProps,
keyed nameKey|stat, into bookprices:{sport} (SNAP_TTL) in snapshotService. Fenced:
reads props, writes its own key, read by nothing on the grade path. Grade proven
byte-identical (test + no-grade-path-reference grep test).
Phase 2: scripts/measure-book-spread.js reports same-line best-vs-worst spread
(cents + implied-prob pts), per sport, never pooled. Pre-registered crown
threshold: median >=8c OR >=2pp. Runs post-deploy on real data.
Phase 3 (backend): compareProp is honest-absent (single-book/flat -> no crown)
and the crown is gated (BOOK_CROWN_ENABLED, default OFF until Phase 2 clears).
/api/books repointed to the snapshot-locked store (fallback odds cache),
nameKey-matched; `source` field is the deploy fingerprint.
HELD unchanged: dedupeProps, snapshot dedup, selector, grade, champion,
challengers, ranking, edge_pct/ev_pct. UI routing of BookComparison + crown
treatment deferred to post-measurement (gated on Phase 2). Full suite 3834 green,
web build exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VsztNChZ7vEvSR61AuMhD1
|
||
|
|
914a057611 |
proj-v1 book-implied: raw odds → de-vigged FAIR (fix self-flattering basis)
proj_book_implied derived from raw book_odds — VIG-INCLUSIVE. A -110/-110 market
implies 52.4%/side (104.8% sum); fair is 50%. Comparing our P against raw book
overstates the book on both sides, biasing the handicapper test IN OUR FAVOR; on
juiced longshots (the Judge HR -18.5pt case) much of that "edge" was vig, not
disagreement.
Fix (fenced to proj-v1's stored comparison basis): proj_book_implied now derives
from DE-VIGGED FAIR via the grade's g.fair_prob — the SAME multiplicative de-vig
the triplet uses (utils/devig.js), so the basis matches the product's shown fair.
Expressed on the OVER basis (under props → 1 - fair) to match our stored P(≥rung);
traded-rung ladder book_implied likewise. HONEST-NULL where fair is uncomputable
(one-sided market, ~14%) — NEVER a raw-book fallback (that would recreate the vig
bias on a subset and mix two bases in one ledger). proj_factors records
book_implied_basis ('fair_multiplicative'|'none').
Phase 0 (prod-verified): fair reachable at store point (g.fair_prob on the grade,
no threading); 86% batting coverage; method = multiplicative/proportional.
Phase 2 FLAG: multiplicative de-vig mis-splits vig on juiced longshots (favorite-
longshot bias), so a longshot fair still carries known method bias — flagged
per-row (longshot_devig_caveat); a better de-vig (Shin/power) is a separate item.
Phase 3: version bumped proj-v1 → proj-v1.1 so pre-fix (raw-book) and post-fix
(fair) rows never silently mix — the projection model is byte-identical, only the
basis changed; pre-fix rows can't be recomputed (only the graded side's odds were
stored). Champion + arch-v1 + contact-v1 + proj-v1's other columns untouched.
proj suites 26/26.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
|
||
|
|
e96b0dbb6d |
proj-v1: book-implied from book_odds (grades carry odds, not fair_prob)
The live fingerprint showed proj_book_implied null on every real row: grades carry book_odds/locked_odds (e.g. -264) but NOT a de-vigged fair_prob, so keying the book comparison off fair_prob yielded null. The book ODDS are exactly "the book's implied probability" the handicapper test needs. Now proj_book_implied + the traded rung's book_implied derive from americanToImplied(book_odds), expressed on the OVER basis (under props → 1 - implied) so it's directly comparable to our P(≥rung). Vigged (a known offset the ledger measures both sides of). proj-v1 suites 24/24. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj |
||
|
|
316b79733e |
proj-v1 sanity fixes (caught in the real-data induction)
1. matchupRead fly-ball signal: the batter metrics `gb_pct_bb`/`fb_ld_pct` are MISLABELED — they're exit velocities by batted-ball type (Judge fb_ld_pct = 100.3 mph, not a rate), not ground/fly RATES. Switched fly-ball lean to avg_launch_angle (league p10/p50/p90 = 7.1/13.9/20.1°), the correct signal. 2. Absolute rate now fits the FULL season (recency-weighted), not a 20-game window: the window under-sampled rare stats — Judge HR projected 0.11 vs his 0.28 season rate (a fake -32pt edge). Now point=0.27 (matches season); the last-5-2x recency lean is preserved. Post-fix induction (real statsapi logs + real statcast): Judge HR 0.27 (P>=1 0.235 vs book 0.42 -> flags the juiced over), Judge TB P>=2 0.548 vs 0.48 (+6.8pt), thin-hot 3-game P>=1 0.726 / P>=3 0.164 (credible low, thin high), .300 hitter != 3.0. proj-v1 suites 23/23. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj |
||
|
|
6386e737b9 |
proj-v1: absolute matchup projection challenger (distribution + full ladder)
A THIRD challenger (after arch-v1, contact-v1), MLB batting v1. Champion is market-relative P(stat>LINE); proj-v1 is ABSOLUTE — what the hitter will DO — emitted as a full distribution from which the WHOLE LADDER (P≥1,P≥2,P≥3) derives. Champion untouched; nothing claimed; the ledger decides per rung, per stat. - projection/distribution.js — Bayesian Gamma-Poisson → negative-binomial predictive. Admits over-dispersion; under-dispersion → Poisson approx (conservative, documented). Uncertainty scales with sample by construction (r=α): thin → WIDE (real mass on P≥1, honestly thin P≥3), thick → tight. NEVER abstains — width carries the honesty. - projection/matchupRead.js — the input the book doesn't use. HONEST FIDELITY: pitcher repertoire is rich (97% pitch-mix) but hitters have NO pitch-type performance, so TRUE repertoire-vs-profile is impossible today. This is the COARSE version (arsenal buckets fastball/sinker/breaking + whiff/hard-hit tendency × hitter whiff/chase/gb-fb/hard-hit) — beats generic L/R, derived + documented + TESTED two-sided. A hitter pitch-type feed unlocks the true form. - projectionChallenger.js — park RELATIVE to the player's own log exposure (isHome→own park, away→opp park; Phase B's raw-multiply bug solved), recency- weighted fit, per-factor breakdown (form/park/weather/platoon/matchup — show your work), full rung set + book-implied per rung. Combined non-form multiplier bounded. - Wired after contact-v1, own try, flag PROJ_V1_ENABLED, reusing arch-v1's already-computed park/weather/platoon (no duplicate env I/O). Own ledger columns (migration 032, applied to prod): distribution, ladder, point, line, our-P, book-implied, factor breakdown — measurable per rung/stat after settle. Phase 0 (prod-verified): venue join via isHome; NB family; uncertainty-as-width; coarse matchup honest fidelity; no lineup-slot (per-game rate, volume implicit). Sanity: thin-hot → wide (credible low rung, thin high rung); .300 hitter ≠ 3.0; matchup two-sided; champion byte-identical. proj-v1 suites 23/23; snapshot/ ledger/siblings 74 green. Forward-only, version-stamped, PROJ_V1_ENABLED kill. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj |
||
|
|
b6f12daa98 |
Contact-quality challenger (contact-v1) — nominate, don't swap
Phase A #2: the champion grade (l5/l20 result-based form) is a HYPOTHESIS that contact quality predicts better — unmeasured on our props, with zero settled p_win yet. Swapping l5/l20 (the champion's two heaviest ±1.0 factors) blind could degrade the core grade undetectably for weeks. So this NOMINATES contact quality as a second challenger, records what it WOULD project per prop, and lets the settled ledger decide. Nothing users see changes; the champion is untouched. - src/services/contactChallenger.js — pure, mirrors challengerProjection. Log- odds lean (capped, never a re-forecast) from SEASON contact quality vs league percentiles. Metric→prop mapping is the whole game: barrel_pct→HR, hard_hit_pct→TB/doubles, k_pct-INVERSE→hits (singles resolve on contact frequency, not barrels), k_pct→batter K. rbi/runs/walks ABSTAIN (opportunity/ discipline — no clean contact predictor). Honest-absent: thin (<50 PA)/absent/ unmapped/non-batter → p_win_contact NULL (no projection), never a fallback; "measured but unremarkable" is distinct (equals champion, delta 0). - Wired in snapshotService AFTER arch-v1, reusing the already-loaded statcast rows; its own try so a second challenger can't break the pipeline. Reads g.p_win, never writes it. - Retained SEPARATELY on the ledger (p_win_contact/contact_delta/ contact_adjustments/contact_version='contact-v1') so each challenger's marginal contribution is measured independently; ledger_entries.stat gives per-prop-type segmentation. Migration 031 (applied to prod). Phase 0 (prod-verified): statcast_aggregates is SEASON cumulative (not rolling), 48h stale now but season-scoped so ~8 PA/600 is negligible; 100% of graded hitters covered, 92% at ≥50 PA; no xBA/xwOBA in the feed. Forward-only, version-stamped (contact_version null on pre-nomination rows). Promotion is a LATER decision on settled evidence, per prop type — never asserted here. contactChallenger 14/14; snapshot/ledger/arch-v1 suites 80 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj |
||
|
|
125919f86a |
Scanner reskin: amber → blue boundary channel + build never-built S6/S7 states
Semantic COLOR fix, not cosmetic. The S6/S7 build predated the current Scanner
States spec: it used AMBER (the quarantine / model-suppressed channel) for the
"no market / line not priced" case, telling users "model suppressed" when the
truth is "the board never priced this." Corrected to the HANDOFF Session-3
blue-boundary law: BLUE (--priced-out #8FB2DE) = no-market boundary; amber stays
QUARANTINE; red stays REFUSAL.
Phase 1 (reskin): NoMarketState → dashed BLUE void box + blue header/copy; the
S7 rows → spec format (o 27.5 · BK −114 · ◆ −105 · OPEN READ ▸) at 44px,
390-legible. Input-area surfacer pills reskinned to the blue channel too.
Phase 2 (never-built states, only those Phase 0 confirmed against live data):
- GREEN CTA with LIVE player count ("PLAYER · N PRICED PROPS ▸"), degrading
honestly to the board path ("N PROPS LIVE · TONIGHT'S BOARD ▸") at 0 — count
from the SAME fresh index as the rows (pricedCountForPlayer), can't disagree.
- CASE A none-priced DEFAULT: "WE PRICE THESE FOR [player]" — the player's other
priced stats (pricedStatsForPlayer, filter by nameKey).
- Typed-line-mismatch blue fact line ("o X ISN'T PRICED · NEAREST ↓").
FLAGGED / not built (no shells): Case C off-slate quiet-stop needs schedule/
roster membership the pricedLines index doesn't carry (out of the presentation
fence). Spec CONTRADICTION: Case B says "fair previews amber," but the law
reserves amber for quarantine — fair renders NEUTRAL ◆ (blue-dim), not amber, to
avoid blurring the channel.
Free-tier gate VERIFIED before rendering FAIR: fair_odds is the de-vigged MARKET
price (valueState: "never hide the honest fair number"), NOT the gated
model_odds — no paid leak. Carried through indexPricedLines (additive; keying/
refresh/onPick/stale-tap all unchanged — the proven S7 data path is untouched).
Scan A byte-identical; PRICED_NUDGE_ENABLED still the kill switch; reversible.
Build exit 0; priced + parity suites green (67).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
|
||
|
|
83e9da3663 |
Consistency classifier: CV → index of dispersion for low-mean counts
The A/D investigation found CV (std/mean) is scale-broken on count data —
for a Poisson-ish stat cv ≈ 1/sqrt(mean), so EVERY stat with mean < 4 blew
past the boom_bust cutoff regardless of behavior. The S63 stopgap made those
return 'unknown', which silently ate a real +1.0 consistency signal on every
MLB batting prop — steady low-mean hitters never got their earned factor.
Fix, fenced to the low-mean branch of consistencyScore (the only branch that
was returning 'unknown'): classify with the index of dispersion (variance/mean,
Poisson baseline 1.0) — the scale-appropriate, UNBIASED statistic for counts.
mean ≥ 4 keeps the NBA-calibrated CV path BYTE-IDENTICAL (zero NBA blast
radius). This is a bug CORRECTION, not threshold loosening: the CV thresholds
and the engine1 ±1.0 delta are unchanged.
Bands (asymmetric around Poisson 1.0, since counts are naturally mildly
over-dispersed): iod<0.60 elite / <0.85 reliable (+1.0) / ≤1.30 volatile
(neutral) / >1.30 boom_bust (−1.0). Sample floor MIN_GAMES_FOR_IOD=8 so a
thin sample abstains ('unknown') — no small-sample guess.
Validated on real 10-game logs (two-sided): Kwan hits 0.67 / Alonso hits
0.78 → reliable (RECOVERED); Alonso TB 2.57 / Henderson hits 1.33 → boom_bust
(no false consistency); HR mean 0.1 → 1.0 → neutral. Direct engine1 proof: a
strong steady prop that grades B+ today reaches A- once the +1.0 fires; a
boom-bust bat stays B (no inflation). A- now emerges NATURALLY from a real
recovered factor. Standing two-sided test pins all three directions.
Forward-only (settled grades are locked in the ledger, never re-graded).
Emitting A- ≠ proving A- — the A-tier record accrues from emission, still
measurement-gated. Full unit suite green (4 pre-existing redis/timing flakes
pass in isolation); web build exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
|
||
|
|
4f3f433aae |
Complete S7 priced-line nudge: freshness on a long-open page + reversible gate
REPORT-FIRST correction. This order's premise — "S7 is a shell that doesn't update per selection" — is not what the code does. pricedForSelection is a useMemo on [pricedIndex, selectedPlayer, stat] and setSelectedPlayer/setStat fire on every user pick, so the chips already update per selection, and the prior session's verification of that stands. The genuine gap was FRESHNESS: the snapshot fetch depended on [sport] only, so pricedIndex was fetched once per sport-change and never refreshed. The pricing cron re-prices at five UTC hours, so a scanner left open across a cron boundary surfaced hour-stale priced lines. That is the real defect, and the only one fixed. FRESHNESS. The fetch is now a refreshPriced callback re-run when the held snapshot is older than PRICED_STALE_MS (30s, matching the /api/snapshot cache) at the moment of use — on selection change and on window focus — so a long-open page never shows a stale line. Sport change still clears the index first, so the old sport's lines never flash. STALE-TAP was already safe and is unchanged: the scan submit re-fetches the live snapshot server-side, so a chip that's gone stale between render and tap either lands on a real triplet (still priced) or degrades to the honest empty state (rotated away) — proven in the prior session and re-confirmed here (an off-snapshot line returns no market and shows the empty state). REVERSIBLE GATE. The whole nudge sits behind one PRICED_NUDGE_ENABLED flag: false empties the surfaced set, so the scanner falls back to S6's link-only empty state with the chips gone. Shipping enabled only after the cases are proven this session; the flag is the instant revert lever. DISPLAY-LAYER ONLY. Only scan/page.tsx changed. GradeResultCard, PriceTriplet, gradeAdapter, valueState, both scan routes and the pure pricedLines helper are byte-identical — Scan A and the scan-submit resolution are untouched, and the change is independently revertible. Tests 3765 passed / 303 suites, web build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj |
||
|
|
e311f53738 |
Read-card scan: honest no-market empty state + surface real priced lines
The Session-78 diagnosis stands: the join works, and a marketless scan rightly
shows no triplet. This makes that absence legible and points the user at what IS
priced, without fabricating a market.
PHASE 0 gate — design-check, reachability, timing, all clear. Design-check: the
bundle has the triplet's own REFUSAL language ("we'd rather show nothing than a
number we can't stand behind") as the honesty precedent, and a designed
EmptyState component whose actions give a path forward — so the empty state is
built in the established visual language, not freelanced. Reachability: the
scanner already fetches games/odds/search per selection; the snapshot is one
more public, 30s-cached fetch per sport, re-run when the sport changes.
Staleness: the snapshot rotates 5x/day and every scan re-validates the market
server-side at submit time, so a surfaced line that goes stale degrades to the
empty state on tap rather than a vanishing triplet — the stale-tap guard is
inherent, not bolted on.
Reversibility was the design constraint. The working card, price triplet, grade
adapter, valueState and the scan route are BYTE-IDENTICAL — a test asserts none
of them even reference the new empty state. Everything new lives in two added
files (lib/pricedLines.js, components/vyndr/NoMarketState.tsx) and additive
blocks in the scan page. Removing them leaves the Scan-A path untouched.
Non-fabricating by construction: indexPricedLines only keeps snapshot rows that
carry a real book price, keyed by exact player+stat via nameKey. A different
stat priced for the same player surfaces nothing for the picked stat; an
off-slate player surfaces nothing; nothing is suggested, interpolated, or
rounded to a nearest line. The empty state shows no market numbers of its own —
only real priced lines as one-tap chips, or a link to the live board when there
are none.
Framing is help, not restriction: a "PRICED TONIGHT" chip row sits under the
free-typed line input, and the scanner still accepts any player, stat and line.
Tapping a chip pre-fills the priced line and re-scans it — the market is
re-resolved server-side, so the tap either yields a real triplet or degrades to
the honest empty state.
Path forward, not a wall: a marketless scan no longer dead-ends in blank space.
It states truthfully that the board didn't price that line, keeps the grade, and
routes the user to the priced lines for that exact player+stat or to tonight's
board.
Tests 3760 passed / 303 suites, web build exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
|
||
|
|
4c9707ffbb |
Platoon polarity: VERIFIED correct, pinned by a standing test
Report-only verification of the two unproven claims from the Session-77 wiring, which fired platoon on synthetic split-less hitters where the K=600 regression zeroed the multiplier and masked both direction and resolution. No product code changed — this adds one standing regression test. FIXTURE — Yordan Alvarez (LHB), real 2026 splits, deep both hands: 115 PA vs LHP at .529 slg, 327 PA vs RHP at .695, overall .652. The regression leaves a material multiplier both ways (0.97 vs LHP, 1.023 vs RHP), so unlike the last test this fixture can actually reveal direction. RESOLUTION — verified on the live slate that the pitcher-hand attached to a hitter is the OPPOSING team's probable, not his own. CLE (home) resolved to Minnesota's away starter 696070; MIN (away) resolved to Cleveland's home starter 800048. The chain — hitter's team, the game, the other team, that team's probable, that pitcher's hand — is correct, and it is pinned independently of direction because a backwards resolution is invisible on a neutral hitter. DIRECTION — deterministic L-vs-R on the frozen Alvarez fixture. Facing RHP nudges UP (1.023) because he slugs .695 there, above his .652 overall — a favorable opposite-hand matchup, exactly what platoon theory predicts for a left-handed bat. Facing LHP nudges DOWN (0.97). The two move opposite directions, and crucially the SPECIFIC sides are asserted, not merely "opposite" — a mirrored-but-inverted implementation would put RHP below 1 and fails here. Both- backwards is ruled out. The test also pins a REVERSE-split hitter, Brandon Nimmo, who hits better vs LHP than RHP. His multiplier goes up vs LHP, following his real numbers rather than a hardcoded LHB-vs-RHP assumption — proof the sign is data-driven, which is the correct design. VERDICT: PASS. Resolution correct, direction correctly signed against both the real split and platoon theory. Pinned by tests/unit/platoonPolarity.test.js so the polarity cannot silently regress — the opp_rank_stat lesson applied. Tests 3750 passed / 302 suites. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj |
||
|
|
7b25d97891 |
Wire the four dormant adjusters live — pure input-wiring
Verified state going in: parkBase, weatherMod and platoonSplits were called by nothing, and env_multiplier was non-null on zero rows across four orders. The adjusters were correct in isolation and starved of inputs. This gives them their inputs and changes none of their internal logic — the five adjuster files are byte-identical after this commit. PHASE 0 GATE — all three inputs are available at snapshot build, and the two join keys already existed. Venue: always, on every schedule game object. First-pitch: always, gameTime on the same object. Opposing-pitcher hand: present once the probable is declared, via the pitchers endpoint's pitcherId joined to statsapi handedness — 15 of 15 games declared this afternoon, though morning locks precede declaration and those props honest-absent on platoon, correctly. The batter-handedness join (statcast bats) and the MLBAM id were already on each grade from earlier sessions. environmentContext.js is the wiring, kept separate from the adjusters so they stay pure. It fetches once per snapshot: the schedule (team to venue, gameTime), probable pitchers (team to opposing pitcher id), one batched handedness call, one Open-Meteo forecast per home park, and batter splits per graded hitter. Park coordinates for 30 parks live here as public geometry, the same class as the dome list and centre-field bearings already in weatherMod, rather than inside an adjuster. Everything is best-effort: a missing venue drops park and weather, an undeclared pitcher drops platoon, and any fetch failure degrades that prop to archetype-only rather than breaking the pipeline the adjusters are measured inside. attachChallenger becomes async and takes a per-grade contextFor that returns the environment coefficient (park_base x weather_mod, composed) and the matchup (platoon). Point-in-time holds: the weather is a forecast for first pitch fetched now, and the split is the hitter's line entering the game — neither reads a settle-time value. Attribution is independent. env_multiplier, env_park_base, env_weather_mod and env_weather_state land in their own ledger columns, and challenger_adjustments keeps every axis — archetype, environment, matchup — as a separate entry, so when volume accrues each of the four can be measured for its own marginal contribution rather than as one blended delta. The combined move stays bounded, tested on the worst case: a Coors slugger with wind out and a favourable platoon, all at once, still moves under 12 percent, because every layer is capped and the total nudge is clamped. Stacking leans, it does not compound into a re-forecast. Non-MLB honest-absents entirely — park, weather and platoon are MLB-only today, so a WNBA prop gets no environment and no matchup. The champion is untouched throughout: p_win is read, never written, the served snapshot payload is still the enriched object, and a test confirms p_win passes through byte-for-byte while the challenger moves. Tests 3741 passed / 301 suites, web build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj |
||
|
|
6dd6f59481 |
Layer 3 Step 6: platoon splits, regressed hard
The highest-value adjuster and the thinnest sample in baseball. The regression is not a refinement here, it is the entire feature: applying raw splits would adjust projections on noise, which is worse than not building it. PHASE 0 — both gates clear, and one was already closed. Splits are a statsapi pull, one call per hitter (statSplits with sitCodes vl,vr). The batter-handedness join that Session 69 recorded as pending is in fact DONE: statcast_aggregates carries bats for 604 of 604 batters, 210 left, 327 right, 67 switch. STATE said pending; the data says otherwise, and the note is corrected. Point-in-time holds as long as the split is fetched before first pitch, since a season split queried this afternoon cannot contain tonight — but a historical backtest would use season-final numbers and leak, so clean measurement is forward-accruing. THE SPINE — regressed = (PA x observed + K x prior) / (PA + K), with K = 600 PA and the prior being the hitter's OWN blended rate rather than the league's. The question a platoon adjustment answers is whether he is DIFFERENT against this hand than he normally is, so his own line is the correct null and a hitter with no evidence of a split correctly gets nothing. K is deliberately conservative: platoon skill is famously slow to stabilise, with the half-signal point for right-handed batters near a thousand PA. THE MAKE-OR-BREAK TEST, both halves. A .310 average against left-handed pitching on 30 PA gets 4.8% weight and moves the projection by 0.003 — essentially nothing, which is the correct answer rather than a limitation. The SAME .310 on 400 PA gets 40% weight and moves it by 0.023, eight times as far. A test asserts that ratio stays above five, so if the regression ever breaks the suite says so instead of the projections quietly drifting onto noise. Real data behaves exactly as the mechanism predicts and is worth recording: Josh Bell hits .259 against lefties and .248 against righties, which looks like a platoon split until the sample speaks — 126 PA earns 17% weight and the adjustment lands at 1.005. Aaron Judge, 76 PA against lefties, comes out at 0.999. Neither is material. Most hitters will get nothing from this adjuster, and that is the honest output, not a failure. Honest-absent has five distinct routes, all returning exactly 1.0: no batter handedness, no pitcher handedness, no splits, a stat platoon says nothing about, and a missing side falling back to the prior rather than to zero. INDEPENDENT of the environment. Park and weather compose into one coefficient because they both describe the stadium; platoon describes this hitter against this pitcher's hand, so it rides its own slot with its own label. Entangling them would make both harder to attribute when the instrument scores them. Directional, mirrored on the under, capped at 15%, and inverted for strikeouts where a higher rate means a higher prop rather than a better hitter. Tests 3729 passed / 300 suites, web build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj |
||
|
|
9f60ceba10 |
Layer 3 Step 5: weather modulation composed onto the park base
Completes the coupled environment: effective = park_base x weather_mod. Weather tilts the park, it never overrides it — a wind-out night at Oracle Park is still Oracle Park. PHASE 0 — both feeds are free and keyless. statsapi /venues gives every park's coordinates in one call; Open-Meteo returns hourly temperature, wind speed and wind direction for those coordinates hours before first pitch, which is when we project. Verified live. THE SPINE — two weather values, two purposes, never crossed. The FORECAST we held at projection time drives the live adjustment AND is what the instrument measures, because it is what we actually knew. It lands on the ledger row beside p_win. The ACTUAL goes only to game_context as raw material for future self-derived weather factors, and is read by nothing that scores a projection. Using the actual to measure tonight would be scoring ourselves on information we did not have. The actual is also pulled from Open-Meteo's ARCHIVE endpoint rather than the forecast endpoint, because asking a forecaster after the fact returns a re-forecast, not what happened. WIND IS PARK-ORIENTATION CONDITIONED. Wind direction is meteorological — the direction it comes FROM — so blowing out to centre means arriving from the opposite bearing. Getting that backwards would invert every wind adjustment in the system, so the 180-degree rotation is commented at the site and pinned by a test on all three cases: straight out, straight in, and crosswind. Centre-field bearings are public geometry, in the same class as the dome list; a park missing from the table gets no wind effect at all rather than a guessed one, and keeps its temperature effect. THREE HONEST DO-NOTHING STATES, all multiplier 1.0, none fabricating an effect. Dome: weather does not apply, and the PARK factor still does — verified that a domed venue keeps its sub-1.0 park base while weather stands down. Forecast absent: none available for this park and time. Sub-threshold: a real forecast below a meaningful bar, because manufacturing a 0.3% nudge on a light breeze is false precision. Weather also says nothing about a strikeout prop and returns not-applicable rather than a neutral it might later be tempted to fill. Conservative and ledger-tunable: every magnitude is an env var, the total is capped at 12%, and nothing here is asserted. This is a nominated challenger that earns its place on the instrument or is cut. Induced at Wrigley, whose centre field bears 32 degrees: wind from 212 at 15 mph computes as 15 mph straight out, weather 1.12 composed with park 1.06 for an effective 1.187 and a +0.043 nudge; the under mirrors exactly; the pitcher's home-runs-allowed prop moves with the hitter's, since both are P(over) on a ball leaving the park. Wind in drops the coefficient to 0.955. A calm 72-degree evening, a dome, and a missing forecast all return 1.0 by three different honest routes, with the park base still applying in each. One correction to the order worth recording: it describes a wind-out night as helping the hitter and hurting "the pitcher there's HR-allowed" as opposite sides. In prop terms both go the same way — the HR-allowed OVER is more likely too. The sign lives in the stat, exactly as established for park factors, and the implementation follows that rather than the phrasing. Migration 036. Tests 3707 passed / 299 suites, web build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj |
||
|
|
f33091ddb8 |
Layer 3 Step 4b: public park base, source-pluggable, plus game-level capture
PHASE 0 — the settle path sees a player's game-log line, not the game. It knows date and teams, never venue or final totals. But the grain is far cheaper than per-prop or even per-game: ONE statsapi schedule call per game DATE returns every game that day with venue, linescore and scoring plays. Fifteen games, one call, verified live. PUBLIC BASE — the ingestion was already done. The static FanGraphs table from Session 15 is the public base; this converts its 100-indexed values into the multipliers the composable architecture wants (Coors 128 becomes 1.28) rather than ingesting a second copy of a number we already hold. It is labelled COMMODITY in the code, not just in a comment. Every resolution carries a provenance record, and the public one reads proprietary: false with the note "Commodity: a public number. Not a VYNDR derivation." The proprietary label exists but belongs only to the self-derived version, and only once it beats this base on the instrument. A surface rendering a park effect can state which it is rather than implying the flattering one. Honest-absent where even the PUBLIC number is thin: a relocated club in a temporary venue gets no factor, because a public number for a park with one season behind it is no more trustworthy than ours would be. SOURCE-PLUGGABLE is the architectural point. resolveParkBase() is the only accessor, public and derived return identical shapes, and callers never branch on source — so when self-derived factors clear their floor they swap into the same slot with nothing downstream to rewrite. A derived source with no factor available returns absent rather than silently falling back to public, because a silent fallback would make a proprietary claim out of a commodity number. GAME-LEVEL CAPTURE starts now because it cannot start retroactively. Game grain, deduped on game_id, never copied onto prop rows — a game's totals belong to the game, and duplicating them per prop is how one fact starts disagreeing with itself. Every field is tied to a named future derivation: venue for park factors, runs for the run environment, HR totals for HR factors. Nothing else is stored. Only Final games are captured, since an in-progress total is not a result, and a game with no scoring plays reports HR as absent rather than zero. HR totals come from scoring plays, which is complete because every home run scores at least the batter. The accrual target is stated rather than promised: 150 home games per venue at roughly 81 per season means about two seasons before a self-derived factor can be nominated, and accrualStatus() reports live progress per venue so the wait is measurable. Induced: Coors home runs +0.061 for the hitter and identically +0.061 for the pitcher's home-runs-allowed at the same park, mirrored on the under; San Francisco negative; Tampa flagged weather-N/A with its factor still applying; the Athletics' temporary venue absent; strikeouts untouched. A real 2025-07-19 capture produced 15 games across 15 venues, 12 with HR totals, zero duplicate game ids. Migration 035. Induce with POST /api/internal/gamectx/:date, progress at /gamectx/accrual. Tests 3688 passed / 298 suites, web build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj |
||
|
|
3ac91c3d96 |
Layer 3 Step 4: derived park factors, composable for weather
PHASE 0 GATE — the answer is BOTH, and the important half was already here. A STATIC FanGraphs park-factor table has existed since Session 15 (src/data/parkFactors.js) and computeFeatures already consumes it, so park is not a new idea in this codebase. What was missing is OUR derivation. I nearly built a second source of truth before finding it; the new service lives at src/services/parkFactors.js and the two are deliberately distinct. That discovery changes the point of this order rather than just its scope. If the champion already sees a park factor, adding one to the challenger risks double-counting — which is exactly the redundancy the Session-72 harness exists to catch. So park ships as a NOMINATED CHALLENGER whose job is to be tested for marginal contribution, not as an assumed improvement. Checked and worth noting: the static table reaches computeFeatures but NOT probabilityEstimator, so it does not currently touch p_win at all. DERIVATION, not ingestion. statsapi gives every game with venue, linescore and scoringPlays in one call per date range — and since every home run scores at least the batter, HR totals are fully recoverable from scoring plays. Derived from 5,055 real games across 2022-2025: Coors tops the run environment at 1.099, Dodger Stadium tops home runs at 1.106, Oracle Park and PNC suppress them at 0.923 and 0.917. Eighteen parks cleared the floor, eighteen did not and are honestly absent. COMPOSABLE BY CONSTRUCTION — the architectural point. Park emits a multiplier around 1.0, never an additive nudge, because weather has to modulate it next order: effective = park_base x weather_mod. Additive terms do not compose correctly (a 5% park and an 8% wind are 1.05 x 1.08, not +13%), and the challenger converts the multiplier to log-odds so stacking stays correct. A test multiplies a placeholder weather term onto the park base to prove the shape composes with no rearchitecting. DIRECTIONAL BY PROP-OWNER: home_runs and home_runs_allowed both key off hr_base in the same direction, because the sign lives in the STAT, not the park. Coors inflates the hitter's home run prop and the pitcher's home-runs-allowed prop identically. THREE HONEST STATES, deliberately distinct. Absent (thin sample, adjust nothing), present (adjust), and weather_na for domes — where the park factor STILL APPLIES because a dome has a real run environment, and the flag exists so next order's weather modulation correctly does nothing there. N/A is not absent; conflating them would either drop a valid park factor or apply wind indoors. Structural breaks: a season deviating past the threshold starts a new regime only if the FOLLOWING season confirms it — one odd year is noise, two consecutive years on the same side is a rebuilt park. Only post-break seasons are used, so a humidor or moved wall cannot be diluted by the stadium that preceded it. Factors regress toward neutral by sample size, so a two-season park cannot assert a Coors-sized coefficient, and fine conditioning stays unavailable until its own larger floor. Tests 3669 passed / 297 suites, web build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj |
||
|
|
927e867a23 |
Layer 3 Step 3: Tier-1 mappings live; Tier-2 nomination harness
PHASE 0 GATE — historical out-of-sample testing is NOT available, and the reason matters. statcast_aggregates is overwritten nightly by design (Layer 1 is a full re-pull upsert), so it holds season-TO-DATE numbers with no point-in-time history. Classifying a player for a 15 July game using today's aggregate would feed the model games from 15-21 July — look-ahead leakage, and the resulting "out-of-sample" verdict would be worthless. The harness therefore reads the archetype vector RETAINED at grade time (Session 70's instrument) and runs FORWARD-ACCRUAL, not historical. Reported rather than worked around. CANONICAL NAMES ASSERTED. Every mapping references the axis keys the classifier actually emits, and a test walks both maps against BATTER_AXES / PITCHER_AXES. A key that does not exist would look active and never fire — a mapping that appears wired while silently doing nothing is the exact failure this guards. TIER 1 IS LIVE, tautological and directional: PUNCHOUT/WHIFF raises strikeouts; SINKER/SEAM lowers home runs allowed and FLY BALL/ELEVATOR raises them (a ball on the ground cannot leave the park); SURGEON ARM/PINPOINT lowers walks allowed; SLUGGER/BOMBER raises total bases and home runs; TECHNICIAN/SURGEON raises hits and lowers strikeouts; GRINDER/SNIPER raises walks. Each adjusts only its named stat, mirrors exactly on the under side, and leaves an average player untouched. SPEED IS HONESTLY ABSENT. BURNER/stolen-bases has no axis to key on — SB is a statsapi field that never reached the aggregate store, so Layer 2 shelved it. The mapping is an empty object rather than an invented one. THE TIER-2 HARNESS tests MARGINAL CONTRIBUTION, not correlation. A ground-ball arm obviously correlates with fewer home runs; the question is whether the archetype explains the PROJECTION'S RESIDUAL (outcome minus p_win). If the projection already knows it, the residual carries no signal and the mapping is rejected as redundant — that hurdle is what catches double-counting. The split is by DATE, never random, because rows from one game share a pitcher, a park and a lineup and would leak across a random split. Direction is validated from the held-out data and a contradicted sign is REJECTED, never silently flipped to whatever the data says, which would be fitting noise. LIFECYCLE ENCODED — nominated, live, claimed. A mapping that survives runs live and is measured; only the quantified public claim waits for the ledger. Nothing sits dark. One fixture bug worth recording: my first synthetic generator aliased the carrier selector against the outcome draw and manufactured a 0.038 effect where the generator had put zero. The harness rejected it correctly — it just gave the sign reason instead of the redundancy reason, which is how I found it. The draw now uses a coprime modulus. Real candidate run end to end, GROUND-BALL to hits-allowed: INSUFFICIENT, 0 of 200 settled rows, because no settled row carries p_win yet (Session 70's instrument starts recording at the next new lock). That is the correct verdict and the expected one. Tests 3654 passed / 296 suites, web build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj |
||
|
|
f2da9dd7e8 |
Layer 3 Step 2: archetype-aware CHALLENGER, measured not claimed
The champion (probabilityEstimator -> p_win) keeps serving and grading users, completely unchanged. The challenger is a second probability computed from the same inputs at the same instant, landing on the same ledger row so it joins to the same outcome and the same close. Identical conditions, one difference — the only clean A/B. NOTHING IS CLAIMED. Running a challenger is honest beta; asserting it is better before the settled ledger says so is not. Promotion stays a later decision gated on Brier and calibration over sufficient segmented volume. INTERPRETABLE, NOT A RE-ESTIMATION. The challenger is the champion's probability adjusted by the Layer-2 axes, applied in log-odds space so a nudge cannot push past 0 or 1 and means the same thing at p=0.5 as at p=0.9. Every deviation is attributable to a named axis and a signed nudge, stored as challenger_adjustments, and the total is capped at 0.45 log-odds — a lean on a real signal, never a re-forecast. Only mechanically obvious stat/axis relationships are mapped; a speculative mapping would be the same guessing this layer exists to replace. IDENTICAL WHERE THERE IS NO SIGNAL, by construction. An unremarkable player, a thin sample, an unmapped stat or a missing classification all return the champion's probability byte-for-byte with an empty adjustment list and a stated reason. The experiment therefore differs only where archetype-awareness could possibly help or hurt, with no dilution from rows the treatment never touched. Induced on real players. Judge home runs over: 0.42 -> 0.447, via BOMBER +0.22 and WHIFF RISK -0.11 — two real opposing signals netting positive. The same prop under mirrors it exactly to -0.027. Judge strikeouts: delta exactly 0, because WHIFF RISK and GRINDER cancel — an honest "no lean" with both signals still recorded. Skubal strikeouts over: 0.60 -> 0.702 via WHIFF, TRAPDOOR and CANNON all aligned; his hits-allowed goes the other way, 0.50 -> 0.392, because a strikeout arm makes hits less likely. Josh Bell and a 12-PA sample are untouched. Isolation is structural: adjust() is pure, the champion field is read and never written, the served snapshot payload is still the untouched champion object, and a challenger failure is caught so it can never break the pipeline it is measured inside. Statcast aggregates load once per snapshot run rather than per prop, so grade-time I/O stays at zero. Migration 034. Tests 3634 passed / 295 suites, web build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj |
||
|
|
474ebc5d3a |
Fix the close-attach: de-vig raw prices, not a column that does not exist
Caught by inducing on real rows. The first attach ran and marked 642 rows market-unavailable while attaching ZERO closes — because it selected a `fair_prob` column from closing_captures, which has none. That table stores over_odds and under_odds deliberately (Session 64) so the de-vig can run later against the same engine the grade-time fair price uses; asking it for a probability returns nothing and makes every row look closeless. The de-vig now runs here, via devigTwoWay, which is what makes lock and close comparable at all. A one-sided capture yields no fair probability and is correctly not a close. Repair checked rather than assumed: the 642 markings turn out to be CORRECT — every one is a game from before closing capture existed on 2026-07-20, so those rows genuinely have no close and the absence is true. Zero capture-era rows were wrongly marked. The bug would have mis-marked every future row, which is what the fix prevents. Two tests added: the de-vig path with real prices, and a source assertion that the query never again asks closing_captures for a column it does not have. Tests 3616 passed / 294 suites. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj |
||
|
|
c5580f333e |
Layer 3 Step 1: wire the measurement instrument
Step 0 found we have been flying without one. p_win lives only in model_snapshots, which has 1,000 rows and ZERO settled outcomes; the closing line lives only in closing_captures, which carries no link to a result; and ledger_entries, the row that actually settles, carries no probability at all. So "is the projection calibrated" and "does it beat the market" have never been answerable — the entire measurable universe was 35 rows recovered by a lossy in-memory join. PHASE 0 — closing coverage verified BEFORE reuse, because an instrument built on a partial close measures a biased subset. closing_captures holds 70,254 rows of which 13,364 are usable, and the 56,890 refusals are candidates we never graded plus one-sided prices — not refusals of our props. Coverage on graded props since capture started is 83/83, 100%. Safe to reuse, with the honest caveat that capture only began 2026-07-20. THE FOUR-TUPLE NOW LANDS ON ONE ROW. ledger_entries gains p_win, fair_prob_lock, archetype_vector and projection_locked_at at LOCK time, and closing_prob plus closing_captured_at from the append-only capture store. The join is the whole point: calibration is p_win against outcome, market-comparison is p_win against the close, and both become plain SQL on one record instead of a join that silently drops 90% of the rows. p_win and the archetype vector are IMMUTABLE — written once at lock via the existing ignoreDuplicates upsert, never re-derived at settle. A re-derivation would measure a projection we never made. The archetype is stored as the VECTOR, not the label. "Did archetype-awareness help?" can only be answered against the axes that were live at grade time, and a single text column cannot express a blend. A grade with no archetype stores null rather than an empty object. HONEST-ABSENT BOTH WAYS. A past game with no usable capture is marked market_unavailable_reason and never given an imputed line; calibration still scores on those rows, only market-comparison is absent. And a game that has not started yet is NOT declared closeless — a close can still arrive, and premature absence is as dishonest as imputation in the other direction. One bug caught before it shipped: the scheduler hook iterated a SPORTS identifier that does not exist in that scope. Inside its try/catch it would have thrown ReferenceError every tick and silently never run — the instrument would have looked wired and captured nothing. Now iterates cadence.ALL_SPORTS. The baseline accrues FORWARD. Historical p_win and closes are gone, discarded before this existed. Calibration and market-comparison stay honest-absent until volume accrues. Tests 3614 passed / 294 suites, web build exit 0. Migration 033 applied. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj |
||
|
|
7ac6aa73e3 |
Layer 2: multi-axis archetype classifier; the FLEX fallback is gone
A player is a blend across independent axes, not one label. Skubal is a STARTER and a strikeout arm and a ground-ball arm and a control arm — four true things at once, and single-label classification threw three of them away. AXIS INDEPENDENCE WAS MEASURED, NOT ASSUMED. Correlations over the live store (467 batters, 531 pitchers); anything |r| >= 0.70 is one underlying trait and was collapsed so we never show one trait as two archetypes. Batter k% ~ whiff% +0.89, hard-hit% ~ exit velo +0.88, chase% ~ swing% +0.87, chase% ~ bb% -0.72; pitcher k% ~ whiff% +0.76, gb% ~ fb% -0.73 — all collapsed. The survivors are genuinely orthogonal, and one result is worth stating: pitcher velocity correlates +0.14 with K%, +0.07 with whiff% and +0.07 with GB%. Velocity is NOT a proxy for missing bats — a hard thrower who misses no bats is a real distinct type, so CANNON earns its own axis rather than being folded into STRIKEOUT. Pitcher K% ~ GB% is -0.10, so PUNCHOUT and SINKER are independent, which is exactly the multi-axis thesis. Cut-lines are the measured p75 (distinctive) and p90 (elite), per role where the tails differ even when the medians agree: reliever GB% p90 is 54.1 against a starter's 48.9, both with a median of 42.5. THE FALLBACK IS DELETED. classify() used to return FLEX (mlb) / SHIELD (wnba) / CONNECTOR (nba) at weight 1.0 when nothing scored — "could not classify" rendered as a fully-confident classification of a real archetype, with descriptive education copy attached. 8 of 18 MLB players carried it, and FLEX could never be earned because its only scoring input had zero writers. Every sport now does what MMA already did: unclassified is absent. Induced on real players. Skubal: STARTER, throws L, WHIFF + SEAM + PINPOINT, all elite. Judge: BOMBER + GRINDER + WHIFF RISK — elite power, patient, strikes out, three true things. Kwan: SURGEON + SNIPER + SLASH with NO power claimed (0.4 barrel% is absent, not "low power"). Josh Bell, who used to classify as DRIVER: empty blend, "No standout profile — league-average across every measured axis." Alan Roden, who was FLEX at weight 1.0 on 21 PA: every axis absent, "Not enough plate appearances yet — no profile claimed." Per-axis honest-absence holds: a velo-less pitcher keeps every other axis, and NO DATA is distinguishable from LEAGUE-AVERAGE rather than collapsing into one shrug. The full vector is stored for Layer 3; only the top three distinctive traits surface. Three existing tests asserted the fallback and were updated to assert absence. One of them surfaced a real robustness gap: classify(sport, null) threw, because an explicit null does not trigger a default parameter and every scorer dereferences its argument. Guarded. Every baseball name is accounted for in docs/ARCHETYPE-AXES.md — built, alias, tier, or shelved with its unlock condition. Zero orphans; cross-sport names left for their sport. Tests 3601 passed / 293 suites. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj |
||
|
|
a49959867d |
Statcast: take the full arsenal, not each pitcher's primary pitch
Caught by spot-checking a real row after the backfill landed: Skubal stored with one pitch. The pitch-movement endpoint with an empty pitch_type returns ONE row per pitcher — their primary offering — so 677 rows for ~700 pitchers, and a five-pitch arsenal was being recorded as a one-pitch one. Not a fabrication, but a silent under-representation of the single most important pitcher-mechanism field, which is worse than useless for Layer 2: it would have classified every pitcher as a one-pitch arm. Mix now comes from pitch-arsenal-stats (3,205 rows = pitcher x pitch type) carrying usage%, whiff%, K%, put-away% and run value per 100 for every pitch. Movement still supplies velo, break and handedness, folded onto the primary pitch; a pitcher present only in the movement feed keeps his handedness and his one measured pitch rather than being dropped. Velo on non-primary pitches is null — absent, not guessed. Skubal now stores 5 pitches, throws L, FF first by usage with velo 96.7. Tests 3583 passed / 292 suites. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj |
||
|
|
a011ae79fe |
Statcast: role belongs in the key (two-way players)
Found by inducing the real job on the server, not by review: the first chunk wrote, the second failed with 'ON CONFLICT DO UPDATE command cannot affect row a second time'. A player can legitimately appear in BOTH the batter and the pitcher feeds — two-way players, position players who pitch, pitchers who bat — so (sport, season, source_id) collapsed two real profiles into one key and a single batch hit the same row twice. Ohtani has a real batter profile and a real pitcher profile. Merging them would invent one player out of two genuinely different sets of measurements, so role goes in the primary key rather than one profile winning. Migration 031 applied; conflict target updated; a two-way case is now a test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj |
||
|
|
528cb1a6d0 |
Layer 1: Statcast mechanism-data ingestion (backfill + nightly refresh)
The data foundation for the archetype and projection layers, built as the pattern every sport inherits. Layers 2 and 3 are not touched. PHASE 0 GATE — both match rates measured live, both 100%. Batters 40/40; PITCHERS 66/66 across five real rosters (CLE, DET, MIN, NYY, LAD) joined by MLBAM id against the 713-pitcher Savant feed. Zero honest-absent on identity, because the join is an integer both systems use natively — and the snapshot pipeline already stores it per graded row. SOURCE — five Baseball Savant CSV leaderboards, free and public, pulled with axios and the CSV parser savantAdapter already runs in prod. pybaseball is deliberately NOT used: it is an MIT wrapper over these same URLs, and adding it would reintroduce a Python runtime in a stack where the existing Python service is already offline. min=1 on every feed, not Savant's default min=q, so the long tail arrives and OUR minimum-sample gate decides what is thin — explicit and testable rather than silently dropped upstream. Measured: 1,354 rows per season (604 batters, 750 pitchers), all five feeds in about five seconds. Pitcher mechanism includes arm angle, GB/FB/LD, chase and whiff; batters get exit velo, launch angle, barrel and hard-hit, chase and z-swing. Handedness rides in free on the movement feed (677 pitchers); batter handedness stays absent pending a roster join rather than being guessed. BACKFILL AND REFRESH ARE THE SAME CALL — a full re-pull upserted on (sport, season, source_id). Idempotent and self-healing: a missed night self-corrects on the next run, with no incremental who-played bookkeeping to drift out of sync. At 1,354 rows the simple thing is also the robust one. HONESTY RULES, each with a test: a metric the feed did not carry is null and never 0; a thin sample is STORED and flagged rather than dropped or inflated, because thin and missing are different claims; an unjoined player is stored with a null player_key and joins later; and if every feed comes back empty the job REFUSES to write, so a bad night can never blank a good table. Freshness is treated as a truth property. updated_at on every row, and the scheduler pages on a failed run AND on silent staleness — a job that stops being scheduled never produces a failure, so staleness has to alarm on its own. Never-built is deliberately not stale: different condition, different fix, and paging on a fresh install teaches the operator to ignore the alarm. Nightly at STATCAST_HOUR_UTC (default 11 UTC, after every game is final), kill switch STATCAST=0, and induce-able at POST /api/internal/statcast/refresh with a freshness probe at /statcast/status — we verify a refresh by running it, not by waiting for the slot. Migration 030 applied. Promoted columns for the classification-critical metrics plus a metrics JSONB carrying every raw field, so Layer 2 can reach something we did not promote without a re-ingest. Raw per-pitch stays out of Postgres on purpose: one season is ~0.85 GB against a 500 MB plan ceiling, and it is re-pullable from the free source if Layer 3 ever needs it. Pattern documented in docs/MECHANISM-DATA.md for NBA tracking and NFL Next Gen. Tests 3581 passed / 292 suites, web build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj |
||
|
|
4e2f488341 |
Forward model_price_locked to the hero — a paywall was wearing poison's copy
Live induction on the deployed landing page caught this; markup review and the unit suite both passed it. With the model leg stripped for anonymous visitors, LiveHeroProp forwarded book/fair/model/ev/quarantine to PriceTriplet but NOT model_price_locked, so deriveValueState fell through to the missing-model-price branch and the card rendered "MODEL READ WITHHELD" — the quarantine state, whose copy says we suppressed our own price because a leg is poisoned. Nothing was poisoned. The real reason was the paywall, and the two must never share a face: one says our data is untrustworthy, the other says you don't have this tier. Now forwarded, with a test asserting both the separation in deriveValueState and the forwarding at the call site. Tests 3559 passed / 291 suites, web build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj |