Disambiguate takeable: THREE questions shared one word, now three names

BYTE-IDENTICAL. The audit found no consumer getting the wrong axis, so this
is a disambiguation, not a bug fix. 4,131 tests / 331 suites green.

STEP 1 AUDIT -- and the order's premise was wrong in a useful way:

  the four accruing challengers   read the flag ZERO times (not four)
  the ranking gate                wants PROMOTION, gets promotion  [correct]
  the ledger column               holds the LEDGER band, consumed as such
  the UI (LiveHeroProp)           TYPES a `takeable` field it never renders

THERE ARE THREE DEFINITIONS, NOT TWO -- and I only found the third by
tracing the ranking gate:

  1. IDENTITY    can it be bet?        book identity (takeability)
  2. LEDGER BAND worth recording?      odds >= -160, UNCAPPED plus
  3. PROMOTION   worth crowning?       -160..+200, i.e. band PLUS a ceiling

(2) and (3) genuinely disagree, and I measured it rather than asserting it:
439 rows -- 28.2% of all takeable=true ledger rows -- carry prices above
+200, up to +1300. A +1300 longshot is a real bet worth RECORDING and not
one worth CROWNING. Both are correct for their own purpose.

THE DANGER WAS NEVER THE LOGIC. It was that three questions shared one
word, so a reader could not tell which answer they held -- and hits, which
must model thin/juiced/one-sided REAL markets, would have been the next
reader to guess wrong.

RESOLUTION: all three now have distinct names in config/takeability.js;
gradeRanking calls isWithinPromotionBand so its intent is self-evident (a
test pins it byte-identical to the old valueEngine call across the whole
price range); the ledger dual-writes within_price_band with `takeable`
kept as a documented DEPRECATED MIRROR so nothing breaks. Column comments
in the database now say what each column actually holds.

I did NOT redefine `takeable` in place. Four readers and a ranking gate
sit on it, and silently changing its meaning under cover of a naming
change is exactly the class of move this session keeps removing.

