Skip to main content

Odds Generation — game · set + set · set (tennis)

Enter the match's Game Handicap and Game Total in Malay and its Moneyline in decimal — margins still embedded. Those three markets are the complete input: no tour or surface constants, no extra quotes — every model number is solved from them. Tennis scores are not counts: they are a nested race — point → game (win by 2 from deuce) → set (first to 6 by 2, tiebreak at 6-6) → match — so totals come out bimodal (straight sets vs a decider) with a parity comb (a set never totals 11 games; a 7-6 counts 13). The engine (ADR-030):

  1. strips each bookmaker margin (Power method — every book here is two-way; tennis has no draw);
  2. back-solves three knobs against the three quotes — the Game Handicap pins the serve difference pA − pB, the Moneyline pins the serve sum: holding the cover fixed, a serve-dominant match needs a bigger underlying edge to produce the same game margin (breaks are rarer), and that bigger edge compounds through sets into more match wins — so the fair ML rises smoothly with Σ and the quote identifies it per match; earlier revisions filled Σ with a tour/surface prior (ATP ≈ 1.29, WTA ≈ 1.14) or a quoted Set-1 total, both gone;
  3. consumes the Game Total with τ, a match-level form mixture (matches shorten), solved nested: the (Σ, τ) ridge is too shallow for alternation, so the Σ bisection runs along the τ-resolved manifold — τ and the difference re-solve inside every Σ trial and all three anchors hold at once (residuals ≤ ~10⁻⁵);
  4. names its boundaries instead of absorbing them: τ ≥ 0 can only shorten a match, so a market running longer than the ML-pinned baseline keeps τ = 0 and surfaces the totals residual (the λ₃ ≥ 0 analog); an even Moneyline carries no Σ information at all (P(A) = ½ at diff 0 for any Σ) — the solve falls back to Σ ← Total and flags it;
  5. prices both scopes off the one solved distribution — set · set (Moneyline, Set Handicap, Set Total) and game · set (match game lines plus every per-set sheet, conditional on the set being played) — a set quoted with its own three markets would simply be its own inversion.

Each stage re-solves the same fair targets, so the stage switcher is a true before/after — flip iid (Σ ← ML) / Length and watch the totals hump move while the handicap and the Moneyline hold. Best-of-3 and best-of-5 are format configs (all four slams play a 10-point decider tiebreak since 2022); switching loads that format's seed quotes.

The contract was verified against a bookmaker's live prematch boards (Saba, July 2026: 95 ATP/WTA/Challenger/ITF matches, 439 out-of-sample quotes at the book's own lines and margins). Set winners land within ~half a tick (S1/S2 median |Δ fair prob| 0.009/0.005), straight-sets — the Set Handicap — within ~1.5 ticks (0.013, bias +0.002), and set totals scatter at ~2 ticks with zero aggregate bias: that residual is the per-match serve-style information three match quotes cannot identify (the even-Moneyline Σ degeneracy above, in its soft form) — not a missing constant, so nothing is calibrated away. The τ ceiling stays where it is for the same reason: quotes that want more shortening than τ = 0.06 are internally inconsistent with the same book's set totals, and the engine correctly surfaces the residual instead of chasing it.

Game Handicap (Malay)
Game Total (Malay)
Moneyline (Decimal) — pins the serve sum Σ

/ steps a field, Shift steps bigger; Malay pairs step their partner the opposite way. The three match quotes are the complete input — every p, Σ and τ below is solved from them (the tour/surface prior and the Set-1 anchor of earlier revisions are gone); 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, each book carrying margin.

p_A 0.6633p_B 0.6236τ 0.0295P(set 3) 41.6%TB 42.5%Σ 1.287round-trip Δp 3.8e-7 / 3.0e-6 / 5.0e-7
Score grid
Match distribution
p_A 0.6633p_B 0.6236Σ 1.287τ 0.0295TB 42.5%E[games] 24.4
Final sets score — P(home s, away s) ×100
A=0A=1A=2
H=017.5
H=116.9
H=240.824.8
Games margin pmf — P(home games − away games) ×100
-10-9-8-7-6-5-4-3-2-10+1+2+3+4+5+6+7+8+9+10+11
0.10.30.71.93.15.26.76.24.73.53.84.16.610.012.811.48.45.92.81.40.40.1
Games total pmf ×100
131415161718192021222324252627282930313233343536373839
0.10.51.63.05.86.98.37.95.38.26.21.13.44.63.23.94.24.35.04.93.12.33.01.70.30.60.5
two humps — straight sets vs a decider — with a parity comb: no set totals 11 games, a 7-6 counts 13
home awayone distribution, both scopes: the sets table is set·set, the pmfs are game·set
Markets
Game Handicap
HDP-3.5-2.5-1.5
H-0.80+0.84+0.64
A+0.72-0.92-0.72
Game Over/Under
Games21.522.523.5
Over+0.62+0.87-0.90
Under-0.70-0.95+0.82
Moneyline
a match always has a winner — no tie leg
OutcomeOdds
Home1.49
Away2.74
5 in scope
-1.5
OutcomeMalay
Home-0.73
Away+0.65
+1.5
OutcomeMalay
Home+0.19
Away-0.27
1 · Strip the bookmaker margins (Power method — every book here is two-way)
Game Handicap -2.50 → P(home covers)
HomeAway
Malay quote+0.82-0.95
Decimal dd1.8200002.052632
Fair p=q1/xp = q^{1/x}0.5316800.468320
Fair decimal 1/p1/p1.8808312.135292
0.5494511/x+0.4871791/x=1    x=0.9479560.549451^{1/x} + 0.487179^{1/x} = 1 \;\Rightarrow\; x = 0.947956
Game Total 22.50 → P(over)
OverUnder
Malay quote+0.85-0.98
Decimal dd1.8500002.020408
Fair p=q1/xp = q^{1/x}0.5231810.476819
Fair decimal 1/p1/p1.9113852.097231
0.5405411/x+0.4949491/x=1    x=0.9496120.540541^{1/x} + 0.494949^{1/x} = 1 \;\Rightarrow\; x = 0.949612
Moneyline → P(home wins) — the SERVE-SUM anchor
HomeAway
Decimal quote1.502.79
Decimal dd1.5000002.790000
Fair p=q1/xp = q^{1/x}0.6559620.344038
Fair decimal 1/p1/p1.5244782.906657
0.6666671/x+0.3584231/x=1    x=0.9616100.666667^{1/x} + 0.358423^{1/x} = 1 \;\Rightarrow\; x = 0.961610

