Skip to Content

Bayesian Height Prediction

What goes wrong with a naive approach

A virtualized list needs the total height of rows it has not rendered yet. The usual answers are all bad:

  • Assume fixed row height. Breaks the moment rows contain wrapping text, emoji, images, or collapsible content. Users scroll past the “end” and then back up when the real height materialises; the scrollbar jumps.
  • Measure every row up front. Defeats virtualisation — you paid the rendering cost you were trying to avoid.
  • Use the running mean. Better, but gives no uncertainty. On a short warm-up the running mean is dominated by the first few rows; scroll far and the estimate is wrong in both directions with no indication.

The list widget needs a distribution over row height, not a point estimate, and it needs finite-sample guarantees — conformal bounds, not asymptotic ones — because users scroll with 10 samples not 10,000.

Mental model

Group rows into categories with similar layout (e.g., “single-line code block”, “multi-line markdown”, “expanded JSON”). Maintain per category:

  • Normal-Normal conjugate posterior over the mean height.
  • Welford running variance for the residual scale.
  • Conformal quantile over recent residuals for a distribution-free upper bound.

The predictor returns a triple (mean, lower, upper). The list reserves space for the upper bound so it never has to reflow when the real row turns out to be taller than expected — no scroll jumps.

Two estimators, two jobs. Normal-Normal gives you the mean with calibrated credible intervals. Conformal gives you the upper bound with finite-sample coverage, even when the height distribution is heavy-tailed.

The math

Normal-Normal conjugate update

Prior μN(μ0,σ02/κ0)\mu \sim \mathcal{N}(\mu_0, \sigma_0^2 / \kappa_0) with precision strength κ0\kappa_0. After nn observations with sample mean xˉ\bar{x}:

κn=κ0+n,μn=κ0μ0+nxˉκn\kappa_n = \kappa_0 + n, \qquad \mu_n = \frac{\kappa_0 \mu_0 + n \bar{x}}{\kappa_n}

Posterior variance of the mean:

Var(μn)=σ02κn\operatorname{Var}(\mu_n) = \frac{\sigma_0^2}{\kappa_n}

Defaults: μ0=1.0\mu_0 = 1.0, κ0=2.0\kappa_0 = 2.0, σ02=4.0\sigma_0^2 = 4.0.

Welford running variance

Online one-pass for residual variance (numerically stable, no catastrophic cancellation):

M2,n=M2,n1+(xnxˉn1)(xnxˉn)M_{2,n} = M_{2,n-1} + (x_n - \bar{x}_{n-1})(x_n - \bar{x}_n)

σ^2=M2,n/(n1)\hat{\sigma}^2 = M_{2,n} / (n-1) gives the per-observation variance — not the posterior’s variance of the mean.

Conformal upper bound

For coverage 1α1 - \alpha (default α=0.10\alpha = 0.10), collect residuals Ri=xiμnR_i = x_i - \mu_n from a rolling calibration window and take:

q=Quantile(1α)(n+1)/n(R1,,Rn)q = \text{Quantile}_{\lceil (1-\alpha)(n+1)/n \rceil} (R_1, \ldots, R_n)

The prediction interval is:

[μnq,  μn+q][\mu_n - q,\; \mu_n + q]

with finite-sample guarantee P(Rn+1q)1αP(R_{n+1} \le q) \ge 1 - \alpha. The list preallocates μn+q\mu_n + q cells per row — a tail row cannot exceed the bound with probability greater than α\alpha.

Why this beats fixed-row-height

assumed = 1 row real = 2 rows (markdown paragraph) → scrollbar ends halfway through content, user scrolls "past" the end, list re-lays-out, scrollbar snaps back. Flicker.

Rust interface

crates/ftui-widgets/src/height_predictor.rs
use ftui_widgets::height_predictor::{HeightPredictor, HeightPrediction}; let mut hp = HeightPredictor::new() .with_prior(1.0, 2.0, 4.0) // mu_0, kappa_0, sigma2_0 .with_coverage(0.90); // conformal 1 - alpha // When a row actually renders, feed back the observed height: hp.observe("markdown", 2.0); // When laying out future rows, ask for the prediction: let HeightPrediction { predicted, lower, upper, observations } = hp.predict("markdown");

Categories (&str keys) let you keep separate posteriors for row types that live in the same list without cross-contamination.

How to debug

The predictor is consumed by the virtualized list; its decisions show up indirectly through diff_decision, conformal_alert, and the list widget’s own tracing. To inspect the posterior state directly:

tracing::debug!( target = "ftui_widgets::height_predictor", ?hp.state(), "height posterior" );

Watch for two smells in the evidence stream:

  • Upper bound stuck at conservative default. The calibration window is too small; the predictor is falling back to the prior variance. Scroll around more to populate the residual buffer.
  • Upper bound collapses to μ\mu. Residuals are all zero because rows are identical. Harmless but wasteful — you can disable the conformal layer for that category.

Pitfalls

Don’t share a single category across layouts that differ. A Markdown list of paragraphs and a Markdown list of fenced code blocks have radically different height distributions. Mixing them under one key gives a bimodal distribution, the Normal-Normal model fits it badly, and the conformal bound balloons.

Prior κ0\kappa_0 matters more than you’d guess. With κ0=50\kappa_0 = 50 the posterior mean barely moves for the first 50 rows — the list behaves like fixed-height until it has seen half a screen. Keep κ05\kappa_0 \le 5 unless you have a strong reason to trust the prior.

Cross-references

Where next