Files
vyndr/specs/resolution-and-clv-investigation.md
T
builtbykev 40c61fbb0b report: resolution + CLV investigation — Part 1 premise false, Part 2 is an env flag
Nothing built. No poller wired, no capture change, no env flipped.

PART 1 — GRADES ALREADY AUTO-SETTLE. snapshotScheduler resolves settleAllOutcomes
(:64) and settleAllLedgers (:67) and runs them FIRST at every snapshot slot before
grading (its own comment at :393, Session 61). The record is self-populating: 937
settled rows, growing daily (07-24 through 07-30: 20, 25, 44, 26, 98, 62, 91), and
/api/accuracy reads it live at 937 @ 58% (MLB 526 @62%, WNBA 411 @54%).

/api/grading/resolve is a separate unreferenced legacy path, not the settlement
path. Wiring an ESPN poller to it would create a SECOND settlement path racing the
working one and double-count an append-only ledger — so nothing was built.

The DNP/VOID requirement is already satisfied: outcome carries void and
unrecoverable as terminal states, and getModelAggregate excludes both from the
record denominator, so a DNP is never counted as a loss (105 void rows exist).
Idempotency is enforced too — settleLedger guards on .is('outcome', null) and
outcomeService dedupes on nameKey|stat|line|side|date.

THE REAL GAP is smaller and different: settlement covers MLB + WNBA only. NBA and
soccer grade but never settle because no free settled-result feed is wired. That
is a per-sport feed problem, not a missing poller.

PART 2 — clvCaptureReliable() is ONE LINE:
  return process.env.CLV_CAPTURE_RELIABLE === '1';
It measures nothing. It fails because the operator has not set the flag, not
because the capture is unreliable. So there is no capture code to repair for the
guard to pass — flipping one env var publishes beat_close_pct immediately, which
makes this a judgement call and precisely the "make a number appear" move the
honesty guard forbids.

The guard itself works: beat_close_pct and clv_distribution publish only when the
flag AND settled>=20 AND clv_sample>0; with it off /record shows NOT PUBLISHED YET
and the computable 34/937 = 3.6% is never the publishing path (clvPanel returns
null and a test forbids the fallback).

CANNOT DETERMINE (Supabase MCP upstream-auth outage): the close-vs-locked
distribution, which is the direct test for the old silent-overwrite bug. The exact
query is in the report. A decision rule is stated BEFORE seeing the number so it
cannot be fitted to it: set the flag only if close_moved is a clear majority of
rows carrying a close AND coverage of settled rows is high enough that the
percentage describes the record rather than the captured subset. If either fails,
leave it off — a CLV near zero because close==locked is the fabrication to avoid
and it would look like success.

PART 3 — full outstanding board included in the report, covering model work
(A-flood grade fix on p_win vs fair_prob, the collapsed-output re-adjudication
list, calibration/time-series with no honest source, price-triplet MODEL leg),
surfaces (D1 mount, share cards, notifications, Offseason, /system, S3 media,
45 unwired glyphs, /record has no nav link) and infra (NBA/soccer never settle,
three credentials still flagged for rotation, migration drift 023-029).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
2026-07-31 19:38:19 -04:00

