diff --git a/specs/model-train.md b/specs/model-train.md index 568fff2..3b0347f 100644 --- a/specs/model-train.md +++ b/specs/model-train.md @@ -153,6 +153,132 @@ timestamp) is unchanged. `toHero` now also passes through --- +## 2C. METRICS-ENGINE NORTH STAR (design philosophy — NOT yet built) + +**VYNDR classifies and measures players by MODERN FUNCTION, not legacy label.** +This is the principle already underneath the archetype system, drawn from Rashad +Phillips' *Basketball Position Metric* — the work VYNDR's classification started +from. Writing it down so every future metric inherits it. + +### The principle +A "center" who shoots 7 threes a game is not doing a center's job. A contact +hitter and a launch-angle hitter share the label "outfielder" and share almost +nothing else. Positions are inherited labels; **function is what a player +actually does this season.** VYNDR models function. + +### The rule every metric must follow +> **Every proprietary metric is baselined against the player's functional +> ARCHETYPE's CURRENT-SEASON behavior — never against the position's inherited +> or historical standard.** + +A STRETCH BIG's rebounding is judged against how stretch bigs rebound *this +season*, not against what centers rebounded in 2015. + +### Why it is an edge, not just a nicety +**The market frequently prices today's players against yesterday's baselines.** +Books and public models lean on positional priors that lag the way the game is +actually played. Where the archetype baseline and the positional baseline +disagree, that gap is a real, repeatable mispricing — and it is ours to measure +because we already classify by function. + +### It generalizes across sports +Launch-angle vs contact hitters · mobile vs pocket QBs · position-less wings · +bullpen-game openers vs traditional starters. The archetype registry is already +cross-sport; the baseline rule is the same everywhere. + +### The moat +Proprietary metrics, calibrated to the CURRENT game, validated on **our private +outcome data** (the ledger). None of those three is individually rare. Together, +and compounding as the ledger grows, they are hard to copy. + +### How metrics get built — VALIDATED FAMILIES, never a big-bang dump +1. Propose ONE metric family with an explicit hypothesis about what it predicts. +2. Implement behind a flag, computed but not surfaced. +3. **Run the backtest harness: does it predict better WITH the metric than + without?** Hit rate, ROI, Brier, CLV where capture exists. +4. Ships only if it earns its place. Fails → deleted, and the negative result is + written down so it is not re-proposed. +5. Then, and only then, it becomes a surface. + +**Nothing in this vision is real until the harness can prove a metric predicts +better than without it.** The harness (Phase 2) is the gate for all of it. + +## 2D. SOURCING SCOPE — REPORT (Session 64, no code written) + +Triggered by tonight's failure: we wired `refreshTeamStats` into production and +it crashed on every team for a shape change nobody noticed, because **it had no +caller, no health check, and no alarm.** The feed was dead and invisible. + +### Q: can opponent strength be DERIVED from data we already ingest? + +**MLB — YES, and better than what we were reaching for. ✅ VERIFIED LIVE.** +`GET https://statsapi.mlb.com/api/v1/teams/stats?season=YYYY&group=pitching&stats=season&sportIds=1` +returns **all 30 teams in ONE call**, free, official, no quota, no auth. Verified +keys include `era`, `whip`, `avg` (opponent batting average against), `obp`, +`slg`, `ops`, `homeRuns`, `strikeOuts`, `runsScoredPer9`, `homeRunsPer9`. + +That is strictly better than the ESPN field we tried to fetch, because it is +**stat-specific** — which is what `opp_rank_stat` actually wants: + +| Prop stat | Opponent-strength input | +|---|---| +| hits | opponent pitching `avg` (BAA) | +| total_bases / home_runs | `slg`, `homeRunsPer9` | +| strikeouts (batter) | team `strikeOuts` per BF | +| earned_runs / pitcher props | opposing lineup's `ops` (group=hitting) | + +Ranking is a normalize-across-30-teams pass we already have in `teamStatsCache`. + +**NBA/WNBA — ESPN cannot do it.** Verified: `/teams/{id}/statistics` carries only +a team's OWN stats (rebounds, FG%, blocks) — no defensive rating, no pace, no +opponent-allowed anything. Options, in order: (a) `stats.nba.com` team +dashboards, which DO carry real `DEF_RATING`/`PACE` (needs careful headers, is +rate-limited, and is the source the offline Python service used); (b) derive +allowed-points ourselves from scoreboard finals we already fetch — free, slower +to build, fully under our control; (c) API-Sports basketball. **WNBA is the +in-season priority.** + +**API-Sports family** — we hold `API_FOOTBALL_KEY` (validated, free tier 100/day, +active to 2027, currently dormant). Same vendor covers basketball/baseball on +separate keys. Worth evaluating as the *fallback* tier, not primary: the free +quota is thin (100/day) and would not survive a per-team-per-day pattern. + +**ESPN stays LAST and always behind an adapter.** It is free and broad, but it is +an undocumented site API that changes shape without notice — which is precisely +what bit us tonight. + +### PROPOSAL: the SOURCE-ADAPTER pattern (design only — not built) + +Every feed behind one interface, so a source can be swapped without touching +feature code, and **so a dead source is loud instead of invisible.** + +``` +interface StatSource { + id: 'mlb-statsapi' | 'espn' | 'nba-stats' | 'api-sports' + supports(sport, capability): boolean // 'team_defense' | 'game_logs' | ... + fetch(sport, capability, params): Promise + health(): Promise<{ ok, checked_at, sample_nonempty, note }> +} +``` + +- **Registry, config-driven:** per (sport × capability), an ordered + `[primary, ...fallbacks]`. MLB team_defense → `[mlb-statsapi, espn]`. + WNBA team_defense → `[nba-stats, derived-from-scoreboard, espn]`. +- **Normalized output only.** Adapters own every vendor quirk; nothing upstream + learns a vendor's shape. (Tonight's `results.stats` object-vs-array would have + been one adapter's problem, not a pipeline crash.) +- **Health check that PAGES.** Each adapter self-tests on a known entity and + asserts a NON-EMPTY, plausible result. Run on a schedule + before the snapshot. + **Empty is a FAILURE, not a pass** — `captured: 0, errored: 15` must page, and + so must `captured: 30` where every value is null. +- **Fallback is announced, never silent.** Dropping to a fallback logs and pages + once per period; a silent degrade is how we ended up with a feature that had + been null for months. +- **A source with zero callers is deleted or health-checked** — never left as a + corpse to be wired up later, which is exactly what `refreshTeamStats` was. +- **Capability coverage is a first-class report:** which (sport × capability) + pairs have a live primary today. This is also the NFL/NBA/soccer scaling gate. + ## 2B. FOUNDATION-FIRST RE-ORDER (Kev, 2026-07-19) — supersedes the arc order New features are paused until the foundation is real. Phases run in order: