43f65d30cbfc477d7656e4f5fcac518d6939eb5f
479 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
38ee83d0be |
STATE: Session 65 — CLV un-claim + FORM un-fabricate, with live fingerprint
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
|
||
|
|
dcdad60896 |
Directional CLV — per-read signal, side-bound, with a real compute trigger
PER-READ ONLY. No aggregate CLV stat, no CLV marketing un-held. PHASE 0 FINDING THAT SHAPED THE BUILD: the LOCK end must come from model_snapshots, NOT ledger_entries. The ledger stores only the graded side's locked_odds (694 rows, single-side) which CANNOT be de-vigged. model_snapshots retains BOTH side prices on 520/520 graded rows AND an already-de-vigged fair_prob on 520/520 — produced by the same devig.devigTwoWay the close uses, so "same method both ends" holds by construction rather than by convention. THE COMPUTE TRIGGER is the SETTLE PASS (ledgerService.settleLedger). At settle the game is final, so the close has landed and the read is final — the only moment both ends of the comparison exist. Grade and locked prices are written hours earlier and the close at lock, so without this trigger a correct CLV function would simply never populate. JOIN INHERITS THE PROVEN KEY: (sport, player_key, stat, side, game_date), WITHOUT line — a close that moved off the graded line is the entire point. Verified clean earlier: 164 identity groups, zero ambiguity. Rows whose capture refused (missed/ambiguous/one-sided) are UNKNOWN for CLV, matching the capture layer's own honesty. SIGN IS SIDE-BOUND and proven by test before the logic existed — the badge-inverting trap. Same market move: OVER-graded -> positive clv +0.0800 (fair .500 -> .580) UNDER-graded -> negative clv -0.0800 (fair .500 -> .420) exact mirrors. FLAT is a PROBABILITY-space threshold always (1.5pp): a 40-cent price move on a deep favourite reads flat, correctly, because price space lies about magnitude. UNKNOWN is a first-class state, never 0 — zero asserts "the market did not move", which is a claim; a missing close asserts nothing. describe() returns null for unknown so a badge can never render for it. migration 030 adds dclv/dclv_state/dclv_fair_lock/dclv_fair_close/ dclv_computed_at as NEW columns rather than reusing the C4 clv fields — conflating a verified per-read signal with a known-broken one would be the worst kind of quiet lie. Caught pre-deploy: the trigger call passed `deps`, which is not in scope in settleLedger (it uses `opts`) — a ReferenceError at the call site, outside the helper's try/catch, which broke two settlement suites. Suite 286/3447 green, build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA |
||
|
|
b1ed675500 | STATE: MLB opp_rank_stat live — consumption proven, coverage 8/14, factor inert this slate | ||
|
|
63302d194e |
Wire MLB opp_rank_stat into live features (consumption path verified)
featureCache.teamFeatures now derives MLB opp_rank_stat from statsapi team
pitching splits when the ESPN path yields nothing — which for MLB is
always, because ESPN's MLB team endpoint carries no defensive metric at
all. mlbStatsAdapter.getTeamPitchingStats fetches all 30 teams in one free
unauthenticated call, cached at the season TTL.
CONSUMPTION PATH VERIFIED before wiring, not assumed:
featureCache.teamFeatures sets out.opp_rank_stat (line 338)
-> engine1.computeFactors READS features.opp_rank_stat (lines 96-102)
-> fires weak_opponent_defense (>=0.70) / top_opponent_defense (<=0.30)
So teamFeatures is the correct insertion point: the grader reads exactly
the field we populate. A value written anywhere else would have been a
dead end — computed, retained, and still not affecting the grade.
Contract preserved: the derived value goes into the SAME field with the
SAME 0-1 scale and the SAME high=weak polarity WNBA uses, so engine1 reads
one field with one meaning across sports. Isolated and best-effort — a
derivation failure leaves the field ABSENT (honest null), never a guessed
rank. Only fills when the ESPN path produced nothing, so WNBA behaviour is
untouched.
Suite 285/3435 green, build exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
|
||
|
|
55157b3288 |
Close-capture retry (lock-walled) + MLB opp_rank_stat derivation
PHASE 1 — CLOSE-CAPTURE RETRY, test-first. The closing capture gets a
retry the snapshot path deliberately does not: a snapshot re-runs at the
next slot, but a MISSED CLOSE IS PERMANENT, and the feed flaked once on a
dry induce. Three hard rules, each driven by a test written before the
logic:
- BOUNDED attempts (default 3) with short backoff so every attempt fits
inside the window. Never infinite.
- HARD LOCK-WALL: inside lockWallMinutes of first pitch (or past it) it
stops and records missed_close. A price captured AT or AFTER lock is
NOT a close; storing one would fabricate the CLV baseline.
- NO BOUND LOCK TIME -> refuse immediately, never burn retries on a prop
whose close cannot be timed.
On exhaustion it records missed_close with NO price — never a stale,
mid-day or post-lock line.
PHASE 3 — MLB opp_rank_stat DERIVED, contract-locked. MLB previously had
no opponent metric at all (ESPN's MLB team endpoint carries none), so
engine1's +/-1.0 opponent factor never fired for the sport carrying most
of our volume. Derived from data we already ingest: statsapi team pitching
splits, all 30 teams in ONE free unauthenticated call.
THE SHARED CONTRACT is documented and TESTED, not assumed: 0-1 scale,
HIGH (>=0.70) = WEAK opponent, LOW (<=0.30) = TOUGH — identical to WNBA's
live semantics. Polarity is the highest-risk part: backwards polarity does
not fail loudly, it silently adjusts every MLB grade the wrong way. A test
asserts MLB polarity EQUALS WNBA polarity using engine1's own thresholds.
PROVEN AGAINST THE LIVE FEED:
Colorado Rockies BAA .286 -> opp_rank 0.983 (weak, fires weak_opponent)
LA Dodgers BAA .215 -> opp_rank 0.017 (tough, fires top_opponent)
POLARITY HOLDS: true
HONEST NULLS, tested: thin league baseline, thin opponent sample, unmapped
stat, unknown opponent, or a missing field all return NULL with a reason —
we are FIXING a silent null, so it is never replaced by a confident guess
off three games. opponentStrengthHealth pages on an empty source AND on
derived-null-for-a-sport-we-expect-to-derive.
Suite 285/3435 green, build exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
|
||
|
|
2321267346 | STATE: harness armed + closing capture started; reverted my own non-close writes | ||
|
|
77a58e4113 |
Arm harness on our scheduler + start closing-line capture (capture only)
PART A — HARNESS ARMED ON OUR OWN INFRA. snapshotScheduler now runs the
nightly backtest at HARNESS_HOUR_UTC (default 14), appends to
harness_results, and pages via opsWatch.harnessStaleAlarm — a validator
that stops running looks exactly like one that keeps passing. No external
dependency: the join is plain SQL through the service client and the
harness is a pure function. POST /api/internal/harness/run induces the
same code path on demand, because a scheduled mechanism is verified by
inducing it, never by waiting for a slot.
PART B PHASE 0 — GATE PASSED for what is capturable:
- C4 diagnosed: closing_line is ONE overwritable field with no timestamp
and no provenance. captureClosing writes the current line and, when a
prop fails to match, silently leaves the earlier value (= the lock) in
place — so "captured a real close" is indistinguishable from "never
updated". It is 92% equal, not 100%: 56 rows DID record movement, so
the defect is provenance, not the value.
- Feeds: normalized props already carry BOTH raw side prices per book,
with game_time, and the intraday refresh polls every ~20 min during
slate hours — so the last observable pre-lock line is available.
- SHARP close: pinnacle is in ALLOWED_BOOKS -> a no-vig reference is
capturable ("beat the market").
- ODAWA: NOT capturable. 'odawa' exists only as a UI preference option in
onboarding/settings; it is in no adapter, no ALLOWED_BOOKS, no feed. An
un-capturable source is a finding, not a gap to paper over.
- JOIN: must drop `line` from the natural key, because a close that MOVED
off the graded line is the entire point of CLV. Verified safe — all 164
current identity groups have exactly ONE line per
(sport, player_key, stat, side, game_date). Zero ambiguity.
PART B PHASE 1 — CAPTURE ONLY, built test-first. The refusal was proven
before the capture logic existed: unbound game_time, doubleheader
ambiguity, a missed pre-lock window, or a one-sided price all record
missed_reason with NO price. A stale or mid-day line substituted for a
close would manufacture a CLV proof from a number that was never the
close.
migration 029 closing_captures: append-only, never overwritten (that is
the provenance C4 lacked), BOTH raw side prices so the existing de-vig
engine can compute a fair closing probability later, sharp vs book line
types kept distinct. Wired into the intraday refresh with a capture-rate
alarm — a missed close is unrecoverable.
NO CLV metric built, as ordered. This starts the clock.
Suite 284/3417 green, build exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
|
||
|
|
e809a0eb3c |
Backtest harness — the validator, built refusal-first
Phase 0 gate PASSED: the join is clean. No FK exists; the natural key (sport, player_key, stat, line, side, game_date) yields 283 clean 1:1 joins with ZERO ambiguity. game_id is NOT usable — 400/550 snapshot rows carry UNK@UNK because home/away names weren't threaded into the grader until Order 1.6. Non-joining rows are EXPECTED, not errors: retention stores both sides plus refusals; the ledger keeps only the graded side. Outcomes are NOT denormalized — ledger_entries stays the source of truth. BUILT TEST-FIRST, and the first property proven is the REFUSAL, not the math. Below threshold the harness emits INSUFFICIENT with n and the shortfall and NO rate anywhere in the payload, so a downstream renderer cannot surface one by accident. A test asserts the payload contains no hit_rate number at all. - Wilson intervals (correct at the n we actually have, unlike the normal approximation which emits negative lower bounds). - Strata NEVER mix sport or model_version. - Denominator excludes quarantined, void, unrecoverable, pending, push — asserted by test. - Monotonicity refuses to RANK buckets whose intervals overlap; it reports "not distinguishable on this sample". - Probability calibration (Brier + reliability) also respects the threshold: a thin sample returns status INSUFFICIENT and a NULL score. - Replay seam reads the STORED feature vector only. A row whose input was never retained is UN-BACKTESTABLE, never scored with substituted current data. Identity replay reproduces the live prediction exactly. The tests caught a real bug in my own code: `Number(null) === 0` let a null p_win through as a confident 0% forecast — this codebase's signature fabrication bug, inside the harness whose entire purpose is refusing invented numbers. Fixed with a strict null guard. FIRST LIVE RUN — the correct, passing output: VERDICT: INSUFFICIENT_HISTORY (can_validate=false) 283 joined -> 35 scored (120 quarantined, 124 pending, 4 terminal) C n=18 (short by 2), B n=17 (short by 3) strata: mlb 7, wnba 28 — never mixed migration 028 adds harness_results (append-only trend log; INSUFFICIENT rows are expected and correct) and opsWatch.harnessStaleAlarm pages if the harness stops running — a validator that isn't running looks exactly like one that keeps passing. Suite 283/3403 green, build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA |
||
|
|
f73fb64a43 | STATE: heal executed — 64 re-voided, 25+200 quarantined, scopes separated | ||
|
|
b33612675d |
Heal execute: quarantine markers, re-enable DNP voiding, two exclusion scopes
Order 2 Phases 2 + 4. Pre-heal rollback point secured first: vyndr-20260720-093821.dump (856,890 bytes) VERIFIED ON THE BOX, not just exit 0. MIGRATION 027 — two DISTINCT exclusion scopes, deliberately separate: - quarantine_reason: the row's GRADE is untrustworthy (wrong_opponent_grade). The row REMAINS a real public settled result — the bet happened, the outcome is real — but it must never train or validate, so getModelAggregate now excludes it from the denominator alongside void/unrecoverable. - analysis_flags: the row is VALID for settlement and the record but unattributable for PER-GAME analysis (doubleheader dates). Explicitly NOT filtered from aggregates. Collapsing these would either wrongly drop 166 doubleheader rows from the record or wrongly keep 25 wrong-opponent grades inside model validation. Tests assert both directions, including that analysis_flags is NOT filtered. Also adds re_settled_at + settlement_source to model_snapshots. DNP VOIDING RE-ENABLED — reversing my own Order 1.5 disable, with scrutiny, because its premise was FALSE. Order 1.5 assumed a missing player row meant the row's DATE was wrong. The Phase 0 dry-run disproved it: across every bindable row the stored date matched a real game (MIS-DATED: 0), and the players I had cited as counter-evidence were genuine DNPs on their true dates (Freeman 07-18; Kwan/Hedges/Davis 07-17 — their teams played, they did not). The evidence is positive: games FINAL + no line in a full-season log = no bet existed. I got this wrong twice tonight in opposite directions; the dry-run is what caught it. Recording the reasoning in the code so the next reader sees why the flag flipped back. Suite 282/3386 green, build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA |
||
|
|
b76e35f575 | STATE: grading date binding fixed + blast radius quantified | ||
|
|
6415751f2e |
Grading binds opponent features to the REAL game, not ESPN's "today"
Order 1.6 Phase 1. This is a MODEL-OUTPUT fix, not bookkeeping. computeFeatures.lookupTodayGame called the ESPN scoreboard with NO date param and took whatever ESPN calls "today". Renamed to lookupGameOnDate and now sends ?dates=YYYYMMDD from the prop's BOUND game — the same game the ledger, retention and settlement use, so all four finally agree. PROVEN against live ESPN (before/after, same instant): dateless "today" CLE->PIT NYY->LAD LAD->NYY (Jul 19 card) bound to 2026-07-20 CLE->MIN NYY->PIT LAD->PHI (the real games) bound to 2026-07-19 CLE->PIT NYY->LAD LAD->NYY (reproduces OLD) Every opponent was wrong. opponentAbbr feeds opp_rank_stat (a +/-1.0 factor) and isHome feeds home_away (+0.5), so late-slot grades were scored against the wrong matchup. Note the window is WIDER than the 01:00/03:00 UTC slots: this ran at 07:5x UTC = 03:5x ET and ESPN's dateless scoreboard was STILL returning the previous day's card. HONEST DEGRADATION: with no bound game date the grader does NOT fall back to a dateless lookup — it records 'no_bound_game_date' and leaves opponentAbbr/isHome/gameId null, so engine1 simply omits the opponent and home/away factors rather than scoring a wrong matchup. Tests assert both directions. Same class of bug fixed alongside: the Tank01 augmentation used TODAY's UTC date for its cache key; it now uses the bound game date. gradeSlateService threads game_date/game_time/home_team/away_team into the grader so the binding reaches computeFeatures at all. Audited the rest of the feature path for dateless/"today" lookups — none remain (weather is current-conditions by venue, park/pace are static). Suite 282/3383 green, build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA |
||
|
|
8e17de4010 | STATE: game_date root fixed, 64 voids reverted, grading blast radius flagged | ||
|
|
1bcdd8b305 |
Fix the ROOT: bind props to their real game, never date by the grade clock
Order 1.5 Phase 1. PropLine emits NO commence_time (grep-verified: zero hits in proplineAdapter), so ledgerService's `dateET(prop.game_time) || dateET(gradedTs)` always fell through to the GRADE timestamp — and a 01:00/03:00 UTC snapshot is 21:00/23:00 ET the PREVIOUS day. Tonight's props were filed under yesterday, settlement correctly found no game there, and Order 1's void logic turned that into 64 destroyed results. gameBinder.attachGameTimes() now matches every prop to a scheduled game by TEAMS across the plausible ET window (grade date, +1, -1) and attaches the GAME'S OWN time/date/id. It runs in snapshotService before grading and before the ledger write, so ledger, retention and settlement all inherit the correct date from one place. HARD CONTRACT: an unbindable prop returns NOTHING. ledgerService no longer has a grade-clock fallback — a row with no real game time is SKIPPED and counted, because a mis-dated row is fabricated data and the ledger holds real values or nothing. A slate that binds nothing pages. DOUBLEHEADERS are reported, never guessed: two games with the same teams on one date mark the binding `ambiguous` so settlement can decline rather than attribute a prop to the wrong game. (Real example already in the data: mlb:2026-07-11:MilwaukeeBrewers@PittsburghPirates(Game1).) Also fixes retention, which had the SAME bug from last night — I had dated model_snapshots rows with the snapshot clock. Rows now take the ET date of the bound game_time. Suite 282/3381 green, build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA |
||
|
|
270c4db47a |
STOP voiding on player-absence — it destroyed real results
Correctness fix to code I shipped minutes ago. The induced live settle pass voided 64 rows as 'player_dnp' and a large share of them are WRONG: the Jul 18 set is everyday starters (Freeman, Bellinger, Tucker, Chisholm, Conforto). They played. ROOT CAUSE — and my Phase 0 diagnosis was wrong. It is not DNP. The ledger row's game_date is WRONG. ledgerService derives game_date from the GRADE timestamp when the feed carries no game_time, and a 01:00/03:00 UTC snapshot is 21:00/23:00 ET the PREVIOUS day, so rows get labelled with the previous ET date. Verified against fresh season logs (cache disabled, so not staleness; found:true, so not name resolution): Freddie Freeman played Jul 17 and Jul 19 (x2, doubleheader) — NOT Jul 18 Steven Kwan played Jul 18 (x2) and Jul 19 — NOT Jul 17 Settlement was correct to find no game on the labelled date. My void logic then converted a data-labelling bug into destroyed results. FIX: never void on player-absence alone. Voiding now requires POSITIVE evidence — the games themselves postponed/cancelled. Absence returns 'unknown' (reason player_absent_unconfirmed), so the row retries and ages out to 'unrecoverable' at the cap. We cannot distinguish "did not play" from "mislabelled date", so we must not claim DNP. Both terminal states are excluded from the record denominator either way. Window-decay remains genuinely fixed (full season log vs a rolling window), and terminal states still prevent immortal rows. NOT DONE HERE: the 64 wrong voids are still in the table, and the game_date derivation is still wrong at the source. Both are reported for the table — no healing in this order. Suite green, build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA |
||
|
|
d4a6170ffa |
Settlement fix forward: date-targeted resolution + terminal states
Order 1 of 2. Push scoring UNTOUCHED — it is correct. No healing here.
PHASE 1 — DATE-TARGETED FETCH replaces the rolling window for settlement.
settleSource.resolveOutcome() resolves the SPECIFIC DATE and, when the
player is absent, reads GAME STATE to learn what the absence MEANS:
game final + player has a line -> SETTLE (a partial game is a real
result, never a void)
game final + player absent -> VOID (confirmed DNP)
postponed / cancelled -> VOID
scheduled / in progress / SUSPENDED -> PENDING (a suspended game resumes;
voiding it would destroy a real bet)
player played, stat missing -> unknown, NEVER void a real appearance
This is FREE for MLB: mlbStatsAdapter.getPlayerGameLog already returned the
full season log and getPlayerStats was discarding it with .slice(-10).
Settlement now reads fullLog — same request, same cache — which removes
window-decay entirely (the verified failure was a Jul 12 game outside a
last10 starting Jul 6). Projections keep using last10, unchanged.
PHASE 2 — TERMINAL STATES (migration 026 applied). outcome CHECK widened to
hit/miss/push/void/unrecoverable; added settle_attempts, settlement_source,
settlement_version, model_version. A row that cannot be resolved after
SETTLE_ATTEMPT_CAP (4) date-targeted attempts becomes 'unrecoverable'
rather than pending forever. CRITICAL: getModelAggregate now EXCLUDES void
and unrecoverable from the settled selection — it used
.not('outcome','is',null), so without this a void would have counted as a
settled row and silently moved the public record. Verified in the record
calc, not just the settle path.
PHASE 3 — SETTLEMENT-RATE ALARM. zeroSettleAlarm only caught a TOTAL zero
while ~30% of a slate failed quietly (Jul 17: 57/86). opsWatch
.settlementRateAlarm pages when resolved/attempted falls below
SETTLE_RATE_FLOOR (0.8). Voids count as RESOLVED — a void is a legitimate
terminal state — so healthy voiding never pages. Third silent-failure
surface of the night, now closed.
PHASE 4 — VERSION STAMPING. src/config/modelEras.js defines the cutoff
ONCE (2026-07-19T22:50:00Z); migration 026 backfilled pre-cutoff rows as
'pre-retention-unknown' (naming the uncertainty, not implying knowledge);
new rows carry model_version.
Regression caught pre-deploy: getScheduleFn was not injectable, so the
ledger suite hit the real network and HUNG. Now injectable via opts and a
no-op under NODE_ENV=test. The "no row -> pending" test was updated to the
new behaviour deliberately: a missing row on a FINAL game now voids.
Suite 281/3373 green, build exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
|
||
|
|
e46c88e364 | STATE: retention clock proven ticking via induced cron entrypoint | ||
|
|
5a5e37e32e |
Retention: fill enrichment fields + page on a zero-write slot
PHASE 1 — cron capture needed NO wiring. Verified in code: the scheduler tick calls runAll = snapshotService.runAllSnapshots, which loops runSnapshot per sport, which already carries the onGraded -> retention hook. The scheduled path and the manual path are the SAME function. The reason no cron cycle had been captured is simply that no slot has fired since retention deployed (slots are 14/19/22/1/3 UTC; retention landed ~02:55). Induced proof follows the deploy. PHASE 2 — archetype/team/opponent were permanently null because retention persisted at GRADE time, before enrichment attaches them. Retention still COLLECTS at grade time (the only moment the feature vector exists) but now PERSISTS after enrichment, merging those three fields via retentionService.mergeEnrichment. The merge is pure and fills ONLY those three fields — features and every model output are grade-time values and must never be rewritten by enrichment; a test asserts that. Unmatched rows (refusals not in the enriched slate) keep nulls rather than guesses. The empty-slate early return now persists too: a refusal-only slate is still history worth keeping. PHASE 3 — ZERO-WRITE ALARM. opsWatch.retentionZeroWriteAlarm pages at missed-snapshot severity when a slot GRADED props but retention wrote fewer rows than the slate (or nothing). runSnapshot now returns retentionRows so the scheduler can evaluate it. Retention is best-effort by design so it can never break a snapshot — which means a broken write is silent by construction. This is the counterweight. A slot that graded nothing never false-pages; an absent count reads as NOTHING and still pages, distinct from a reported 0. Suite 280/3349 green, build exit 0. Outcome stamping deliberately NOT implemented (depends on the settlement fix). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA |
||
|
|
c2f6041406 |
STATE: off-box round trip CLOSED — 645 restored from the box copy
Phase 3 complete. The insurance chain is proven end to end rather than assumed: dump -> validated -> pushed off-box -> verified on the box -> pulled back down -> rebuilt into a live database. Pulled vyndr-20260720-051158.dump FROM the Storage Box (not the local copy) with the in-session key through the pinned host key, never bypassing StrictHostKeyChecking. Restored into scratch Postgres 17: 715 archive objects, 42 public tables, ledger_entries with all 27 columns and real spot-checked rows. ASSERTION PASSED: ledger_entries restored 645 == live 645 (target >= 645). model_snapshots restored 100/100, so the retention store shipped yesterday is covered by backups from day one. Records the operational gotcha the restore surfaced: the dump is written by pg_dump 17 (Supabase 17.6) and pg_restore 16 CANNOT read it — 'unsupported version (1.16) in file header'. The first attempt failed on exactly this. Any DR runbook must use PG17+ tooling. Restoring into vanilla Postgres also logs 12 ignored errors (Supabase roles/extensions absent locally) which are harmless. Scratch DB torn down, pulled copy deleted, both dumps still on the box, nightly cron untouched. No private key material echoed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA |
||
|
|
491e636b7f |
STATE: off-box backup working + verified on the box
Records off-box as WORKING with the root cause (key was only in Hetzner's project store, never in the box's authorized_keys — the box previously offered an EMPTY auth list) and the proof: offbox_ok:true, and the file independently VERIFIED on the box via rsync --list-only through the pinned host key (vyndr-20260720-051158.dump, 833,917 bytes, 05:12:28 UTC, byte-identical to the local dump). Env truth captured from the run output: key is correctly base64-decoded, destination has no leading-slash bug. Hardening recorded: host key statically pinned (accept-new gone, missing pin refuses the push), remote dir guaranteed, failed required push now pages at urgent with offbox_ok:false while the exit code still tracks on-box durability. Flags the ONE outstanding acceptance item honestly: the round-trip restore is NOT done, because the dev box cannot authenticate to the Storage Box (the authorized key is Kev's, not the in-session keypair) and the container has no Postgres server. Lists both unblocks and the assertion target (>= 645). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA |
||
|
|
2bfae804da |
Verify off-box presence by reading the remote dir back
Exit 0 from the backup script is deliberately tied to ON-BOX durability, so it is not proof the off-box copy landed. GET /api/internal/backup/offbox runs rsync --list-only against BACKUP_REMOTE using the SAME pinned known_hosts as the push (checking never disabled) and returns the dumps actually present, with size and timestamp — so off-box presence is a verified fact rather than an inference from an exit code. Needed because the dev box cannot authenticate to the Storage Box: the authorized key installed there is Kev's ~/vyndr-backup-key, not the keypair generated in-session, so independent verification has to run from the container that does hold working credentials. Suite 280/3338 green, build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA |
||
|
|
c4c9b97604 |
Off-box backup: pin the host key, guarantee the remote dir, page on failure
PHASE 1 — HOST KEY STATICALLY PINNED. ssh-keyscan -p 23 returned an ED25519 key whose fingerprint EQUALS the out-of-band value SHA256:XqONwb1S0zuj5A1CDxpOSuD2hnAArV1A3wKY7Z3sdgM, so it is safe to pin. scripts/storagebox_known_hosts now carries that verified line and ships to the container (Dockerfile already COPYs scripts/). backup-db.sh uses StrictHostKeyChecking=yes + UserKnownHostsFile=<pin> instead of accept-new, which was trust-on-first-use and would have accepted an impostor on the very first run. A missing pin file REFUSES the push rather than silently falling back. Never weakened to accept-new/=no//dev/null — a test asserts that on executable lines. PHASE 1b — REMOTE DIR GUARANTEED. The box has only .ssh/, and rsyncing a file into a missing parent either fails or silently writes the dump AS the directory name — one file, overwritten nightly, reading as "backups exist" while retaining exactly one. Uses rsync --mkpath when available, else an explicit remote mkdir -p ahead of the push. PHASE 2b — FAILED OFF-BOX PUSH IS NOW LOUD. Off-box is required, so the failed-push path pages at "urgent" (was "low"/deferred) and the script emits a machine-readable OFFBOX_OK=1/0/deferred that POST /api/internal/backup/run surfaces as a distinct offbox_ok field. Exit code deliberately still reflects ON-BOX durability — a good on-box dump must not raise a false total-failure alarm. Surfacing the truth, not manufacturing a failure. No key material is echoed anywhere; only the PUBLIC host key is committed. Suite 280/3338 green, build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA |
||
|
|
7e150f9342 | STATE.md: pin header + point to the orientation block | ||
|
|
7ea0af2081 |
STATE.md: CURRENT STATUS + OPEN ITEMS orientation block
Top-of-file ground-truth block for orienting a fresh session. Records what shipped tonight (probability layer revived 32/32, value engine arc 1, grade-range work, backup durable on-box at 643/643, model_snapshots retention live with 100 rows incl 36 refusals, ESPN parser fix) WITH two honest qualifiers rather than a clean win: A still does not emit in production so the A-RATED marketing hold stands, and EV is overconfident (+62%/+61%/+56.9% captured, p_win clamps at 0.95) while hero v2 already ranks on it. OFF-BOX BACKUP recorded as NOT WORKING and deferred — never succeeded once, every dump lives only on the Hetzner volume. Documents the two real blockers fixed (missing base64 decode; container had rsync but no ssh binary) and the remaining one: the Storage Box offers an EMPTY auth-method list, which is an account refusing all auth rather than a wrong key. Explicitly marks as UNVERIFIED that no Chrome/UI diagnostic was run — no data on the SSH-support toggle, external reachability, project-vs-box key scope, or any Hetzner outage — and lists those as untested hypotheses in likelihood order rather than implying they were checked. The full scratch-Postgres restore proof is recorded as still OWED. Open items with status: settlement zero-pushes bug, ~28 props/day unsettled, permanent model-version contamination (with the hard-cutoff rule), A-grade unreachable, EV overconfidence, edge_pct broken scale, C4 CLV, consistency CV stopgap. Plus the three live credentials to rotate: Storage Box password, VYNDR_INTERNAL_KEY (pasted in a transcript), and the GitHub PAT still in .git/config. Next queued: backtest harness (needs ~2wk history, currently holds one night), settlement audit, opponent-strength sourcing (MLB solved via statsapi pitching splits; NBA/WNBA open) behind the source-adapter pattern, then the metrics engine gated on the harness. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA |
||
|
|
971f641d12 |
STATE: retention live + settlement findings + version contamination
Records model_snapshots as LIVE and verified capturing (100 rows over 2 cycles: MLB 14 graded/36 refused, WNBA 50 graded; features, grade_11, p_win, ev_pct on 100% of graded rows). First-ever refusal visibility: juiced_no_edge 18, rare_event_over_below_ line 13, insufficient_data 5 — the MLB gate refused 36 of 50 sides (72%), now measurable for the first time. Flags EV as OVERCONFIDENT and not fit to surface: first captured values include +62.1%/+61%/+56.9%, which real markets do not offer. Cause is the estimator clamping p_win at PROB_CEIL 0.95 off ~10 games. Hero v2 already ranks on ev_pct, so it will pick the MOST overconfident read — calibration must gate this before EV drives anything user-facing. Logs the two settlement-correctness findings Kev asked to track (zero pushes across 470 settled rows; ~28 props/day never settling) and the ledger model-version contamination, with the rule that any backtest off existing history must treat the 2026-07-19 fix boundary as a hard cutoff. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA |
||
|
|
14f47af74b |
Dockerfile: install openssh-client — rsync cannot exec ssh without it
The off-box push failed with 'rsync: Failed to exec ssh: No such file or directory (2)'. The container had rsync and pg_dump from S62 but no ssh binary, and rsync shells out to ssh for every remote transport. The dump itself succeeded, so this failed AFTER a good backup and reads like a network/auth problem when it is a missing package. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA |
||
|
|
d3ffa1b8c2 |
Retention: model_snapshots live + base64 SSH key support
RETENTION (Phase 2, priority zero). History starts compounding tonight. migration 025 model_snapshots — APPLIED to prod. Append-only, one row per graded prop PER SIDE PER CYCLE, with a unique index on (snapshot_id, player_key, stat, line, side) so a retried cycle cannot duplicate. RLS on, service-role writes only. What it captures that the ledger never did: - features jsonb — the model's INPUTS. Without these a backtest can only grade our own homework; with them any future model can be replayed against the exact conditions this one faced. - REFUSALS (refused + refusal_reason). The ledger drops them, so a gate refusing props that would have WON is invisible — unmeasurable lost edge. Captured via a new onGraded hook in gradeSlateService that fires with BOTH sides before any filtering. - grade_11, the pre-collapse grade. The 4-letter map throws away the entire live C-/C/C+/B- range. - model_version + code_sha on every row. ledger_entries mixes pre/post-fix grades with no marker and cannot be separated retroactively. - p_win / ev_pct / fair_odds / takeable / value — none of which any permanent store held. Wiring: analyzeViaEngine1 attaches _features/_grade_11 (underscore = internal); gradeSlateService fires onGraded then STRIPS them so they never reach a cache or API payload; snapshotService builds rows and persists best-effort. Retention reuses the LEDGER's dateET/gameIdFor helpers so rows share the ledger's natural key exactly — otherwise the settle pass could never join outcomes onto them. Rows are written BEFORE the empty- slate early return: a slate that refused everything is exactly the case worth recording. CONTRACT HELD: retention is injectable and every path is caught. persist() returns errors, never throws; a missing Supabase client is SKIPPED, not an error. A retention failure can never break a snapshot. BACKUP: backup-db.sh now accepts BACKUP_SSH_KEY as base64 (recommended — survives env-var newline mangling, which is how injected SSH keys usually break silently) OR raw PEM, detected by decoding and looking for the PEM header. Verified both forms detect correctly against a real generated key. Suite 279/3325 green, build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA |
||
|
|
04a09ec1b2 |
Phase 2 (a) report + (b) retention design — REPORT-FIRST, nothing built
(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 |
||
|
|
8aafceaa3b |
STATE.md: pin header to cc8e478
|
||
|
|
cc8e47884d | STATE.md: backup is durable on-box and verified by read-back (643==643) | ||
|
|
97e4dc72d5 |
Backup: chown /app/backups in image + report uid/writability
The real backup run failed: pg_dump could not write to /app/backups — 'Permission denied'. Cause: the container runs as the non-root 'vyndr' user (Dockerfile USER vyndr) and the Coolify-mounted volume is root-owned, so the mount is present but unwritable. - Dockerfile now creates AND chowns /app/backups to vyndr alongside the existing /app/data + /app/.pm2 line. Docker seeds ownership into a NAMED volume on first creation, so this fixes it for a fresh volume; a host bind-mount still needs a host-side chown, which is why the next change exists. - GET /api/internal/backup/verify now reports process uid/gid, backup_dir_writable and the access errno, so the exact chown target is observable instead of guessed. A mounted-but-unwritable volume reads as 'configured' everywhere else — this makes it loud. Suite 278/3310 green, build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA |
||
|
|
ef7f17610f |
Backup: durable on-box volume, off-box DEFERRED, and a real read-back check
BACKUP_DIR is now a persistent volume (/app/backups), so the dump already survives redeploys — the container-ephemeral risk that made this urgent is closed. Storage Box SSH auth is not sorted yet, so the off-box push is explicitly DEFERRED rather than failing: - gated on BACKUP_OFFBOX=1 (plus BACKUP_REMOTE and BACKUP_SSH_KEY); until then the script logs "off-box push DEFERRED" and exits clean. - if an enabled push DOES fail, it is a LOW-priority "deferred" notice, not a failure — the durable on-box dump succeeded, and calling that an incident would train us to ignore backup alerts. Adds the read-back check, because a backup nobody has read is a hope: countRowsInDump() runs `pg_restore --data-only --table=X -f -` and counts the rows between `FROM stdin;` and the terminating `\.`, proving the archive CONTAINS the data rather than merely parsing. Needs no Postgres server, so it runs inside the API container. Validated against a real pg_dump from a scratch Postgres: counted exactly 604 rows. GET /api/internal/backup/verify exposes it (newest dump in BACKUP_DIR, size, table, rows_in_dump). Unit tests inject spawn/fs so CI needs neither docker nor pg_restore. Suite 278/3310 green, build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA |
||
|
|
13ca070096 |
Spec: metrics-engine north star + sourcing scope report (no code)
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 |
||
|
|
40aba37f83 |
Backup: env-injected SSH key, nightly off-box push, triggerable run
Closing the backup for real. Three changes, each fixing something that
would have made the Storage Box target fail or silently rot.
1. SSH KEY COMES FROM ENV, not from the container. Generating a keypair
inside the API container was the obvious move and it is wrong: the
container filesystem is ephemeral, so the key dies on the next
redeploy and the off-box push starts failing silently. backup-db.sh
now reads BACKUP_SSH_KEY (a Coolify secret), writes it to a 0600 temp
file per run, and removes it on exit via trap.
2. PORT 23, verified live. Hetzner Storage Box runs full OpenSSH on 23;
port 22 answers with mod_sftp (SFTP only). Banner-checked both against
u635423.your-storagebox.de. rsync now uses
-e "ssh -p ${BACKUP_SSH_PORT:-23} ... -i <key>"; the old invocation had
no -e at all and would have gone to 22.
3. OFF-BOX PUSH IS NIGHTLY, not Sundays-only. A weekly push meant up to
six days of dumps existed ONLY inside an ephemeral container, which is
the same as not existing. Alert copy updated to say exactly that when
the push fails or is skipped.
Also adds POST /api/internal/backup/run (internal-key gated) so a real
backup can be TRIGGERED and OBSERVED — it returns exit code, duration,
output tail, and whether the remote + ssh key are configured. The backup
can only run where SUPABASE_DB_URL and the Supabase route live (this
container), and there was no way to fire or inspect it without a shell.
Connectivity established this session: Storage Box reachable from the dev
box on 22/23; Supabase :5432 NOT reachable from WSL2 (so the dump must
run in-container, as designed); docker IS available locally, so the
restore-verify can run against a scratch Postgres using the real dump.
Suite 278/3305 green, build exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
|
||
|
|
26b276fbfb |
Fix the ESPN team-stats parser + report: opponent rank is still underivable
Ran the manual regrade with the internal key (thanks). Results are mixed
and the honest half matters more.
CONFIRMED WORKING — the probability layer is fully alive in production.
After POST /api/internal/snapshot/{mlb,wnba}: p_win, ev_pct, model_odds
and value are present on 32/32 live grades (mlb 7/7, wnba 25/25), up from
0/8 before. That fix is done.
NOT WORKING — the grade-range half did not land, and I am not going to
claim it did. The live distribution is unchanged (wnba B17/C8 before AND
after; mlb B4/C3), no A, no D, same four confidence values. Diagnosis:
matchup_grade is 0/25 on the live board, i.e. opp_rank_stat is still
null, so engine1's +/-1.0 opponent factor still never fires and the
ceiling is still +3.0 against the +4.5 an A requires.
Two distinct causes, both verified against the live ESPN feed:
1. refreshTeamStats CRASHED on every team — "buckets is not iterable",
captured 0 / errored 15. ESPN's current shape is results.stats =
an OBJECT with categories[], not an array. The old parser did for...of
on it. This was invisible until S63 gave the function its first
production caller. FIXED here (now captured 15 / errored 0) with a
regression test covering the current shape, the legacy array shape,
and empty payloads.
2. Even parsed correctly, the endpoint does not carry a
defensive-strength metric at all: defensive_rating, opponent_ppg,
pace and opponent_fg_pct all normalize to null — it returns only a
team's OWN stats. So defensive_rank_normalized cannot be computed and
opp_rank_stat remains underivable from this source. A test documents
the gap and will fail if that ever changes.
Consequence: A STILL DOES NOT EMIT, so the A-RATED marketing hold STAYS.
Reviving the opponent factor needs a different derivation (opponent
points allowed from scoreboard/schedule, or a different ESPN endpoint) —
logged as the concrete next item, not hand-waved as done.
Suite 278/3305 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
|
||
|
|
b742230d94 |
Phase 1: ship the backup cron as CODE + a manual regrade trigger
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 |
||
|
|
bf8ecb45ad |
Track U-deg pt2 (edge_pct scale) + the dispersion classifier as open items
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 |
||
|
|
58ce1c3e56 | STATE.md: header to deployed+fingerprinted HEAD | ||
|
|
a80868c4eb |
S63 fingerprint: probability layer verified live (p_win/ev_pct/model_odds)
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 |
||
|
|
1a94ef5fcf |
Revive the dead probability layer + restore grade range ON MERIT
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
|
||
|
|
416639efe4 |
Grade collapse: mechanism traced — A is mathematically unreachable
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
|
||
|
|
3fe840ab83 |
Diagnose grade collapse + find the DEAD probability layer (report only)
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 |
||
|
|
669479097c |
Model Train G-b/C-cal: gate simulation + calibration report (docs only)
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 |
||
|
|
18bf3ecb51 |
Reconcile the record: STATE.md header + Model Train arc 1 spec (docs only)
Arc 1 ( |
||
|
|
7a925f43eb |
Model Train arc 1 (engine): de-vig + EV + takeable/value gates + hero v2 + triplet
Steps 1-6 — make "real opportunities at takeable prices" the engine, not a filter. 1. DE-VIG (src/utils/devig.js): two-way multiplicative de-vig strips the vig and returns fair prob + fair price per side + the overround. One side missing → fair UNAVAILABLE (null), never faked. Method noted in code + the `devig_method` field. 2. EV (devig.evPct): ev_pct = model prob × decimal − 1 at the graded side's ACTUAL price. This is the ranking signal now, replacing raw |model−consensus|. 3. TAKEABLE gate (src/config/valueEngine.js, TAKEABLE_ODDS_CEILING −160 .. +200, env-tunable): promoted surfaces only (hero/featured/alerts). The full board still shows everything; Parlay Lab exempt; JUICE_ODDS_FLOOR (−400) stays the absolute backstop underneath. Strict null-guard (Number(null)===0 would have made a missing price "takeable"). 4. VALUE flag: passes BOTH gates (takeable AND ev_pct ≥ VALUE_EV_THRESHOLD). Grade = read quality; value = the price pays you. Shipped in payloads. 5. HERO v2 (heroPropService): highest ev_pct among takeable A/B reads — a huge gap on a −900 line is trivia, not an opportunity. 6. VALUE TRIPLET: book_odds · fair_odds · model_odds on every read (snapshot, hero, scan — they all spread the grade). Handoff documents the fields; the rendering is Session-2 Design's job. All wired in analyzeViaEngine1's existing p_win/kelly block (real quantile probability × real book odds, or nothing). 33 new tests; suite 276/3306 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
348a82b4a0 |
Generalize the no-edge guard: suppress by the BOOK'S PRICE, not a stat whitelist
Follow-up to the rare-event under fix — the whitelist (doubles/triples/HR/SB) was fragile: the same juiced-under problem exists for steals, blocks, and any other low-frequency market, and a new stat would slip through. The real signal is the book's own price. The doubles unders were priced -625 to -1100 — laying 6-11x to win 1x on an ~82% event, with no value the model could recover. So the PRIMARY guard is now stat/sport-agnostic: analyzeViaEngine1 refuses any read whose graded-side odds are past the juice floor (JUICE_ODDS_FLOOR, default -400, env-tunable). That catches every version of this — steals, blocks, anything — and it also keeps the public record honest (those -800 "wins" hit ~82% of the time and would inflate the hit rate, the same class as the projection-0 degradation). The structural rare-event rules stay as the BACKUP for props with no odds (list also expanded cross-sport: + steals, blocks). Normal + longshot prices (-110, -250, +600) are preserved. 16 tests cover both layers. Reported: the doubles projection was REAL per-player (not a fallback); the fix is the price guard, not a bigger list. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
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> |