Blockchain networks that use a GHOST-style fork-choice rule (Ethereum’s LMD-GHOST being the most famous example) don’t just pick “the longest chain.” Instead, they walk the block tree from the root and, at every fork, follow the branch that carries the heaviest subtree — the branch that accumulated the most validator support, weighted by stake and by how “fresh” that support is.
That freshness weighting is controlled by tunable parameters. Get them wrong, and the protocol either reacts too slowly to real progress (slow finality) or reacts too fast to network noise (constant reorgs). In this post we build a small, self-contained simulator of a GHOST-style fork-choice rule with two tunable parameters, run a parameter sweep to find the sweet spot, and visualize the tradeoff in 3D.
The math behind the tuning
In classical GHOST, the head of the chain is chosen recursively by walking down the tree and always choosing the heaviest child subtree:
$$
\text{head} = \arg\max_{c ,\in, \text{children}(b)} W(c)
$$
where the subtree weight $W(b)$ is the total weighted support accumulated by block $b$ and all of its descendants.
In “latest message” variants (like LMD-GHOST), each validator’s vote only counts if it is recent. We generalize this with an exponential decay parameter $\beta$ that determines how quickly old votes lose influence:
$$
W_\beta(b, t) = \sum_{i=1}^{N} s_i \cdot e^{-\beta,(t - \tau_i(b))}
$$
where $s_i$ is validator $i$’s stake and $\tau_i(b)$ is the last time validator $i$ voted in support of $b$ (or a descendant of $b$).
A second parameter, the switching threshold $\gamma$, adds hysteresis so the perceived head doesn’t flip-flop on tiny weight differences:

We want to choose $(\beta, \gamma)$ to jointly maximize finality speed and chain stability. That’s captured in a single objective function:
$$
J(\beta,\gamma) = w_1 \cdot \frac{n_{slots} - \overline{T_{fin}}(\beta,\gamma)}{n_{slots}} ;+; w_2 \cdot \frac{1}{1 + \overline{R}(\beta,\gamma)}
$$
where $\overline{T_{fin}}$ is the average number of slots until the majority branch takes a durable lead, and $\overline{R}$ is the average number of head-reorgs during the simulation window. We’ll search for:
$$
(\beta^*, \gamma^*) = \arg\max_{\beta,\gamma} J(\beta,\gamma)
$$
Simulation design
We simulate a network of validators, each with an independent, random network propagation delay (exponentially distributed — a standard model for gossip-network latency). At every slot, each validator votes for whichever branch it currently perceives, with a majority branch (“branch A”) that is objectively correct 60% of the time once information has propagated. Because of delay, early votes are noisy; over time they converge toward the true majority.
The fork-choice rule accumulates these votes into decayed subtree weights $W_\beta^A(t)$ and $W_\beta^B(t)$, and applies the $\gamma$-threshold switching rule above. We track two outcomes per run: how long it took for the head to durably settle on branch A, and how many times the perceived head flipped.
A naive implementation would use three nested Python loops — one over (beta, gamma) grid points, one over Monte Carlo trials, and one over individual validators per slot — which becomes extremely slow (hundreds of thousands of scalar Python operations). Instead, the code below vectorizes the validator and trial dimensions with NumPy, so each grid point only requires a lightweight loop over slots (60 iterations) operating on whole matrices at once. This cuts runtime from several minutes down to well under a minute for a 20×20 parameter grid.
Full source code
1 | import numpy as np |
Grid search finished in 12.7 seconds (400 parameter combinations, 250 Monte Carlo trials each). Optimal beta (vote decay rate) : 0.020 Optimal gamma (switch threshold) : 0.032 Best objective score J(beta, gamma) : 0.642 -> mean finality time : 13.00 slots -> mean reorg count : 1.00
Walking through the code
simulate_fork_choice(beta, gamma, ...) is the core simulator, and it’s fully vectorized over n_trials Monte Carlo runs at once — every array has shape (n_trials, n_validators) or (n_trials,), so a single NumPy operation updates all trials simultaneously.
delaysdraws one propagation delay per validator per trial from an exponential distribution — a standard way to model gossip-network latency, where most messages arrive quickly but a long tail arrives late.- Inside the slot loop,
p_arrivedcomputes, for every validator in every trial, the probability that block information has reached them by slott, using the classic $1 - e^{-t/\text{delay}}$ arrival curve. p_vote_Ablends the “correct” vote probability (p_majority = 0.6) with a 50/50 coin flip for validators who haven’t yet received the information — this is what makes early votes noisy and later votes converge to the truth.weight_Aandweight_Bare the decayed subtree weights from the math section: at every slot, the previous weight is multiplied bydecay = exp(-beta)(older evidence fades) and the new slot’s votes are added.cond_A/cond_Bimplement the $\gamma$-threshold switching rule — the head only flips branches if one side leads by more than a(1+gamma)margin.reorgscounts how many timesheadactually changes value — our stability metric.finalized_atrecords the first slot (after a 20%-of-window warm-up) at which the head is on branch A and clears the threshold — our speed metric.
The grid search loops over a 20×20 grid of (beta, gamma) pairs, running the vectorized simulator (250 trials each) at every point, and combines the two resulting metrics into the objective $J(\beta,\gamma)$ defined earlier, with equal weights w_speed = w_stability = 0.5. The grid point that maximizes objective_grid is our tuned parameter recommendation.
The visualization produces three complementary views of the same result:
- A 3D surface plot of $J(\beta,\gamma)$ — this is the most intuitive way to see the shape of the tradeoff landscape and spot the peak.
- A heatmap of the same surface viewed from directly above, which makes it easier to read off precise coordinates of the optimum.
- A scatter plot of raw finality time vs. reorg count for every grid point, colored by
beta, which shows the speed/stability tradeoff directly without going through the objective function — useful if you want to re-weightw_speedandw_stabilitylater without rerunning the simulation.

Reading the results
Look for these general patterns in the plots above:
- Low $\beta$ (slow decay, long memory): the fork-choice rule remembers votes for a long time, so it takes longer to build a decisive weight advantage — finality is slow, but once the head settles it rarely moves. This region shows low reorg counts but high finality times.
- High $\beta$ (fast decay, short memory): the rule reacts almost entirely to the last few slots of votes, so it can finalize quickly once the network has mostly converged — but if $\gamma$ is too small, transient noise from late-arriving votes can cause the head to flip back and forth, driving reorg counts up.
- $\gamma$ acts as a stabilizer: increasing the switching threshold suppresses reorgs at any given $\beta$, at the cost of slightly delaying how fast the rule commits to the correct branch.
The optimum typically sits in a middle band — moderate-to-high $\beta$ paired with a modest $\gamma$ — where the rule reacts fast enough to converge quickly, but the hysteresis margin is wide enough to filter out noise from network delay. This mirrors real design decisions in production GHOST-based protocols, where “latest message” weighting is deliberately combined with damping mechanisms to avoid oscillation under realistic network conditions.
Beyond grid search
A 20×20 grid is fine for two parameters, but production fork-choice rules often expose more knobs (per-epoch decay schedules, stake-weighted quorum thresholds, slashing-aware discounts, etc.). For higher-dimensional tuning, the same objective_grid computation can be replaced with scipy.optimize.differential_evolution or Bayesian optimization (e.g., scikit-optimize), calling simulate_fork_choice as a black-box objective instead of exhaustively scanning the grid. The vectorized simulator above is already fast enough to serve as the inner loop for either approach.

































