Skip to main content

Odds Generation — run · inning (baseball)

Enter the full game's Moneyline in decimal and its Total and Run Line quotes in Malay — margins still embedded. Those three markets are the complete input: no league constants, no shape priors — every number below is solved from the quotes. Baseball runs are counts but not Poisson: an inning is scoreless ~72% of the time yet carries a fat "big-inning" tail, so a team's game variance (~10) is more than double its mean. The engine (ADR-029):

  1. strips each bookmaker margin (Power method — all three books are two-way);
  2. models each inning as a zero-inflated geometric (mean μ/9, tail q) and back-solves (μ_H, μ_A) by nested bisection — the Total fixes the run environment, the Moneyline fixes the split — with the big-inning tail q bisected against the Run Line around that nest: at a fixed Moneyline the RL is pure dispersion information, exactly as the ML pins basketball's σM. Three quotes, three knobs — nothing left to prior;
  3. applies the sport's endgame rules and re-solves all three anchors — the corrections here are not a statistical tilt but exact stopping rules: the bottom of the 9th is skipped when home already leads, a walk-off stops play the moment the lead is taken (home wins compress toward +1 — the margin mix is the ZIG burst's own geometric overshoot from the fitted q, capped at 4 because one swing scores at most a grand slam), and extra innings replay the same inning process until the tie breaks;
  4. derives First 5/3/7 innings and Inning 1 markets by convolving the same per-inning distributions — the inning is the period unit, no scaling knobs.

The contract has honest limits, and the walkthrough names them: real walk-offs end at +1 more often (~75%) than the memoryless burst says (1 − q ≈ 54% — most winning hits are not homers, and the stop rule then counts only the winning run), and extras carry no ghost-runner multiplier (a measured ~2× league constant is out of contract), so the extras tail runs slightly long.

Every stage consumes all three anchors — q is fit to the Run Line at both — so the score grid's stage switcher is a true before/after of the rules, not of a prior: flip Convolution / Endgame rules and watch the home-win-by-1 stripe fatten.

Moneyline (Decimal) — incl. extras
Total quote (Malay)
Run Line (Malay) — pins the big-inning tail q

/ steps a field, Shift steps bigger; Malay pairs step their partner the opposite way. The three full-game quotes (extra innings included) are the complete input — every μ and q below is solved from them, and First-N periods derive by convolving the same per-inning distributions. Each market card carries its own trader margin, repricing the output sheet only.

Valid inputs: decimals above 1, Malay in [−1, +1] non-zero, each book carrying margin. The solve walks all 18 half-innings at every q trial — allow it a second.

μ_H 5.2730μ_A 4.2110q 0.5806extras 8.6%round-trip Δp 1.3e-9 / 1.8e-9 / 1.9e-5Convolution → Endgame rules
Score grid
Score grid — P(home = h, away = a) ×100
μ_H 5.273μ_A 4.211q 0.581skip-9 49.3%walk-off 9.4%extras 8.6%
A=0A=1A=2A=3A=4A=5A=6A=7A=8A=9A=10A=11A=12A=13A=14
H=00.01.21.11.00.90.70.60.50.40.30.20.20.10.10.1
H=12.30.01.51.31.10.90.70.60.40.30.30.20.10.10.1
H=21.92.50.01.51.31.00.80.60.50.40.30.20.20.10.1
H=31.71.72.60.01.41.10.90.70.50.40.30.20.20.10.1
H=41.51.41.62.30.01.10.90.70.50.40.30.20.20.10.1
H=51.31.21.31.41.90.00.80.60.50.40.30.20.10.10.1
H=61.11.01.01.01.01.40.00.60.40.30.20.20.10.10.1
H=70.90.80.80.80.70.71.00.00.40.30.20.10.10.10.1
H=80.70.70.60.60.50.50.50.70.00.20.20.10.10.10.0
H=90.60.50.50.50.40.40.30.30.40.00.20.10.10.10.0
H=100.40.40.40.40.30.30.20.20.20.30.00.10.10.00.0
H=110.30.30.30.30.20.20.20.10.10.10.20.00.10.00.0
H=120.20.20.20.20.20.10.10.10.10.10.10.10.00.00.0
H=130.20.20.20.10.10.10.10.10.10.00.00.00.00.00.0
H=140.10.10.10.10.10.10.10.00.00.00.00.00.00.00.0
home win tie away winsettlement grid — extras resolved; note the fat home-win-by-1 stripe (walk-offs)
Markets
Run Line
RL-2.5-1.5-0.5
H-0.55-0.80+0.67
A+0.47+0.72-0.75
Total Runs
Runs8.08.59.0
Over+0.82+0.95-0.89
Under-0.90+0.97+0.81
Moneyline
settles after extra innings — the endgame construction leaves no tie mass
OutcomeOdds
Home1.66
Away2.33
2 in scope
lines centred on the model mean — the home total is truncated by the skipped bottom 9 and walk-offs
4.0
OutcomeMalay
Over-0.96
Under+0.88
4.5
OutcomeMalay
Over-0.96
Under+0.88
5.0
OutcomeMalay
Over-0.65
Under+0.57
1 · Strip the bookmaker margins (Power method — all books two-way)
Moneyline (extra innings included) → P(home wins)
HomeAway
Decimal quote1.652.30
Decimal dd1.6500002.300000
Fair p=q1/xp = q^{1/x}0.5873330.412667
Fair decimal 1/p1/p1.7026112.423262
0.6060611/x+0.4347831/x=1    x=0.9410180.606061^{1/x} + 0.434783^{1/x} = 1 \;\Rightarrow\; x = 0.941018
Total 8.50 → P(over)
OverUnder
Malay quote+0.90+0.92
Decimal dd1.9000001.920000
Fair p=q1/xp = q^{1/x}0.5028040.497196
Fair decimal 1/p1/p1.9888462.011280
0.5263161/x+0.5208331/x=1    x=0.9335320.526316^{1/x} + 0.520833^{1/x} = 1 \;\Rightarrow\; x = 0.933532
Run Line -1.50 → P(home covers)
HomeAway
Malay quote-0.80+0.72
Decimal dd2.2500001.720000
Fair p=q1/xp = q^{1/x}0.4306950.569305
Fair decimal 1/p1/p2.3218271.756529
0.4444441/x+0.5813951/x=1    x=0.9626950.444444^{1/x} + 0.581395^{1/x} = 1 \;\Rightarrow\; x = 0.962695

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

2 · The inning model — zero-inflated geometric runs

Runs are not Poisson: an inning is scoreless ≈ 72% of the time yet carries a fat “big inning” tail, so a team's game variance (≈ 10) is more than double its mean (≈ 4.6). Each inning draws

P(0)=π=1m(1q)P(k1)=(1π)(1q)qk1m=μ/9P(0) = \pi = 1 - m(1-q) \qquad P(k \ge 1) = (1-\pi)(1-q)\,q^{\,k-1} \qquad m = \mu/9

— the mean per inning mm carries the team rate and the tail qq is the big-inning knob (this run: home π=0.754\pi = 0.754, q = 0.581). A team's nine innings convolve into its runs distribution, and the two teams multiply into the score grid. The Total is the size and the Moneyline is the split — the same nested bisection as goal-regular recovers (μH,μA)=(5.273, 4.211)(\mu_H, \mu_A) = (5.273,\ 4.211) — and the Run Line pins q around that nest, exactly as the ML pins basketball's σ_M: at a fixed Moneyline the RL is pure dispersion information. Three quotes, three knobs — nothing left to prior.

3 · Endgame rules — the corrections are the sport's own stopping rules

Basketball needed a tie-inflation ι; baseball's corrections are deterministic rules, applied on the inning walk: the bottom of the 9th is skipped when home already leads (49.3% here — the home total is truncated); a walk-off stops play the moment home takes the lead, compressing home wins toward +1 — the margin mix is the ZIG burst's own geometric overshoot from the fitted q (memorylessness: P(k)(1q)qk1P(k) \propto (1-q)\,q^{\,k-1}), capped at 4 because one swing scores at most a grand slam ({1: 47%, 2: 27%, 3: 16%, 4: 9%} this run — no calibration constant); extra innings (8.6%) replay until the tie breaks at the same inning rate. Every stage re-solves the same fair targets:

Stageμ_Hμ_Aqskip-9walk-offextrasΔ MLΔ totalΔ RL
Convolution4.9424.1060.4540.0%0.0%0.0%9.3e-91.7e-81.0e-5
Endgame rules5.2734.2110.58149.3%9.4%8.6%1.3e-91.8e-91.9e-5

q is fit to the Run Line at BOTH stages, so the switcher shows what the endgame RULES move — a different q against the same quotes — not what a prior did. Honest limits of the contract: real walk-offs end at +1 ≈ 75% of the time where the memoryless burst says 1 − q ≈ 42% (most winning hits are not homers, and the stop rule then counts only the winning run), and extras carry no ghost-runner multiplier (a measured ~2× league constant — out of contract), so the extras tail runs slightly long. Note μ's are hypothetical full-nine rates: the settled total mean sits below them because skipped bottom-9s and walk-offs remove runs.

4 · Periods — first-N-innings convolutions

Baseball's period model needs no scaling knobs: the inning is the unit. First-5 markets convolve the same per-inning distributions five times; ties are real outcomes there, so the F5 moneyline pushes on a tie (R = 15.7% here, fair home = W/(1R)W/(1-R) = 57.58%). The endgame rules never touch innings 1–8, so first-N grids are rule-free by construction. Inning weights (starters vs bullpen, top of the order in inning 1) are a calibration refinement — ADR-029 O1.

5 · Read “Home Team Total Runs” off the grid · margins · notes

Over/Under on the home-runs marginal — truncated by skipped bottom-9s and walk-offs. Fair groups are re-margined exactly as in goal-regular (Power ladder two-way, Shin multi-way).

targets  tW = powerStrip(ML pair)      tO = powerStrip(Total pair)      tR = powerStrip(RL pair)

stage ∈ {Convolution, Endgame rules}:          # each re-solves ALL THREE targets
  bisect q until P(home covers RL | grid) = tR           # outer — probed ends, kept endpoint
    bisect μ_total (size)  until P(over T | grid)  = tO  #   — goal-regular's solveLambdas
      bisect μ_H   (split) until P(home ML | grid) = tW  # inner

grid: per-inning ZIG(m = μ/9, q) → innings 1–8 convolve → top 9 → bottom 9
      skipped if home leads · walk-off margin = Geometric(q) overshoot capped at 4
      (grand slam) · extras replay the SAME inning process until the tie breaks
periods: first-N convolutions of the same inning pmfs (no endgame rules)

Grid runs 0..34 runs a side (tail mass clamps into the last cell). Engine + invariants: docs/src/lib/odds/baseball.ts, __tests__/baseball.test.ts; decision record ADR-029.

The pipeline in one file

Everything the breakdown above does — steps 1–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. Run it with Node, no install:

node baseball-run-inning.js

It prices this page's default quotes on the same 0–34 clamped run grid — the zero-inflated-geometric innings, both solved stages (convolution and endgame rules) and the First-N period views — 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 baseball-run-inning.js, or read it here:

baseball-run-inning.js — the complete listing
/* ═══════════════════════════════════════════════════════════════════════════
* baseball-run-inning.js — the run · inning 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 inning model ZIG innings; (μ_H, μ_A) by nested
* bisection, big-inning tail q ← Run Line
* step 3 · Endgame rules bottom-9 skip · walk-off · extras,
* all three anchors re-solved
* step 4 · Periods First 7/5/3 and Inning 1 by
* convolving the same innings
*
* Source of truth: docs/src/lib/odds/core.ts + docs/src/lib/odds/baseball.ts
* (the page /odds-generation/baseball-run-inning/ 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 (`buildBaseballMarkets`
* lays the grid out as Moneyline/Run Line/Total cards for the page); the
* model and every solved parameter live here in full.
*
* Run it:
*
* node baseball-run-inning.js
*
* It prices the page's DEFAULT quotes — Moneyline 1.65/2.30 (decimal, extra
* innings included), Total 8.5 @ +0.90/+0.92, Run Line −1.5 @ −0.80/+0.72
* (Malay) — then prints the "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. Baseball runs are counts but not Poisson —
* an inning is scoreless ~72% of the time yet carries a fat "big inning"
* tail — so each inning draws from a zero-inflated geometric (mean μ/9, tail
* q) and a team's nine innings convolve into a runs distribution whose
* variance is more than double its mean. The three quotes are the complete
* input: the Total sizes the run environment and the Moneyline splits it
* (nested bisection over (μ_H, μ_A)), while the Run Line pins q — at a fixed
* Moneyline the RL is pure dispersion information — so three quotes fix
* three knobs with nothing left to prior. The endgame stage replays the same
* inning process under the sport's exact stopping rules — the bottom of the
* 9th is skipped when home already leads, a walk-off stops play the moment
* the lead is taken (the margin is the burst's own Geometric(q) overshoot,
* capped at the grand-slam 4), and extra innings repeat until the tie
* breaks — and re-solves all three anchors, so the conv → endgame switch
* shows what the RULES move, not what a prior did.
* ═══════════════════════════════════════════════════════════════════════════
*/

'use strict'

/* ─────────────────────────────────────────────────────────────────────────
* Section 0 — per-inning runs: the zero-inflated geometric (ZIG)
*
* Everything downstream is assembled from one inning pmf. mean m and tail q
* parameterize it as
*
* P(0) = π = 1 − m(1−q) P(k ≥ 1) = (1−π)(1−q)·q^(k−1)
*
* q is the "big-inning" knob: larger q ⇒ fatter inning tails ⇒ wider team
* totals and margins, at the SAME per-inning mean. Convolving n innings
* (capped at BB_MAX so tail mass clamps into the last cell, ≈1e-9 at MLB
* scale) gives a team's n-inning runs distribution — the same primitive
* builds the full game, the endgame walk and every First-N period.
* ───────────────────────────────────────────────────────────────────────── */

/** Score grid cap — mass clamped into the last cell (≈1e-9 at MLB scale). */
const BB_MAX = 34
const INN_MAX = 12

/** One inning's run pmf, indices 0…INN_MAX, renormalized after the k > 12
* truncation. Domain gates match the solver's exploration range: the tail
* must sit in (0.05, 0.9) and the mean must be positive. When the requested
* mean is too big for the zero mass (π would fall under 0.05), π is floored
* and the tail re-tuned to carry the mean instead — that keeps extreme
* solver trials well-defined; realistic MLB means never reach the branch. */
function inningPmf(mean, q) {
if (!(q > 0.05 && q < 0.9) || !(mean > 0)) return null
let qq = q
let pi = 1 - mean * (1 - qq)
if (pi < 0.05) {
// the ZIG cannot carry a mean ≥ 1/(1−q) at this zero mass — floor π and
// let the tail carry the mean instead, so solver exploration at extreme
// splits stays well-defined (realistic MLB means never reach this branch)
pi = 0.05
qq = Math.min(0.95, 1 - (1 - pi) / mean)
}
const pmf = [pi]
let p = (1 - pi) * (1 - qq)
for (let k = 1; k <= INN_MAX; k++) {
pmf.push(p)
p *= qq
}
const s = pmf.reduce((a, b) => a + b, 0)
return pmf.map((v) => v / s)
}

/** Convolution of two pmfs with the sum clamped at `cap` — overflowing mass
* lands in the last cell instead of leaking, so Σ is preserved. */
function convCap(a, b, cap) {
const out = new Array(Math.min(cap, a.length - 1 + b.length - 1) + 1).fill(0)
for (let i = 0; i < a.length; i++) {
const ai = a[i]
if (!(ai > 0)) continue
for (let j = 0; j < b.length; j++) out[Math.min(i + j, cap)] += ai * b[j]
}
return out
}

/** n-fold self-convolution — a team's runs over n innings. */
function convInnings(pmf, n, cap) {
let out = [1]
for (let i = 0; i < n; i++) out = convCap(out, pmf, cap)
return out
}

/* ─────────────────────────────────────────────────────────────────────────
* 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. All three baseball books are two-way, so one
* tool strips them all — the Power method, which removes more margin from
* the longshot side (favourite–longshot bias). The stripped values become
* the solver's three targets: P(home wins) from the Moneyline, P(over) from
* the Total, P(home covers) from the Run Line.
* ───────────────────────────────────────────────────────────────────────── */