Try the strips standalone — Margin — two-way (Power): the same math on any quotes, with the bisection narrated.

2 · The nested race — point → game → set → match

Tennis games are not counts: every level is a race with absorbing barriers. A server holding with point probability pp wins the game with the closed form

Phold(p)=p4 ⁣(1+4q+10q2)+20p3q3p212pqq=1pP_{\text{hold}}(p) = p^4\!\left(1 + 4q + 10q^2\right) + 20p^3q^3\,\frac{p^2}{1-2pq} \qquad q = 1-p

(deuce is a geometric race; this run: hold(A) = 0.851, hold(B) = 0.783). A tiny DP walks the tiebreak over the real serve rotation (F, S, S, F, F, …; the 6-6 deuce closes as α/(α+β)\alpha/(\alpha+\beta)), the set lattice alternates servers by game — a set totals {6..10, 12, 13} games, never 11, and a 7-6 counts 13 — and the match folds sets carrying the games margin and total pmfs, the sets joint and every per-set grid. The pre-match serve coin toss is averaged. Everything is exact DP; the engine was validated against a 200k-run Monte Carlo.

3 · Three knobs, three anchors — the Moneyline pins the serve sum

The handicap pins the serve difference; the serve sum Σ and the form shock τ sit on a near-flat ridge against the games Total alone. Earlier revisions filled Σ with a tour/surface prior (ATP ≈ 1.29, WTA ≈ 1.14) or a quoted Set-1 total, and left the ML as a readout — but the Moneyline is Σ information: holding the cover fixed, a serve-dominant match (high Σ) needs a bigger underlying edge to produce the same game margin (breaks are rarer), and that bigger edge compounds through sets into more match wins — so the fair ML rises smoothly with Σ and the quote identifies it per match. Stage iid fits (Σ ← ML, diff ← cover) at τ = 0; the totals residual is what the one-dial baseline misses. Stage Length consumes it with τ\tau — a 3-node mixture (pA+zτ,  pBzτ), z{3,0,+3}(p_A + z\tau,\; p_B - z\tau),\ z \in \{-\sqrt3, 0, +\sqrt3\} that preserves the sum but compounds over sets, shortening matches — solved nested: the ridge is too shallow for alternation, so the outer Σ bisection runs along the τ-resolved manifold (τ ← OU and diff ← cover re-solve inside every Σ trial) and all three anchors hold at once.

StageΣ = p_A + p_Bp_Ap_BτknobΔ coverΔ totalΔ ML
iid (Σ ← ML)1.15490.59270.56220.00005.7e-72.2e-28.9e-7
Length (τ)1.28700.66330.62360.0295tau3.8e-73.0e-65.0e-7

Boundaries are flagged, never absorbed: τ ≥ 0 can only shorten a match, so a market running longer than the ML-pinned baseline keeps τ = 0 and Δ total surfaces (the goal-regular λ₃ ≥ 0 analog); and an even Moneyline carries no Σ information (P(A) = ½ at diff 0 for any Σ) — the solve then falls back to Σ ← OU and flags it.

4 · Periods — sets are random, so per-set prices are conditional

Unlike basketball quarters, set 3 exists only with probability 41.6% here (set 3 with 41.6%). Per-set markets are void if the set is not played, so their fair prices condition on it — and under the τ mixture the conditioning re-weights the form nodes (deciders come disproportionately from the close-form nodes). Set 1 is unconditional. The same fold also yields the set·set scope for free: the sets joint prices the ML, set handicap and set totals with zero extra parameters.

5 · Read “Set Handicap” · margins · notes

Home covers at ±1.5 sets (±2.5 in a bo5) on the final sets score. 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 — bisect Σ until P(A wins | dist) = tW          # Σ ← ML
                 bisect diff until P(A covers | dist) = tH           # inside every Σ trial
               Δ total = what the one-dial baseline misses
stage length : bisect Σ on the τ-resolved manifold until P(A wins) = tW
                 τ ← OU (τ ∈ [0, .06], only shortens) and diff ← cover
                 re-solve inside every Σ trial — all three anchors at once
boundaries   : market longer than the ML-pinned baseline ⇒ τ = 0, Δ total surfaces
               |ML − ½| < .035 ⇒ Σ unidentifiable (P(A) = ½ at diff 0) ⇒ Σ ← OU, flagged

dist: hold(p) closed form → tiebreak DP (serve rotation) → set lattice (alternating serve;
      totals ∈ {6..10,12,13}, 7-6 = 13 games) → match fold over (sets, first server)
scopes: ONE dist prices set·set (ML, set HDP/OU) and game·set (match + per-set, conditional)

Engine + invariants: docs/src/lib/odds/tennis.ts, __tests__/tennis.test.ts; decision record ADR-030 (extends ADR-029).

The pipeline in one file

Everything the breakdown above does through step 4 — 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: the margin strips, the point → game → set → match race, both stage solves and the conditional period views (the market-card re-pricing on top of the solved distribution is the only part left to the page). Run it with Node, no install:

node tennis-game-set.js

It prices this page's default quotes (best-of-3) through both stages — the games margin and total pmfs, the sets joint and every per-set conditional grid — and prints 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 tennis-game-set.js, or read it here:

