(a) WHAT REPLAYABLE HISTORY EXISTS — the headline is confirmed and worse
than "6 days".
ledger_entries is the ONLY store of model history in the database: 640
public rows, 6 distinct game days (Jul 11/12/16/17/18/19 — 13/14/15 are
missing entirely), 2 sports, 215 players, 470 settled, 465 settled WITH
odds. Every other candidate is 0 rows: grade_history, line_snapshots,
historical_props, closing_lines, resolution_results, accuracy_tracking,
model_predictions_extended, engine1_weights, prediction_registry and ~30
more. A data warehouse was designed and never filled. Redis holds no
history either (latest/previous at 24h TTL; the outcomes log carries no
odds/confidence/projection).
The blocking gap is not the day count, it is that NO MODEL INPUTS ARE
STORED ANYWHERE. No feature vectors, so we can score the grades we
emitted but cannot ask whether a different model would have done better —
which is the only question a harness exists to answer, and the exact gate
the metrics-engine north star requires. Also missing: p_win/ev_pct/
fair_odds (born tonight, on no column), grade_11 (only the 4-letter
collapse is stored, so the entire live C-/C/C+/B- range is unrecoverable),
and any model_version, so pre- and post-fix rows are already silently
mixed in one table. CLV remains unusable (C4). Settlement gaps surfaced
too: Jul 17 MLB 86 graded/57 settled, Jul 18 103/75, and 0 pushes across
470 settled rows — both feed the settlement-correctness audit.
Verdict: we cannot meaningfully backtest yet. Retention is priority zero;
every night without it is history we can never recover.
(b) DESIGN PROPOSAL — model_snapshots in Postgres (not Redis, which is
what lost us history twice). One append-only row per graded prop PER
CYCLE, capturing market values, model output, outcome (stamped later by
the settle pass), and critically a `features` JSONB — the counterfactual
enabler. Carries model_version + code_sha so eras never mix, grade_11 so
resolution is not thrown away, and refused/refusal_reason because
refusals are training data the ledger currently discards entirely.
Written from snapshotService (the existing chokepoint), best-effort so a
retention failure can never break a snapshot.
Volume: ~800 rows/day ~ 292k/year, ~300-600MB/yr of features, which would
exceed the Supabase free tier alone — so the proposal keeps full features
90 days and scalars forever. Three open questions for Kev before building:
the 90-day policy, whether to backfill the 640 existing rows as
scalars-only with explicit null features, and confirming we store
refusals.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
NORTH STAR (design philosophy, not built): VYNDR measures players by
MODERN FUNCTION, not legacy label — the principle already under the
archetype system, from Rashad Phillips' Basketball Position Metric. The
rule: every proprietary metric is baselined against the player's
functional ARCHETYPE's CURRENT-SEASON behavior, never the position's
inherited standard. The edge is that the market often prices today's
players against yesterday's baselines, so archetype-vs-position baseline
disagreement is a repeatable mispricing. Generalizes across sports. Moat
= proprietary metrics x current-game calibration x our private outcome
data. Metrics ship as VALIDATED FAMILIES: hypothesis, flagged build,
backtest, ship-or-delete with the negative result written down. Nothing
is real until the harness proves it predicts better.
SOURCING SCOPE (report, no code): MLB opponent strength IS derivable from
statsapi, verified live — one free call returns all 30 teams' pitching
splits (era/whip/avg/slg/ops/homeRuns/strikeOuts/HR9), which beats the
ESPN field we were reaching for because it is STAT-SPECIFIC, exactly what
opp_rank_stat wants. NBA/WNBA cannot use ESPN (its team endpoint carries
only a team's own stats, no defensive rating or pace); options are
stats.nba.com dashboards, deriving allowed-points from scoreboard finals
we already fetch, or API-Sports. API-Sports is a fallback tier at best —
100/day will not survive per-team-per-day. ESPN stays last, always behind
an adapter.
Proposed the SOURCE-ADAPTER pattern: one interface per feed, config-driven
primary+fallback per (sport x capability), normalized output so vendor
quirks stay in adapters, fallback announced rather than silent, sources
with zero callers deleted rather than left as corpses, and a health check
that PAGES when a source returns empty or broken — where EMPTY IS A
FAILURE. Tonight's crash (captured 0 / errored 15) and the months-null
opp_rank_stat are both exactly what that check exists to catch.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
FOUNDATION-FIRST re-order, phase 1 (tooling + safety).
BACKUP (highest-severity open item) — INSTALLED, not re-proven.
src/backupScheduler.js runs scripts/backup-db.sh nightly from inside the
API container, armed at boot in server.js. The container already has
SUPABASE_DB_URL, pg_dump and the Supabase route, so deploy == installed:
no host crontab, no Coolify click. Arming is deliberately opt-OUT (armed
whenever SUPABASE_DB_URL exists; BACKUP_CRON=0 kills it) because the S62
design was opt-in and nobody ever opted in — the DB went unbacked every
night for weeks. A failed run pages high-priority ntfy; silence is the
danger with backups.
Durability is the one part still needing a human: the container FS is
ephemeral, so a dump dies on redeploy unless BACKUP_REMOTE (off-box
rsync) or BACKUP_DIR (persistent volume) is set. The scheduler detects
that and pages a WARNING at boot rather than letting an undurable backup
read as "backed up". Runbook rewritten to lead with the code path.
MANUAL REGRADE TRIGGER — scripts/run-snapshot.js, runnable via
docker exec with no VYNDR_INTERNAL_KEY and no new HTTP surface. Runs the
SAME snapshotService.runSnapshot the cron runs (including the team-stats
refresh that powers opp_rank_stat), supports `all` and `--settle`, and
prints the grade/confidence distribution plus p_win/ev_pct presence —
which is the thing you actually want when verifying a grading change.
ACCESS BLOCKER, logged honestly in specs/model-train.md: there is no
VYNDR_INTERNAL_KEY in the local .env and SSH to the box times out from
WSL2, so I can neither curl the internal endpoints (which already exist
from S45) nor docker exec. The trigger is built and correct but only Kev
can run it until a key or SSH access exists. This is the highest-leverage
unblock for phases 2 and 3, which both need on-demand regrade+settle to
verify anything.
Also logged the standing cautions: CLV ledger stays private until
backtest-proven; "self-improving model" is unsupported marketing until
the loop closes; the engine is MLB/WNBA-calibrated and NFL/NBA/soccer
need their own calibration before the hub grades them (scaling gate).
Suite 277/3300 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
Not new work — logging so neither gets lost.
U-deg part 2: edge_pct is on a broken scale and it is the number users
actually see. Live: edge_pct 100 on a single-digit-edge prop; ledger-wide
311/604 rows (51.5%) exceed the frontend's sane cap of 40, 39 exceed 100,
worst 620. Mapped the consumers, and the split is the whole problem: 13
frontend files + deskShowcase/contentTemplate/parlayScan/tierGating read
the BROKEN edge_pct, and ledgerService:199 persists it to the
column of an append-only table right now. NOTHING on the frontend reads
ev_pct; only heroPropService does. Noted that S-b (rank board on EV) is
the real remedy and should be done as one piece with the scale fix, and
that EDGE_BOARD_SANE_MAX is damage control that nulls half the board.
Dispersion classifier: MIN_MEAN=4 is the honest stopgap; it leaves a
+/-1.0 dead for MLB low-count stats. The scale-free fix is variance/mean
vs the Poisson baseline of 1.0. Logged with its explicit validation bar —
backtest harness first (still does not exist), replay settled outcomes,
show no tier degradation, report before flipping, env-gate it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
POST /api/analyze/prop on prod returns p_win 0.523, ev_pct -10.4,
model_odds -109, confidence_basis grade_band, value false — every one of
which was absent on 100% of grades before this change. The value triplet
is whole (book -140 / fair -125 / model -109) and correctly refuses to
call a -140 price value when the model gives it 52.3%.
A-emission still pending the 01:00 UTC snapshot (opp_rank_stat populates
only when refreshTeamStats runs in a snapshot). MARKETING HOLD on A-RATED
copy stays until that passes. edge_pct scale remains broken (U-deg pt 2).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
Folds re-sequenced steps 1+2 into one change (Kev's call): same bug
family — features wired to sources that return null.
THE PROBABILITY LAYER WAS DEAD IN PRODUCTION. p_win/ev_pct/kelly/
model_odds/value were absent on 0/8 live grades because
gameLogService.getGameLogs returns null for MLB by construction and
depends on the offline Python service for NBA/WNBA, so meta.gameLogs was
[] for every sport. This was the S46 bug in a second location — that fix
gave featureCache an MLB branch (why grades still worked) but never the
estimator. featureCache.getStatRows now supplies normalized rows
([{date,[statType]:v}], most-recent-first) for every sport, feeding the
estimator AND consistency AND game_count_in_7d from one fetch.
VERIFIED on real props: p_win 25/25 WNBA, 8/8 MLB (was 0).
GRADE RANGE, ON MERIT — never by rescaling (permanent founder ruling:
minting A's without new information is a relabelled B sold as an A and
corrupts an append-only ledger).
- refreshTeamStats wired into runSnapshot — it had ZERO production
callers, so opp_rank_stat was permanently null and a +/-1.0 factor
could never fire. Test-env no-op (opsNotify precedent).
- L20 made SYMMETRIC: both branches were delta +1.0, so the season
baseline could only ever ADD. No negative path was a structural reason
D was unreachable. New l20_contradicts_* carries -1.0.
- game_count_in_7d derived from real logged dates (heavy_workload_7d).
- NOT wired, deliberately, with reasons inline: teamId (no team_id
column; getFeatures reads it top-level; factor also needs a starter-id
list) and season_type (ESPN 2 = REGULAR season; threading it raw would
fire veteran_in_playoffs in July). Dead code dressed as a fix is the
thing we are removing, not adding.
CALIBRATION GUARD (found by verifying, not assuming): consistency CV is
NBA-tuned; for a Poisson-ish stat cv ~ 1/sqrt(mean), so any stat with
mean < 4 auto-classifies boom_bust. First verification run showed 8/8 MLB
props boom_bust — a blanket -1.0 that dropped the board to all-C. Floored
at CONSISTENCY_MIN_MEAN=4 -> 'unknown' below. Absent beats wrong. MLB
low-count stats therefore still get no consistency factor: honest, not
fixed. Scale-free index-of-dispersion classifier is the open follow-up.
CONFIDENCE IS NOT A PROBABILITY: payloads carry confidence_basis:
'grade_band'. Corrected mlb-grade-degradation.md — its "25/25
grade<->confidence agreement" is a TAUTOLOGY (confidence is derived FROM
the letter, so it would report 25/25 even if every grade were wrong), not
a validation. Removed dead mlbGrader.js (referenced only by its own test)
and the stale computeFeatures comment claiming a penalty that never ran.
VERIFICATION (scripts/verify-grade-range.js, real props/logs/engine):
WNBA 25 props B 68%->32%, C 32%->64%, D 0->1 (4%); 11-step spread went
from 2 steps to 5 (C/C+/B-/D). The D is earned: Angel Reese assists o2.5,
p_win 0.365. Nothing flooded — grades got HARDER. A did not emit locally
because opp_rank_stat needs the Redis cache only prod populates (local
ceiling +3.0 vs the +4.5 A needs); reachability is proven arithmetically
and locked in tests. Prod A-emission is the outstanding fingerprint.
MARKETING HOLD: "A-RATED" (AccuracyBadge, TopSignals) is unsupported
until that fingerprint. Confirmed honest fallbacks render today —
/api/ledger/accuracy has B and C buckets only, so the badge shows
"MODEL · 63% HIT" and TopSignals self-hides. Nothing fabricated ships.
Suite 276/3286 green, web build exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
Completes the diagnosis. Report only; no grade logic or thresholds changed.
The grade is an integer index (GRADE_SCALE, NEUTRAL_INDEX 3) moved by a
flat sum of +/-1.0 and +/-0.5 factor deltas, then clamped and rounded.
grade_thresholds.json is NOT an input mapper in the JS path — engine1
reads it BACKWARDS, taking the letter the index already produced and
looking up that band's midpoint to manufacture `confidence`. So
confidence is a cosmetic re-encoding of the letter: zero information
beyond it, and it can never disagree with it. There is no
data-sufficiency penalty in the live path (the one CLAUDE.md describes is
in mlbGrader.js, which is dead code).
Six of thirteen factors are wired to features nothing populates —
verified: refreshTeamStats has ZERO production callers (so opp_rank_stat
is permanently null, killing a +/-1.0), teamId/season_type/
game_count_in_7d are never passed (gameContext is built as {home_away}
and nothing else), and MLB consistency starves on the same dead
gameLogService path as Finding 2. Also verified: BOTH l20 branches are
delta +1.0 — there is no negative L20 contribution at all.
Arithmetic: an A needs sum >= +4.5; the live maximum is +3.0 (+2.0 on a
back-to-back, and MLB rest_days is 0 most days). D needs <= -1.51; the
live minimum is -1.5 and Math.round(1.5)=2, so it misses by one rounding
tick. Reachable band is index 2..6 = {C-,C,C+,B-,B}, which the adapter's
FOUR_LETTER_MAP (a 3->1 collapse) renders as exactly {C,B} — the observed
output, derived from first principles. Reachable confidences {42,47,52,
57,63} match the live values {47,52,57,63} exactly; C- is truncated by
gradeSlateService keeping the higher-confidence side.
mlb-grade-degradation.md's "25/25 grade<->confidence agreement" is a
TAUTOLOGY, not a validation — confidence is derived from the letter, so
it would report 25/25 even if every grade were wrong.
Recommends feeding the starving factors (restores A/D on merit) and
explicitly REJECTS re-scaling thresholds, which would mint A's without
adding information — every "A" would be a relabelled B.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
Kev's call: investigate the B/C grade collapse before building. Report
only — no grade logic, thresholds, or engine code touched.
FINDING 1 — the collapse is real, live and structural. Across 604 ledger
rows and both sports the engine has emitted exactly TWO grades (B, C) and
NINE confidence values (63/57/55/52/47/45/35/25/20), ceiling 63. Still
true today on both sports. Confidence does NOT determine the letter:
conf 45 -> B while 47 and 52 -> C (non-monotonic), so the surfaced
confidence is not the quantity the letter came from. Edge scale still
broken: 311/604 rows exceed the frontend's sane cap of 40, 39 exceed 100,
worst 620.
FINDING 2 (bigger) — the entire probability layer is DEAD in production.
Live /api/snapshot/mlb: p_win, kelly, ev_pct, model_odds and value are
absent on 0/8 grades, while alt_lines (Desk-gated) IS present 8/8 —
proving nothing is tier-stripped, they are simply never computed.
Root cause: gameLogService.pythonPath returns null for MLB by
construction and the Python service is offline for NBA/WNBA, so
meta.gameLogs is [] for every sport; estimateProbability returns
p_over null; every field guarded by `if (pWin != null)` is skipped.
This is the S46 bug in a second location — that fix added an MLB branch
to featureCache.gameLogFeatures (which is why grades/projections still
work) but never to the estimator path.
Consequences: EV — the Model Train's whole ranking signal — has never
been computed on a live prop. Hero v2 matches nothing and always falls
through to the recent-read fallback (live /api/hero-prop returns
is_recent:true). Quarter-Kelly, sold on the pricing page and listed BUILT
in PROMISE-AUDIT.md, never runs. The value triplet is a duet live.
Recommend re-sequencing: revive the probability layer BEFORE G-a and
C-led (C-led would persist a column of nulls; G-a's EV_FLEX_THRESHOLD
would gate on a permanently-null value — Kev's EV_FLEX_ENFORCE=0 ruling
accidentally prevented an outage). featureCache:206-226 already has both
adapter branches and is the template.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
REPORT-FIRST per the arc order. G-a is HELD — the data changes the
recommended dials. No engine code touched.
Replayed against live ledger_entries (576 rows, 6 game days, 470 settled)
because the "30 days of stored snapshots" does not exist: snapshot Redis
keys are latest/previous only at 24h TTL, and no backtest harness exists
anywhere in the repo.
Findings that change the plan:
- The -400 floor shipped this morning was the whole win: past -400 hit
80.3% against an 86.9% breakeven = -13.29u / -7.7% ROI on 173 settled.
- Arc 2's incremental cut over the live gate is ~11 props in 6 days. The
only material change is gating the flex band behind 2x EV.
- The flex band (-161..-250) is our BEST band (+2.2% ROI, n=70) and the
takeable band is flat (-0.3%, n=209) — the opposite of the assumption
behind EDGE_FLEX_WALL. Recommend shipping the knob with enforcement
OFF until EV is persisted and measured.
- ev_pct/p_win are on NO ledger row, so the EV half of the gate cannot be
replayed at all. C-led (persist EV) is now the highest-leverage item.
- Confidence is monotonic but understates hit rate by ~20-25 points, and
the entire public ledger contains only B and C grades — zero A/A+.
That breaks hero v2 (isAB) and undermines "A-RATED" copy. Escalated.
- L-a answered: alt_lines carry NO odds and the feed has no alternate
markets. L-b is blocked on a data source, not engine work.
- C-led needs no odds backfill (locked_odds 99.1% populated).
- U-deg: the projection==0 leak is already closed (0 since 07-18).
- C4 confirmed in data (359/376 MLB closes == the lock). Stays suppressed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
Arc 1 (7a925f4) shipped without a spec and without a STATE.md entry — the
plan lived only in a session context that was lost. Both written from the
code on disk, not from memory.
- STATE.md: header was stale at a8e383e; now 7a925f4 (pushed, NOT yet
deploy-fingerprinted). New top section records de-vig + EV + takeable/
value gates + hero v2 + the value triplet, the real config values, and
the 274/3289 -> 276/3306 test baseline.
- specs/model-train.md (NEW, per CLAUDE.md rule #1): every knob and its
ACTUAL default (TAKEABLE_ODDS_CEILING -160, TAKEABLE_ODDS_MAX +200,
VALUE_EV_THRESHOLD 2, JUICE_ODDS_FLOOR -400, RARE_EVENT_LINE_MAX 0.5);
the gate AS BUILT (flat price-only refusal at -400 pre-feature, NOT
edge-aware, no -250 wall; the -160..+200 band never refuses a grade and
is enforced on the hero alone); the triplet + hero v2; and a checklist
of what is NOT built.
Recorded honestly rather than assumed: EDGE_FLEX_WALL, HARD_JUICE_WALL,
LADDER_ODDS_MAX and MIN_RUNG_PROBABILITY do not exist in the codebase; no
frontend reads ev_pct/fair_odds/model_odds/book_odds/suppressed_reason yet;
the value fields are ungated to free tier; a second EV implementation
(processing/EVCalculator.js) still coexists with devig.evPct.
No engine code touched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
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>
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>
Kev's call: the 30D accuracy surfaces must read TRUTH, not a cache that can't be
filtered. My earlier degraded-row exclusion only touched getModelAggregate
(Postgres); the public buckets/badge still read outcomeService (Redis outcome
log), which counts degraded projection-0 outcomes and has no field to filter on.
- /api/accuracy (AccuracyBadge) + /api/ledger/accuracy (buckets/ModelRecord)
now source from the clean Postgres ledger aggregate via new
ledgerService.getAccuracyView + accuracyBucketsFromAgg (model_value > 0
excludes degraded rows). Same response shapes → no frontend change. Redis
outcome log is now read by nothing public; it can age out or be rebuilt.
- BEAT CLOSE is a MEASURED-WRONG ZERO: captureClosing re-records the locked line
as the "closing" line, so clv is flat on the whole sample and beat_close reads
0% (comparing a number to itself). Full write-up: specs/audit-data/
clv-capture-broken.md (the fix belongs to C4). Until then, beat_close_pct +
clv_distribution are SUPPRESSED at the source (getModelAggregate, gated by
clvCaptureReliable() / CLV_CAPTURE_RELIABLE=1). Every public surface already
renders BEAT CLOSE only when non-null, so they all hide it now — no wrong zero
anywhere. HIT RATE (real) is unaffected.
Suite 271/3261 green, web build exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Work-order #4 closed. First post-deploy snapshot (2026-07-17 14:00:53 UTC)
re-graded with the fix. Before → after:
- projection==0: 9/25 → 0/25 (the nine now refuse)
- grade<->confidence: mismatch → 25/25 agree
- edge_pct: {20,60,100,140} cluster → 7 continuous values, all-positive projections
The lone remaining edge=100 is a REAL projection (Abreu hits, line 0.5, proj 1.0
over = 100% by (model-line)/line), not the old proj=0 degeneracy. Verified.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
scripts/validate-grade-fix.js checks the live MLB snapshot for the three
degradation signatures (projection=0, edge=100 cluster, grade/conf disagreement)
— run after the next 14:00 UTC regrade to fingerprint the fix. The finding doc
now records root causes, fixes (commits 888d103/9fc4edf), the blast-radius SQL
(box can't reach Supabase directly), and the shared-path note for NBA/WNBA.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
Supersedes the prior partial import. From claude.ai/design project 370ba6df:
- HANDOFF.md (the entry point — exact tokens, glyph library, liftable
behaviors, laws)
- vyndr-mobile.html (19 mobile screens — closes all 10 previously-uncovered
surfaces: landing/pricing/streaks/ledger×2/empty/scan/player/team/explore +
combat/pitcher/archetypes/correlation), vyndr-system.html (desktop terminal),
support.js
- assets/glyphs/ — all 74 archetype marks as real SVGs + MANIFEST, GENERATED
from the authoritative glyphDefs() in the desktop file (currentColor, 24-grid,
duotone) rather than 74 fetches. These replace the generic star/plus
placeholders (the archetype family was never rendering as a distinct 74-mark
system).
- removed my interim MOBILE-SPEC.md distillation (the real 19-screen file +
HANDOFF supersede it).
Landing (desktop marketing) captured in-context — it's M2/M3-desktop scope;
raw file stays in the design project until that wave.
RECONCILE + build (token alignment, glyph lift, M1b surfaces, M3) follow.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Design now provides mobile designs (Vyndr Mobile.dc.html, 5 screens at 390×844).
Captured as specs/design-reference/MOBILE-SPEC.md — the buildable distillation
(the raw dc-runtime HTML needs React to render; the design project holds the
verbatim source). I'm now IMPLEMENTING Design's mobile, not inferring it.
RECONCILE of the already-shipped M1a against Design's mobile:
- EKG hidden <768px — Design AGREES (mobile app bar carries no EKG). KEPT.
- .vbtn 44px tap target — Design AGREES (buttons are 44/46px). KEPT.
- .m-hero generic clamp — CONFLICT. Design uses CONTEXT-SPECIFIC hero sizes
(74px grade tier / 40px live tier / 24px /u stats), not one clamp, and
nothing consumed the class. REMOVED; the specific sizes land per-surface in M1b.
- Mobile HEADER shape — Design OVERRIDES: mobile is an app bar (logo + sync
clock) + a breadth strip + a bottom wire, NOT the collapsed heartbeat bar.
The EKG-drop is a compatible interim step; the full app-bar recomposition is
M1b (the board/shell surface). Flagged in the CSS + MOBILE-SPEC.
Built to Design's mobile spec, VISUALLY UNVERIFIED at 390px (Chrome audit is eyes).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SETUP (required first step): overwrote specs/design-reference/ with the CURRENT
authoritative mockup ("Vyndr System.dc.html" from claude.ai/design, the version
with the combat card / pitcher identity / live grade-shift / correlation builder
/ FREE|PRO pricing / TRANSMISSION QUIET). 12 surfaces. Confirmed it has NO media
queries — desktop-only, so the 390px expression is a deliberate design decision,
not a shrink.
M1a — mobile foundation (built to spec, VISUALLY UNVERIFIED at 390px; WSL2↔Chrome
unreachable, the Chrome audit is the eyes):
- Header-zone collapse (the concrete audit finding: ticker + SIGNAL LIVE + STALE
stacking in ~110px). The decorative EKG is dropped <768px so the heartbeat
reads as ONE clean status line; signal pulse + ticker are the single animated
element (DESIGN-SPEC §4). LiveLayer gains .heartbeat-bar / .hb-ekg hooks.
- Primary CTA (.vbtn) meets the 44px touch target on mobile; dense `small`
buttons opt out (density is a feature).
- .m-hero mono-hero clamp for the one-figure-per-card grammar at 390px.
- M4 test-lock: vyndrParityQA asserts the header collapse, tap target, overflow
containment, and the honest "UNVERIFIED at 390px" label are all in source.
Per-surface stacking (M1b), billboards (M2), desktop parity (M3) continue.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The session-opening doc: 3 trains shipped (2757 tests), live infra map,
pending Coolify env vars + entity placeholders, honest open items.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MLB statsapi + WNBA ESPN live boxscores -> per-player current values
(live:{sport}:{date} TTL 90s, /api/live/:sport + Next proxy). Pure
propState math (HIT / ON PACE / NEEDS N / HOLDS / LINE PASSED — never
red in-progress), attachLiveProgress strip join on nameKey+statType,
proximity-to-hit slate float, StatStrip LiveTracker in the ROW-GRAMMAR
outcome slot (spec amended + lock test updated). Grades never change
in-game — tracking, labeled as such. 2698 -> 2757 tests, web build 0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- specs/ROW-GRAMMAR.md: the row grammar law (slot order, color law, mark
law, mobile stacking, no-truncation), locked by tests/unit/rowGrammar.
StatStrip violations fixed: MovementChip before the grade (market
context before model output); ViabilityChips after the archetype
(identity is one contiguous run).
- Line-movement sparklines: intradayRefreshService.trackHistory captures
real {t,line} points per grade (seeded with the lock, deduped when
flat, capped 24) inside the snapshot write-back; StatStrip.LineSparkline
renders at >=3 points (green toward / amber against / dim flat).
- Last-10 dot strips: services/last10Dots (streaksService accessors) ->
/api/snapshot/:sport attaches last10_dots from rosterlogs:{sport};
StatStrip.DotStrip renders vs the LOCKED line, newest first.
- CLV distribution: getModelAggregate emits clv_distribution (7 signed
buckets, outliers clamped) only past the centralized n>=20 gate;
ledger MODEL header renders the bar strip.
- Global search: SearchModal (cmd-K via GlobalHosts + window.__search),
players via /api/players/search per sport + static lib/teams.js
(soccer deliberately absent); Nav search icon + Search first in the
mobile More sheet. Explore tab untouched.
- Landing LCP: fade-up floored at opacity .6 (hero h1 contentful on
first frame, S33 visible-floor rule) + IBM Plex Mono preload:false
(4 decorative font files off the slow-4G critical path).
2654 -> 2698 tests (226 suites) green; web build exit 0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
tesseract.js (self-hosted WASM, Apache-2.0) + pure per-book layout
parsers (DK/FD/MGM/Caesars) with per-field confidence and needs_review
honesty — the reader never guesses. POST /api/slips/parse (auth, free
1/day paid 10/day, 4MB cap) + Next proxy. Gated /slip page: upload or
paste, manual-correct UI, per-leg grades through the normal engine
(refusals render honestly), add-all to Parlay Lab, share card. Vision
model upgrade logged post-revenue. 2574 -> 2608 tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>