/** 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 between the pair). 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, 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 — over for
* the Total pair, home cover for the Run Line 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 — fair market readers (Run Line / Total off a grid)
*
* The solver needs "what does THIS grid say the Run Line / Total fair
* 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 = E[W] / (1 − E[R]) with W the stake-weighted win fraction and R the
* stake-weighted push (refund) fraction of each scoreline. The Moneyline is
* the same reader at line 0: W = home leads, R = tied — exact for the
* endgame grid (no ties survive it) and the fair "tie pushes" conditional
* for the convolution baseline and the First-N periods.
* ───────────────────────────────────────────────────────────────────────── */

/** Quarter line ⟺ 4·x is an odd integer (±0.25, ±0.75, …). */
function isQuarterLine(x) {
const q4 = Math.round(x * 4)
if (Math.abs(q4 - x * 4) > 1e-9) return false
return q4 % 2 !== 0
}

/** 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) {
if (isQuarterLine(line)) {
const q4 = Math.round(line * 4)
return [
{ line: (q4 - 1) / 4, weight: 0.5 },
{ line: (q4 + 1) / 4, weight: 0.5 },
]
}
return [{ line, weight: 1 }]
}

/** Fair home/away probability at a HANDICAP (Run Line) line read off a grid.
* Home covers when h − a + line > 0, pushes when it lands exactly on 0. */
function fairHcpDetail(joint, N, line) {
const comps = componentLines(line)
let Wbar = 0 // E[win fraction of the stake]
let Rbar = 0 // E[refunded (push) fraction of the stake]
for (let h = 0; h <= N; h++) {
for (let a = 0; a <= N; a++) {
const p = joint[h][a]
if (!(p > 0)) continue
let W = 0
let R = 0
for (const c of comps) {
const diff = h - a + c.line
if (diff > 1e-9) W += c.weight
else if (diff > -1e-9) R += c.weight
}
Wbar += p * W
Rbar += p * R
}
}
const denom = 1 - Rbar // condition on "no push"
if (denom <= 1e-12) return null
return { components: comps, Wbar, Rbar, pHome: Wbar / denom, pAway: (1 - Wbar - Rbar) / denom }
}

/** Fair over/under probability at a TOTAL line — same push/quarter handling,
* with the win event h + a > line. */
function fairTotalDetail(joint, N, line) {
const comps = componentLines(line)
let Wbar = 0
let Rbar = 0
for (let h = 0; h <= N; h++) {
for (let a = 0; a <= N; a++) {
const p = joint[h][a]
if (!(p > 0)) continue
const total = h + a
let W = 0
let R = 0
for (const c of comps) {
const diff = total - c.line
if (diff > 1e-9) W += c.weight
else if (diff > -1e-9) R += c.weight
}
Wbar += p * W
Rbar += p * R
}
}
const denom = 1 - Rbar
if (denom <= 1e-12) return null
return { components: comps, Wbar, Rbar, pOver: Wbar / denom, pUnder: (1 - Wbar - Rbar) / denom }
}

/* ─────────────────────────────────────────────────────────────────────────
* Section 3 — the score grid P(h, a): convolution and endgame constructions
*
* Two grids share the same inning pmfs. The CONVOLUTION grid is the plain
* product of two independent 9-fold convolutions — both teams bat all nine,
* ties keep their mass on the diagonal. The ENDGAME grid walks the sport's
* stopping rules exactly: innings 1–8 are unconditional convolutions, then
* the 9th (and extras) resolve on the joint (home, away) state — the bottom
* of the 9th is SKIPPED when home already leads after 8½, a WALK-OFF stops
* play the moment home takes the lead, and EXTRA INNINGS replay the same
* inning process until the tie breaks. No calibration constants anywhere:
* the walk-off margin is the ZIG burst's own geometric overshoot from the
* SAME q the Run Line fits.
* ───────────────────────────────────────────────────────────────────────── */

/** Walk-off winning margins 1..4 derived from the model's own tail: the ZIG
* burst is geometric, so the overshoot past the deficit is Geometric(q)
* (memorylessness), capped at 4 — one swing scores at most a grand slam.
* No calibration constant; the honest cost is that real walk-offs end at +1
* more often (~75%) than the memoryless burst says (most winning hits are
* not homers, and the stop rule then counts only the winning run). */
function walkoffWeights(q) {
const w = []
let p = 1 - q
for (let k = 0; k < 4; k++) {
w.push(p)
p *= q
}
return w
}