tennis-game-set.js — the complete listing
/* ═══════════════════════════════════════════════════════════════════════════
* tennis-game-set.js — the game · set + set · set pricing pipeline in one file
*
* A dependency-free JavaScript port of the docs page's live engine, covering
* the full breakdown, steps 1–4:
*
* step 1 · Strip the bookmaker margins Power (all three books are two-way)
* step 2 · The nested race point → game → set → match, exact DP
* step 3 · Three knobs, three anchors diff ← Handicap · Σ ← Moneyline · τ ← Total
* step 4 · Periods per-set views, conditional on being played
*
* Source of truth: docs/src/lib/odds/tennis.ts (+ the margin strips from
* core.ts and the 1-D pmf readers from basket-core.ts) — the page
* /odds-generation/tennis-game-set/ 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 (buildTennisMarkets — it re-prices the
* solved distribution into the page's market cards); dropping it changes no
* solved number.
*
* Run it:
*
* node tennis-game-set.js
*
* It prices the page's DEFAULT quotes — best-of-3, Game Handicap −2.5 @
* +0.82/−0.95, Game Total 22.5 @ +0.85/−0.98, Moneyline 1.50/2.79 — through
* both solve stages, 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
* its numbers for any quotes you enter).
*
* The model in three sentences. Tennis scores are not counts but a nested
* race — point → game (win by 2 from deuce) → set (first to 6 by 2, tiebreak
* at 6-6) → match (best of 3/5) — driven by two serve-point probabilities
* (p_A, p_B) plus τ, a 3-node form mixture on A's RELATIVE strength. The
* three match quotes are the complete input, no tour or surface priors: the
* Game Handicap pins the serve DIFFERENCE, the Moneyline pins the serve SUM
* (at a fixed cover the fair ML rises smoothly with Σ — the ADR-030
* re-model), and the Game Total pins τ (mixing over relative form can only
* SHORTEN matches). One solved (p_A, p_B, τ) prices BOTH catalog scopes —
* set·set off the sets joint, game·set off the games pmfs plus every per-set
* grid, conditional on the set being played.
* ═══════════════════════════════════════════════════════════════════════════
*/

'use strict'

/* ─────────────────────────────────────────────────────────────────────────
* Section 0 — point → game → tiebreak
*
* The bottom two levels of the race. A game is closed-form: the server wins
* to love/15/30 outright, and from deuce the race is geometric — each
* two-point block ends it with probability p² (win) or q² (lose), so
* P(win | deuce) = p²/(p² + q²) = p²/(1 − 2pq). The tiebreak has no closed
* form because the serve alternates mid-race (F, S, S, F, F, …), so it is a
* tiny memoized DP; its 6-6 deuce closes the same geometric way over
* two-point blocks (one serve each): P = α/(α+β).
* ───────────────────────────────────────────────────────────────────────── */

/** P(server holds a game | serve-point win prob p) — O'Malley's closed form. */
function tennisGameWin(p) {
const q = 1 - p
return p ** 4 * (1 + 4 * q + 10 * q * q) + 20 * p ** 3 * q ** 3 * ((p * p) / (1 - 2 * p * q))
}

/** P(A wins a tiebreak to `to`, win by 2) — first ∈ {0: A serves point 1, 1: B}.
* The serve rotation is the real one: point 1 by `first`, then two points
* each — server of point k+1 is ((k+1)>>1)&1 flips of `first`. */
function tennisTbWin(pA, pB, first, to) {
const winPt = (k) => {
const srv = (((k + 1) >> 1) & 1) === 0 ? first : 1 - first
return srv === 0 ? pA : 1 - pB
}
const memo = new Map()
const rec = (a, b) => {
if (a >= to && a - b >= 2) return 1
if (b >= to && b - a >= 2) return 0
if (a === to - 1 && b === to - 1) {
// (to−1, to−1) deuce: two-point blocks, one serve each — geometric race.
const alpha = pA * (1 - pB)
const beta = (1 - pA) * pB
return alpha / (alpha + beta)
}
const key = a * 64 + b
const hit = memo.get(key)
if (hit !== undefined) return hit
const w = winPt(a + b)
const v = w * rec(a + 1, b) + (1 - w) * rec(a, b + 1)
memo.set(key, v)
return v
}
return rec(0, 0)
}

/* ─────────────────────────────────────────────────────────────────────────
* 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. Every book on this page is two-way (tennis has
* no draw), so one strip serves all three: the Power method deflates the pair
* along p = q^(1/x), removing more margin from the longshot side. The three
* fair numbers that come out — P(A covers), P(over), P(A wins) — are
* EVERYTHING the solver consumes.
* ───────────────────────────────────────────────────────────────────────── */

/** 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). Diagnostic only — the strip uses the
* Power exponent, not 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 inv2 = 1 / x
return { p1: Math.pow(q1, inv2), p2: Math.pow(q2, inv2), 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 — A's 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 — step 2 · the set DP
*
* Exact distribution over terminal set scores, as a lattice DP over (gA, gB).
* Servers alternate by game, so the server of the next game is pure parity —
* srv = (first + gamesPlayed) % 2 — and never a state dimension. A game is one
* Bernoulli draw at the closed-form hold probability; 6-6 hands the remaining
* mass to the tiebreak DP. A 7-6 set counts 13 games (tiebreak = 1 game — the
* Saba settlement convention). Note the support: a set can total
* {6..10, 12, 13} games but never 11 — the parity comb no smooth count model
* can express (from 5-5 the set runs to 7-5 or 6-6, never stopping at 6-5).
* ───────────────────────────────────────────────────────────────────────── */

