Skip to main content

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):

  1. strips each bookmaker margin (Power method — all three books two-way);
  2. bisects the rally-win share r against the Point Handicap on the exact race DP (win-by-2 barriers, golden-point cap);
  3. 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;
  4. 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;
  5. 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.

Point Handicap (Malay)
Points Total (Malay)
Moneyline (Decimal) — the κ_M anchor

/ 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.

r 0.5296γ 0.0076κ_M 0.0391G3 40.4%round-trip Δp 1.0e-5 / 9.8e-6Δ ML 0.00%
Score grid
Match distribution
r 0.5296γ 0.0076κ_M 0.0391G3 40.4%E[pts] 90.6
Final games score — P(home g, away g) ×100
A=0A=1A=2
H=016.3
H=115.8
H=243.424.6
Points margin pmf — P(home pts − away pts) ×100
-21-20-19-18-17-16-15-14-13-12-11-10-9-8-7-6-5-4-3-2-10+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.10.10.10.20.30.40.60.81.01.31.61.92.22.52.72.92.93.31.82.01.81.82.02.42.34.74.75.15.45.45.35.04.64.13.63.02.52.01.61.20.90.60.50.30.20.10.10.0
Points total pmf ×100
585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136
0.00.10.10.20.30.50.70.91.21.62.02.52.93.43.94.24.54.64.54.23.83.22.41.61.70.81.10.40.70.20.40.10.20.10.10.00.10.00.10.10.10.10.20.20.40.50.60.81.11.31.61.92.22.42.62.72.82.72.52.32.01.71.41.21.00.80.70.50.50.30.30.20.20.10.10.10.10.00.0
two humps — a straight-games match vs a decider — the bimodality no unimodal count model can price
home awayone distribution, both scopes: the games table is game·game, the pmfs are point·game
Markets
Point Handicap
HDP-7.0-5.5-4.0
H-0.81+0.90+0.66
A+0.73-0.98-0.74
Over/Under
Points76.578.580.5
Over+0.58+0.82-0.97
Under-0.66-0.90+0.89
Moneyline
a match always has a winner — no tie leg
OutcomeOdds
Home1.43
Away2.92
3 in scope
-1.5
OutcomeMalay
Home-0.81
Away+0.73
+1.5
OutcomeMalay
Home+0.17
Away-0.25
1 · Strip the bookmaker margins (Power method — every book two-way)
Point Handicap -5.50 → P(home covers)
HomeAway
Malay quote+0.88+0.99
Decimal dd1.8800001.990000
Fair p=q1/xp = q^{1/x}0.5149420.485058
Fair decimal 1/p1/p1.9419682.061607
0.5319151/x+0.5025131/x=1    x=0.9511380.531915^{1/x} + 0.502513^{1/x} = 1 \;\Rightarrow\; x = 0.951138
Points Total 78.50 → P(over)
OverUnder
Malay quote+0.80-0.93
Decimal dd1.8000002.075269
Fair p=q1/xp = q^{1/x}0.5375050.462495
Fair decimal 1/p1/p1.8604492.162184
0.5555561/x+0.4818651/x=1    x=0.9467940.555556^{1/x} + 0.481865^{1/x} = 1 \;\Rightarrow\; x = 0.946794
Moneyline → P(home wins) — the κ_M anchor (stage 3)
HomeAway
Decimal quote1.452.99
Decimal dd1.4500002.990000
Fair p=q1/xp = q^{1/x}0.6796510.320349
Fair decimal 1/p1/p1.4713433.121599
0.6896551/x+0.3344481/x=1    x=0.9621630.689655^{1/x} + 0.334448^{1/x} = 1 \;\Rightarrow\; x = 0.962163

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:

P(A wins the rally at x-y)=clip ⁣(r+γ(yx))P(\text{A wins the rally at } x\text{-}y) = \operatorname{clip}\!\left(r + \gamma\,(y - x)\right)

γ > 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)
Stagerγκ_MΔ coverΔ totalΔ ML
Race0.52240.00000.00004.1e-63.7e-20.83%
γ0.5167-0.00960.00009.4e-67.9e-65.42%
κ_M0.52960.00760.03911.0e-59.8e-60.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,
}