/** Build the joint runs grid for one (μ_H, μ_A, q) triple.
*
* input = { muH, muA, q, endgame } — μ's are hypothetical full-nine rates
* (per game); q is the big-inning tail (always fit to the Run Line; MLB
* solves land ≈ 0.46); `endgame` switches the stopping-rule construction on.
*
* Returns { N, joint, pSkip9, pWalkoff, pExtras } — the three shares are 0
* by definition on the convolution grid.
*/
function buildBaseballGrid(input) {
const pH = inningPmf(input.muH / 9, input.q)
const pA = inningPmf(input.muA / 9, input.q)
if (!pH || !pA) return null
const N = BB_MAX

if (!input.endgame) {
const H9 = convInnings(pH, 9, N)
const A9 = convInnings(pA, 9, N)
const joint = []
for (let h = 0; h <= N; h++) {
joint.push([])
for (let a = 0; a <= N; a++) joint[h].push((H9[h] ?? 0) * (A9[a] ?? 0))
}
return { N, joint, pSkip9: 0, pWalkoff: 0, pExtras: 0 }
}

const H8 = convInnings(pH, 8, N)
const A9 = convInnings(pA, 9, N) // away always bats all nine
const F = Array.from({ length: N + 1 }, () => new Array(N + 1).fill(0))
let pSkip9 = 0
let pWalkoff = 0

// walk-off: home takes the lead mid-inning with X runs against deficit d —
// play stops; the margin is the geometric burst overshoot (from the SAME q
// the Run Line fits) capped at the natural X−d and the grand-slam 4
const WOFF = walkoffWeights(input.q)
const walkoff = (base, a, cap) => {
const kMax = Math.min(WOFF.length, cap)
let wSum = 0
for (let k = 0; k < kMax; k++) wSum += WOFF[k]
for (let k = 0; k < kMax; k++) F[Math.min(a + k + 1, N)][a] += (base * WOFF[k]) / wSum
}

// bottom of the 9th
let tied = new Array(N + 1).fill(0) // mass tied at (t, t) after 9
for (let h = 0; h <= N; h++) {
const mh = H8[h] ?? 0
if (!(mh > 0)) continue
for (let a = 0; a <= N; a++) {
const m = mh * (A9[a] ?? 0)
if (!(m > 1e-15)) continue
if (h > a) {
pSkip9 += m // home leads after 8½ — bottom 9 never played
F[h][a] += m
continue
}
const d = a - h
for (let x = 0; x < pH.length; x++) {
const mx = m * pH[x]
if (!(mx > 1e-15)) continue
if (x < d) F[Math.min(h + x, N)][a] += mx
else if (x === d) tied[Math.min(a, N)] += mx
else {
pWalkoff += mx
walkoff(mx, a, x - d)
}
}
}
}
const pExtras = tied.reduce((s, v) => s + v, 0)

// extra innings: away bats a full frame, home answers under walk-off rules;
// the SAME inning process continues (no ghost-runner multiplier — a measured
// constant is out of the market-only contract); rounds cover ≈ 99.9% of
// ties — the residual settles as a ±1 coin
const pHe = pH
const pAe = pA
for (let round = 0; round < 12; round++) {
const next = new Array(N + 1).fill(0)
for (let t = 0; t <= N; t++) {
const m0 = tied[t]
if (!(m0 > 1e-15)) continue
for (let xa = 0; xa < pAe.length; xa++) {
const m1 = m0 * pAe[xa]
if (!(m1 > 1e-15)) continue
const a = Math.min(t + xa, N)
const d = a - t
for (let xh = 0; xh < pHe.length; xh++) {
const m2 = m1 * pHe[xh]
if (!(m2 > 1e-15)) continue
if (xh < d) F[Math.min(t + xh, N)][a] += m2
else if (xh === d) next[a] += m2
else {
pWalkoff += m2
walkoff(m2, a, xh - d)
}
}
}
}
tied = next
}
for (let t = 0; t <= N; t++) {
const r = tied[t]
if (!(r > 0)) continue
F[Math.min(t + 1, N)][t] += r / 2
F[t][Math.min(t + 1, N)] += r / 2
}

return { N, joint: F, pSkip9, pWalkoff, pExtras }
}

/* ─────────────────────────────────────────────────────────────────────────
* Section 4 — step 2 · recover (μ_H, μ_A) by nested bisection
*
* The same solver goal-regular uses — two unknowns, two equations, both
* solved by 1-D bisection (no gradients):
*
* OUTER (size) — bisect μ_total ∈ [2, 26] until the grid's fair P(over)
* hits the Total target. More runs ⇒ more overs, so P(over) is
* increasing in μ_total: a clean bisection knob.
*
* INNER (split) — at each candidate μ_total, bisect the home share
* μ_H ∈ [pad, μ_total − pad] until fair P(home wins) hits the Moneyline
* target (μ_A = μ_total − μ_H). Shifting runs toward home raises the
* home-win probability, so this is monotone too.
*
* `computeFair(lh, la)` abstracts the grid: the caller decides what grid the
* rates parameterize — here every call builds a baseball grid with its trial
* q (and stage) baked in, which is ALL it takes for the soccer solver to
* serve baseball. Returns { lh, la, fair } with fair the achieved
* { pHcp, pTot } (pHcp doubles as the Moneyline read at line 0).
* ───────────────────────────────────────────────────────────────────────── */

function solveLambdas(targetHcpP, targetTotP, computeFair, opts = {}) {
const lambdaMin = opts.lambdaMin || 0.02
const lambdaMax = opts.lambdaMax || 8.0
const iters = opts.iters || 50
const tol = opts.tol || 1e-6

// Split — solve the home share at a FIXED μ_total.
function innerSolve(lambdaTotal) {
// Keep both rates strictly positive: pad shrinks with tiny totals.
const pad = Math.min(0.01, lambdaTotal * 0.01)
let lo = pad
let hi = lambdaTotal - pad
if (hi <= lo) {
// Degenerate bracket (μ_total ≈ 0) — split evenly and report.
const mid0 = lambdaTotal / 2
return { lh: mid0, fair: computeFair(mid0, lambdaTotal - mid0) }
}
const fLo = computeFair(lo, lambdaTotal - lo)
const fHi = computeFair(hi, lambdaTotal - hi)
if (!fLo || !fHi) return null
// Target outside the reachable range — clamp to the nearer end rather
// than bisect toward a root that is not there.
if (fLo.pHcp >= targetHcpP) return { lh: lo, fair: fLo }
if (fHi.pHcp <= targetHcpP) return { lh: hi, fair: fHi }
let mid = (lo + hi) / 2
let f = fLo
for (let i = 0; i < iters; i++) {
mid = (lo + hi) / 2
f = computeFair(mid, lambdaTotal - mid)
if (!f) return null
if (Math.abs(f.pHcp - targetHcpP) < tol) break // value-tolerance stop
if (f.pHcp < targetHcpP) lo = mid // too few home runs — raise the share
else hi = mid
}
return { lh: mid, fair: f }
}

// Size — bisect μ_total; every probe runs a full inner solve so the
// Moneyline holds at each candidate size.
let lo = lambdaMin
let hi = lambdaMax
let bestMid = (lo + hi) / 2
let bestInner = innerSolve(bestMid)
for (let k = 0; k < iters; k++) {
const mid = (lo + hi) / 2
const step = innerSolve(mid)
if (!step || !step.fair) {
// Invalid trial (grid failed) — treat as "too big" and shrink.
hi = mid
continue
}
bestMid = mid
bestInner = step
if (Math.abs(step.fair.pTot - targetTotP) < tol) break
if (step.fair.pTot < targetTotP) lo = mid // too few runs — grow the size
else hi = mid
}
if (!bestInner || !bestInner.fair) return { ok: false, reason: 'no convergence' }
return {
ok: true,
lh: bestInner.lh,
la: Math.max(0, bestMid - bestInner.lh),
fair: bestInner.fair,
}
}