/** Terminal set scores {gA, gB, prob, games} at serve-point probs (pA, pB);
* `first` is who serves game 1, `tbTo` the tiebreak target (7, or 10 in a
* slam decider). */
function tennisSetDist(pA, pB, first, tbTo) {
const holdA = tennisGameWin(pA)
const holdB = tennisGameWin(pB)
// lattice DP over (gA, gB) — the server of the next game is determined by
// parity: srv = (first + gamesPlayed) % 2, so it is not a state dimension
const live = new Float64Array(64) // gA * 8 + gB
live[0] = 1
const out = []
const emit = (gA, gB, prob) => {
if (prob > 0) out.push({ gA, gB, prob, games: gA + gB })
}
for (let played = 0; played <= 12; played++) {
const srv = (first + played) % 2
const pw = srv === 0 ? holdA : 1 - holdB
for (let gA = Math.max(0, played - 6); gA <= Math.min(6, played); gA++) {
const gB = played - gA
if (gB > 6) continue
const prob = live[gA * 8 + gB]
if (!(prob > 0)) continue
live[gA * 8 + gB] = 0
if (gA === 6 && gB === 6) {
const t = tennisTbWin(pA, pB, srv, tbTo)
emit(7, 6, prob * t)
emit(6, 7, prob * (1 - t))
continue
}
const winA = prob * pw
const winB = prob - winA
if ((gA + 1 >= 6 && gA + 1 - gB >= 2) || gA + 1 === 7) emit(gA + 1, gB, winA)
else live[(gA + 1) * 8 + gB] += winA
if ((gB + 1 >= 6 && gB + 1 - gA >= 2) || gB + 1 === 7) emit(gA, gB + 1, winB)
else live[gA * 8 + gB + 1] += winB
}
}
return out
}

/* ─────────────────────────────────────────────────────────────────────────
* Section 3 — step 2 · the match fold, then the τ mixture
*
* The match DP runs over (sets A, sets B, first server), carrying the games
* MARGIN and TOTAL pmfs per state — they convolve independently across sets,
* and no market needs their joint. Serve order carries across sets by
* game-count parity; the pre-match coin toss is averaged (each `first` starts
* with mass ½). Along the way the fold collects the sets joint (the whole
* set·set scope), P(any tiebreak), and every per-set score grid with its
* P(played) — the raw material for the conditional period views of step 4.
* ───────────────────────────────────────────────────────────────────────── */

// Format configs — the ONLY difference between tour and slam pricing. All four
// slams play a 10-point deciding-set tiebreak since 2022.
const TENNIS_TOUR_BO3 = { setsToWin: 2, deciderTbTo: 7 }
const TENNIS_SLAM_BO5 = { setsToWin: 3, deciderTbTo: 10 }

function emptyJoint(n) {
return Array.from({ length: n + 1 }, () => new Array(n + 1).fill(0))
}

/** Exact match distribution at fixed (pA, pB) — one node of the τ mixture.
* Returns { marginPmf, totalPmf, OFF, setsJoint, setsN, mlA, pAnyTb, sets,
* meanGames }: the games margin pmf indexes m + OFF, per-set grids are
* CONDITIONAL on the set being played (with pPlayed alongside). */
function tennisMatchDist(pA, pB, format) {
const { setsToWin, deciderTbTo } = format
const maxSets = 2 * setsToWin - 1
const OFF = 13 * maxSets // a set spans at most 13 games — the pmf bound
const marginPmf = new Float64Array(2 * OFF + 1)
const totalPmf = new Float64Array(OFF + 1)
const setsJoint = emptyJoint(setsToWin)
const sets = Array.from({ length: maxSets }, () => ({ pPlayed: 0, joint: emptyJoint(7) }))
let mlA = 0
let noTbTotal = 0

// Set distributions are cached per (first server, tiebreak target) — only
// the decider's target can differ, so at most four distinct sets exist.
const dists = {}
const setFor = (first, decider) => {
const tbTo = decider ? deciderTbTo : 7
const key = `${first},${tbTo}`
return (dists[key] ??= tennisSetDist(pA, pB, first, tbTo))
}

const key = (sA, sB, first) => (sA * 4 + sB) * 2 + first
let states = new Map()
const init = (first) => {
const margin = new Float64Array(2 * OFF + 1)
const total = new Float64Array(OFF + 1)
margin[OFF] = 0.5 // the coin toss: each opening server carries mass ½
total[0] = 0.5
states.set(key(0, 0, first), { mass: 0.5, noTb: 0.5, margin, total })
}
init(0)
init(1)

for (let played = 0; played < maxSets; played++) {
const next = new Map()
for (const [k, st] of states) {
const first = k % 2
const sB = (k >> 1) % 4
const sA = k >> 3
if (!(st.mass > 0)) continue
const decider = sA === setsToWin - 1 && sB === setsToWin - 1
const dist = setFor(first, decider)
const per = sets[played]
per.pPlayed += st.mass // every live state plays this set
for (const t of dist) {
per.joint[t.gA][t.gB] += st.mass * t.prob
const won = t.gA > t.gB
const nA = sA + (won ? 1 : 0)
const nB = sB + (won ? 0 : 1)
const nFirst = (first + t.games) % 2 // serve order carries by parity
const shiftM = t.gA - t.gB
const done = nA === setsToWin || nB === setsToWin
if (done) {
setsJoint[nA][nB] += st.mass * t.prob
if (nA === setsToWin) 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 + t.games] += p * t.prob
}
if (t.games !== 13) noTbTotal += st.noTb * t.prob // 13 games ⟺ a 7-6 set
continue
}
const nk = key(nA, nB, nFirst)
let ns = next.get(nk)
if (!ns) {
ns = {
mass: 0,
noTb: 0,
margin: new Float64Array(2 * OFF + 1),
total: new Float64Array(OFF + 1),
}
next.set(nk, ns)
}
ns.mass += st.mass * t.prob
if (t.games !== 13) ns.noTb += st.noTb * 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 + t.games] += p * t.prob
}
}
}
states = next
}