Gates: 4,131 tests / 331 suites green; next build exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
This commit is contained in:
Kev
2026-08-02 18:19:17 -04:00
parent 8c764c22a4
commit d103ecf4c3
4 changed files with 94 additions and 6 deletions
+42 -4
View File
@@ -29,14 +29,31 @@
* plus). A -300 prop is takeable AND outside the
* band. Both statements are true at once.
*
* These answer different questions and are stored in different columns. The
* ledger's `takeable` column is the PRICE-BAND answer; its name predates this
* distinction and is misleading — flagged, not silently redefined, because four
* challengers and the ranking gate currently read it.
* ── AND A THIRD, WHICH IS WHY THIS FILE EXISTS ───────────────────────────
*
* isWithinPromotionBand(odds) — SHOULD we crown it? -160..+200, i.e. the
* ledger band PLUS a ceiling on the plus side.
*
* (2) and (3) genuinely disagree. Measured 2026-08-02: 439 rows — 28.2% of all
* `takeable=true` ledger rows — carry prices above +200, up to +1300. A +1300
* longshot is a real bet worth RECORDING and not one worth CROWNING. Both are
* right for their own purpose.
*
* THE DANGER WAS NEVER THE LOGIC. It was that three questions shared one word,
* so a reader could not tell which answer they were holding. All three now have
* distinct names and live here.
*
* AUDITED 2026-08-02 — no consumer was getting the wrong axis:
* - the four accruing challengers do NOT read the flag at all;
* - the ranking gate wants PROMOTION and gets promotion (now named as such);
* - the ledger column holds the LEDGER band and is consumed as such;
* - the UI types a `takeable` field it never renders.
* So this change is a DISAMBIGUATION, not a bug fix, and is byte-identical.
*/
const { TAKEABLE_BOOKS, isTakeableBook } = require('./bookRoles');
const { isLedgerTakeable, LEDGER_TAKEABLE_FLOOR } = require('./takeableStandard');
const { isTakeable: valueEngineBand } = require('./valueEngine');
/**
* THE canonical takeability rule. Every path that asks "can this be bet?" calls
@@ -74,13 +91,34 @@ function assessQuote({ book, odds } = {}) {
book: book || null,
takeable: isTakeableMarket(book),
within_price_band: isWithinPriceBand(odds),
within_promotion_band: isWithinPromotionBand(odds),
price_band_floor: LEDGER_TAKEABLE_FLOOR,
};
}
/**
* THE THIRD BAND — the PROMOTION gate the ranking uses (-160..+200).
*
* Distinct from `isWithinPriceBand` by a CEILING on the plus side: the ledger
* band is uncapped upward, this one is not. Measured 2026-08-02, they disagree
* on 439 rows (28.2% of all takeable=true rows), on prices up to +1300 — a
* +1300 longshot is a real bet worth RECORDING and not one worth CROWNING.
*
* Both are correct for their own purpose. The danger was never the logic, it was
* that all three questions shared one word, so a new reader could not tell which
* answer they held. All three now have distinct names and live in this file.
*/
function isWithinPromotionBand(americanOdds) {
return valueEngineBand(americanOdds);
}
module.exports = {
// 1. can it be bet? -> book identity
isTakeableMarket,
// 2. worth recording? -> ledger price band (floor, uncapped plus)
isWithinPriceBand,
// 3. worth crowning? -> promotion band (floor AND ceiling)
isWithinPromotionBand,
assessQuote,
TAKEABLE_BOOKS,
LEDGER_TAKEABLE_FLOOR,
+4
View File
@@ -285,6 +285,10 @@ function rowsFromSnapshot(sport, grades, oddsProps, nowIso) {
// tagged under and a re-derivation can re-tag safely. Absent price → NULL, an
// honest absence, never false.
takeable: takeableFor(locked.odds != null ? locked.odds : oddsForSide(priceProp, side)),
// Same value, honest name. `takeable` never meant "can this be bet" (that
// is book identity) -- it is the ledger PRICE band. Dual-written so no
// reader breaks; `takeable` is deprecated and can be dropped later.
within_price_band: takeableFor(locked.odds != null ? locked.odds : oddsForSide(priceProp, side)),
takeable_floor: LEDGER_TAKEABLE_FLOOR,
// BOOK: the takeable book the price came from, else the book the grade was
// COMPUTED on. Never the widened display list's first match.
+6 -2
View File
@@ -22,7 +22,11 @@
* p_win is the only signal whose takeable-MLB-over CLV survived the skew audit.
*/
const { isTakeable } = require('../config/valueEngine');
// INTENT: this gate asks "should we CROWN this?", not "can it be bet?". It is
// the PROMOTION band (floor AND a +200 ceiling) -- a +1300 longshot is a real,
// placeable bet that we deliberately do not crown. Named explicitly since three
// different questions used to share the word "takeable".
const { isWithinPromotionBand } = require('../config/takeability');
const GRADE_RANK = Object.freeze({
'A+': 0, A: 1, 'A-': 2, 'B+': 3, B: 4, 'B-': 5, 'C+': 6, C: 7, 'C-': 8, D: 9, F: 10,
@@ -55,7 +59,7 @@ function takeablePWin(g) {
const p = strictNum(g && g.p_win);
if (p == null) return null;
const price = strictNum(g && g.book_odds) ?? strictNum(g && g.gradedAt && g.gradedAt.odds);
if (price == null || !isTakeable(price)) return null;
if (price == null || !isWithinPromotionBand(price)) return null;
return p;
}
+42
View File
@@ -147,3 +147,45 @@ describe('GUARD 2 — takeability is BOOK IDENTITY, never price shape', () => {
}
});
});
// ──────────────────── THREE BANDS, THREE NAMES ────────────────────
describe('the three questions that used to share the word "takeable"', () => {
const { isTakeableMarket, isWithinPriceBand, isWithinPromotionBand, assessQuote } =
require('../../src/config/takeability');
it('a +1300 longshot: real bet, worth RECORDING, not worth CROWNING', () => {
// The measured disagreement: 439 prod rows (28.2% of takeable=true) sit
// above +200. All three answers differ in intent, none is wrong.
expect(isTakeableMarket('draftkings')).toBe(true); // can be bet
expect(isWithinPriceBand(1300)).toBe(true); // worth recording
expect(isWithinPromotionBand(1300)).toBe(false); // not worth crowning
});
it('a -300 favourite: real bet, outside BOTH bands', () => {
expect(isTakeableMarket('draftkings')).toBe(true);
expect(isWithinPriceBand(-300)).toBe(false);
expect(isWithinPromotionBand(-300)).toBe(false);
});
it('the ledger band is UNCAPPED upward; the promotion band has a ceiling', () => {
expect(isWithinPriceBand(200)).toBe(true);
expect(isWithinPromotionBand(200)).toBe(true);
expect(isWithinPriceBand(201)).toBe(true); // still recordable
expect(isWithinPromotionBand(201)).toBe(false); // ceiling bites here
});
it('the ranking gate uses the PROMOTION band — byte-identical to before', () => {
const { takeablePWin } = require('../../src/utils/gradeRanking');
const { isTakeable } = require('../../src/config/valueEngine');
for (const odds of [-160, -161, -110, 100, 200, 201, 1300]) {
const g = { p_win: 0.6, book_odds: odds };
// gate open iff the OLD valueEngine rule said so — no behaviour moved.
expect(takeablePWin(g) !== null).toBe(isTakeable(odds));
}
});
it('assessQuote reports all three axes so a caller cannot pick the wrong one blind', () => {
const q = assessQuote({ book: 'draftkings', odds: 1300 });
expect(q).toMatchObject({ takeable: true, within_price_band: true, within_promotion_band: false });
});
});