/* ─────────────────────────────────────────────────────────────────────────
* Section 5 — steps 2–3 · fit q to the Run Line and run both stages
*
* One knob (q) against one target (the stripped Run Line), with (μ_H, μ_A)
* re-solved from scratch inside EVERY q trial — so the Moneyline and Total
* anchors hold at every candidate tail, and the Run Line reads pure
* dispersion. The q search is a plain bisection over [0.25, 0.68]: the
* bracket is a solver bound, not a prior; the RL response direction is
* probed at the ends, and a target outside the reachable band keeps the
* nearer endpoint — the residual surfaces in errRl instead of distorting
* the means. Stage 'conv' fits all three anchors on the plain convolution
* grid; stage 'endgame' re-fits the SAME three anchors with the stopping
* rules exact — a different q against the same quotes, which is why the
* page's stage switcher shows what the rules move.
* ───────────────────────────────────────────────────────────────────────── */

/** Moneyline read: W/(1−R) at line 0 = P(home wins | not tied) — exact for
* the endgame grid (no ties survive it) and the fair conditional for the
* convolution baseline. */
function mlRead(grid) {
return fairHcpDetail(grid.joint, grid.N, 0)?.pHome ?? null
}

/** Run the whole pipeline for one quote set.
*
* input = {
* mlDecimal: [home, away], // Moneyline (decimal, extras included)
* totLine, totMalay: [over, under], // Total line + Malay pair
* rlLine, rlMalay: [home, away], // Run Line + Malay pair
* }
*
* Returns { ok, mlStrip, totStrip, rlStrip, stages, final } with stages =
* [conv, endgame] and final the endgame stage (what the page prices from).
*/
function priceBaseball(input) {
const fail = (reason) => ({
ok: false,
reason,
mlStrip: null,
totStrip: null,
rlStrip: null,
stages: [],
final: null,
})

// ── step 1 · strip the margins ──────────────────────────────────────────
const [dH, dA] = input.mlDecimal
if (!(dH > 1 && dA > 1)) return fail('Moneyline decimal invalid (each price > 1)')
const qH = 1 / dH
const qA = 1 / dA
if (qH + qA <= 1) return fail('Moneyline book invalid (overround Q > 1)')
const mlPow = powerStrip(qH, qA)
if (!mlPow) return fail('Moneyline strip failed')
const mlStrip = {
decimalHome: dH,
decimalAway: dA,
pricedHome: qH,
pricedAway: qA,
overround: qH + qA,
exponent: mlPow.exponent,
pHome: mlPow.p1,
pAway: mlPow.p2,
}
const totStrip = stripTwoWayMalay(input.totMalay[0], input.totMalay[1])
const rlStrip = stripTwoWayMalay(input.rlMalay[0], input.rlMalay[1])
if (!totStrip) return fail('TOTAL pair invalid (Malay ∈ [−1,+1] non-zero, overround Q > 1)')
if (!rlStrip) return fail('RUN LINE pair invalid (Malay ∈ [−1,+1] non-zero, overround Q > 1)')

// ── (μ_H, μ_A) at a frozen q (one nested solve) ─────────────────────────
const solveAt = (q, endgame) => {
const compute = (muH, muA) => {
const g = buildBaseballGrid({ muH, muA, q, endgame })
if (!g) return null
const ml = mlRead(g)
const over = fairTotalDetail(g.joint, g.N, input.totLine)?.pOver
if (ml == null || over == null) return null
return { pHcp: ml, pTot: over }
}
const sol = solveLambdas(mlStrip.pHome, totStrip.pHome, compute, {
lambdaMin: 2,
lambdaMax: 26,
iters: 26,
tol: 1e-8,
})
if (!sol.ok || sol.lh == null || sol.la == null) return null
const grid = buildBaseballGrid({ muH: sol.lh, muA: sol.la, q, endgame })
if (!grid) return null
const ml = mlRead(grid)
const over = fairTotalDetail(grid.joint, grid.N, input.totLine)?.pOver
const rl = fairHcpDetail(grid.joint, grid.N, input.rlLine)?.pHome
if (ml == null || over == null || rl == null) return null
return {
muH: sol.lh,
muA: sol.la,
grid,
errMl: Math.abs(ml - mlStrip.pHome),
errTot: Math.abs(over - totStrip.pHome),
rlFair: rl,
}
}

// Package one finished stage with its anchor residuals.
const mk = (key, q, s) => ({
key,
muH: s.muH,
muA: s.muA,
q,
errMl: s.errMl,
errTot: s.errTot,
errRl: Math.abs(s.rlFair - rlStrip.pHome),
grid: s.grid,
})

// ── steps 2–3 · q ← Run Line with (μ_H, μ_A) re-solved inside every trial.
// The bracket is a solver bound, not a prior; the RL response direction is
// probed at the ends, and a target outside the reachable band keeps the
// nearer endpoint — the residual surfaces in errRl instead of distorting
// the means.
const fitQ = (endgame) => {
let lo = 0.25
let hi = 0.68
const at = (q) => solveAt(q, endgame)
const sLo = at(lo)
const sHi = at(hi)
if (!sLo || !sHi) return null
const rising = sHi.rlFair > sLo.rlFair
const target = rlStrip.pHome
if (target <= Math.min(sLo.rlFair, sHi.rlFair) + 1e-12)
return rising ? { q: lo, s: sLo } : { q: hi, s: sHi }
if (target >= Math.max(sLo.rlFair, sHi.rlFair) - 1e-12)
return rising ? { q: hi, s: sHi } : { q: lo, s: sLo }
let best = sLo
let bestQ = lo
for (let i = 0; i < 12; i++) {
const mid = (lo + hi) / 2
const s = at(mid)
if (!s) {
hi = mid
continue
}
best = s
bestQ = mid
if (Math.abs(s.rlFair - target) < 1e-7) break
if (s.rlFair < target === rising) lo = mid
else hi = mid
}
return { q: bestQ, s: best }
}

const conv = fitQ(false)
if (!conv) return fail('solver failed to converge (convolution baseline)')
const endgame = fitQ(true)
if (!endgame) return fail('solver failed to converge (endgame stage)')
const stages = [
mk('conv', conv.q, conv.s),
mk('endgame', endgame.q, endgame.s),
]
return { ok: true, mlStrip, totStrip, rlStrip, stages, final: stages[stages.length - 1] }
}

