Files
vyndr/specs/resolution-and-clv-investigation.md
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

8.4 KiB
Raw Permalink Blame History

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.