Odds Generation — point · game + game · game (badminton)
Enter the match's Point Handicap and Points Total in Malay and its Moneyline in decimal — margins still embedded. Those three markets are the complete input: three knobs, three quotes, no priors anywhere (the γ/κ bounds are solver brackets, the race lengths tournament rules). Rally scoring makes the score a pure race — first to 21 by 2, capped at 30 (golden point at 29-29), best of three games — so one strength parameter would pin the totals the moment the handicap is known: the same one-parameter straitjacket that made Poisson wrong for basketball. The engine (ADR-030):
- strips each bookmaker margin (Power method — all three books two-way);
- bisects the rally-win share r against the Point Handicap on the exact race DP (win-by-2 barriers, golden-point cap);
- fits γ, a score coupling ("restoring force": the rally probability tilts by γ · deficit), to the Points Total — γ > 0 keeps games close and lengthens them, γ < 0 shortens; a two-sided knob a mixture cannot give;
- fits κ_M, a match-level form shock, to the Moneyline with a probed direction — counterintuitively κ_M raises the re-solved favorite's ML, and an ML outside the reachable band is a cross-anchor inconsistency surfaced as Δ ML, not a solver failure;
- prices both scopes off the one solved distribution — game · game (Moneyline, ±1.5 game handicap) and point · game (match point lines plus per-game sheets, game 3 conditional on being played). Game-1 quotes are deliberately not consumed: every knob is already market-fit, so an extra quote would demote a main anchor — the per-game sheets are derived, conditional slices of the solved match, and a game quoted with its own three markets would simply be its own inversion.
Each stage re-solves the same fair targets — flip Race / γ / κ_M and watch the totals humps breathe while the handicap holds. Race lengths are per-tournament config: the BWF approved 3×15 (cap 21) at the 2026 AGM with rollout pending, so both formats ship here; switching loads that format's seed quotes.
↑/↓ steps a field, Shift steps bigger; Malay pairs step their partner the opposite way. The three whole-match quotes are the complete input — r, γ and κ_M below are all solved from them, nothing calibrated — and the per-game sheets derive from the same solve, game 3 conditional on being played. Each market card carries its own trader margin, repricing the output sheet only.
Valid inputs: Malay in [−1, +1] non-zero with margin, decimals above 1. The κ_M fit re-solves (r, γ) at every trial — allow it a second.
Score grid
| A=0 | A=1 | A=2 | |
|---|---|---|---|
| H=0 | 16.3 | ||
| H=1 | 15.8 | ||
| H=2 | 43.4 | 24.6 |
| -21 | -20 | -19 | -18 | -17 | -16 | -15 | -14 | -13 | -12 | -11 | -10 | -9 | -8 | -7 | -6 | -5 | -4 | -3 | -2 | -1 | 0 | +1 | +2 | +3 | +4 | +5 | +6 | +7 | +8 | +9 | +10 | +11 | +12 | +13 | +14 | +15 | +16 | +17 | +18 | +19 | +20 | +21 | +22 | +23 | +24 | +25 | +26 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0.1 | 0.1 | 0.1 | 0.2 | 0.3 | 0.4 | 0.6 | 0.8 | 1.0 | 1.3 | 1.6 | 1.9 | 2.2 | 2.5 | 2.7 | 2.9 | 2.9 | 3.3 | 1.8 | 2.0 | 1.8 | 1.8 | 2.0 | 2.4 | 2.3 | 4.7 | 4.7 | 5.1 | 5.4 | 5.4 | 5.3 | 5.0 | 4.6 | 4.1 | 3.6 | 3.0 | 2.5 | 2.0 | 1.6 | 1.2 | 0.9 | 0.6 | 0.5 | 0.3 | 0.2 | 0.1 | 0.1 | 0.0 |
| 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0.0 | 0.1 | 0.1 | 0.2 | 0.3 | 0.5 | 0.7 | 0.9 | 1.2 | 1.6 | 2.0 | 2.5 | 2.9 | 3.4 | 3.9 | 4.2 | 4.5 | 4.6 | 4.5 | 4.2 | 3.8 | 3.2 | 2.4 | 1.6 | 1.7 | 0.8 | 1.1 | 0.4 | 0.7 | 0.2 | 0.4 | 0.1 | 0.2 | 0.1 | 0.1 | 0.0 | 0.1 | 0.0 | 0.1 | 0.1 | 0.1 | 0.1 | 0.2 | 0.2 | 0.4 | 0.5 | 0.6 | 0.8 | 1.1 | 1.3 | 1.6 | 1.9 | 2.2 | 2.4 | 2.6 | 2.7 | 2.8 | 2.7 | 2.5 | 2.3 | 2.0 | 1.7 | 1.4 | 1.2 | 1.0 | 0.8 | 0.7 | 0.5 | 0.5 | 0.3 | 0.3 | 0.2 | 0.2 | 0.1 | 0.1 | 0.1 | 0.1 | 0.0 | 0.0 |
Markets
| HDP | -7.0 | -5.5 | -4.0 |
|---|---|---|---|
| H | -0.81 | +0.90 | +0.66 |
| A | +0.73 | -0.98 | -0.74 |
| Points | 76.5 | 78.5 | 80.5 |
|---|---|---|---|
| Over | +0.58 | +0.82 | -0.97 |
| Under | -0.66 | -0.90 | +0.89 |
| Outcome | Odds |
|---|---|
| Home | 1.43 |
| Away | 2.92 |
| Outcome | Malay |
|---|---|
| Home | -0.81 |
| Away | +0.73 |
| Outcome | Malay |
|---|---|
| Home | +0.17 |
| Away | -0.25 |
1 · Strip the bookmaker margins (Power method — every book two-way)
| Home | Away | |
|---|---|---|
| Malay quote | +0.88 | +0.99 |
| Decimal | 1.880000 | 1.990000 |
| Fair | 0.514942 | 0.485058 |
| Fair decimal | 1.941968 | 2.061607 |
| Over | Under | |
|---|---|---|
| Malay quote | +0.80 | -0.93 |
| Decimal | 1.800000 | 2.075269 |
| Fair | 0.537505 | 0.462495 |
| Fair decimal | 1.860449 | 2.162184 |
| Home | Away | |
|---|---|---|
| Decimal quote | 1.45 | 2.99 |
| Decimal | 1.450000 | 2.990000 |
| Fair | 0.679651 | 0.320349 |
| Fair decimal | 1.471343 | 3.121599 |
Try the strips standalone — Margin — two-way (Power): the same math on any quotes, with the bisection narrated.
2 · The race — rally scoring is a pure best-of race
Every rally scores a point, so a game is a race to 21 by 2, capped at 30 (golden point at 29-29), best of three. A single strength r would pin the totals once the handicap is known — the same one-parameter straitjacket that made Poisson wrong for basketball — so the rally probability carries a score coupling:
γ > 0 is a restoring force (the trailing player lifts — games stay close and run long, deuces multiply); γ < 0 lets games run away and shorten. The DP walks the exact (x, y) lattice, so the coupling costs nothing; the match folds games carrying the points margin and total pmfs, the games joint and each game's grid. Validated against a 200k-run Monte Carlo.
3 · Stages — γ from the total, κ_M from the Moneyline (direction probed)
| Stage | r | γ | κ_M | Δ cover | Δ total | Δ ML |
|---|---|---|---|---|---|---|
| Race | 0.5224 | 0.0000 | 0.0000 | 4.1e-6 | 3.7e-2 | 0.83% |
| γ | 0.5167 | -0.0096 | 0.0000 | 9.4e-6 | 7.9e-6 | 5.42% |
| κ_M | 0.5296 | 0.0076 | 0.0391 | 1.0e-5 | 9.8e-6 | 0.00% |
Three knobs, three quotes — r ← Handicap, γ ← Total, κ_M ← Moneyline — with nothing left to prior (the γ/κ bounds are solver brackets, the race lengths tournament rules). κ_M is a 3-node form mixture on r. Counterintuitively it RAISES the re-solved favorite’s ML — the mixture widens cumulative point margins faster than the game race, so the handicap re-solves to a stronger r — hence the direction is probed at the bracket ends, never assumed. An ML outside the reachable band keeps the nearer endpoint and leaves Δ ML large: a cross-anchor inconsistency (alertable), not a solver failure.
4 · Periods — game 3 is a random period · formats
Game 3 exists with probability 40.4% here; its markets are void if it is not played, so its sheet prices conditional on being played — and under the κ_M mixture the conditioning re-weights the form nodes (deciders come from the close-form nodes). The same fold yields the game·game scope for free (Moneyline, ±1.5 game handicap, correct score). The per-game sheets are derived, conditional slices of the solved match — a game quoted with its own three markets would simply be its own inversion. Race lengths are per-tournament config: the BWF approved 3×15 (cap 21) at the AGM on 2026-04-25 with rollout pending, so 3×21 and 3×15 will coexist — the format toggle above re-prices the same latents under either race.
5 · Read “Handicap (Games)” · margins · notes
Home covers at ±1.5 games on the final games score (2-0 vs any other). Fair groups are re-margined exactly as in goal-regular (Power ladder two-way, Shin multi-way).
targets tH = powerStrip(HDP pair) tO = powerStrip(OU pair) tW = powerStrip(ML pair)
stage iid : γ = 0, κ_M = 0 bisect r until P(A covers | dist) = tH
stage γ : bisect γ until P(over | dist) = tO (r re-solved inside — restoring
force lengthens games, runaway shortens; two-sided, unlike a mixture)
stage κ_M : probe ml(0) vs ml(κ_max) at re-solved (r, γ) → bisect toward tW
(κ_M RAISES the re-solved favorite's ML; unreachable target ⇒ Δ ML alert)
dist: game DP on the (x, y) lattice — race to 21 by 2, cap 30 (golden point) —
folded best-of-3 with margin/total pmfs, games joint, per-game grids
scopes: ONE dist prices game·game (ML, ±1.5) and point·game (match + per-game, conditional)Engine + invariants: docs/src/lib/odds/badminton.ts, __tests__/badminton.test.ts; decision record ADR-030 (extends ADR-029).
The pipeline in one file
Everything the breakdown above does — steps 1–5, same constants, tolerances, iteration counts and loop order as the live engine — as one dependency-free JavaScript file, written to be read top-to-bottom and ported. Run it with Node, no install:
node badminton-point-game.js
It prices this page's default quotes (BWF 3×21) through the full stage ladder
— race, γ, κ_M — prints the one solved match distribution both scopes read
from (final-games joint, points margin/total pmfs, per-game conditional
views) and a Check a port table with PASS/FAIL per assertion; it
reproduces the engine's numbers to the last float bit, not just to 6 dp. Edit
the DEMO block at the bottom to price any other quote set.
Download badminton-point-game.js, or
read it here:
badminton-point-game.js — the complete listing
/* ═══════════════════════════════════════════════════════════════════════════
* badminton-point-game.js — the point · game pricing pipeline in one file
*
* A dependency-free JavaScript port of the docs page's live engine, covering
* the full breakdown, steps 1–5:
*
* step 1 · Strip the bookmaker margins Power method (all three books two-way)
* step 2 · The race bisect the rally-win share r against
* the Point Handicap on the exact race DP
* step 3 · Fit γ to the Points Total score coupling ("restoring force")
* step 4 · Fit κ_M to the Moneyline 3-node form mixture, direction PROBED
* step 5 · Periods per-game views off the one solved
* distribution, game 3 conditional
*
* Source of truth: docs/src/lib/odds/badminton.ts + core.ts + basket-core.ts
* (the page /odds-generation/badminton-point-game/ renders that engine live).
* Every constant, tolerance, iteration count and loop order below matches the
* engine exactly, so this file reproduces the page's numbers to the digit.
* The only thing omitted is the market-sheet builder (buildBadmintonMarkets) —
* ladders and re-margining are presentation; every price it prints is a pure
* read off the distributions this file computes.
*
* Run it:
*
* node badminton-point-game.js
*
* It prices the page's DEFAULT quotes — Point Handicap −5.5 @ +0.88/+0.99,
* Points Total 78.5 @ +0.80/−0.93, Moneyline 1.45/2.99, format BWF 3×21 —
* then prints a "Check a port" table with PASS/FAIL per assertion. Edit DEMO
* at the bottom to price other quotes (the literal-expectation rows apply to
* the default quotes only; the live page recomputes for any quotes you enter).
*
* The model in three sentences. Rally scoring makes a game a pure race —
* first to 21 by 2, capped at 30 (golden point at 29-29) — walked exactly on
* the (x, y) lattice with A's rally probability tilted by γ·(deficit), so
* γ > 0 keeps games close and LENGTHENS them while γ < 0 lets them run away.
* Games fold into a best-of-3 match carrying the points-margin and points-
* total pmfs, the final-games joint and each game's conditional score grid —
* ONE distribution pricing both scopes (game·game and point·game). κ_M is a
* match-level form shock (3-node mixture on r) fitted to the Moneyline;
* counterintuitively it RAISES the re-solved favourite's ML, so the fit
* direction is probed, never assumed, and an ML outside the reachable band is
* surfaced as a Δ ML inconsistency instead of being forced.
* ═══════════════════════════════════════════════════════════════════════════
*/
'use strict'
/* ─────────────────────────────────────────────────────────────────────────
* Section 0 — race formats
*
* The race lengths are per-tournament CONFIG, not constants: the BWF approved
* a 3×15 system (cap 21) at the AGM on 2026-04-25 — rollout pending — so 3×21
* and 3×15 coexist as first-class formats. Everything downstream takes the
* format as a parameter; nothing else about the model changes between them.
* ───────────────────────────────────────────────────────────────────────── */
/** { pointsTo, cap, gamesToWin }: race to `pointsTo` by 2, hard-capped at
* `cap` (the golden point — first to cap wins by 1), best of 2·gamesToWin−1. */
const BWF_3X21 = { pointsTo: 21, cap: 30, gamesToWin: 2 }
const BWF_3X15 = { pointsTo: 15, cap: 21, gamesToWin: 2 }
const clamp = (x, lo, hi) => Math.min(hi, Math.max(lo, x))
/* ─────────────────────────────────────────────────────────────────────────
* Section 1 — step 1 · Strip the bookmaker margins
*
* Quotes arrive with the margin baked in: the implied probabilities
* q = 1/decimal sum to Q > 1. Step 1 removes it with the Power method — all
* three badminton books are two-way (a match always has a winner, so even the
* Moneyline has no tie leg). The three fair numbers that come out are the
* COMPLETE input: r ← Handicap, γ ← Total, κ_M ← Moneyline, no priors
* anywhere downstream (the γ/κ bounds are solver brackets, the race lengths
* tournament rules).
* ───────────────────────────────────────────────────────────────────────── */
/** Exact Malay → decimal: +m pays 1+m per unit; −m risks |m| to win 1, i.e.
* decimal 1 + 1/|m|. Domain is [−1, +1] excluding 0; null when outside. */
function malayToDecimalExact(m) {
if (!Number.isFinite(m) || m === 0 || m < -1 || m > 1) return null
return m > 0 ? 1 + m : 1 - 1 / m
}
/** Ladder MARGIN (Malay-points gap) — a diagnostic the strip result carries;
* the strip itself uses only the Power exponent, never this gap. */
function ladderMargin(mHome, mAway) {
if (mHome > 0 && mAway > 0) return 2 - (mHome + mAway)
if (mHome > 0 && mAway < 0) return -mAway - mHome
if (mHome < 0 && mAway > 0) return -mHome - mAway
return null
}
/** Power-method margin strip for a two-way pair. The priced implied
* probabilities (q1, q2), q1 + q2 = Q > 1, are deflated along the power
* family p = q^(1/x): solve q1^(1/x) + q2^(1/x) = 1 for the exponent
* x ∈ (0, 1) with Newton's method. Unlike proportional scaling, the Power
* strip removes more margin from the longshot side (favourite–longshot
* bias), which is how the two-way books here are assumed to be built. */
function powerStrip(q1, q2) {
if (!(q1 > 0 && q2 > 0)) return null
if (q1 + q2 <= 1) return null // no overround — nothing to strip
const lnQ1 = Math.log(q1)
const lnQ2 = Math.log(q2)
let x = 0.9 // warm start near "almost fair"
for (let i = 0; i < 200; i++) {
const inv = 1 / x
const e1 = Math.pow(q1, inv)
const e2 = Math.pow(q2, inv)
const f = e1 + e2 - 1 // root function: fair probs must sum to 1
if (Math.abs(f) < 1e-14) break
const fp = (-e1 * lnQ1 - e2 * lnQ2) / (x * x) // df/dx
if (fp === 0) break
x = x - f / fp
if (x < 1e-6) x = 1e-6 // keep the exponent in (0, 1)
if (x > 1 - 1e-12) x = 1 - 1e-12
}
const inv = 1 / x
return { p1: Math.pow(q1, inv), p2: Math.pow(q2, inv), exponent: x }
}
/** Strip a two-way Malay pair to fair probabilities (+ the diagnostics the
* page shows). `pHome` is the fair probability of the FIRST side — home
* cover for the Handicap pair, over for the Total pair. */
function stripTwoWayMalay(mHome, mAway) {
const dH = malayToDecimalExact(mHome)
const dA = malayToDecimalExact(mAway)
if (dH === null || dA === null) return null
const qH = 1 / dH
const qA = 1 / dA
if (qH + qA <= 1) return null
const strip = powerStrip(qH, qA)
if (!strip) return null
return {
pHome: strip.p1,
pAway: strip.p2,
exponent: strip.exponent,
decimalHome: dH,
decimalAway: dA,
pricedHome: qH,
pricedAway: qA,
overround: qH + qA,
ladderMargin: ladderMargin(mHome, mAway),
}
}
/* ─────────────────────────────────────────────────────────────────────────
* Section 2 — the game DP
*
* Exact terminal distribution of ONE game. A wins each rally with
* clamp(r + γ·(y − x), 0.02, 0.98): the deficit tilt is the Gabel–Redner
* score-coupling object — γ > 0 is a restoring force (the trailing player
* lifts, deuces multiply, games lengthen), γ < 0 lets games run away and
* shorten. That two-sidedness is why γ is the totals knob: a mixture can only
* widen. The DP walks the (x, y) lattice by rallies played, so the score-
* dependence stays exact — no simulation anywhere (the engine was validated
* against a 200k-run Monte Carlo once, then the DP became the truth).
*
* Terminals are the win-by-2 barrier past `pointsTo` plus the golden-point
* corner at the cap: a cell is done when (x ≥ to or y ≥ to) with |x − y| ≥ 2,
* or when either side hits `cap` exactly. Only terminal scores carry mass in
* the returned grid — 21-20 does not exist as a final score.
* ───────────────────────────────────────────────────────────────────────── */
/** Returns { terminals, joint, pWin }: the terminal (x, y) list with
* probabilities, the same mass on a (cap+1)² grid, and P(A wins the game). */
function badmintonGame(r, gamma, format) {
const { pointsTo: to, cap } = format
const W = cap + 1
// double-buffered rally walk — the solver builds thousands of games per fit,
// so per-round allocation would dominate as GC churn
let live = new Float64Array(W * W)
let next = new Float64Array(W * W)
live[0] = 1
const joint = Array.from({ length: W }, () => new Array(W).fill(0))
const terminals = []
let pWin = 0
// pts = rallies played = x + y: each level is a diagonal of the lattice, so
// every reachable cell is visited exactly once
for (let pts = 0; pts < 2 * cap; pts++) {
next.fill(0)
let any = false
for (let x = Math.max(0, pts - cap); x <= Math.min(cap, pts); x++) {
const y = pts - x
if (y < 0 || y > cap) continue
const p = live[x * W + y]
if (!(p > 0)) continue
const done = ((x >= to || y >= to) && Math.abs(x - y) >= 2) || x === cap || y === cap
if (done) {
joint[x][y] += p
terminals.push({ x, y, prob: p })
if (x > y) pWin += p
continue
}
const pr = clamp(r + gamma * (y - x), 0.02, 0.98)
next[(x + 1) * W + y] += p * pr
next[x * W + (y + 1)] += p * (1 - pr)
any = true
}
const swap = live
live = next
next = swap
if (!any && pts > 2 * to) break
}
return { terminals, joint, pWin }
}
/* ─────────────────────────────────────────────────────────────────────────
* Section 3 — the match fold + the κ_M mixture
*
* badmintonMatchNode folds one game into a best-of race over iid games
* (within one form node), walking the (games A, games B) states and carrying
* a points-margin pmf and a points-total pmf per state. The two pmfs convolve
* independently with each game's terminal (x − y, x + y) shifts — no market
* needs their joint, so none is kept. Terminal transitions (a side reaches
* `gamesToWin`) land in the final-games joint and flush that state's pmfs,
* shifted by the deciding game's score, into the match pmfs.
*
* badmintonMatchDist wraps the node in the κ_M form shock: a 3-node mixture
* on r at r + z·κ_M for z ∈ {−√3, 0, +√3} with weights 1/6 · 2/3 · 1/6 (the
* quadrature that matches a standard normal's first five moments). Per-game
* grids are CONDITIONAL on the game being played, so the mixture re-weights
* each node by its P(played) — a deciding game is reached more often on the
* close-form nodes — then renormalizes.
* ───────────────────────────────────────────────────────────────────────── */
function emptyJoint(n) {
return Array.from({ length: n + 1 }, () => new Array(n + 1).fill(0))
}
/** One form node. Returns the full match distribution:
* { marginPmf, totalPmf, OFF, gamesJoint, gamesN, mlA, pDecider, games,
* meanPoints } — marginPmf is indexed m + OFF, totalPmf indexed t. */
function badmintonMatchNode(r, gamma, format) {
const { cap, gamesToWin } = format
const maxGames = 2 * gamesToWin - 1
const OFF = maxGames * cap // the widest possible cumulative points margin
const game = badmintonGame(r, gamma, format)
const marginPmf = new Float64Array(2 * OFF + 1)
const totalPmf = new Float64Array(2 * OFF + 1)
const gamesJoint = emptyJoint(gamesToWin)
// Within one node games are iid, so every game's unconditional grid IS the
// single-game grid; only P(played) varies by game index.
const games = Array.from({ length: maxGames }, () => ({ pPlayed: 0, joint: game.joint }))
let mlA = 0
// State = (games won A, games won B), keyed wA·4 + wB, carrying the mass and
// the margin/total pmfs accumulated over the games already played.
const key = (wA, wB) => wA * 4 + wB
let states = new Map()
const margin0 = new Float64Array(2 * OFF + 1)
const total0 = new Float64Array(2 * OFF + 1)
margin0[OFF] = 1
total0[0] = 1
states.set(key(0, 0), { mass: 1, margin: margin0, total: total0 })
for (let played = 0; played < maxGames; played++) {
const next = new Map()
for (const [k, st] of states) {
const wB = k % 4
const wA = (k / 4) | 0
if (!(st.mass > 0)) continue
games[played].pPlayed += st.mass
for (const t of game.terminals) {
const won = t.x > t.y
const nA = wA + (won ? 1 : 0)
const nB = wB + (won ? 0 : 1)
const shiftM = t.x - t.y
const shiftT = t.x + t.y
const done = nA === gamesToWin || nB === gamesToWin
let target = null
if (!done) {
target = next.get(key(nA, nB))
if (!target) {
target = {
mass: 0,
margin: new Float64Array(2 * OFF + 1),
total: new Float64Array(2 * OFF + 1),
}
next.set(key(nA, nB), target)
}
}
if (done) {
// Match over — this game's score is the last shift; flush the pmfs.
gamesJoint[nA][nB] += st.mass * t.prob
if (nA === gamesToWin) mlA += st.mass * t.prob
for (let i = 0; i < st.margin.length; i++) {
const p = st.margin[i]
if (p > 0) marginPmf[i + shiftM] += p * t.prob
}
for (let i = 0; i < st.total.length; i++) {
const p = st.total[i]
if (p > 0) totalPmf[i + shiftT] += p * t.prob
}
continue
}
const ns = target
ns.mass += st.mass * t.prob
for (let i = 0; i < st.margin.length; i++) {
const p = st.margin[i]
if (p > 0) ns.margin[i + shiftM] += p * t.prob
}
for (let i = 0; i < st.total.length; i++) {
const p = st.total[i]
if (p > 0) ns.total[i + shiftT] += p * t.prob
}
}
}
states = next
}
let meanPoints = 0
for (let t = 0; t < totalPmf.length; t++) meanPoints += t * totalPmf[t]
const pDecider = games[maxGames - 1].pPlayed
return {
marginPmf,
totalPmf,
OFF,
gamesJoint,
gamesN: gamesToWin,
mlA,
pDecider,
games,
meanPoints,
}
}
// The 3-node form mixture: z-abscissae ±√3 and 0 with weights 1/6, 2/3, 1/6.
const NODES3 = [
[-Math.sqrt(3), 1 / 6],
[0, 2 / 3],
[Math.sqrt(3), 1 / 6],
]
/** The match distribution under the κ_M form shock. κ_M ≤ 0 short-circuits to
* the single node — that is the pre-κ stages' distribution exactly. */
function badmintonMatchDist(r, gamma, kappaM, format) {
if (!(kappaM > 0)) return badmintonMatchNode(r, gamma, format)
const parts = NODES3.map(([z, w]) => ({
w,
d: badmintonMatchNode(clamp(r + z * kappaM, 0.08, 0.92), gamma, format),
}))
const base = parts[0].d
const marginPmf = new Float64Array(base.marginPmf.length)
const totalPmf = new Float64Array(base.totalPmf.length)
const gamesJoint = emptyJoint(format.gamesToWin)
const games = base.games.map(() => ({ pPlayed: 0, joint: emptyJoint(format.cap) }))
let mlA = 0
let pDecider = 0
let meanPoints = 0
for (const { w, d } of parts) {
for (let i = 0; i < marginPmf.length; i++) marginPmf[i] += w * d.marginPmf[i]
for (let i = 0; i < totalPmf.length; i++) totalPmf[i] += w * d.totalPmf[i]
for (let a = 0; a <= format.gamesToWin; a++)
for (let b = 0; b <= format.gamesToWin; b++) gamesJoint[a][b] += w * d.gamesJoint[a][b]
mlA += w * d.mlA
pDecider += w * d.pDecider
meanPoints += w * d.meanPoints
// Conditional grids mix weighted by P(played) — the close-form nodes reach
// a decider more often, so they weigh more in game 3's grid.
d.games.forEach((g, i) => {
games[i].pPlayed += w * g.pPlayed
for (let x = 0; x <= format.cap; x++)
for (let y = 0; y <= format.cap; y++) games[i].joint[x][y] += w * g.pPlayed * g.joint[x][y]
})
}
for (const g of games) {
if (!(g.pPlayed > 0)) continue
for (let x = 0; x <= format.cap; x++)
for (let y = 0; y <= format.cap; y++) g.joint[x][y] /= g.pPlayed
}
return {
marginPmf,
totalPmf,
OFF: base.OFF,
gamesJoint,
gamesN: format.gamesToWin,
mlA,
pDecider,
games,
meanPoints,
}
}
/* ─────────────────────────────────────────────────────────────────────────
* Section 4 — fair market readers (cover / over off the pmfs)
*
* The solver needs "what does THIS distribution say the Handicap / Total fair
* probability is". Same win/push settlement as a 2-D grid reader — fair
* p = W / (1 − R) with W the stake-weighted win fraction and R the push
* (refund) fraction; a quarter line (±0.25, ±0.75, …) splits into half a
* stake on each adjacent half-step line — specialized to the 1-D marginals so
* the solver's inner loop stays O(width) instead of O(cells).
* ───────────────────────────────────────────────────────────────────────── */
/** A quarter line (4·line odd) splits into its two neighbouring half-step
* lines at half stake each; any other line is itself at full stake. */
function componentLines(line) {
const q4 = Math.round(line * 4)
const isQuarter = Math.abs(q4 - line * 4) < 1e-9 && q4 % 2 !== 0
if (isQuarter) {
return [
{ line: (q4 - 1) / 4, weight: 0.5 },
{ line: (q4 + 1) / 4, weight: 0.5 },
]
}
return [{ line, weight: 1 }]
}
/** Fair P(home covers) at a handicap line, off the margin pmf (indexed
* m + N): home covers ⟺ m + line > 0, pushes when it lands exactly on 0. */
function coverRead(marginPmf, N, line) {
const comps = componentLines(line)
let W = 0
let R = 0
for (let i = 0; i < marginPmf.length; i++) {
const p = marginPmf[i]
if (!(p > 0)) continue
const m = i - N
for (const c of comps) {
const diff = m + c.line
if (diff > 1e-9) W += p * c.weight
else if (diff > -1e-9) R += p * c.weight
}
}
if (1 - R <= 1e-12) return null
return { W, R, p: W / (1 - R) }
}
/** Fair P(over) at a total line, off the total pmf (indexed t). */
function overRead(totalPmf, line) {
const comps = componentLines(line)
let W = 0
let R = 0
for (let t = 0; t < totalPmf.length; t++) {
const p = totalPmf[t]
if (!(p > 0)) continue
for (const c of comps) {
const diff = t - c.line
if (diff > 1e-9) W += p * c.weight
else if (diff > -1e-9) R += p * c.weight
}
}
if (1 - R <= 1e-12) return null
return { W, R, p: W / (1 - R) }
}
/** Fair P(A covers) at a points-handicap line (quarter lines split ½/½). */
function badmintonCover(d, line) {
return coverRead(d.marginPmf, d.OFF, line)
}
/** Fair P(over) at a points-total line. */
function badmintonOver(d, line) {
return overRead(d.totalPmf, line)
}
/* ─────────────────────────────────────────────────────────────────────────
* Section 5 — steps 2–4 · the stage pipeline
*
* Three quotes, three knobs, all 1-D bisections (no gradients):
*
* stage 'iid' (step 2) — γ = 0, κ_M = 0: bisect r ∈ [0.25, 0.75] until
* the fair point-handicap cover hits its target. More rally share ⇒ more
* covers, so the read is increasing in r. The totals residual this stage
* leaves is the one-parameter straitjacket made visible.
*
* stage 'gamma' (step 3) — bisect γ ∈ [−0.045, 0.045] until the fair over
* hits its target, with r RE-SOLVED inside every trial so the handicap
* holds at each candidate γ. Restoring force lengthens games within one
* game, but the over-read along the nest is HUMP-SHAPED, not monotone:
* positive γ compresses margins, so r re-solves away from ½ and the match
* shortens — past a knee that feedback wins (live SBOBet boards near the
* feasibility edge exposed it, 2026-07-17). The increasing-bisection stays
* as the fast path (bit-stable for every interior fit); a missed target
* engages a rescue — grid scan, bisect every crossing, ML anchor selects
* among roots, no crossing keeps the closest-approach γ (errOver
* surfaces the shortfall).
*
* stage 'ml' (step 4) — bisect κ_M ∈ [0, 0.08] toward the fair ML with
* the direction PROBED at re-solved anchors first: counterintuitively the
* mixture widens cumulative point margins faster than the game race, so
* the handicap re-solves to a stronger r and κ_M RAISES the favourite's
* ML. A target outside the reachable band keeps the nearer endpoint and
* leaves errMl large — a cross-anchor inconsistency (alertable), never a
* forced fit. (r, γ) are re-solved at every κ trial, same "each stage
* re-solves the same fair targets" discipline as goal-regular.
*
* The brackets are structural bounds, not priors: |γ| beyond ~0.045 breaks
* the race monotonicities, and the κ_M cap keeps the form nodes inside (0, 1)
* at every deficit the DP visits.
* ───────────────────────────────────────────────────────────────────────── */
const GAMMA_LO = -0.045
const GAMMA_HI = 0.045
const KAPPA_MAX = 0.08
/** Generic monotone bisection: `read` maps a knob value to the model's fair
* probability (null = invalid trial, treated as "too big"); `increasing`
* says which way the read moves with the knob. Early-exits on a 1e-10 value
* hit; otherwise returns the final bracket midpoint. */
function bisect(lo0, hi0, iters, target, read, increasing) {
let lo = lo0
let hi = hi0
for (let i = 0; i < iters; i++) {
const mid = (lo + hi) / 2
const v = read(mid)
if (v == null) {
hi = mid
continue
}
if (Math.abs(v - target) < 1e-10) return mid
if (v < target === increasing) lo = mid
else hi = mid
}
return (lo + hi) / 2
}
/** Run the whole pipeline for one match's quote set.
*
* input = {
* format, // BWF_3X21 or BWF_3X15 (or any {pointsTo,cap,gamesToWin:2})
* hcpLine, hcpMalay: [A, B], // Point Handicap line + Malay pair
* totLine, totMalay: [over, under], // Points Total line + Malay pair
* mlDecimal: [A, B], // Moneyline decimal pair — the κ_M anchor, required
* }
*
* Returns { ok, hcpStrip, totStrip, mlStrip, stages, final } with stages the
* full ladder [iid, gamma, ml] — each { key, r, gamma, kappaM, errCover,
* errOver, errMl, dist } — and final an alias of the last stage.
*/
function priceBadminton(input) {
const fail = (reason) => ({
ok: false,
reason,
hcpStrip: null,
totStrip: null,
mlStrip: null,
stages: [],
final: null,
})
const f = input.format
if (!(f.pointsTo >= 11 && f.cap > f.pointsTo && f.cap <= 40 && f.gamesToWin === 2))
return fail('format invalid (pointsTo ≥ 11, cap > pointsTo ≤ 40, best-of-3)')
const hcpStrip = stripTwoWayMalay(input.hcpMalay[0], input.hcpMalay[1])
const totStrip = stripTwoWayMalay(input.totMalay[0], input.totMalay[1])
if (!hcpStrip) return fail('HCP pair invalid (Malay ∈ [−1,+1] non-zero, overround Q > 1)')
if (!totStrip) return fail('TOTAL pair invalid (Malay ∈ [−1,+1] non-zero, overround Q > 1)')
const [dA, dB] = input.mlDecimal
if (!(dA > 1 && dB > 1)) return fail('Moneyline decimal invalid (each price > 1)')
const qA = 1 / dA
const qB = 1 / dB
if (qA + qB <= 1) return fail('Moneyline book invalid (overround Q > 1)')
const strip = powerStrip(qA, qB)
if (!strip) return fail('Moneyline strip failed')
const mlStrip = {
decimalHome: dA,
decimalAway: dB,
pricedHome: qA,
pricedAway: qB,
overround: qA + qB,
exponent: strip.exponent,
pHome: strip.p1,
pAway: strip.p2,
}
// Iteration counts sized to quote granularity: r to ~4e-6, γ to ~6e-6, κ_M
// to ~8e-5 — orders beyond a 0.01-Malay tick, and the κ fit re-solves the
// whole nest per trial, so every iteration is a full inner solve.
const solveR = (gamma, kappaM) =>
bisect(
0.25,
0.75,
17,
hcpStrip.pHome,
(r) => {
const read = badmintonCover(badmintonMatchDist(r, gamma, kappaM, f), input.hcpLine)
return read == null ? null : read.p
},
true,
)
const GAMMA_RESCUE_TOL = 1e-3 // well below a quote tick, well above bisection noise
const GAMMA_GRID = 15
const mlDist = (g, kappaM) =>
Math.abs(badmintonMatchDist(solveR(g, kappaM), g, kappaM, f).mlA - mlStrip.pHome)
const solvePair = (kappaM) => {
const readOver = (g) => {
const r = solveR(g, kappaM)
const read = badmintonOver(badmintonMatchDist(r, g, kappaM, f), input.totLine)
return read == null ? null : read.p
}
const target = totStrip.pHome
let gamma = bisect(GAMMA_LO, GAMMA_HI, 14, target, readOver, true)
const landed = readOver(gamma)
const step = (GAMMA_HI - GAMMA_LO) / GAMMA_GRID
if (landed != null && Math.abs(landed - target) <= GAMMA_RESCUE_TOL) {
// the bisection converged to the rising-branch root. A falling-branch
// twin may exist right of the hump with a very different ML — scan for
// the first grid pair falling through the target, bisect it, and let
// the ML anchor select (in a round trip the generated ML comes from the
// true root, so selection recovers it by construction).
let prevG = gamma
let prevAbove = false // the landed value is target-noise; demand a clear rise first
for (let g = gamma + step; g <= GAMMA_HI + 1e-12; g += step) {
const gc = Math.min(g, GAMMA_HI)
const v = readOver(gc)
if (v == null) break
if (prevAbove && v < target) {
const twin = bisect(prevG, gc, 12, target, readOver, false)
if (mlDist(twin, kappaM) < mlDist(gamma, kappaM)) gamma = twin
break
}
if (v > target + GAMMA_RESCUE_TOL) prevAbove = true
prevG = gc
}
} else {
// the hump defeated the bisection: two roots, or none
const vs = []
for (let i = 0; i <= GAMMA_GRID; i++) vs.push(readOver(GAMMA_LO + i * step))
const roots = []
for (let i = 0; i < GAMMA_GRID; i++) {
const a = vs[i]
const b = vs[i + 1]
if (a == null || b == null || (a - target) * (b - target) > 0) continue
roots.push(
bisect(GAMMA_LO + i * step, GAMMA_LO + (i + 1) * step, 12, target, readOver, b > a),
)
}
let bestD = Infinity
if (roots.length > 0) {
// multiple roots — the ML anchor selects the branch (the probed-
// direction discipline extended to branch selection)
for (const g of roots) {
const d = mlDist(g, kappaM)
if (d < bestD) {
bestD = d
gamma = g
}
}
} else {
// unreachable total: closest-approach γ, errOver carries the shortfall
for (let i = 0; i <= GAMMA_GRID; i++) {
const v = vs[i]
if (v == null) continue
const d = Math.abs(v - target)
if (d < bestD) {
bestD = d
gamma = GAMMA_LO + i * step
}
}
}
}
const r = solveR(gamma, kappaM)
return { r, gamma, dist: badmintonMatchDist(r, gamma, kappaM, f) }
}
// Package one stage: the knobs plus the residual against each fair target.
// errMl is LARGE on the pre-κ stages by design — it is what κ_M absorbs.
const mk = (key, r, gamma, kappaM, dist) => {
const cover = badmintonCover(dist, input.hcpLine)
const over = badmintonOver(dist, input.totLine)
return {
key,
r,
gamma,
kappaM,
errCover: Math.abs((cover == null ? NaN : cover.p) - hcpStrip.pHome),
errOver: Math.abs((over == null ? NaN : over.p) - totStrip.pHome),
errMl: Math.abs(dist.mlA - mlStrip.pHome),
dist,
}
}
const rIid = solveR(0, 0)
const iid = mk('iid', rIid, 0, 0, badmintonMatchDist(rIid, 0, 0, f))
const g = solvePair(0)
const gammaStage = mk('gamma', g.r, g.gamma, 0, g.dist)
const stages = [iid, gammaStage]
// Probe the κ direction at re-solved anchors before bisecting: evaluate the
// reachable ML band [ml(0), ml(κ_max)] and only bisect a target inside it.
const hiSol = solvePair(KAPPA_MAX)
const mlLoV = g.dist.mlA
const mlHiV = hiSol.dist.mlA
const increasing = mlHiV > mlLoV
const target = mlStrip.pHome
let ml
if (target > Math.min(mlLoV, mlHiV) && target < Math.max(mlLoV, mlHiV)) {
const kap = bisect(0, KAPPA_MAX, 10, target, (k) => solvePair(k).dist.mlA, increasing)
const s = solvePair(kap)
ml = mk('ml', s.r, s.gamma, kap, s.dist)
} else if (Math.abs(target - mlLoV) <= Math.abs(target - mlHiV)) {
// Unreachable below: κ_M ≥ 0 cannot move the ML toward the quote — the
// anchors disagree; keep the gamma solution and surface errMl.
ml = mk('ml', g.r, g.gamma, 0, g.dist)
} else {
ml = mk('ml', hiSol.r, hiSol.gamma, KAPPA_MAX, hiSol.dist)
}
// The ML read along the nest can be hump-shaped as well (root selection
// moves with κ): endpoints below the target do not prove the interior is —
// a missed landing engages a κ-grid rescue, crossings bisected, else the
// closest approach, kept only when it beats the endpoint fit.
const KAPPA_RESCUE_TOL = 1e-3
if (ml.errMl > KAPPA_RESCUE_TOL) {
const KAPPA_GRID = 8
const kstep = KAPPA_MAX / KAPPA_GRID
const sols = [{ kap: 0, sol: g }]
for (let i = 1; i < KAPPA_GRID; i++) sols.push({ kap: i * kstep, sol: solvePair(i * kstep) })
sols.push({ kap: KAPPA_MAX, sol: hiSol })
let best = null
const consider = (kap, sol) => {
const cand = mk('ml', sol.r, sol.gamma, kap, sol.dist)
if (best == null || cand.errMl < best.errMl) best = cand
}
for (let i = 0; i < sols.length - 1; i++) {
const a = sols[i]
const b = sols[i + 1]
if ((a.sol.dist.mlA - target) * (b.sol.dist.mlA - target) > 0) continue
const kap = bisect(
a.kap,
b.kap,
12,
target,
(k) => solvePair(k).dist.mlA,
b.sol.dist.mlA > a.sol.dist.mlA,
)
consider(kap, solvePair(kap))
}
if (best == null) for (const s of sols) consider(s.kap, s.sol)
if (best != null && best.errMl < ml.errMl) ml = best
}
stages.push(ml)
return {
ok: true,
hcpStrip,
totStrip,
mlStrip,
stages,
final: stages[stages.length - 1],
}
}
/* ─────────────────────────────────────────────────────────────────────────
* Section 6 — step 5 · periods
*
* 'full' prices off the match pmfs + the games joint; 'g1'..'g3' off that
* game's conditional (x, y) grid. Game 3 is a RANDOM period — it settles only
* if played (Saba voids otherwise) — so its fair prices are conditional, with
* P(played) carried on the view. The per-game sheets are derived, conditional
* slices of the solved match: Game-1 quotes are deliberately NOT inputs —
* every knob is already market-fit, so consuming G1 would demote a main
* anchor, and a game quoted with its own three markets is simply its own
* inversion.
* ───────────────────────────────────────────────────────────────────────── */
/** View onto one period of a solved stage. Returns { id, gameIndex,
* gameJoint, capN, pPlayed, dist }, or null for a game that is effectively
* never played (P(played) ≤ 1e-9 — e.g. game 3 under a crushing favourite). */
function badmintonPeriod(stage, id, format) {
if (id === 'full')
return { id, gameIndex: -1, gameJoint: null, capN: format.cap, pPlayed: 1, dist: stage.dist }
const idx = Number(id.slice(1)) - 1
const g = stage.dist.games[idx]
if (!g || !(g.pPlayed > 1e-9)) return null
return {
id,
gameIndex: idx,
gameJoint: g.joint,
capN: format.cap,
pPlayed: g.pPlayed,
dist: stage.dist,
}
}
/* ─────────────────────────────────────────────────────────────────────────
* Demo — the page's default quotes, plus the "Check a port" table
* ───────────────────────────────────────────────────────────────────────── */
const DEMO = {
format: BWF_3X21, // race to 21 by 2, cap 30 (golden point), best of three
hcpLine: -5.5, // Point Handicap −5.5 @ +0.88 / +0.99 (Malay)
hcpMalay: [0.88, 0.99],
totLine: 78.5, // Points Total 78.5 @ +0.80 / −0.93 (Malay)
totMalay: [0.8, -0.93],
mlDecimal: [1.45, 2.99], // Moneyline (decimal) — the κ_M anchor
}
function main() {
const f6 = (x) => x.toFixed(6)
const f8 = (x) => x.toFixed(8)
const f4 = (x) => x.toFixed(4)
const e1 = (x) => x.toExponential(1)
const pct = (x) => (x * 100).toFixed(1) + '%'
const log = console.log
const r = priceBadminton(DEMO)
if (!r.ok) {
console.error('solve failed:', r.reason)
process.exit(1)
}
const [iid, gammaStage, mlStage] = r.stages
const d = r.final.dist
log('badminton-point-game.js — pricing the page defaults (BWF 3×21)\n')
log('step 1 · strip the margins (Power — all three books two-way)')
const hs = r.hcpStrip
const ts = r.totStrip
const ms = r.mlStrip
log(
` Point Handicap ${DEMO.hcpLine.toFixed(1)} @ +0.88/+0.99 → P(home covers) = ${f8(hs.pHome)}`,
)
log(` (Power x = ${f6(hs.exponent)}, overround ${hs.overround.toFixed(4)})`)
log(` Points Total ${DEMO.totLine.toFixed(1)} @ +0.80/−0.93 → P(over) = ${f8(ts.pHome)}`)
log(` (Power x = ${f6(ts.exponent)}, overround ${ts.overround.toFixed(4)})`)
log(` Moneyline 1.45/2.99 → P(home ML) = ${f8(ms.pHome)}`)
log(` (Power x = ${f6(ms.exponent)}, overround ${ms.overround.toFixed(4)})\n`)
log('steps 2–4 · the stage ladder (each stage re-solves the same fair targets)')
log(' stage r γ κ_M Δ cover Δ total Δ ML')
for (const s of r.stages) {
const name = { iid: 'race ', gamma: 'γ ', ml: 'κ_M ' }[s.key]
log(
` ${name} ${f4(s.r)} ${s.gamma < 0 ? '' : ' '}${f4(s.gamma)} ${f4(s.kappaM)}` +
` ${e1(s.errCover)} ${e1(s.errOver)} ${(s.errMl * 100).toFixed(2)}%`,
)
}
log(` — the race stage's Δ total is the one-parameter straitjacket;`)
log(` the pre-κ stages' Δ ML is what κ_M absorbs\n`)
log('step 5 · the solved match, both scopes')
log(` P(A wins) = ${f6(d.mlA)} · P(three games) = ${f6(d.pDecider)} (${pct(d.pDecider)})`)
log(` E[total points] = ${d.meanPoints.toFixed(1)}`)
const gj = d.gamesJoint
log(
` final games score: 2-0 ${f6(gj[2][0])} · 2-1 ${f6(gj[2][1])}` +
` · 1-2 ${f6(gj[1][2])} · 0-2 ${f6(gj[0][2])}`,
)
const g3 = badmintonPeriod(r.final, 'g3', DEMO.format)
log(` per-game views: g1 P(played) = 1 · g3 conditional, P(played) = ${f6(g3.pPlayed)}\n`)
// ── the "Check a port" table ──────────────────────────────────────────────
// The rows quoting 6-dp literals pin this file to the page's DEFAULT-quote
// values (computed by the real engine); the mass/closed-form/consistency
// rows are invariants that hold for ANY quotes.
let mSum = 0
for (let i = 0; i < d.marginPmf.length; i++) mSum += d.marginPmf[i]
let tSum = 0
for (let i = 0; i < d.totalPmf.length; i++) tSum += d.totalPmf[i]
let gSum = 0
for (let a = 0; a <= d.gamesN; a++) for (let b = 0; b <= d.gamesN; b++) gSum += gj[a][b]
const massErr = Math.max(Math.abs(1 - mSum), Math.abs(1 - tSum), Math.abs(1 - gSum))
// Closed form: at the iid stage (γ = 0, κ_M = 0, no clamp bite for
// r ∈ [0.25, 0.75]) the only path to a 21-0 whitewash is 21 straight rally
// wins, so game 1's grid must hold exactly r^21 there (as the DP's left-fold
// product) — and 0-21 must hold (1 − r)^21.
const to = DEMO.format.pointsTo
let wpA = 1
let wpB = 1
for (let i = 0; i < to; i++) {
wpA *= iid.r
wpB *= 1 - iid.r
}
const iidG1 = iid.dist.games[0].joint
const closedErr = Math.max(Math.abs(iidG1[to][0] - wpA), Math.abs(iidG1[0][to] - wpB))
// Consistency: the games joint must retell the same story as the scalars —
// ML = P(2-0) + P(2-1), decider = P(2-1) + P(1-2) — and the g3 period view
// must carry exactly the match's P(decider).
const mlVsJoint = Math.abs(d.mlA - (gj[2][0] + gj[2][1]))
const decVsJoint = Math.abs(d.pDecider - (gj[2][1] + gj[1][2]))
let g3Mass = 0
for (let x = 0; x <= g3.capN; x++) for (let y = 0; y <= g3.capN; y++) g3Mass += g3.gameJoint[x][y]
const knobs = `${f6(r.final.r)} · ${f6(r.final.gamma)} · ${f6(r.final.kappaM)}`
const preKnobs = `${f6(iid.r)} · ${f6(gammaStage.r)} · ${f6(gammaStage.gamma)}`
const scalars = `${f6(d.mlA)} · ${f6(d.pDecider)} · ${f6(d.meanPoints)}`
const rows = [
['final (r, γ, κ_M)', knobs, knobs === '0.529573 · 0.007556 · 0.039102'],
['race r · γ-stage (r, γ)', preKnobs, preKnobs === '0.522406 · 0.516672 · -0.009643'],
['P(A wins) · P(decider) · E[pts]', scalars, scalars === '0.679643 · 0.403691 · 90.633107'],
[
`Δ cover / Δ over (final stage)`,
`${e1(r.final.errCover)} / ${e1(r.final.errOver)}`,
r.final.errCover < 1e-4 && r.final.errOver < 1e-4,
],
['Σ margin / total / games pmfs', `max |1 − Σ| = ${e1(massErr)}`, massErr < 1e-9],
['21-0 / 0-21 whitewash closed form', `Δ ${e1(closedErr)}`, closedErr < 1e-18],
[
'games joint vs ML & decider',
`Δ ${e1(mlVsJoint)} / ${e1(decVsJoint)}`,
mlVsJoint < 1e-12 && decVsJoint < 1e-12,
],
[
'g3 view: P(played) · grid mass',
`${f6(g3.pPlayed)} · Σ = ${g3Mass.toFixed(9)}`,
g3.pPlayed === d.pDecider && Math.abs(1 - g3Mass) < 1e-9,
],
]
log('check a port — eight assertions (page defaults)')
let allPass = true
for (const [label, shown, pass] of rows) {
allPass = allPass && pass
log(` ${pass ? 'PASS' : 'FAIL'} ${String(label).padEnd(34)} ${shown}`)
}
if (allPass) log('\nall assertions hold — the port matches the page.')
else {
log('\nsome assertions FAILED — the classic causes: a drifted race terminal rule')
log('(win-by-2 vs the golden point), an unprobed κ direction, or reading game 3')
log('unconditionally instead of conditional on being played.')
process.exitCode = 1
}
}
if (require.main === module) main()
module.exports = {
BWF_3X21,
BWF_3X15,
malayToDecimalExact,
ladderMargin,
powerStrip,
stripTwoWayMalay,
badmintonGame,
badmintonMatchNode,
badmintonMatchDist,
coverRead,
overRead,
badmintonCover,
badmintonOver,
bisect,
priceBadminton,
badmintonPeriod,
}