/* ─────────────────────────────────────────────────────────────────────────
* Section 6 — step 4 · periods: First N innings and Inning 1
*
* Baseball's period model needs no scaling knobs: the inning is the unit.
* First-N markets convolve the SAME per-inning distributions N times —
* innings 1..N are always fully played, so the endgame rules never touch
* them and First-N grids are rule-free by construction. Ties are real
* outcomes there (the F5 moneyline pushes on a tie; the readers' W/(1−R)
* conditioning handles it).
* ───────────────────────────────────────────────────────────────────────── */

const BASEBALL_INNINGS = {
full: 9,
f7: 7,
f5: 5,
f3: 3,
i1: 1,
}

/** A period's joint grid + marginal means, derived from a solved stage.
* 'full' returns the stage's own grid (with its exact means, read off the
* grid — the settled totals sit below the hypothetical μ's because skipped
* bottom-9s and walk-offs remove runs); any other id convolves the stage's
* inning pmfs n times and multiplies the teams. */
function baseballPeriod(stage, id) {
if (id === 'full') {
const g = stage.grid
let mh = 0
let ma = 0
for (let h = 0; h <= g.N; h++)
for (let a = 0; a <= g.N; a++) {
mh += h * g.joint[h][a]
ma += a * g.joint[h][a]
}
return { id, innings: 9, joint: g.joint, N: g.N, meanH: mh, meanA: ma }
}
const n = BASEBALL_INNINGS[id]
const pH = inningPmf(stage.muH / 9, stage.q)
const pA = inningPmf(stage.muA / 9, stage.q)
if (!pH || !pA) return null
const H = convInnings(pH, n, BB_MAX)
const A = convInnings(pA, n, BB_MAX)
const joint = []
for (let h = 0; h <= BB_MAX; h++) {
joint.push([])
for (let a = 0; a <= BB_MAX; a++) joint[h].push((H[h] ?? 0) * (A[a] ?? 0))
}
return {
id,
innings: n,
joint,
N: BB_MAX,
meanH: (stage.muH * n) / 9,
meanA: (stage.muA * n) / 9,
}
}

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

const DEMO = {
mlDecimal: [1.65, 2.3], // Moneyline 1.65 / 2.30 (decimal, extra innings included)
totLine: 8.5, // Total 8.5 @ +0.90 / +0.92 (Malay)
totMalay: [0.9, 0.92],
rlLine: -1.5, // Run Line −1.5 @ −0.80 / +0.72 (Malay)
rlMalay: [-0.8, 0.72],
}

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

const r = priceBaseball(DEMO)
if (!r.ok) {
console.error('solve failed:', r.reason)
process.exit(1)
}
const conv = r.stages[0]
const end = r.final

log('baseball-run-inning.js — pricing the page defaults (full-game ML · Total · Run Line)\n')

log('step 1 · strip the margins')
log(` Moneyline 1.65/2.30 → P(home wins) = ${f8(r.mlStrip.pHome)}`)
log(` (Power x = ${f6(r.mlStrip.exponent)}, overround ${r.mlStrip.overround.toFixed(4)})`)
log(` Total ${DEMO.totLine.toFixed(2)} @ +0.90/+0.92 → P(over) = ${f8(r.totStrip.pHome)}`)
log(` (Power x = ${f6(r.totStrip.exponent)}, overround ${r.totStrip.overround.toFixed(4)})`)
log(` Run Line ${DEMO.rlLine.toFixed(2)} @ −0.80/+0.72 → P(home covers) = ${f8(r.rlStrip.pHome)}`)
log(` (Power x = ${f6(r.rlStrip.exponent)}, overround ${r.rlStrip.overround.toFixed(4)})\n`)

log('step 2 · the inning model — convolution stage (all three anchors fit)')
log(` (μ_H, μ_A) = ${f6(conv.muH)} · ${f6(conv.muA)}, big-inning tail q = ${f6(conv.q)}`)
const piH = 1 - (conv.muH / 9) * (1 - conv.q)
log(` per-inning ZIG: home m = μ_H/9 = ${f6(conv.muH / 9)}, π = 1 − m(1−q) = ${f6(piH)}`)
log(` anchor errors: ML ${e1(conv.errMl)} · Total ${e1(conv.errTot)} · RL ${e1(conv.errRl)}\n`)

