6bc18d823c5e683b667b961e8f6359425bf4a884
168 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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 |
||
|
|
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
|
||
|
|
3440738d9e |
Landing hero → bundle visual system; claims filtered to earned only
Migrate the Landing hero (Landing-only component; LiveHeroProp/triplet untouched) to the design bundle's visual language: mono "SPORTS INTELLIGENCE TERMINAL" eyebrow, tightened 56px/800 headline "Every line, graded before you bet it.", dual mono CTAs (OPEN THE TERMINAL / SEE THE PUBLIC RECORD), proof chips. Claims audited against the held list — the bundle's marketing copy carries claims we have not earned; those did NOT ship: - "CLV-VERIFIED" chip + "verified against closing lines" — HELD (C4 broken) - "312 props / 47 games" count — fabricated demo → ABSENT (no count) Shipped copy is earned only: pre-graded, edge-ranked, settled in public, misses included; PUBLIC LEDGER / MISSES INCLUDED / 5 FREE READS chips. HELD + FLAGGED for a design pass (fabrication-backed, no honest designed state — check-don't-freelance): the edge-board demo (A+/A don't emit), the "grades calibrate · A+ hit 80%" claim (calibration unmeasured), and the tier-record band (real data is B/C-only, no ROI). Not built with fake data. Tokens via var(--g-a/--void/--border/…) with bundle-hex fallbacks (documented-intentional pattern). Build exit 0; parity QA green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
063e9fb3f7 |
STATE: Session 69 — multi-axis archetypes live, FLEX fabrication deleted
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj |
||
|
|
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 |
||
|
|
aaa41134d4 |
Close the second leak path: /api/hero-prop bypassed the snapshot gate
The landing hero reads the snapshot from Redis DIRECTLY via heroPropService, so it never passed through routes/snapshot.js and was still serving model_odds, ev_pct, value and takeable to anonymous visitors after the first fix. Same strip, same tier resolution, same private-cache rule for authenticated callers; the Next proxy now forwards the bearer token. PRODUCT CONSEQUENCE, FLAGGED RATHER THAN BURIED: the landing hero is served to anonymous visitors, so it now renders BOOK and FAIR with the model leg LOCKED instead of the full triplet it showed this morning. That follows the stated free-tier rule exactly, but it does trade a strong marketing moment (VALUE +21.1% VS FAIR on the shop window) for consistency of the gate. Reversing is one line — add 'model_price' to the free tier in src/config/tiers.js, or special-case the hero route — and is a product call, not a correctness one. Tests 3557 passed / 291 suites, web build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj |
||
|
|
fbcb00b7b1 |
Close the public model-price leak; wire the read card's price layer
PHASE 0.5 GATE — the three checks, and one correction.
`fairLine` does not exist. Zero hits across src/ and web/src. Option A as
written had no referent, but it resolves better than feared: `fair_odds` is
already a real de-vigged American price on every graded snapshot row, so there
is nothing to derive.
Gate 1 (is it a price): PASS. fair_odds is American odds from
impliedProbToAmerican inside devigTwoWay; fair_prob is the probability. Both
distinct from `line`, the stat threshold.
Gate 2 (numeric match): PASS, 8/8 exact. Recomputed fair_odds and fair_prob
independently from the stored raw over/under prices; every value matched the
stored one to the integer and to 3dp. Same de-vig, same numbers the component
was proven against.
Gate 3 (poison independence): PASS, and proven on the quarantined cohort
itself. devigTwoWay's inputs are (over_odds, under_odds) — market prices
only, no model term is reachable. The 8 rows recomputed above are all
wrong_opponent_grade rows, and their fair prices reproduce exactly from the
market. The poison is in the grade, not the price. Quarantine therefore
suppresses the MODEL leg only; the fair leg stands, as designed.
THE LEAK WAS REAL AND ALREADY LIVE. GET /api/snapshot/:sport is public and
unauthenticated, and it was serving model_odds, p_win, ev_pct, value and
takeable to anonymous callers on every graded row — 25 of 25 on the live wnba
board. The Session-66 gate on /api/analyze was bypassed entirely by this
endpoint.
The strip covers more than model_odds, because model_odds is not the only way
to read the model price: p_win IS the price in another base, and ev_pct is
INVERTIBLE — ev is a function of p_win and book_odds, and book_odds is public,
so leaving ev behind hands the price over. All five model-derived fields go.
book_odds, fair_odds, fair_prob, overround and devig_method stay on every tier:
the fair leg is never the paywall. Rows that keep a book+fair pair are stamped
model_price_locked so a gated price is never mistaken for a missing one.
Tier comes from resolveTierFromRequest, which reads a bearer token when one is
present and otherwise returns 'free'. It FAILS CLOSED on every error path, so a
resolution failure can only ever withhold the price. The response now varies by
entitlement, so the /:sport handler downgrades Cache-Control to private for
authenticated callers and the browser proxy forwards the bearer token —
otherwise a CDN could hand a paid payload to an anonymous viewer, or every
request would look anonymous and paid users would lose the leg.
READ CARD — a manual scan carries no market. The request is {player, stat,
line, direction}, so the engine has no over/under prices to de-vig and
book_odds/fair_odds are legitimately absent from its response; that is why the
triplet was hidden there. lookupSnapshotPrices recovers them from the
pre-graded snapshot via the same cache-only read this route already performs
for locked odds and team. The join is exact on player + stat + line + side
(fair_odds is side-specific), and returns nothing unless book and fair are BOTH
present — a user-chosen line the board never graded has no market attached, so
the triplet stays hidden rather than borrowing another line's price.
FAIR-LEG ABSENCE, measured before shipping: 636 graded rows, 636 with book,
636 with fair, 0 one-sided. Absence rate 0.0%. The hero number is not a
sometimes-number on current data.
Tests 3556 passed / 291 suites, web build exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
|
||
|
|
8cf9cbfc26 |
Price-layer token foundation + the price triplet, built natively on it
PHASE 0 finding, reported before building: the token layer this order asked
me to establish ALREADY EXISTS and already matches HANDOFF.md exactly.
web/src/app/globals.css :root carries the design's surfaces, borders, text
ramp, fonts and grade colours byte-for-byte (aligned 2026-07-16), and
lib/colorContract.js already encodes the green-is-edge-only and
glow-is-A-tier-only laws with a test enforcing them. The stack is Tailwind v4
CSS-first (no config file) with components styled by inline style={{}} reading
var(--x) — 1,916 such reads — so CSS custom properties are the only vehicle
the stack natively consumes. Creating a second parallel layer would have meant
two competing sources of truth, so this EXTENDS the existing one.
PHASE 1 — additive only. globals.css gains one colour the system did not have,
the priced-out blue (#8fb2de + tints), plus a tokenized A-tier glow and the
fair-leg tints. The block writes the LAWS into the token layer itself — green
= takeable edge only, glow = A-tier only, amber = caution + the fair leg, red
= miss/negative only, blue = edge priced out, JetBrains Mono = all data — and
a test asserts every newly-declared name is new (zero collisions, zero
overrides). No existing hardcoded style was touched and no live surface was
migrated: the diff over existing files is 149 insertions, 0 deletions.
PHASE 2 — lib/valueState.js is the single verdict function; the component
renders what it returns and never re-derives one. VALUE fires only on
ev >= 2 AND a takeable price, mirroring src/config/valueEngine.js with a test
that cross-checks both files and fails on drift. Five states: VALUE (green),
EDGE-NOT-TAKEABLE (blue), NO EDGE (grey, stated at full voice), QUARANTINE
(model leg withheld, book+fair stand), REFUSAL (nothing rendered). Free tier
is gated at the wire — tierGating strips model_odds and sets
model_price_locked, so the lock is real rather than a blur over data already
sent; book and fair pass through on every tier because the fair leg is never
the paywall. Wired into the landing hero (data was already on /api/hero-prop)
and the read card, where the projection block reads first and the triplet sits
beside it, not in place of it. Ledger and public profile are out of scope —
no fair-odds columns exist there.
PHASE 3 — induced all six states in a real browser and read back computed
styles, not just markup. Green resolves on VALUE alone: rgb(0,212,160) on the
model leg and verdict; the +11.7%-EV-at-210 row renders rgb(143,178,222) blue
and a white model leg; quarantine shows MODEL "—" with book and fair intact;
refusal renders no legs at all; free tier renders a lock bar with book -120 and
fair -104 still honest. Landing hero on live data: book -153, fair -129, model
-343, VALUE +21.1% vs fair at +28.1% EV. At a real 390px column the three legs
hold at 117px each with no horizontal overflow and fair no smaller than its
neighbours.
Induction caught a real bug that markup review would not have: the "VS FAIR"
figure compared BOOK to fair, printing "VALUE · -6.5% VS FAIR" — a
contradiction on screen. The design's own two worked examples pin the formula
as MODEL minus FAIR in implied-probability percentage points; modelVsFair now
reproduces both exactly (+2.9 and -1.8) and a test locks them. The figure is
shown only when its sign agrees with the verdict, so a row that clears the EV
bar on the book price while our price sits level with fair leads with the EV
instead of a number that reads as a contradiction.
Tests 3539 passed / 290 suites, web build exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
|
||
|
|
f5156dd16d |
Un-claim CLV on the public profile; un-fabricate player-page FORM
Two Truth-Law fixes found by auditing the product logged-out. FIX 1 — /u/[handle] claimed a "CLV-verified record" with "closing-line value included" while ZERO closing-line value renders there. Verified live: GET /api/profiles/vyndr returns beat_close_pct null (gated behind CLV_CAPTURE_RELIABLE, unset while C4 is open). Eight instances found — two of them (the OG + portrait "CLV-VERIFIED RECORD · 30D" eyebrows) only by the post-removal residual sweep; two more printed the claim in exactly the no-record branch. Copy now describes what the page shows. The gated CLV-VERIFIED badge and the BEAT CLOSE figure are removed from the public profile, OG card and portrait card. DISPLAY ONLY: beat_close_pct, clvCaptureReliable() and the whole CLV data path are untouched, and the earned directional badge stays Analyst+Desk. The claim returns when CLV genuinely renders here. Also fixes the doubled "· VYNDR · VYNDR" title (layout's '%s · VYNDR' template already supplies the suffix); verified on composed output by serving the build and reading the real HTML, not on source. FIX 2 — the player page's FORM was `70 + 4 × (count of tonight's graded props)`. Nothing on the HTTP path ever sets stats.form, so that fallback WAS the live number: Josh Bell's "74" is 70 + 4×1 prop, confirmed against his live payload. MATCHUP was gradeFromForm(that number), with a hardcoded 'B' on the no-archetype branch — both fabricated letters with no opponent input on the path. Systemic: buildIntel is the unconditional path for every player and sport. FORM and MATCHUP now render "—" (kind 'plain', so no bar width or colour is computed off a null). gradeFromForm is deleted and the prop count is no longer passed into buildIntel. computeFormScore's hardcoded 75 now returns undefined. Induced across MLB/NBA/WNBA: all render cleanly, and real values (USAGE 3.6 AB/G, REST B2B) still render. Neither form value feeds the grade — engine1 reads raw l5_avg/l20_avg against the line and never a form key; buildIntelFields decorates the already-graded object. Grade inputs are byte-identical. Held (needs a per-sport headline-stat design call): a real player-level form metric + label disambiguation. Tests 3491 passed / 289 suites, web build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj |
||
|
|
ca9ca34cbb |
Remove legacy C4 CLV from FIVE surfaces; install directional badge on ledger
TRUTH FIX FIRST. row.clv/clv_result derive from the overwritable closing_line — 637/699 rows had closing == locked — so of the 578 rendered chips, 522 "flat"s encoded a CAPTURE FAILURE as a held line. That number was live on FIVE surfaces, not the one the order assumed: 1. ledger card ClvChip@319 (authed) 2. public profile /u/ duplicate chip@260 (PUBLIC, shareable) 3. dashboard "· CLV BEAT"@508 (authed) 4. board / Slate "CLV BEAT/FADED"@422 (FREE surface) 5. board / Slate "✓ HIT · CLV BEAT"@517 (FREE surface) Removing it from the ledger alone would have left the lie live on three surfaces including two public ones, defeating the stated PRIMARY GOAL, so the removal covers all five. That is a deliberate extension beyond the "scoped to ledger card" guardrail and is flagged as such — the guardrail protected against feature creep, and this is the same defect at four more addresses. DIRECTIONAL BADGE installed in the old ledger slot. Three time states now read distinctly on the row: entry (locked_odds, at-grade) · close (badge, at-close) · outcome (hit/miss, final). The RESULT stays the row hero. The PUBLIC profile deliberately gets NO badge — it never receives dclv data (Analyst+Desk, server-gated), so that surface now shows the settled result alone. HONEST ABSENCE, tested: the 578 formerly-chipped rows now render NOTHING — not a "flat", which is the old chip's lie in subtler form. Rows settled before capture existed will never get dclv, and permanent silence is the correct output. All six states verified in place; a 40-row dense ledger stays legible (27 badges, max 40 chars, one line, consistent slot after OutcomeChip); no CLV sort or filter exists. FIELD REMOVAL DEFERRED as a separate scoped cleanup: /api/ledger/mine and /api/profiles still SEND clv/clv_result, and dashboard/Slate still type them. Display removal is local and safe; stripping the fields mid-swap could break a response consumer. Suite 289/3485 green, build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA |
||
|
|
da8bfdf1db |
Render directional-CLV badge — Analyst+Desk, server-gated, receipt-bearing
CARD BADGE ONLY. Ticker-CLV explicitly DEFERRED (named, not lost).
PHASE 1 — SERVER-SIDE GATE AT THE DATA LAYER. A Free request never
RECEIVES dclv data: the CLV columns are appended to the SELECT only behind
canAccess(tier,'clv_badge') (new capability, analyst+desk), and responses
are ALSO stripped as defence in depth so a future SELECT change cannot
quietly leak. No CSS/client gate — data that reaches the browser has left
the building. dclv_fair_lock/fair_close are de-vig internals and are never
sent at all.
SURFACE AUDIT, all six channels, each test-locked to contain no CLV:
public profile (share link), snapshot/card feed, ticker feed, share
card/OG, embeddable widget, newsletter. A test also asserts no
ledger_entries read uses select('*') — a star would auto-leak every new
column, which is exactly how a gate becomes theatre.
PHASE 2 — IMMUTABLE ONCE COMPUTED. A settle can re-run (stat correction,
protested game) and a badge that flips positive->negative AFTER a user saw
or screenshotted it is a credibility failure. First computation wins: dclv
is only computed when dclv_computed_at is null, so a re-settle can never
rewrite a shown badge. Same discipline as the locked grade.
PHASE 3 — RENDER, test-first, ABSENCE IS HONEST. unknown / flat / null /
missing-receipt all render NOTHING — no element, no placeholder, no
"pending". Proven on an ALL-NULL board (today: 0 badges) and a MIXED board
(tomorrow: 1 of 4 badged, badge-less cards clean). Binary states only:
positive -> MOVED TOWARD US "graded -110 · closed -145"
negative -> MOVED AWAY "graded -110 · closed +120"
The RECEIPT is the persuasive part, so a badge with no numbers is
suppressed rather than shown as a bare claim. Negative is neutral context
and NEVER touches the locked grade — no back-door re-grading.
NO aggregate, count or rollup exists by construction: the module exports
exactly {clvBadge, fmtPrice} and a badge payload carries exactly
{tone,label,receipt} — asserted by test, because an on-screen tally would
be the held aggregate claim through the side door.
Build gotcha hit and fixed: clvBadge is CommonJS (allowJs) with no TS
types, so the .tsx needed an explicit cast at the call site — the build
worker exits 1 on type errors even though compilation "succeeds".
Suite 288/3473 green, build exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
|
||
|
|
f72f063e6f |
Suppress rare-event 0.5 unders (juiced, no-edge) — config-driven grade + board fix
Betting-logic audit: the CONSENSUS-vs-MODEL board flooded with fake reads like "DOUBLES u0.5 · MODEL 0.2 · +edge" — the juiced under side of rare counting-stat markets (doubles/triples/HR/SB), which is never a takeable edge and violates the no-unders-default doctrine. Report finding (item 3/4): the doubles projection is REAL per-player, not a flat fallback — 'doubles' maps to a real game-log field (MLB_LOG_FIELD doubles→ doubles) and the live values varied (0.03/0.16/0.2/0.22). So no projection-gate refusal for fakeness; the problem is purely structural (a rare event's real projection always sits below a 0.5 line, so the under always "wins"). Fix (config-driven — src/config/rareEventMarkets.js, tunable stat list + line threshold): - Grade layer (analyzeViaEngine1): a rare-event UNDER at ≤0.5 is always REFUSED (grade null + suppressed flag/reason). A rare-event OVER at ≤0.5 is refused UNLESS the model genuinely projects the event above the line — because a 0.2-over-0.5 carries the SAME |edge| as the suppressed under and would just take its rank on the board. The over grades normally once projection > line. - Board layer (marketBreadth.collectBreadth): drops null-model rows so a suppressed/ungraded prop can't rank a "MODEL —" placeholder onto the board. 10 suppression tests + config locks; also fixed a settingsPage book assertion left over from the ESPN→theScore swap. Suite 274/3289 green, web build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
ccb9668f0c |
Task B — founder checkout is SEAT-GATED; payment-decline grace spans retries
1+2. Checkout price was CODE-gated (founder price only with a valid founder
code) — so "Claim a Founder Desk" would have charged the $44.99 standard
price, not the advertised $34.99. Now it's SEAT-gated: resolveCheckoutPrice()
attaches the founder price while founder seats remain (< FOUNDER_SEATS_TOTAL,
read from the SAME countFounderSeats() truth as the ClaimMeter), and flips to
standard at seat 100. createCheckoutSession uses it; the founderCode param is
kept for back-compat but no longer drives price. The meter flips to "SOLD
OUT" at capacity. When the count can't be verified we honor the advertised
founder price (never overcharge).
- Also hardened countFounderSeats to manual pagination (the for-await form
broke on non-async-iterable list mocks).
3. Tests: resolveCheckoutPrice at seat 0 → founder, seat 100 → standard, the
99/100 boundary, null-count → advertised founder price.
4. Grace: invoice.payment_failed now sets a 14-DAY grace (spans Stripe's Smart
Retry window) instead of 48h — a transient decline no longer revokes access
mid-retry. Access is revoked only when Stripe actually cancels
(customer.subscription.deleted keeps its 48h grace). Test updated.
Stripe + founders suites green, web build exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
889e8621b4 |
Item 6 — activate the Stripe billing portal link from account settings
The billing portal is fully configured in Stripe (cancellations, plan switching, invoice history) and the Express endpoint (POST /api/stripe/portal) existed, but nothing in the UI linked to it. Added the Next proxy (app/api/stripe/portal) and a "Manage billing →" button in the profile billing section (paid tiers) that mints a portal session and redirects. Kev still activates the hosted portal in the Stripe dashboard; this is the app-side link. Dunning verification (item 6): cancel-on-exhaustion is correctly wired — Smart Retries exhausting cancels the subscription → customer.subscription.deleted → webhook sets a 48h grace → middleware/gracePeriod.checkGracePeriod downgrades tier to free in both users + user_profiles after the grace expires. See the report for one nuance (the 48h grace on the FIRST payment_failed is shorter than Stripe's 2-week retry window — self-correcting via subscription.updated, but worth a product decision). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
ae3cff9dbd |
Item 7 — book roster: ESPN BET → theScore Bet (PENN)
ESPN BET is defunct — PENN/ESPN terminated the deal; PENN rebranded it to theScore Bet (Dec 1 2025) and ESPN is now exclusive with DraftKings. Removed the ESPN BET entries from the BookChip map (web/src/lib/books.js) and added theScore Bet (mono TS, slug thescore) as the successor. Added 'thescore' to the backend oddsNormalizer ALLOWED_BOOKS so the feed's lines are accepted; synced the bookWordmark test list. The ESPN references in src/config/sports.js are ESPN's STATS API (data provider, unrelated to the sportsbook) — left untouched. Flagged in specs/design-reference/HANDOFF.md that the design mockups' BookChip row still shows ESPN BET and needs the same one-swap on the next refresh. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
a8e383e7e7 |
Item 8 fix: articles live in web/content (the runtime content root), orphan deleted
The blog showed "Posts coming soon" live: the app reads process.cwd()/content = web/content at runtime (that's where the old orphan lived and rendered), but the 5 articles were committed to REPO-ROOT content/articles — which the deployed app never reads. Moved them to web/content/articles (verified getAllPosts finds all 5 from cwd=web) and deleted the orphan file web/content/blog/line-movement-guide.mdx (the route already 301s). Test paths updated to web/content/articles. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
cb3237cdce |
Item 6 — Desk showcase renders REAL data (or hides), kills the mocked ladder
The pricing Desk showcase hardcoded an alt-line ladder (1.5 A +7.1% / 2.5 A+ +11.4% / 4.5 C -3.8%), QUARTER-KELLY 2.4%, and PARLAY φ 0.34 — a mocked demo selling something we weren't proving. - deskShowcaseService reads the pre-graded snapshot for a real A/B prop's alt-line ladder (prefers the one with the most grade variation — the most compelling real example). Edge per rung shows only when it's a plausible market value; the inflated (model-line)/line artifact on small lines is guarded to "—" rather than shown as a fake +91%. - PARLAY φ is now REAL: the model's same-team correlation (0.34, mirroring the frontend parlayMath team constant) computed for TWO REAL same-team legs, named. No real same-team pair on the board → the tile hides, never an invented number. - QUARTER-KELLY tile is REMOVED: the snapshot has no odds, so a real quarter-Kelly % can't be computed here — a fabricated 2.4% is worse than nothing. Kelly stays a real in-app Desk feature; the showcase just doesn't fake it. - DeskShowcase is now a client component fetching /api/desk-showcase; when the board has no real ladder the whole visuals column hides (real-or-hidden, same law as the hero). The pitch copy is unchanged. 5 service tests. Change-affected suites green, web build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
9b9aab4262 |
Item 5 — daily hero prop is a live RULE (biggest model-vs-market disagreement)
The landing hero was a static Jokic "Example" card with a name-length pick and a
hardcoded A- 73% +6.2% fallback. Now it's deterministic and live:
- heroPropService.pickHeroProp reads the pre-graded snapshot and selects the
prop with the LARGEST |projection - line| gap among A/B grades (conviction,
not noise) — the read where VYNDR disagrees most with the market, the card
that makes a stranger argue. No curation, no grading (reads cache → no API
credits). GET /api/hero-prop (backend) + repointed Next proxy.
- The card shows the disagreement EXPLICITLY: the book's line vs VYNDR's model,
side by side (model in green), with the real grade timestamp ("Graded 2:14
PM"). The EXAMPLE chip is gone.
- Empty slate → the MOST RECENT real graded read (flagged "LATEST READ", real
date). Nothing cached → { available:false } and the card HIDES. No
hand-written fallback — the Jokic card is deleted. Survives a dead night: a
live rule shows tonight's real MLB read, never a phantom July NBA card.
7 service tests lock the rule (max-gap, A/B gate, projection/line required,
empty→recent, hidden, cross-sport). colorContract updated to the new
disagreement display. Change-affected suites green, web build exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
3b7a1f59bc |
Item 8 — wire the 5 real articles into /blog, retire the backdated orphan
The live /blog showed "How to Read Line Movement Like a Sharp" dated 2026-03-22 — an orphaned, uncommitted file that predates the product. Meanwhile 5 genuinely-real articles sat in content/articles/, unwired. - blog.ts now reads content/articles (not the untracked content/blog). Maps `excerpt` → description, skips `status: draft`, reads explicit `slug`. - Added honest dates (2026-07-17, the real publish day) + flipped the 5 articles to `status: published`. Real content: how-vyndr-grades-a-prop, why-our-misses-are-public, what-clv-is, how-streaks-lie, the-vyndr-originals. - Retired the orphan: /blog/line-movement-guide 301s to /blog (next.config). - Added a minimal, dependency-free, XSS-safe markdown renderer so headers/bold render as HTML instead of literal "##". Each article keeps title/date/ read-time + the existing OG/JSON-LD metadata (affiliate-review ready). Richer media (images/charts) is the separate design train. Web build exit 0 (SSGs all 5 slugs); backend suite unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
41fc2b90e2 |
Item 2 — founder counter is REAL or hidden (kills the hardcoded 47/100)
ClaimMeter rendered a fabricated "47 / 100 CLAIMED" (a hardcoded default; the
comment even said "Cosmetic conversion driver… Static here"). Now:
- GET /api/founders/count counts ONLY real paying founders — user_profiles
where founder_pricing = true AND subscription_status = 'active' (the
Stripe-webhook-synced mirror, so we never hammer the Stripe API). Cached 5
min in Redis on top of that.
- If the source is unavailable (Supabase unconfigured, query error, column not
migrated, client throws) the endpoint returns { available: false } and the
ClaimMeter renders NOTHING — counter and progress bar both hidden. We never
fall back to a number.
- A low real count is shown honestly (0 → "0 / 100"); the truth is the feature.
Next proxy at app/api/founders/count. 6 route tests cover real count, low
count, error/unconfigured/throw → hidden, and cache-hit. Suite 271/3260 green,
web build exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
66d52a9ce0 |
Item 1 — VERB LAW: one verb, READ (never SCAN), + a lint that enforces it
The product argued with itself: FAB/nav said "Scan", Free tier "5 scans", ticker "MLB slate scanned" — while the Ledger says "MY READS". Swept every user-visible surface to READ: - BottomTabBar FAB + Nav link: 'Scan' → 'Read' - Pricing free tier: '5 scans to try the model' → '5 reads …' - StatStrip: 'Awaiting next scan' → 'Awaiting next read' - Ticker badge + snapshotService event: tag 'SCAN' → 'READ', 'slate scanned' → 'slate read' (readSportOf parses BOTH old and new so cached ticker items dedupe cleanly through the rollover) - upgradePitch: 'You've scanned N parlays' / 'unlimited scans' → read/reads Internal untouched (not user-visible): /api/scan routes, scan_count column, scanning state, DemoScan/ScanIcon, scanlines CSS, the transitional SCAN color-map key. tests/unit/verbLaw.test.js is the enforcement: it fails on user-visible scan/scanned/scans copy across web/src + src/services (skips comments). Suite 270/3254 green, web build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
71e35e90fd |
P2-10: ledger read-card density — tighter padding/margins (3-4 per phone screen)
Phone audit: read cards were huge, only 1-2 fit per screen. Compressed the
vertical spacing — article padding 16->12, header margin 8->5, name 15->14px,
ladder-rungs margin 10->8, book/date line 12->8.
Kept the archetype showDesc: it renders INLINE (same row as the badge), so it
adds zero vertical height — dropping it wouldn't help density and would break
the ds5 design lock ("the badge shows its one-line meaning where it leads").
Locked the density in vyndrParityQA (P2-10).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
77e8937a56 |
P2-9: leaderboard stat labels (SB/ER/TB) + FLAG the grade-degradation root cause
DISPLAY FIX (shipped): the league leaderboard rendered raw snake_case
("stolen_bases U0.5", "earned_runs U2.5"). New canonical short-label lib
web/src/lib/statAbbrev.js (one source, CommonJS + unit-tested) maps stat_type
to SB/ER/TB/HR/K/PTS/… and ExploreHub routes through it. Unknown ids upper-case
their words so raw snake_case can never leak again.
FLAG (reported, NOT silently changed — per the audit's instruction): the "B at
45% confidence" is a BACKEND grading issue, diagnosed against live snapshot:
- 25/25 grades mismatch their own confidence vs grade_thresholds.json (B shown
at conf 55 = the B- band; a systematic one-sub-tier gap on every prop). The
surfaced `confidence` is not the probability that derived the letter (likely
the data-sufficiency penalty applied to display-only).
- 9/25 have projection=0 — the MLB feature path feeds 0 instead of refusing
(S58 insufficient_data), which also produces the P1-7 broken edge_pct.
Full write-up + do-not list: specs/audit-data/mlb-grade-degradation.md. NOT
re-lettering or shifting thresholds on the frontend — that would hide the bug.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
00537eb84c |
P1-8: truth in Compare verdict — no phantom "tonight", cite what actually wins
Phone audit: the Compare verdict read "the edge tonight tilts his way" for Jokić vs Wembanyama — an NBA claim in July, when NBA has 0 games. The page is sample/form data with no game resolution, so "tonight" can never be verified. Reframed to "on current form" (the rows ARE L10 form) — always honest, in or out of season. Also fixed the cited dimensions: the verdict claimed "usage", but in the sample Jokić's Usage% (29.1) is LOWER than Wemby's (31.0) — he wins scoring, boards, and playmaking, not usage. Copy now matches the data. Locks both P1-7 and P1-8 in vyndrParityQA. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
d755b43f05 |
P1-7: flat edge board — guard the broken edge placeholder (absent > fabricated)
Phone audit called the board 'mostly-empty'. Two causes, both now addressed: 1. Dead images (P0-2, already fixed) → the matchup/team chips render logos now. 2. Degraded edge data. Live snapshot edge_pct is on a broken scale (distinct values 20/60/100/140 — not a market %), with projection=0 and confidence 35-55%. A real prop-market edge is single-digit, never past ~40%. Leading the board with '+140%' fabricates a signal (Data Semantics Rule). Fix: an edge whose |value| > 40 is treated as ABSENT at BOTH layers — the data layer (flattenToEdgeBoard nulls it, so it can't RANK a fake +140% above a real +8.4%) and the display (EdgeCell shows '—'). Board falls through to the grade-rank tiebreak when edges are unreliable. Real edges (≤40) are untouched. The root cause — edge_pct/projection/confidence degradation — is a BACKEND grading issue (same family as the P2-9 '45% B' flag), reported separately; this is the honest frontend guard, not a fix for the data. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
ff0d3b199e |
P1-6 fix: one freshness source — Slate 'UPDATED' reads pipeline refreshed_at
Phone audit: the same screen showed green 'SIGNAL LIVE · UPDATED 0s ago' AND amber 'SYNC 46:03' — two components reading different fields. The Slate's 'UPDATED Xs ago' measured the CLIENT poll time (always ~0s, since it refetches every 60s), while the app-bar clock honestly measured the pipeline's data age (refreshed_at). '0s ago' claimed a freshness the data didn't have. Now the Slate captures snap.refreshed_at from the snapshot it already fetches (newest across sports) and 'UPDATED' shows THAT — the same source of truth as the clock. The clock still owns the amber/STALE reaction; the strip just states the honest data age. No more contradiction. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
8b35fd06ec |
P1-5 fix: 390px containment — consensus rows stack, pitcher line wraps
Phone audit: CONSENSUS VS MODEL bled off the right edge ('2 BO…', 'MO…') and
STARTING-pitcher lines truncated ('2.64 E…'). The M4 lock only hid the DOCUMENT
scroll (html/body overflow-x) — content still clipped inside cards. Now contained:
- .breadth-row stacks (flex-direction:column) at <430px, each field on its own
line with overflow-wrap:anywhere — no bleed.
- the game-card starting-pitcher inner spans wrap + shrink (flexWrap + minWidth:0)
so name/ERA/archetype flow onto a second line instead of clipping.
- STRENGTHENED the lock: vyndrParityQA now asserts the CONTAINMENT patterns
(breadth-row stacks, pitcher spans wrap), not just document overflow.
MLB stat pills: the game-lines grid already scrolls-within-card (<640 M1); if
the audit still shows pill clipping elsewhere, it's a follow-up targeted pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
ff53f31bfc |
P0-4 fix: mobile header collapses to ONE line — logo + clock + sync dot
Phone audit: at 390px we still rendered the full desktop 3-row header (nav + TOP MOVES ticker + SYNC line) eating ~20% of the viewport, and its height clipped page titles under it (MY READS tabs, HEAD TO HEAD). Implemented Design's mobile app bar <768px: - New MobileSyncClock (extracted from HeartbeatBar) lives in the Nav's right cluster — wall clock rests, amber/STALE reacts off the shared freshness tier. - <768px: the ticker row (.nav-ticker) AND the whole heartbeat bar are hidden; only the nav row shows (logo + clock + search). main padding-top → 62px and the Slate sticky tabs → top:60px, so nothing clips under the bar. - Locked in vyndrParityQA (P0-4): ticker+heartbeat hidden, nav clock shown, paddings collapsed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
db61876b2f |
P0-3b/c: consensus grouped by player + ledger cards nest alt lines as a ladder
Completes P0-3 across all three surfaces: - CONSENSUS VS MODEL (MarketBreadth): dedupe by player+market so Ben Williamson's alt-line variants show as ONE consensus row (no per-market cap — a consensus table just shouldn't repeat a player). - LEDGER cards: group by player+market via groupIntoLadders — Alec Bohm's strikeout ladder (U1.6/U1.3/O1.5) is now ONE card with the rungs nested (each its own side/line + tier-colored grade), not three separate cards. - playerGrouping reads player OR player_name (ledger rows use player_name) — regression-tested so the ledger doesn't silently empty. The Alt Line Ladder shape is what /pricing already demos; the record now uses it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
55f5cdc57d |
P0-3a: dedupe the Explore leaderboard — one row per player+market + per-market cap
Phone audit: leaderboard flooded with 9 consecutive identical 'stolen_bases U0.5 45% B' rows. New shared lib/playerGrouping (dedupeLeaders + groupIntoLadders, name-key aware, 7 unit tests): ONE row per (player, market family) keeping the best-ranked, then a per-market cap (4) so no single prop type floods the board. Applied to ExploreHub. Ledger cards + Consensus grouping follow in P0-3b/c using the same lib. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
c868d1638e |
P0-2 fix: entity images load EAGERLY — the lazy-defer killed the whole pipeline
Phone audit: 78 ESPN <img> at naturalWidth:0, ZERO espncdn/mlbstatic requests ever fire — the browser never attempts the fetch. CSP was NOT the cause (img-src already allows a.espncdn.com + img.mlbstatic.com, confirmed in the live header). Root cause = native loading="lazy" on the tiny entity <img>s never triggering on device (the audit's suspect b). TeamLogo + PlayerAvatar now load="eager" (+ decoding="async") — they're 10-40px core-visual entities, so eager is correct and cheap, and it forces the fetch on parse regardless of size/viewport. Browser-request confirmation is the audit's Chrome domain (I can't observe network from server curls); eager semantics guarantee the fetch once the src (which 200s server-side) is in the DOM. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
489d849f1e |
P0-1 fix: ledger/u grade badges are token-derived — kill blue-B / amber-C / non-A glow
Phone audit found LEDGER READ CARDS rendering B badges with BLUE borders + C with AMBER right now in prod — GradePill (components/GradeCard.tsx) hardcoded the OLD palette (rgba(74,158,255) blue-B, rgba(255,179,71) amber-C) for bg/border while the text used the migrated token. Migrated bg/border to color-mix on the grade token, so B renders neutral-white and C grey (matching the board). - globals.css .grade-*-bg → token-derived color-mix (was raw blue/amber rgba). - DELETED glow from .grade-glow-b/c/d (glow is A-tier ONLY, by law) — B/C/D keep their token color, no text-shadow. - Purged the last dead grade-blue #4A9EFF fallbacks (SoccerGradeResult, the intelligence INFO dot). - REGRESSION LOCK: vyndrParityQA fails if #4a9eff / rgba(74,158,255) reappears anywhere in web/src, if GradePill hardcodes blue/amber rgba, or if grade-glow B/C/D grow a text-shadow again. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
4f32eab859 |
Color contract: 'N LIVE' count → green (Design's LIVE color), not red
Design colors every LIVE indicator green (active = green; the live-dots and LIVE·Q3 labels are all green). The slate header's 'N LIVE' count was red (--live #ff3b3b) — contradicts the one-meaning-per-color contract (red = miss/ error, which live isn't). Aligned to --g-a. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
bf8bf16e5c |
Rev 3 chips: pitcher identity 'vs' — team + opponent TeamChips
PitcherArsenal's meta line showed team + 'vs OPP' as plain text; Rev 3 anchors the pitcher's team and opponent with TeamChips (real logo / monogram). Combat FightCard intentionally keeps fighter monograms (no photos — likeness rule). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
8a24ac9129 |
M1b screen 01: the flat EDGE BOARD — Design's mobile board IA
The one genuinely-new mobile screen. Design's mobile BOARD is a FLAT edge-ranked list (all graded props across every game on one list, sorted by edge) — not the desktop's game-grouped cards. Implemented to the drawing with REAL snapshot data: - slateAdapter.flattenToEdgeBoard(cards) — pure transform of the assembled GameCardData[] (grade→game join already done) into ranked rows, edge desc. STRICT null edge sorts LAST (never 0-coerced to the top — Data Semantics Rule). Threaded edge_pct through buildPlayerStripsFromProps (was dropped). 6 unit tests. - MobileEdgeBoard component — Design's exact screen-01 rows: rank (green #1), player + prop, matchup sub-line with TeamChips + live-dot, tier grade chip, and the edge% as the one bold mono hero (green +, red −). Ranked opacity ramp (1 → .55) + green inset border on the top reads. Breadth strip EDGES/AVG CLV/ GAMES — CLV honest '—' (per-slate CLV isn't computed; never fabricated). - Slate: <768px renders the flat board, ≥768px keeps game cards (same data, toggled by width). Ungraded slate still shows game cards on phones (no blank). Built to Design's screen-01 drawing, VISUALLY UNVERIFIED at 390px — the core mobile screen, top of the master-audit list. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
baf977f3fe |
M1b grade-reveal (screen 02): Rev 3 team chip + Design mobile hero (74px)
GradeResultCard already carried Design's §7 grade-reveal structure (hero + identity + 3-col grid + intel box) — the screen was built to the same contract Design's mobile follows. Applied the Rev 3 gaps: team context now a TeamChip (real logo/monogram), and the mobile grade hero → 74px (Design's exact mobile size; M1a had 80). Desktop hero unchanged (116px). Built to Design's mobile spec, VISUALLY UNVERIFIED at 390px. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
f24f9412f1 |
Rev 3 matchup chips: TeamChip primitive + board-row team context
Design Rev 3 anchors every matchup/context abbr with a 10-12px team tile. New reusable TeamChip renders the real TeamLogo (licensed ESPN logo where it resolves, team-colored monogram otherwise — the resolver is already built) at that size + the abbr, sitting inside the row so it inherits the ranked opacity ramp. First placement: StatStrip's player/team context (name → team-chip → archetype). ROW-GRAMMAR identity-run test updated to the chip marker. Remaining Rev 3 placements to thread TeamChip into (reusable, mechanical): parlay legs, grade-shift header, pitcher "vs", /u recent-settled, other matchup context lines. Game-card headers already carry TeamLogo (TeamLink). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
771d8b0ba4 |
Rev 3 glyphs: wire the 9 classifier-legacy marks (74→83), all backend archetypes now render real marks
Design Rev 3 drew the 9 legacy classifier marks (BRUSH #E0B84A, WHIFF #E86A6A, CONNECTOR #9AB0C4, DISTRIBUTOR #7AB8D8, FASTBREAK #4AA0E8, FLEX #A08AC8, HYBRID #C88AB0, SWITCH #C0B08A, SWITCHBOARD #90A0E8). Wired each to its own Design mark + color (front lib/archetypes.js + backend archetypeService.js, color-synced). These were the 9 NO-MARK backend keys — now none are on a generic placeholder. They're classifier-side fallback renders (never user-facing archetype names, per MANIFEST). Re-imported Rev 3 package over specs/design-reference/ (83 glyph SVGs + MANIFEST regenerated from the authoritative glyphDefs(); HANDOFF Rev 3 note). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
ebaab007d4 |
M3 palette consistency: orphaned B-blue monogram + old text-0 in OG billboards
Token alignment (M3.1) orphaned a few bare literals: PlayerAvatar's team-less monogram accent still fell back to the retired B-blue #4A9EFF → Design neutral #B8BCC8; the player/u OG + portrait billboards painted old text-0 #E8E8F0 → Design #F0F0F0 (satori canvas needs literals). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
1558710458 |
M3.3a book wordmark — BookComparison renders BookChip, not the raw key
The only surface still showing a raw book key: BookComparison rendered
{b.book} capitalized ('Betmgm'). Now uses the entity-layer BookChip (branded
wordmark + name), matching ledger/GameCard. No lowercase/raw book text anywhere.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|