let meanGames = 0
for (let t = 0; t < totalPmf.length; t++) meanGames += t * totalPmf[t]
// conditionalize the per-set grids
for (const s of sets) {
if (!(s.pPlayed > 0)) continue
for (let a = 0; a <= 7; a++) for (let b = 0; b <= 7; b++) s.joint[a][b] /= s.pPlayed
}
return {
marginPmf,
totalPmf,
OFF,
setsJoint,
setsN: setsToWin,
mlA,
pAnyTb: 1 - noTbTotal,
sets,
meanGames,
}
}

// τ mixture — the form-volatility stage. Three Gauss-Hermite nodes shock A's
// RELATIVE strength (pA + zτ, pB − zτ): the serve sum is preserved, but the
// leverage compounding over sets fattens the match-outcome tails (the fix for
// the iid model's known favorite overconfidence — Kovalchik 2016). Node probs
// clamp into (P_LO, P_HI), the band where the race DP stays well-conditioned.
const NODES3 = [
[-Math.sqrt(3), 1 / 6],
[0, 2 / 3],
[Math.sqrt(3), 1 / 6],
]
const P_LO = 0.31
const P_HI = 0.91
const clamp = (x, lo, hi) => Math.min(hi, Math.max(lo, x))

/** Mixture of three tennisMatchDist runs over the τ nodes; at τ = 0 it IS the
* single exact match DP. Per-set grids re-conditionalize: each node's
* conditional grid is re-weighted by that node's own P(played) — deciders
* come disproportionately from the close-form nodes. */
function tennisMixture(pA, pB, tau, format) {
if (!(tau > 0)) return tennisMatchDist(pA, pB, format)
const parts = NODES3.map(([z, w]) => ({
w,
d: tennisMatchDist(clamp(pA + z * tau, P_LO, P_HI), clamp(pB - z * tau, P_LO, P_HI), format),
}))
const base = parts[0].d
const marginPmf = new Float64Array(base.marginPmf.length)
const totalPmf = new Float64Array(base.totalPmf.length)
const setsJoint = emptyJoint(format.setsToWin)
const sets = base.sets.map(() => ({ pPlayed: 0, joint: emptyJoint(7) }))
let mlA = 0
let pAnyTb = 0
let meanGames = 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.setsToWin; a++)
for (let b = 0; b <= format.setsToWin; b++) setsJoint[a][b] += w * d.setsJoint[a][b]
mlA += w * d.mlA
pAnyTb += w * d.pAnyTb
meanGames += w * d.meanGames
// per-set: conditional grids re-weighted by each node's P(played)
d.sets.forEach((s, i) => {
sets[i].pPlayed += w * s.pPlayed
for (let a = 0; a <= 7; a++)
for (let b = 0; b <= 7; b++) sets[i].joint[a][b] += w * s.pPlayed * s.joint[a][b]
})
}
for (const s of sets) {
if (!(s.pPlayed > 0)) continue
for (let a = 0; a <= 7; a++) for (let b = 0; b <= 7; b++) s.joint[a][b] /= s.pPlayed
}
return {
marginPmf,
totalPmf,
OFF: base.OFF,
setsJoint,
setsN: format.setsToWin,
mlA,
pAnyTb,
sets,
meanGames,
}
}

/* ─────────────────────────────────────────────────────────────────────────
* Section 4 — fair market readers (Handicap / Total off the 1-D pmfs)
*
* The solver needs "what does THIS distribution say the fair cover / over
* probability is". Half/integer/quarter lines are handled uniformly: a
* quarter line (±0.25, ±0.75, …) is priced as half a stake on each adjacent
* half-step line, and pushes are removed by conditioning — fair
* p = W / (1 − R) with W the stake-weighted win fraction and R the
* stake-weighted push (refund) fraction. Same settlement math as the score-
* grid readers, specialized to the 1-D margin/total pmfs.
* ───────────────────────────────────────────────────────────────────────── */

/** A quarter line 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(A covers) at a handicap line, off the margin pmf (A 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. */
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 games-handicap line off a match distribution. */
function tennisCover(d, line) {
return coverRead(d.marginPmf, d.OFF, line)
}

/** Fair P(over) at a games-total line off a match distribution. */
function tennisOver(d, line) {
return overRead(d.totalPmf, line)
}

/* ─────────────────────────────────────────────────────────────────────────
* Section 5 — step 3 · three knobs, three anchors (the stage solves)
*
* The three match quotes are the COMPLETE input — three knobs, three anchors,
* no priors: the Game Handicap pins the serve DIFFERENCE, the Moneyline pins
* the serve SUM (earlier revisions took the sum from a tour/surface prior or
* a Set-1 quote — but at a fixed game handicap the match ML responds smoothly
* to the sum: the same games edge needs a BIGGER serve edge when holds
* dominate, and that bigger edge compounds through sets into MORE match wins,
* so the quote identifies Σ per match), and the games Total pins the form
* shock τ.
*
* Stage 'iid': τ = 0, (Σ ← ML, diff ← cover) nested — the totals residual
* shows what the one-dial baseline misses. Stage 'length': the (Σ, τ) ridge
* is too shallow for plain alternation, so the outer Σ bisection runs along
* the τ-RESOLVED manifold (τ ← OU and diff ← cover re-solved inside every Σ
* trial), pricing all three anchors simultaneously.
*
* Boundaries stay one-sided and flagged, never absorbed: τ ≥ 0 can only
* SHORTEN a match (a market running longer than the ML-pinned baseline keeps
* τ = 0 and errOver surfaces — the goal-regular λ₃ ≥ 0 analog), and an even
* Moneyline carries no Σ information (P(A) = ½ at diff 0 for ANY sum) — the
* solve then falls back to Σ ← OU and flags 'even-ml'.
* ───────────────────────────────────────────────────────────────────────── */

