Skip to main content

Odds Generation — run · inning (cricket)

Pick the format (T20/ODI), say who bats first, and enter the batting-first innings total in Malay plus the Match Winner in decimal — margins still embedded. A limited-overs innings is overs of six balls scoring {0, 1, 2, 3, 4, 6}, so the model is compound from the ball up (ADR-029):

  1. strips the margins (Power method) to fair targets;
  2. builds ball → over → innings under a phase curve (powerplay / middle / death — cricket's quarter weights) and back-solves the two innings abilities: the innings line fixes μ₁, the Match Winner fixes μ₂;
  3. independent overs come out far too thin (σ ≈ 15 vs ≈ 28 real), so a day-form mixture G = 1 + τZ scales every over and τ is solved to the dispersion prior — collapses and flat tracks correlate overs;
  4. the chase truncates: the second innings stops at the target (won chases pile up just past it, failed ones fall short), scores level is the tie — the 1X2's third leg, settled by the super over in T20 — so 2nd-innings Over/Unders price off the settled distribution, not the ability;
  5. when the book also quotes the 2nd-innings total (it settles on that truncated chase), stage Inn-2 τ re-solves τ against it — the σ prior becomes a per-match reading. The response direction depends on where the line sits (bulk lines fall with τ, deep tails rise), so the fit probes the bracket ends first; an unreachable target keeps the nearer endpoint and surfaces Δ over₂ — a cross-anchor risk signal;
  6. group runs (the powerplay) and single-over markets read the same phase curve and mixture, consistent with the innings by construction.

Wickets and resource decay are folded into the phase curve and mixture rather than modelled per ball — the honest limit of a quotes-anchored page; ball-level state belongs to the ADR-029 phase-2 engine.

Innings 1 total (Malay) — Home bats
Match Winner (Decimal)
Innings 2 total (Malay) — pins τ
Shape prior
Phase shares % (PP · middle · death)

/ steps a field, Shift steps bigger; the Malay pair steps its partner the opposite way. Quotes: the batting-first innings total + the match winner; when the book also quotes the 2nd-innings total (it settles on the truncated chase), stage Inn-2 τ re-solves τ against it and the σ prior becomes a fallback — clear the line to see the prior-driven pipeline.

Valid inputs: Malay in [−1, +1] non-zero, decimals above 1, each book carrying margin, σ above ~8. The solve mixes 7 form nodes over 20 overs — allow it a second or two.

μ₁ 166.52μ₂ 162.47τ 0.1369σ₁ 27.98tie 1.0%τ src Inn-2 quoteround-trip Δp 4.0e-7 / 4.5e-7 / 2.3e-7Overs → Form τ → Chase → Inn-2 τ
Score grid
Innings runs — P(runs ∈ bin) ×100 · bins of 10
μ₁ 166.5μ₂ 162.5τ 0.137σ₁ 28.0tie 1.0%
90100110120130140150160170180190200210220230240250
Innings 1 (Home)0.21.02.75.48.411.112.913.512.811.08.55.93.61.90.90.30.1
Innings 2 (Away) — ability0.52.15.610.414.917.216.713.69.45.52.61.00.30.10.00.00.0
independent overs — compare the width against the Form stage
Markets
1X2
the third leg is scores-level — a T20 tie heads to the super over
OutcomeOdds
Home1.80
Draw (tie)49
Away2.10
Match Winner
two-way: the tie settles on the super over (≈ a coin), so each side carries half the tie mass
OutcomeOdds
Home1.81
Away2.11
1 · Strip the bookmaker margins (Power method)
Innings 1 total 165.5 → 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
Match Winner → P(chasing side wins)
HomeAway
Decimal quote1.802.10
Decimal dd1.8000002.100000
Fair p=q1/xp = q^{1/x}0.5402780.459722
Fair decimal 1/p1/p1.8508982.175229
0.5555561/x+0.4761901/x=1    x=0.9547090.555556^{1/x} + 0.476190^{1/x} = 1 \;\Rightarrow\; x = 0.954709

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

2 · Ball → over → innings, and why independent overs are not enough

A ball scores {0, 1, 2, 3, 4, 6}; six balls convolve into an over, and the innings convolves 20 overs under a phase curve — powerplay / middle / death get 29/41/30% of the runs across 6/9/5 overs (cricket's quarter weights). But independent overs give σ ≈ 18.8 — real innings run σ ≈ 28.0 because collapses and flat tracks correlate every over. The fix is a day-form mixture: every over's mean scales by G=1+τZG = 1 + \tau Z, and τ is solved so the innings dispersion hits the prior:

Stageμ₁μ₂ττ srcσ₁tieΔ overΔ MLΔ over₂
Overs166.0163.40.000zero18.81.5%1.9e-74.3e-72.8e-1
Form τ166.5162.50.137prior28.01.0%4.9e-71.9e-71.7e-1
Chase166.5162.50.137prior28.01.0%4.9e-71.9e-72.7e-4
Inn-2 τ166.5162.50.137inn228.01.0%4.0e-74.5e-72.3e-7

Each stage re-solves both fair targets: the innings line pins μ₁ (size) and the Match Winner pins μ₂ (the chasing ability) — with ties heading to the super over, the ML read is P(chase wins)+12P(tie)P(\text{chase wins}) + \tfrac{1}{2}P(\text{tie}). With a 2nd-innings quote the last stage bisects τ against Δ over₂ (direction probed at the bracket ends — bulk lines fall with τ, deep tail lines rise); watch Δ over₂ collapse from Chase to Inn-2 τ while Δ over and Δ ML stay pinned.

3 · The chase — the second innings truncates at the target

Whoever bats second stops the moment the target falls: with a first-innings score r1r_1 and chasing ability A2A_2,

r2={A2A2r1    (failed chase — or the tie at A2=r1)r1+1+UA2r1+1    (won — U is the winning hit’s overshoot)r_2 = \begin{cases} A_2 & A_2 \le r_1 \;\; \text{(failed chase — or the tie at } A_2 = r_1\text{)} \\ r_1 + 1 + U & A_2 \ge r_1 + 1 \;\; \text{(won — } U \text{ is the winning hit's overshoot)} \end{cases}

which is why the settled second-innings pmf (the Chase stage in the panel) piles up just past the target and never shows the ability tail — and why 2nd-innings Over/Unders must price off the settled distribution, not the ability. Who wins is truncation-free, so μ₂ matches the Form stage. The tie mass P = 1.0% is the 1X2's third leg.

4 · Read “” · margins · notes

Fair groups are re-margined exactly as in goal-regular (Power ladder two-way, Shin multi-way).

targets  tO = powerStrip(innings pair)     tW = powerStrip(match-winner pair)

stage ∈ {Overs (τ=0), Form τ, Chase}:          # each re-solves the same targets
  sweep: bisect μ₁ until P(runs₁ > line) = tO
         bisect τ  until sd(innings₁)   = σ prior          # Form/Chase stages
  bisect μ₂ until P(chase wins) + ½·P(tie) = tW

innings: ball pmf(mean) → over (6-fold conv) → phase curve over 20 overs
         → mix over day-form G = 1 + τZ (7 nodes)
chase:   r₂ = A₂ if short; = target + overshoot if won; level = tie (super over)

Wickets/resource decay are folded into the phase curve and mixture rather than modelled per ball — the honest limit of a quotes-anchored page (ADR-029 phase 2 owns ball-level state). Engine + invariants: docs/src/lib/odds/cricket.ts, __tests__/cricket.test.ts.

The pipeline in one file

Everything the breakdown above does — steps 1–6, 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 cricket-run-inning.js

It prices this page's default quotes (T20 — the innings total, the Match Winner and the 2nd-innings total) through the same Overs → Form τ → Chase → Inn-2 τ stage pipeline, reads the powerplay and single-over segments off the fitted curve, and prints the 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. The only piece not ported is the market-sheet builder (the Over/Under ladders and re-margining) — every pmf those ladders read is in the file. Edit the DEMO block at the bottom to price any other quote set. Download cricket-run-inning.js, or read it here:

cricket-run-inning.js — the complete listing
/* ═══════════════════════════════════════════════════════════════════════════
* cricket-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–6:
*
* step 1 · Strip the bookmaker margins Power method (both books two-way)
* step 2 · Ball → over → innings back-solve μ₁ (innings line) · μ₂ (Match Winner)
* step 3 · Day-form mixture solve τ so the innings σ hits the prior
* step 4 · The chase truncates settled 2nd-innings pmf + the tie leg
* step 5 · Inn-2 τ re-solve τ against the quoted 2nd-innings total
* step 6 · Segments group (powerplay) and single-over pmfs
*
* Source of truth: docs/src/lib/odds/cricket.ts, plus the shared margin strips
* from core.ts and normInv from basket-core.ts (the page
* /odds-generation/cricket-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 (the Over/Under ladders and
* re-margining) — every pmf those ladders read is built here.
*
* Run it:
*
* node cricket-run-inning.js
*
* It prices the page's DEFAULT quotes — T20, Innings 1 total 165.5 @
* +0.90/+0.92, Match Winner 1.80/2.10, 2nd-innings total 149.5 @ +0.95/+0.89 —
* and prints the stage pipeline (Overs → Form τ → Chase → Inn-2 τ) followed by
* the "check a port" table with PASS/FAIL per assertion. Edit DEMO at the
* bottom to price other quotes (the four literal-expectation rows apply to the
* default quotes only; the live page recomputes for any quotes you enter).
*
* The model in three sentences. A limited-overs innings is compound from the
* ball up — each ball scores {0, 1, 2, 3, 4, 6}, six balls convolve into an
* over, and the overs convolve into the innings under a powerplay / middle /
* death phase curve (cricket's quarter weights). Independent overs come out
* far too thin (σ ≈ 15 vs ≈ 28 real — collapses and flat tracks correlate
* overs), so one day-form factor G = 1 + τZ scales every over's mean and τ is
* solved to a dispersion target: a CALIBRATED format prior by default, or the
* quoted 2nd-innings total when the book supplies one. The second innings then
* STOPS at the target — won chases pile up just past it, failed ones fall
* short, scores level is the tie (the 1X2's third leg, a super over in T20) —
* so 2nd-innings totals settle on the truncated distribution, never the
* ability one.
*
* Honesty note on constants: unlike the market-only pages, this page keeps
* calibrated shape constants — the per-ball pmf, the winning-hit overshoot,
* the phase shares and the σ priors below are fitted to observed limited-overs
* scoring, not derived from the quotes. The quotes pin μ₁, μ₂ and (with a
* 2nd-innings total) τ; the calibrated constants supply everything else.
* ═══════════════════════════════════════════════════════════════════════════
*/

'use strict'

/* ─────────────────────────────────────────────────────────────────────────
* Section 0 — normal primitives
*
* The day-form mixture needs Gaussian quantiles: the factor G = 1 + τZ is
* discretized over 7 mid-probability nodes Φ⁻¹((k + ½)/7). The engine's Φ is
* Abramowitz–Stegun 26.2.17 and its inverse is a plain 80-step bisection on
* that Φ — port BOTH as-is; swapping in a "better" normInv moves every mixture
* node and with it every number downstream.
* ───────────────────────────────────────────────────────────────────────── */

const SQRT_2PI = Math.sqrt(2 * Math.PI)

/** Standard normal CDF Φ(z) — Abramowitz–Stegun 26.2.17, |err| < 7.5e-8.
* Evaluated on the positive side and mirrored so both tails keep precision. */
function normCdf(z) {
if (z < 0) return 1 - normCdf(-z)
const t = 1 / (1 + 0.2316419 * z)
const poly =
t * (0.31938153 + t * (-0.356563782 + t * (1.781477937 + t * (-1.821255978 + t * 1.330274429))))
return 1 - (Math.exp(-0.5 * z * z) / SQRT_2PI) * poly
}

/** Φ⁻¹(p) by bisection on normCdf (monotone); p clamped away from {0, 1}. */
function normInv(p) {
const q = Math.min(1 - 1e-12, Math.max(1e-12, p))
let lo = -12
let hi = 12
for (let i = 0; i < 80; i++) {
const mid = (lo + hi) / 2
if (normCdf(mid) < q) lo = mid
else hi = mid
}
return (lo + hi) / 2
}

/* ─────────────────────────────────────────────────────────────────────────
* 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. Both books on this page are TWO-WAY (the innings
* totals in Malay, the Match Winner in decimal), so a single Power strip
* serves them all — no Shin needed here. The stripped values become the
* solver's targets: P(innings-1 over) pins μ₁, P(chasing side wins) pins μ₂,
* and P(innings-2 over) — when quoted — pins τ.
* ───────────────────────────────────────────────────────────────────────── */

/** 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; it just rides along in the strip result. */
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 — Over for
* both totals pairs on this page. */
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 — steps 2–3 · ball → over → innings
*
* The micro-model. A ball scores {0, 1, 2, 3, 4, 6} from a CALIBRATED base
* pmf; an "aggression" scalar s moves boundary mass (4s and 6s) against dots
* to hit any target per-ball mean, so the ball pmf's mean equals its target
* EXACTLY by construction. Six balls convolve into an over, overs convolve
* into the innings under the phase curve (powerplay / middle / death get
* different shares of the runs — cricket's quarter weights), and one day-form
* factor G = 1 + τZ scales EVERY over's mean together, discretized over 7
* Gaussian nodes. At τ = 0 the overs are independent — the too-thin baseline
* the Form stage exists to fix.
* ───────────────────────────────────────────────────────────────────────── */

/** Format specs — calibrated. `shares` is the default split of the innings
* runs across the phases (Σ = 1); `rmax` caps the pmf support; `sigDefault`
* is the innings-dispersion prior used when no better σ is supplied. */
const CRICKET_FORMATS = {
t20: { overs: 20, phases: [6, 9, 5], shares: [0.29, 0.41, 0.3], rmax: 360, sigDefault: 28 },
odi: { overs: 50, phases: [10, 30, 10], shares: [0.21, 0.55, 0.24], rmax: 560, sigDefault: 46 },
}

// Base per-ball pmf (runs 0,1,2,3,4,6), mean ≈ 1.366 (≈ 8.2 an over). The
// aggression scalar s moves boundary mass against dots to hit a target mean.
const BALL_BASE = [
[0, 0.38],
[1, 0.36],
[2, 0.075],
[3, 0.008],
[4, 0.115],
[6, 0.062],
]
const BALL_MEAN = BALL_BASE.reduce((s, [r, p]) => s + r * p, 0)
const BOUNDARY_MEAN = 4 * 0.115 + 6 * 0.062 // the mass the scalar moves

/** Per-ball pmf at a target mean: scale the 4/6 mass by s, park the freed
* (or borrowed) mass on the dot ball. null when the target is so extreme the
* boundary mass goes negative (s ≤ 0.05) or the dots run out (p₀ ≤ 0.01). */
function ballPmf(targetMean) {
const s = 1 + (targetMean - BALL_MEAN) / BOUNDARY_MEAN
if (!(s > 0.05)) return null
const p = new Array(7).fill(0)
for (const [r, pr] of BALL_BASE) p[r] = pr
p[4] *= s
p[6] *= s
p[0] += (0.115 + 0.062) * (1 - s)
if (!(p[0] > 0.01)) return null
return p
}

/** Convolution with an upper cap: mass that would land past `cap` folds onto
* the cap cell, so no probability is ever truncated away — the innings pmf
* stays mass-complete (Σ = 1) no matter how many overs pile up. */
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 by binary exponentiation. */
function convPow(pmf, n, cap) {
let result = [1]
let base = pmf
let k = n
while (k > 0) {
if (k & 1) result = convCap(result, base, cap)
base = convCap(base, base, cap)
k >>= 1
}
return result
}

/** One over: the ball pmf at mean/6, convolved six-fold (cap 40 — an over
* maxes at 36, so nothing folds). */
function overPmf(overMean) {
const b = ballPmf(overMean / 6)
return b ? convPow(b, 6, 40) : null
}

/** Phase-weighted, day-form-mixed innings ability (untruncated). Each of the
* 7 nodes scales every over's mean by g = max(0.25, 1 + τ·Φ⁻¹((k+½)/7)),
* builds the innings by convolving the three phases, and the nodes average
* with equal weight. τ = 0 collapses every node to g = 1 — independent
* overs. Returns { pmf, mean, sd }; null when a phase mean is unreachable. */
function inningsAbility(mu, tau, spec, shares) {
const NODES = 7
const acc = new Array(spec.rmax + 1).fill(0)
for (let k = 0; k < NODES; k++) {
const g = Math.max(0.25, 1 + tau * normInv((k + 0.5) / NODES))
let pmf = [1]
for (let ph = 0; ph < 3; ph++) {
const overs = spec.phases[ph]
const phaseMean = ((mu * shares[ph]) / overs) * g
const o = overPmf(phaseMean)
if (!o) return null
pmf = convCap(pmf, convPow(o, overs, spec.rmax), spec.rmax)
}
for (let r = 0; r < pmf.length; r++) acc[r] += pmf[r] / NODES
}
let m = 0
let m2 = 0
for (let r = 0; r <= spec.rmax; r++) {
m += r * acc[r]
m2 += r * r * acc[r]
}
return { pmf: acc, mean: m, sd: Math.sqrt(Math.max(0, m2 - m * m)) }
}

/* ─────────────────────────────────────────────────────────────────────────
* Section 3 — step 4 · the chase
*
* The sport's structural quirk. Team 2 stops the moment the target falls:
* settled runs truncate to r₁ + 1 + the winning hit's overshoot on a win,
* stay at the ability score on a failed chase, and land exactly level on the
* tie. Win probability itself is truncation-free — who wins doesn't depend on
* when play stops — which is why μ₂ is identical across the Form and Chase
* stages and only the SETTLED pmf changes.
* ───────────────────────────────────────────────────────────────────────── */

/** Winning-hit overshoot beyond the target (needing 1, the hit distribution
* given ≥ 1 run): 0 = scampered single, 3/5 = boundary through the line.
* Calibrated, like the ball pmf. */
const OVERSHOOT = [
[0, 0.581],
[1, 0.121],
[2, 0.013],
[3, 0.185],
[5, 0.1],
]

/** The chase against a first-innings pmf p1 and a chasing ABILITY pmf a2.
* Returns { pWin2, pTie, pWin1, settled } with `settled` the second-innings
* runs pmf as the market SETTLES it (truncated at target + overshoot). */
function chase(p1, a2, rmax) {
// survival S2[t] = P(A2 ≥ t)
const S2 = new Array(rmax + 2).fill(0)
for (let t = rmax; t >= 0; t--) S2[t] = S2[t + 1] + (a2[t] ?? 0)
let pWin2 = 0
let pTie = 0
const settled = new Array(rmax + 1).fill(0)
for (let r1 = 0; r1 <= rmax; r1++) {
const p = p1[r1] ?? 0
if (!(p > 0)) continue
const win = S2[r1 + 1]
const tie = a2[r1] ?? 0
pWin2 += p * win
pTie += p * tie
settled[r1] += p * tie // scores level — the tie
for (let r2 = 0; r2 < r1 && r2 < a2.length; r2++) settled[r2] += p * (a2[r2] ?? 0) // failed chase
for (const [u, w] of OVERSHOOT) settled[Math.min(r1 + 1 + u, rmax)] += p * win * w // stops at target
}
return { pWin2, pTie, pWin1: Math.max(0, 1 - pWin2 - pTie), settled }
}

/* ─────────────────────────────────────────────────────────────────────────
* Section 4 — the stage pipeline (steps 1–5 solved end to end)
*
* Stage 1 'overs': τ = 0 (independent overs — too thin); stage 2 'form':
* τ solved to the σ prior; stage 3 'chase': second-innings settlement
* truncation + the tie leg. Stage 4 'inn2' (with a 2nd-innings total quote):
* τ re-solved against the fair settled-runs over — the prior becomes a
* per-match reading. Each stage re-solves (μ₁, μ₂) against the same fair
* targets: the innings-1 total line and the Match Winner ML. All the 1-D
* solves are plain bisections; the ML target is the CHASING side's fair win
* probability, and ties split (a T20 super over is ≈ a coin), so the ML read
* is pWin2 + ½·pTie.
*
* input = {
* format, // 't20' | 'odi'
* innLine, innMalay: [o, u], // batting-first innings total + Malay pair
* mlDecimal: [bat, chase], // Match Winner decimal pair
* sigInn, // dispersion prior σ (format default if NaN)
* shares: [pp, mid, death], // phase run shares (%) — normalized on use
* inn2Line?, inn2Malay?, // optional 2nd-innings total — pins τ
* }
* ───────────────────────────────────────────────────────────────────────── */

function priceCricket(input) {
const fail = (reason) => ({
ok: false,
reason,
innStrip: null,
mlStrip: null,
inn2Strip: null,
spec: CRICKET_FORMATS[input.format],
shares: input.shares,
stages: [],
final: null,
})
const spec = CRICKET_FORMATS[input.format]

// ── step 1 · strip the margins ──────────────────────────────────────────
const innStrip = stripTwoWayMalay(input.innMalay[0], input.innMalay[1])
if (!innStrip) return fail('INNINGS pair invalid (Malay ∈ [−1,+1] non-zero, overround Q > 1)')
let inn2Strip = null
if (input.inn2Line != null && input.inn2Malay) {
if (!(input.inn2Line > 10 && input.inn2Line < spec.rmax))
return fail(`2nd-innings line must be in (10, ${spec.rmax})`)
inn2Strip = stripTwoWayMalay(input.inn2Malay[0], input.inn2Malay[1])
if (!inn2Strip)
return fail('2ND-INNINGS pair invalid (Malay ∈ [−1,+1] non-zero, overround Q > 1)')
}
const [d1, d2] = input.mlDecimal
if (!(d1 > 1 && d2 > 1)) return fail('Match Winner decimal invalid (each price > 1)')
const q1 = 1 / d1
const q2 = 1 / d2
if (q1 + q2 <= 1) return fail('Match Winner book invalid (overround Q > 1)')
const mlPow = powerStrip(q1, q2)
if (!mlPow) return fail('Match Winner strip failed')
const mlStrip = {
decimal1: d1,
decimal2: d2,
priced1: q1,
priced2: q2,
overround: q1 + q2,
exponent: mlPow.exponent,
p1: mlPow.p1,
p2: mlPow.p2,
}
const shareSum = input.shares[0] + input.shares[1] + input.shares[2]
if (!(shareSum > 0)) return fail('phase shares must be positive')
const shares = [
input.shares[0] / shareSum,
input.shares[1] / shareSum,
input.shares[2] / shareSum,
]
const sigTarget =
Number.isFinite(input.sigInn) && input.sigInn > 4 ? input.sigInn : spec.sigDefault

const muLo = spec.overs * 3
const muHi = spec.overs * 12.5

// Fair P(over) at an integer-runs line: strictly past the line is Over, an
// exact landing is a push (removed by conditioning), the rest is Under.
const pOverLine = (pmf, line) => {
let over = 0
let push = 0
for (let r = 0; r < pmf.length; r++) {
if (r > line + 1e-9) over += pmf[r]
else if (Math.abs(r - line) < 1e-9) push += pmf[r]
}
return 1 - push <= 1e-12 ? null : over / (1 - push)
}
const pOverAt = (m) => pOverLine(m.pmf, input.innLine)
const build = (mu, tau) => inningsAbility(mu, tau, spec, shares)

// step 2 (size) — bisect μ₁ over [3, 12.5] runs-per-over × overs until the
// innings pmf's fair P(over the line) hits the stripped target. More runs ⇒
// more overs land past the line: monotone, a clean bisection knob.
const solveMu1 = (tau) => {
let lo = muLo
let hi = muHi
let best = null
let mid = (lo + hi) / 2
for (let i = 0; i < 22; i++) {
mid = (lo + hi) / 2
const m = build(mid, tau)
if (!m) return null
best = m
const p = pOverAt(m)
if (p == null) return null
if (Math.abs(p - innStrip.pHome) < 1e-9) break
if (p < innStrip.pHome) lo = mid
else hi = mid
}
return best ? { mu1: mid, inn1: best } : null
}

// step 3 (dispersion) — bisect τ ∈ [0, 0.45] until the achieved innings sd
// hits the σ target. Mixing only widens, so sd is increasing in τ.
const solveTau = (mu1) => {
let lo = 0
let hi = 0.45
for (let i = 0; i < 12; i++) {
const mid = (lo + hi) / 2
const m = build(mu1, mid)
if (!m) {
hi = mid
continue
}
if (m.sd < sigTarget) lo = mid
else hi = mid
}
return (lo + hi) / 2
}

// Match Winner target is the CHASING side's fair win probability; ties split
// (a T20 super over is ≈ a coin) so the ML read is pWin2 + ½·pTie.
const solveMu2 = (tau, inn1) => {
let lo = muLo
let hi = muHi
let best = null
let mid = (lo + hi) / 2
for (let i = 0; i < 22; i++) {
mid = (lo + hi) / 2
const m = build(mid, tau)
if (!m) return null
const c = chase(inn1.pmf, m.pmf, spec.rmax)
best = { inn2: m, c }
const p = c.pWin2 + 0.5 * c.pTie
if (Math.abs(p - mlStrip.p2) < 1e-9) break
if (p < mlStrip.p2) lo = mid
else hi = mid
}
return best ? { mu2: mid, inn2: best.inn2, c: best.c } : null
}

// fair P(settled 2nd-innings over) at a trial τ, (μ₁, μ₂) re-solved — the
// 'inn2' stage's read. Truncation is always on: the quote settles on it.
const readOver2 = (tau) => {
if (!inn2Strip || input.inn2Line == null) return null
const s1 = solveMu1(tau)
if (!s1) return null
const s2 = solveMu2(tau, s1.inn1)
if (!s2) return null
return pOverLine(s2.c.settled, input.inn2Line)
}

// step 5 · τ from the 2nd-innings quote. The response direction depends on
// where the line sits (bulk lines fall with τ, deep upper-tail lines rise),
// so probe both bracket ends first. Unreachable target ⇒ keep the nearer
// endpoint; the stage's errOver2 surfaces the residual (alertable).
const solveTauInn2 = () => {
let hi = 0.45
let vHi = null
for (; hi >= 0.1; hi -= 0.05) {
vHi = readOver2(hi)
if (vHi != null) break
}
const vLo = readOver2(0)
if (vLo == null || vHi == null) return 'inn2 stage failed (no feasible τ bracket)'
const target = inn2Strip.pHome
const increasing = vHi > vLo
if (target <= Math.min(vLo, vHi) + 1e-12 || target >= Math.max(vLo, vHi) - 1e-12)
return Math.abs(target - vLo) <= Math.abs(target - vHi) ? 0 : hi
let lo = 0
for (let i = 0; i < 16; i++) {
const mid = (lo + hi) / 2
const v = readOver2(mid)
if (v == null) {
hi = mid
continue
}
if (Math.abs(v - target) < 1e-9) return mid
if ((v < target) === increasing) lo = mid
else hi = mid
}
return (lo + hi) / 2
}

// ── one stage: pick τ, then re-solve (μ₁, μ₂) against the same targets ──
const runStage = (key) => {
let t
let tauSource
if (key === 'overs') {
t = 0
tauSource = 'zero'
} else if (key === 'inn2') {
const solved = solveTauInn2()
if (typeof solved === 'string') return solved
t = solved
tauSource = 'inn2'
} else {
// μ₁ and τ interact (mixing widens the pmf around a different μ) — two
// alternating sweeps settle both against the same targets
t = 0.2
let seed = solveMu1(t)
if (!seed) return 'innings-1 solve failed'
for (let sweep = 0; sweep < 2; sweep++) {
t = solveTau(seed.mu1)
const again = solveMu1(t)
if (!again) return 'innings-1 solve failed'
seed = again
}
tauSource = 'prior'
}
const s1 = solveMu1(t)
if (!s1) return 'innings-1 solve failed'
const s2 = solveMu2(t, s1.inn1)
if (!s2) return 'innings-2 solve failed'
// 'chase'/'inn2' hand markets the SETTLED pmf; earlier stages the ability.
const settled2 = key === 'chase' || key === 'inn2' ? s2.c.settled : s2.inn2.pmf
const pOver = pOverAt(s1.inn1)
const pOver2 =
inn2Strip && input.inn2Line != null ? pOverLine(settled2, input.inn2Line) : null
return {
key,
mu1: s1.mu1,
mu2: s2.mu2,
tau: t,
tauSource,
sd1: s1.inn1.sd,
pWin1: s2.c.pWin1,
pWin2: s2.c.pWin2,
pTie: s2.c.pTie,
inn1: s1.inn1,
inn2: s2.inn2,
settled2,
errOver: pOver == null ? NaN : Math.abs(pOver - innStrip.pHome),
errMl: Math.abs(s2.c.pWin2 + 0.5 * s2.c.pTie - mlStrip.p2),
errOver2: pOver2 == null || !inn2Strip ? NaN : Math.abs(pOver2 - inn2Strip.pHome),
}
}

const stages = []
const keys = inn2Strip ? ['overs', 'form', 'chase', 'inn2'] : ['overs', 'form', 'chase']
for (const key of keys) {
const s = runStage(key)
if (typeof s === 'string') return fail(s)
stages.push(s)
}
return {
ok: true,
innStrip,
mlStrip,
inn2Strip,
spec,
shares,
stages,
final: stages[stages.length - 1],
}
}

/* ─────────────────────────────────────────────────────────────────────────
* Section 5 — step 6 · sub-innings segments
*
* The group (e.g. the powerplay) and a single over, consistent with the
* innings BY CONSTRUCTION: the same phase curve maps each over to its phase
* mean and the same 7-node day-form mixture scales it — no separate
* calibration, so the segment markets can never drift from the innings they
* sit inside.
* ───────────────────────────────────────────────────────────────────────── */

function segmentPmf(mu, tau, spec, shares, overFrom, overTo) {
const NODES = 7
const cap = spec.rmax
const acc = new Array(cap + 1).fill(0)
const phaseOfOver = (o) =>
o <= spec.phases[0] ? 0 : o <= spec.phases[0] + spec.phases[1] ? 1 : 2
for (let k = 0; k < NODES; k++) {
const g = Math.max(0.25, 1 + tau * normInv((k + 0.5) / NODES))
let pmf = [1]
for (let o = overFrom; o <= overTo; o++) {
const ph = phaseOfOver(o)
const phaseMean = ((mu * shares[ph]) / spec.phases[ph]) * g
const op = overPmf(phaseMean * 1)
if (!op) return null
pmf = convCap(pmf, op, cap)
}
for (let r = 0; r < pmf.length; r++) acc[r] += pmf[r] / NODES
}
let m = 0
let m2 = 0
for (let r = 0; r <= cap; r++) {
m += r * acc[r]
m2 += r * r * acc[r]
}
return { pmf: acc, mean: m, sd: Math.sqrt(Math.max(0, m2 - m * m)) }
}

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

const DEMO = {
format: 't20', // 20 overs · phases 6/9/5 · runs cap 360
innLine: 165.5, // Innings 1 total 165.5 @ +0.90 / +0.92 (Malay)
innMalay: [0.9, 0.92],
mlDecimal: [1.8, 2.1], // Match Winner (decimal) — [bats first, chases]
sigInn: 28, // σ prior — the fallback once the 2nd-innings quote pins τ
shares: [29, 41, 30], // phase run shares % (powerplay · middle · death)
inn2Line: 149.5, // 2nd-innings total 149.5 @ +0.95 / +0.89 — enables Inn-2 τ
inn2Malay: [0.95, 0.89],
}

function main() {
const f2 = (x) => x.toFixed(2)
const f4 = (x) => x.toFixed(4)
const f6 = (x) => x.toFixed(6)
const f8 = (x) => x.toFixed(8)
const e1 = (x) => (Number.isFinite(x) ? x.toExponential(1) : '—')
const log = console.log

const r = priceCricket(DEMO) // the full pipeline (all four stages)
if (!r.ok) {
console.error('solve failed:', r.reason)
process.exit(1)
}

log('cricket-run-inning.js — pricing the page defaults (T20 · 20 overs · phase curve 6/9/5)\n')

log('step 1 · strip the margins (Power, all three books two-way)')
const is = r.innStrip
log(` Innings 1 total ${DEMO.innLine.toFixed(1)} @ +0.90/+0.92 → P(over) = ${f8(is.pHome)}`)
log(` (Power x = ${f6(is.exponent)}, overround ${is.overround.toFixed(4)})`)
const ms = r.mlStrip
log(` Match Winner 1.80/2.10 → P(chase wins) = ${f8(ms.p2)} (ties split: read = P₂ + ½·tie)`)
log(` (Power x = ${f6(ms.exponent)}, overround ${ms.overround.toFixed(4)})`)
const i2 = r.inn2Strip
log(` Innings 2 total ${DEMO.inn2Line.toFixed(1)} @ +0.95/+0.89 → P(over₂) = ${f8(i2.pHome)}`)
log(` (Power x = ${f6(i2.exponent)}, overround ${i2.overround.toFixed(4)})\n`)

log('stages · Overs → Form τ → Chase → Inn-2 τ (each re-solves μ₁ and μ₂)')
log(' stage μ₁ μ₂ τ σ₁ tie Δ over Δ ML Δ over₂')
const labels = { overs: 'Overs', form: 'Form τ', chase: 'Chase', inn2: 'Inn-2 τ' }
for (const s of r.stages) {
log(
` ${labels[s.key].padEnd(8)} ${f2(s.mu1).padStart(6)} ${f2(s.mu2).padStart(6)} ` +
`${f4(s.tau)} ${f2(s.sd1).padStart(5)} ${f4(s.pTie)} ` +
`${e1(s.errOver).padStart(8)} ${e1(s.errMl).padStart(8)} ${e1(s.errOver2).padStart(8)}`,
)
}
const overs = r.stages[0]
const form = r.stages[1]
const final = r.final
log(` — independent overs give σ₁ ≈ ${f2(overs.sd1)}: far too thin; the Form stage`)
log(` solves τ to the σ = ${DEMO.sigInn} prior, Inn-2 τ re-solves it against the quote`)
log(`${f4(form.tau)}${f4(final.tau)}; watch Δ over₂ collapse while the anchors hold)`)
log(` — Chase keeps Form's (μ₁, μ₂, τ): who WINS is truncation-free — only the`)
log(` settled 2nd-innings pmf changes; scores level = tie = ${f4(final.pTie)}\n`)

const group = segmentPmf(final.mu1, final.tau, r.spec, r.shares, 1, r.spec.phases[0])
const over1 = segmentPmf(final.mu1, final.tau, r.spec, r.shares, 1, 1)
log('step 6 · segments off the same curve and mixture')
log(` powerplay (overs 1–${r.spec.phases[0]}) mean ${f2(group.mean)} · over-1 mean ${f2(over1.mean)}\n`)

// ── the "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.
const mass1 = final.inn1.pmf.reduce((s, v) => s + v, 0)
const mass2 = final.settled2.reduce((s, v) => s + v, 0)
// Closed form: ballPmf hits its target mean exactly by construction, and a
// 6-fold convolution multiplies the mean by 6 — so overPmf(x) has mean x
// (the cap at 40 never folds: an over maxes at 36).
const oTest = overPmf(8.5)
const oMean = oTest.reduce((s, v, i) => s + i * v, 0)
const chaseEqForm =
r.stages[2].mu1 === form.mu1 && r.stages[2].mu2 === form.mu2 && r.stages[2].tau === form.tau

const trip = `${f6(final.mu1)} · ${f6(final.mu2)} · ${f6(final.tau)}`
const legs = `${f6(final.pWin1)} · ${f6(final.pTie)} · ${f6(final.pWin2)}`
const sds = `${f6(final.sd1)} vs ${f6(overs.sd1)}`
const rows = [
['final (μ₁, μ₂, τ)', trip, trip === '166.517179 · 162.468035 · 0.136855'],
['match legs (P₁ · tie · P₂)', legs, legs === '0.535309 · 0.009939 · 0.454751'],
['σ₁ final vs Overs stage', sds, sds === '27.976837 vs 18.812835'],
[
`powerplay mean (overs 1–${r.spec.phases[0]})`,
f6(group.mean),
f6(group.mean) === '48.289982',
],
['Σ innings-1 pmf', `|1 − Σ| = ${e1(Math.abs(1 - mass1))}`, Math.abs(1 - mass1) < 1e-9],
['Σ settled inn-2 pmf', `|1 − Σ| = ${e1(Math.abs(1 - mass2))}`, Math.abs(1 - mass2) < 1e-9],
[
'overPmf(8.5) mean closed form',
`mean = ${f6(oMean)}`,
Math.abs(oMean - 8.5) < 1e-9,
],
['Chase ≡ Form on (μ₁, μ₂, τ)', chaseEqForm ? 'bitwise equal' : 'DIFFERS', chaseEqForm],
]
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(32)} ${shown}`)
}
if (allPass) log('\nall assertions hold — the port matches the page.')
else {
log('\nsome assertions FAILED — the classic causes: truncating a convolution cap')
log('instead of folding it, a different normInv, or pricing the 2nd innings off')
log('the ability pmf instead of the settled one.')
process.exitCode = 1
}
}

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

module.exports = {
normCdf,
normInv,
malayToDecimalExact,
ladderMargin,
powerStrip,
stripTwoWayMalay,
CRICKET_FORMATS,
ballPmf,
convCap,
convPow,
overPmf,
inningsAbility,
chase,
priceCricket,
segmentPmf,
}