150 lines
8.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# RESOLUTION PIPELINE + CLV CAPTURE — INVESTIGATION (report; nothing built)
2026-07-31. No poller wired, no capture change, no env flipped.
---
# PART 1 — 🔴 THE PREMISE IS FALSE. GRADES **DO** AUTO-SETTLE.
**"Grades never auto-settle" is not the case, and building an ESPN poller onto
`/api/grading/resolve` would create a SECOND settlement path racing a working
one — double-counting an append-only ledger.** That is why nothing was built.
**VERIFIED — settlement runs on every snapshot slot.** `src/snapshotScheduler.js`
resolves `settleAllOutcomes` (`:64`) and `settleAllLedgers` (`:67`) and, per its own
comment at `:393`, *"settlement is scheduled IN THIS tick (settleAllOutcomes +
settleAllLedgers run FIRST at every snapshot slot, before grading)"* (Session 61).
**VERIFIED — the record is already self-populating.** Public ledger: **937 settled**,
and it grows daily. Settled rows by game_date, measured this session:
| 07-24 | 07-25 | 07-26 | 07-27 | 07-28 | 07-29 | 07-30 |
|---|---|---|---|---|---|---|
| 20 | 25 | 44 | 26 | 98 | 62 | 91 |
`/api/accuracy` reads it live: overall **937 @ 58%**, MLB 526 @ 62%, WNBA 411 @ 54%.
**So what IS `/api/grading/resolve`?** A separate, unreferenced legacy path (Wave 3
found zero callers). It is not the settlement path and never was. **Wiring a poller
to it would settle rows a second time**, and `ledger_entries` is append-only with an
`ignoreDuplicates` upsert — the failure would be silent and permanent.
### The DNP/VOID requirement — ALREADY SATISFIED, and load-bearing
The order is right that DNP-as-loss is a downward fabrication. It is already handled:
`outcome` values include **`void`** and **`unrecoverable`**, both terminal, and
`getModelAggregate` **excludes them from the record denominator**
(`.not('outcome','in','("void","unrecoverable")')`) — *"void (no bet existed:
DNP/postponed) and unrecoverable (truth not fetchable) are TERMINAL but are NOT
results. They must never enter a record denominator, exactly as pushes are."*
**105 void rows exist in the public ledger.** So DNPs are already excluded, not
counted as losses.
**Idempotency is already enforced too:** `settleLedger` guards with `.is('outcome',
null)` on the update, so a re-poll cannot re-settle. `outcomeService` dedupes on
`nameKey|stat|line|side|date`.
### What is ACTUALLY missing (the real gap, much smaller than the order assumes)
Settlement covers **MLB + WNBA only** (`outcomeService.SPORTS`). NBA and soccer grade
but never settle — no free settled-result feed is wired. **That** is the resolution
gap worth an order, and it is a per-sport feed problem, not a missing poller.
---
# PART 2 — 🔴 `clvCaptureReliable()` IS AN ENV FLAG, NOT A DATA CHECK
function clvCaptureReliable() { return process.env.CLV_CAPTURE_RELIABLE === '1'; }
That is the whole function (`ledgerService.js`). **It does not measure anything.**
It fails because the operator has not set `CLV_CAPTURE_RELIABLE=1` — not because the
capture is computing something unreliable.
**This reframes Part 2 entirely.** There is no capture code to "repair" so the guard
"legitimately passes": **flipping one env var publishes `beat_close_pct` immediately.**
Which makes this a judgement call, not an engineering task — and precisely the call
the honesty guard says must not be made to "make a number appear."
**What the guard gates (VERIFIED):** `beat_close_pct` and `clv_distribution` publish
only when `clvCaptureReliable() && settled >= 20 && clv_sample > 0`. With the flag
off they are null, `/record` renders **"NOT PUBLISHED YET"**, and the
computable-but-untrustworthy `clv_beat/clv_sample` (**34/937 = 3.6%**) is never the
publishing path — `clvPanel` returns `beatClosePct: null` and a test forbids the
fallback. **The honest guard is working exactly as designed.**
### The evidence that must decide the flag — and one measurement I could NOT run
Known from the 07-28 repair (verified earlier this session): `closing_prob` went
**59 → 406** (MLB 248, WNBA 158) against ~1,250 public rows, and the straight finding
was **MLB unders lag the close by 9.1 prob-pts (74% lose)** while **MLB overs are
+2.0 and WNBA is flat**.
**CANNOT DETERMINE (blocked, Supabase MCP upstream-auth outage):** the current
close-vs-locked distribution — specifically `count(closing_line = line)` vs
`count(closing_line <> line)`. That is the direct test for the old silent-overwrite
bug (92% close==locked). **Run exactly this before deciding the flag:**
select count(*) filter (where outcome is not null) as settled,
count(closing_prob) as with_close,
count(*) filter (where closing_line::numeric = line::numeric) as close_equals_locked,
count(*) filter (where closing_line::numeric <> line::numeric) as close_moved,
count(*) filter (where market_unavailable_reason is not null) as unavailable
from ledger_entries where user_id is null;
**Recommended decision rule, stated before seeing the number so it cannot be fitted
to it:** set `CLV_CAPTURE_RELIABLE=1` **only if** (a) `close_moved` is a clear
majority of rows carrying a close — i.e. the capture is reading a genuine close, not
mirroring the locked line — **and** (b) coverage of settled rows is high enough that
the published percentage describes the record rather than the subset we happened to
capture. **If either fails, leave it off.** A CLV near zero *because close==locked* is
the fabrication to avoid, and it would look like success.
---
# PART 3 — THE OUTSTANDING BOARD (authoritative, from the matrix + STATE)
**Shipped this session (closed):** Book Comparison wired · hero ranking fix ·
grade-board sort · `/api/props/top-graded` selector · takeable tagging (migration
034) · edge_pct display retirement · Wave 1 wiring (`/intelligence`, `/slip`,
`/parlay`, `/marketplace` honesty) · `/compare` real head-to-head · D1-A design
implementation · D1 row-anatomy modules · Build-1 settled/live gate · `/record`
proof surface · Build-2 founder mechanism Phase A + B.
**OPEN — model (highest leverage):**
1. **A-flood grade fix** — rebuild the grade on `p_win` vs `fair_prob`. The served
letter correlates **r ≈ 0.005** with outcomes (B 52.4% vs C 56.9% — *inverted*),
while the probability letter reaches **r = 0.236 on MLB**. This is the single
biggest truth gap in the product.
2. **Re-adjudication list** — p_win→CLV 0.375, the skew audit, proj-v1.1's "NOT
PROVEN", the C1 takeable floor, the calibration curves, and **ROI-by-grade
(MLB-C +4.57% is likely an artifact of a meaningless letter)** — all measured on
the collapsed output.
3. **Calibration curve + accuracy-over-time** — no honest source (no claimed-vs-actual
endpoint; `window_days` fixed at 30, no series).
4. **Price-triplet MODEL leg** — waits on the EV layer producing `p_win`/`ev_pct`.
**OPEN — surfaces:** D1 reveal/rationale/chips **mount** (modules built, not mounted;
blocked on a ROW-GRAMMAR slot amendment + the board-reasoning gating decision) ·
share-card masters + M2 crops (**blocked: the resolve fanout has no generation step
and no trigger**) · `/notifications` (store exists, no trigger, no consent surface) ·
Offseason hub · `/system` · S3 article media · the 45 unwired glyphs + the 41-vs-74
archetype scope call (D1-B) · **`/record` has no nav link yet.**
**OPEN — infra/ops:** NBA + soccer never settle (Part 1's real gap) · **three
credentials still flagged for rotation** (Storage Box, `VYNDR_INTERNAL_KEY`, the
GitHub PAT in `origin`) · migration drift 023029 untracked in the repo.
**NOT previously tracked, surfaced here:** the Stripe **live secret + webhook secret
were pasted into a chat transcript** this session. Stored correctly (`.env`, 0600,
gitignored, untracked; scan confirms no tracked file holds them) and Kev has declined
rotation — recorded so the decision is visible, not to relitigate it.
---
## TAGS
VERIFIED: settlement runs every slot and the record self-populates (937 settled,
daily growth); void/unrecoverable already excluded from the denominator (105 void
rows); settlement idempotent; `clvCaptureReliable` is an env flag; the honest CLV
guard works and the 3.6% path is blocked by test.
**CANNOT DETERMINE: the close-vs-locked distribution (Supabase MCP outage) — the one
number that should decide the CLV flag.**
**BLOCKED: nothing was built — Part 1 because the premise is false and a second
settlement path would double-count; Part 2 because the "repair" is a one-flag
judgement call that needs the blocked measurement first.**