// Solver brackets — structural bounds, not priors: the sum range keeps both
// serve probabilities inside the DP's (P_LO, P_HI); τ beyond ~0.06 pushes the
// mixture nodes out of it. The even-ML window is where ∂ML/∂Σ ≈ 0.
const SUM_LO = 1.045
const SUM_HI = 1.555
const TAU_MAX = 0.06
const EVEN_ML_WINDOW = 0.035

/** Plain 1-D bisection with a known direction; a null read counts as "too
* big" and shrinks the bracket from above. Value-tolerance stop at 1e-10. */
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, // TENNIS_TOUR_BO3 | TENNIS_SLAM_BO5
* hcpLine, hcpMalay: [A, B], // Game Handicap line + Malay pair
* totLine, totMalay: [O, U], // Game Total line + Malay pair
* mlDecimal: [A, B], // Moneyline decimal pair — the Σ anchor, required
* }
*
* Returns { ok, hcpStrip, totStrip, mlStrip, stages: [iid, length], final }.
*/
function priceTennis(input) {
const fail = (reason) => ({
ok: false,
reason,
hcpStrip: null,
totStrip: null,
mlStrip: null,
stages: [],
final: null,
})
// ── step 1 · strip the margins ──────────────────────────────────────────
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,
}

// The evaluation map: (Σ, diff, τ) → the mixture distribution, guarded to
// the DP's serve-probability band.
const evalAt = (sum, diff, tau) => {
const pA = (sum + diff) / 2
const pB = (sum - diff) / 2
if (pA <= P_LO || pA >= P_HI || pB <= P_LO || pB >= P_HI) return null
return tennisMixture(pA, pB, tau, input.format)
}
// diff ← cover at fixed (Σ, τ): shifting serve strength toward A raises
// every cover probability, so the bisection knob is monotone. The diff
// bracket must stay inside the DP's serve-probability band — both
// (sum ± diff)/2 ∈ (P_LO, P_HI). Without the clip, a heavy AWAY favourite
// at a high Σ probe needs a diff below the band, every read there is null,
// and bisect's null-means-too-big heuristic walks the bracket the wrong
// way — the whole solve then fails on quotes a home favourite of the same
// strength solves fine.
const readAt = (sum, tau) => {
const dLo = Math.max(-0.5, sum - 2 * P_HI + 1e-6, 2 * P_LO - sum + 1e-6)
const dHi = Math.min(0.5, 2 * P_HI - sum - 1e-6, sum - 2 * P_LO - 1e-6)
if (!(dLo < dHi)) return null
const diff = bisect(
dLo,
dHi,
22,
hcpStrip.pHome,
(d) => {
const m = evalAt(sum, d, tau)
return m ? (tennisCover(m, input.hcpLine)?.p ?? null) : null
},
true,
)
const dist = evalAt(sum, diff, tau)
return dist ? { diff, dist } : null
}
// Package one evaluated stage: solved knobs + the residual against each of
// the three anchors (errMl is large on 'iid'/fallback — what the fit absorbs).
const mk = (key, sum, tau, knob, fallback) => {
const r = readAt(sum, tau)
if (!r) return null
const cover = tennisCover(r.dist, input.hcpLine)
const over = tennisOver(r.dist, input.totLine)
if (!cover || !over) return null
return {
key,
pA: (sum + r.diff) / 2,
pB: (sum - r.diff) / 2,
sum,
diff: r.diff,
tau,
lengthKnob: knob,
fallback,
errCover: Math.abs(cover.p - hcpStrip.pHome),
errOver: Math.abs(over.p - totStrip.pHome),
errMl: Math.abs(r.dist.mlA - mlStrip.pHome),
dist: r.dist,
}
}

// Σ ← ML at fixed τ, diff re-solved to the cover inside every trial. The
// direction is probed at the bracket ends (∂ML/∂Σ flips sign with the
// favorite); a target outside the reachable band keeps the nearer endpoint
// and errMl surfaces the cross-anchor disagreement instead of distorting τ.
const solveSumFromMl = (tau) => {
const read = (s) => readAt(s, tau)?.dist.mlA ?? null
const vLo = read(SUM_LO)
const vHi = read(SUM_HI)
if (vLo == null || vHi == null) return null
const target = mlStrip.pHome
if (target <= Math.min(vLo, vHi) + 1e-12 || target >= Math.max(vLo, vHi) - 1e-12)
return Math.abs(target - vLo) <= Math.abs(target - vHi) ? SUM_LO : SUM_HI
return bisect(SUM_LO, SUM_HI, 18, target, read, vHi > vLo)
}
// Σ ← OU at τ = 0 (the even-ML fallback) — more holds, longer matches.
const solveSumFromOver = () =>
bisect(
SUM_LO,
SUM_HI,
16,
totStrip.pHome,
(s) => {
const r = readAt(s, 0)
return r ? (tennisOver(r.dist, input.totLine)?.p ?? null) : null
},
true,
)
// τ ← OU at a fixed Σ. τ only shortens, so a market running longer than
// the τ = 0 baseline keeps τ = 0 (the one-sided limit; errOver surfaces).
const solveTau = (sum) => {
const base = readAt(sum, 0)
if (!base) return null
const overBase = tennisOver(base.dist, input.totLine)
if (!overBase) return null
if (!(overBase.p > totStrip.pHome)) return 0
return bisect(
0,
TAU_MAX,
14,
totStrip.pHome,
(t) => {
const r = readAt(sum, t)
return r ? (tennisOver(r.dist, input.totLine)?.p ?? null) : null
},
false,
)
}