log('step 3 · endgame rules — the same three anchors re-solved')
log(` (μ_H, μ_A) = ${f6(end.muH)} · ${f6(end.muA)}, q = ${f6(end.q)}`)
log(` skip-9 ${pct(end.grid.pSkip9)} · walk-off ${pct(end.grid.pWalkoff)}` +
` · extras ${pct(end.grid.pExtras)}`)
const woff = walkoffWeights(end.q)
const woffSum = woff.reduce((a, b) => a + b, 0)
const mix = woff.map((w, i) => `${i + 1}: ${Math.round((w / woffSum) * 100)}%`).join(', ')
log(` walk-off margin mix {${mix}} — Geometric(q) overshoot, capped at 4`)
log(` anchor errors: ML ${e1(end.errMl)} · Total ${e1(end.errTot)} · RL ${e1(end.errRl)}`)
const full = baseballPeriod(end, 'full')
log(` settled means: E[home] ${f6(full.meanH)}, E[away] ${f6(full.meanA)}` +
' — below the hypothetical μ’s (skipped bottom-9s and walk-offs remove runs)\n')

log('step 4 · periods — first-N convolutions of the same innings')
const f5 = baseballPeriod(end, 'f5')
const f5Ml = fairHcpDetail(f5.joint, f5.N, 0)
log(` F5: E[home] ${f6(f5.meanH)} · E[away] ${f6(f5.meanA)} (= 5/9 of each μ)`)
log(` F5 moneyline pushes on ties: R = ${f6(f5Ml.Rbar)},` +
` fair home = W/(1−R) = ${f6(f5Ml.pHome)}\n`)

// ── the "Check a port" table ──────────────────────────────────────────────
// The rows quoting 6-dp literals pin this file to the page's DEFAULT-quote
// values; the error/closed-form rows are invariants that hold for ANY quotes.
const sum = end.grid.joint.flat().reduce((s, v) => s + v, 0)
let tieInterior = 0
for (let t = 0; t < end.grid.N; t++) tieInterior = Math.max(tieInterior, end.grid.joint[t][t])
// Closed form for the Inning-1 corner cell: each team's inning pmf has
// P(0) = π/s with π = 1 − m(1−q) (floored at 0.05, tail re-tuned) and the
// k > 12 truncation normalizer s = π + (1−π)(1−q¹²); the i1 grid is the
// product of the two inning pmfs, so P(0, 0) multiplies the teams.
const zigP0 = (mu) => {
const m = mu / 9
let qq = end.q
let pi = 1 - m * (1 - qq)
if (pi < 0.05) {
pi = 0.05
qq = Math.min(0.95, 1 - (1 - pi) / m)
}
const s = pi + (1 - pi) * (1 - Math.pow(qq, 12))
return pi / s
}
const i1 = baseballPeriod(end, 'i1')
const p00 = i1.joint[0][0]
const p00Closed = zigP0(end.muH) * zigP0(end.muA)

const convFit = `${f6(conv.muH)} · ${f6(conv.muA)} · ${f6(conv.q)}`
const endFit = `${f6(end.muH)} · ${f6(end.muA)} · ${f6(end.q)}`
const shares = `${f6(end.grid.pSkip9)} · ${f6(end.grid.pWalkoff)} · ${f6(end.grid.pExtras)}`
const rows = [
['conv (μ_H, μ_A, q)', convFit, convFit === '4.941541 · 4.105836 · 0.453557'],
['endgame (μ_H, μ_A, q)', endFit, endFit === '5.273049 · 4.211021 · 0.580583'],
[
'P(home wins) anchor, both stages',
`err ${e1(Math.max(conv.errMl, end.errMl))}`,
Math.max(conv.errMl, end.errMl) < 1e-6,
],
[
`P(over ${DEMO.totLine.toFixed(2)}) anchor, both stages`,
`err ${e1(Math.max(conv.errTot, end.errTot))}`,
Math.max(conv.errTot, end.errTot) < 1e-6,
],
['skip-9 · walk-off · extras', shares, shares === '0.493357 · 0.093838 · 0.086110'],
[
`Σ P(h, a), all ${(end.grid.N + 1) ** 2} endgame cells`,
`|1 − Σ| = ${e1(Math.abs(1 - sum))}`,
Math.abs(1 - sum) < 1e-9,
],
[
'no tie survives the endgame grid',
`max interior P(t, t) = ${tieInterior.toExponential(1)}`,
tieInterior === 0,
],
[
'Inning-1 P(0, 0) vs ZIG closed form',
`${f6(p00)} · Δ ${e1(Math.abs(p00 - p00Closed))}`,
Math.abs(p00 - p00Closed) < 1e-12,
],
]
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(36)} ${shown}`)
}
if (allPass) log('\nall assertions hold — the port matches the page.')
else {
log('\nsome assertions FAILED — the classic causes: a truncating (not clamping)')
log('convolution, a walk-off margin not renormalized over its reachable caps,')
log('or q fit only once instead of separately at both stages.')
process.exitCode = 1
}
}

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

module.exports = {
BB_MAX,
BASEBALL_INNINGS,
inningPmf,
convCap,
convInnings,
malayToDecimalExact,
ladderMargin,
powerStrip,
stripTwoWayMalay,
isQuarterLine,
componentLines,
fairHcpDetail,
fairTotalDetail,
solveLambdas,
walkoffWeights,
buildBaseballGrid,
priceBaseball,
baseballPeriod,
}