const evenMl = Math.abs(mlStrip.pHome - 0.5) < EVEN_ML_WINDOW
let stages
if (!evenMl) {
// stage 'iid': τ = 0, (Σ ← ML, diff ← cover) — the OU residual shows
// what the one-dial baseline misses. Stage 'length': NESTED — the Σ–τ
// ridge is shallow, so plain alternation converges too slowly; instead
// the outer Σ bisects the ML along the τ-resolved manifold (τ ← OU and
// diff ← cover re-solved inside every Σ trial), pricing all three
// anchors simultaneously.
const sum0 = solveSumFromMl(0)
if (sum0 == null) return fail('iid stage failed (sum bisection found no interior solution)')
const iid = mk('iid', sum0, 0, null, null)
if (!iid) return fail('iid stage failed (diff bisection found no interior solution)')
const readMlOnManifold = (s) => {
const t = solveTau(s)
if (t == null) return null
return readAt(s, t)?.dist.mlA ?? null
}
const vLo = readMlOnManifold(SUM_LO)
const vHi = readMlOnManifold(SUM_HI)
if (vLo == null || vHi == null) return fail('length stage failed (manifold probe)')
const target = mlStrip.pHome
const sum =
target <= Math.min(vLo, vHi) + 1e-12 || target >= Math.max(vLo, vHi) - 1e-12
? Math.abs(target - vLo) <= Math.abs(target - vHi)
? SUM_LO
: SUM_HI
: bisect(SUM_LO, SUM_HI, 16, target, readMlOnManifold, vHi > vLo)
const tau = solveTau(sum)
if (tau == null) return fail('length stage failed')
const length = mk('length', sum, tau, tau > 0 ? 'tau' : null, null)
if (!length) return fail('length stage failed')
stages = [iid, length]
} else {
// even Moneyline: P(A) ≈ ½ at diff ≈ 0 for ANY Σ — the ML cannot see
// the sum, so the OU takes it (τ = 0), and τ mops up only if the market
// runs shorter than even the lowest-sum baseline. Flagged as 'even-ml'.
const sum = solveSumFromOver()
const iid = mk('iid', sum, 0, 'sum', 'even-ml')
if (!iid) return fail('iid stage failed (even-ML fallback)')
const tau = solveTau(sum)
if (tau == null) return fail('length stage failed (even-ML fallback)')
const length = mk('length', sum, tau, tau > 0 ? 'tau' : 'sum', 'even-ml')
if (!length) return fail('length stage failed (even-ML fallback)')
stages = [iid, length]
}
return { ok: true, hcpStrip, totStrip, mlStrip, stages, final: stages[stages.length - 1] }
}

/* ─────────────────────────────────────────────────────────────────────────
* Section 6 — step 4 · periods
*
* 'full' prices off the match pmfs + the sets joint; 's1'..'s5' off that
* set's conditional score grid. Unlike basketball quarters, sets 2+ are
* RANDOM events: per-set markets settle only if the set is played (Saba voids
* otherwise), so their fair prices are CONDITIONAL — the view carries
* P(played) for display. Set 1 is unconditional (pPlayed = 1); a set index
* past the format (s4 in a best-of-3) returns null.
* ───────────────────────────────────────────────────────────────────────── */

function tennisPeriod(stage, id) {
if (id === 'full') return { id, setIndex: -1, setJoint: null, pPlayed: 1, dist: stage.dist }
const idx = Number(id.slice(1)) - 1
const s = stage.dist.sets[idx]
if (!s || !(s.pPlayed > 1e-9)) return null
return { id, setIndex: idx, setJoint: s.joint, pPlayed: s.pPlayed, dist: stage.dist }
}

/* ─────────────────────────────────────────────────────────────────────────
* Demo — the page's default quotes, plus the "check a port" table
* ───────────────────────────────────────────────────────────────────────── */

const DEMO = {
format: TENNIS_TOUR_BO3, // best-of-3, 7-point tiebreaks throughout
hcpLine: -2.5, // Game Handicap −2.5 @ +0.82 / −0.95 (Malay)
hcpMalay: [0.82, -0.95],
totLine: 22.5, // Game Total 22.5 @ +0.85 / −0.98 (Malay)
totMalay: [0.85, -0.98],
mlDecimal: [1.5, 2.79], // Moneyline (decimal) — the serve-sum anchor
}

function main() {
const f3 = (x) => x.toFixed(3)
const f4 = (x) => x.toFixed(4)
const f6 = (x) => x.toFixed(6)
const f8 = (x) => x.toFixed(8)
const e1 = (x) => x.toExponential(1)
const pct = (x) => (x * 100).toFixed(1) + '%'
const log = console.log

const r = priceTennis(DEMO)
if (!r.ok) {
console.error('solve failed:', r.reason)
process.exit(1)
}
const [iid, fin] = r.stages
const d = fin.dist

log('tennis-game-set.js — pricing the page defaults (best-of-3)\n')

log('step 1 · strip the margins (Power — all three books are two-way)')
const hs = r.hcpStrip
const ts = r.totStrip
const ms = r.mlStrip
log(` Game Handicap ${DEMO.hcpLine.toFixed(2)} @ +0.82/−0.95 → P(A covers) = ${f8(hs.pHome)}`)
log(` (Power x = ${f6(hs.exponent)}, overround ${hs.overround.toFixed(4)})`)
log(` Game Total ${DEMO.totLine.toFixed(2)} @ +0.85/−0.98 → P(over) = ${f8(ts.pHome)}`)
log(` (Power x = ${f6(ts.exponent)}, overround ${ts.overround.toFixed(4)})`)
log(` Moneyline 1.50/2.79 → P(A wins) = ${f8(ms.pHome)} — the Σ anchor`)
log(` (Power x = ${f6(ms.exponent)}, overround ${ms.overround.toFixed(4)})\n`)

log("step 3 · stage 'iid' — τ = 0, Σ ← ML, diff ← cover")
log(` Σ = ${f6(iid.sum)}, diff = ${f6(iid.diff)} → (p_A, p_B) = ${f6(iid.pA)} · ${f6(iid.pB)}`)
log(` anchors: Δ cover ${e1(iid.errCover)} · Δ ML ${e1(iid.errMl)}`)
log(` Δ total ${e1(iid.errOver)} — what the one-dial baseline misses\n`)

log("step 3 · stage 'length' — Σ ← ML on the τ-resolved manifold")
log(` Σ = ${f6(fin.sum)}, diff = ${f6(fin.diff)}, τ = ${f6(fin.tau)}` +
` (knob: ${fin.lengthKnob ?? '—'}${fin.fallback ? ', ' + fin.fallback : ''})`)
log(` (p_A, p_B) = ${f6(fin.pA)} · ${f6(fin.pB)}`)
log(` hold(A) = ${f6(tennisGameWin(fin.pA))}, hold(B) = ${f6(tennisGameWin(fin.pB))}`)
log(` anchors: Δ cover ${e1(fin.errCover)} · Δ total ${e1(fin.errOver)} · Δ ML ${e1(fin.errMl)}\n`)

log('step 2 · the solved race, read out')
const sj = d.setsJoint
log(` sets score P(2-0 · 2-1 · 1-2 · 0-2) = ${f6(sj[2][0])} · ${f6(sj[2][1])}` +
` · ${f6(sj[1][2])} · ${f6(sj[0][2])} (set·set scope)`)
log(` E[games] = ${f6(d.meanGames)}, P(any tiebreak) = ${f6(d.pAnyTb)}\n`)

log('step 4 · periods — per-set views are conditional')
for (const id of ['s1', 's2', 's3']) {
const v = tennisPeriod(fin, id)
const J = v.setJoint
const tbShare = J[7][6] + J[6][7]
log(` set ${v.setIndex + 1}: P(played) = ${f6(v.pPlayed)},` +
` P(7-6 or 6-7 | played) = ${pct(tbShare)}`)
}
log('')

// ── the page's "check a port" table ──────────────────────────────────────
// The rows quoting 6-dp literals pin this file to the page's DEFAULT-quote
// values; 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 sSum = 0
for (let a = 0; a <= d.setsN; a++) for (let b = 0; b <= d.setsN; b++) sSum += sj[a][b]
const massErr = Math.max(Math.abs(1 - mSum), Math.abs(1 - tSum), Math.abs(1 - sSum))
// Closed-form spot check: at p = ½ every term of the hold formula is an
// exact dyadic, and the game is symmetric — hold(½) = ½ to the last bit.
const holdHalf = tennisGameWin(0.5)
// Parity comb: a set totals {6..10, 12, 13} games, never 11 (from 5-5 the
// race runs to 7-5 or 6-6) — structural for ANY serve probabilities.
const never11 = tennisSetDist(fin.pA, fin.pB, 0, 7).every((o) => o.games !== 11)
// Stage/period consistency (any quotes): the ML is the top row of the sets
// joint; set 3 exists exactly when the match goes 2-1 either way; set 1 is
// unconditional.
const mlFromJoint = sj[2][0] + sj[2][1]
const s3 = tennisPeriod(fin, 's3')
const pS3FromJoint = sj[2][1] + sj[1][2]
const s1 = tennisPeriod(fin, 's1')

const knobs = `${f6(fin.pA)} · ${f6(fin.pB)} · ${f6(fin.tau)}`
const sumDiff = `${f6(fin.sum)} · ${f6(fin.diff)}`
const setsScore = `${f6(sj[2][0])} · ${f6(sj[2][1])} · ${f6(sj[1][2])} · ${f6(sj[0][2])}`
const shape = `${f6(d.meanGames)} · ${f6(d.pAnyTb)}`
const rows = [
['(p_A, p_B, τ)', knobs, knobs === '0.663341 · 0.623628 · 0.029537'],
['(Σ, diff)', sumDiff, sumDiff === '1.286969 · 0.039714'],
['sets P(2-0 · 2-1 · 1-2 · 0-2)', setsScore,
setsScore === '0.408154 · 0.247809 · 0.168671 · 0.175367'],
['E[games] · P(any tiebreak)', shape, shape === '24.432860 · 0.424608'],
[
'anchors re-read off the fit',
`Δ ${e1(fin.errCover)} · ${e1(fin.errOver)} · ${e1(fin.errMl)}`,
fin.errCover < 1e-6 && fin.errOver < 1e-5 && fin.errMl < 1e-5,
],
[
'mass: margin · total · sets pmfs',
`max |1 − Σ| = ${e1(massErr)}`,
massErr < 1e-9,
],
[
'closed forms: hold(½) = ½ · no 11-game set',
`${f6(holdHalf)} · ${never11 ? 'comb holds' : 'comb BROKEN'}`,
holdHalf === 0.5 && never11,
],
[
'periods vs the sets joint',
`ML Δ ${e1(Math.abs(d.mlA - mlFromJoint))} · P(s3) Δ ${e1(Math.abs(s3.pPlayed - pS3FromJoint))}`,
Math.abs(d.mlA - mlFromJoint) < 1e-12 &&
Math.abs(s3.pPlayed - pS3FromJoint) < 1e-12 &&
Math.abs(s1.pPlayed - 1) < 1e-12, // the mixture weights ⅙+⅔+⅙ re-sum in FP
],
]
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(42)} ${shown}`)
}
if (allPass) log('\nall assertions hold — the port matches the page.')
else {
log('\nsome assertions FAILED — the classic causes in this pipeline:')
log('a truncated pmf (OFF must span 13 games × max sets), a tiebreak counted as')
log('two games instead of one, or the serve rotation drifting across sets.')
process.exitCode = 1
}
}

if (require.main === module) main()

module.exports = {
tennisGameWin,
tennisTbWin,
malayToDecimalExact,
ladderMargin,
powerStrip,
stripTwoWayMalay,
TENNIS_TOUR_BO3,
TENNIS_SLAM_BO5,
tennisSetDist,
tennisMatchDist,
tennisMixture,
coverRead,
overRead,
tennisCover,
tennisOver,
bisect,
priceTennis,
tennisPeriod,
}