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 with precision strength . After observations with sample mean :
Posterior variance of the mean:
Defaults: , , .
Welford running variance
Online one-pass for residual variance (numerically stable, no catastrophic cancellation):
gives the per-observation variance — not the posterior’s variance of the mean.
Conformal upper bound
For coverage (default ), collect residuals from a rolling calibration window and take:
The prediction interval is:
with finite-sample guarantee . The list preallocates cells per row — a tail row cannot exceed the bound with probability greater than .
Why this beats fixed-row-height
Fixed 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
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 . 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 matters more than you’d guess. With the posterior mean barely moves for the first 50 rows — the list behaves like fixed-height until it has seen half a screen. Keep unless you have a strong reason to trust the prior.
Cross-references
/widgets/virtualized-list— the consumer that turns(predicted, lower, upper)into row offsets.- Vanilla conformal — the theory behind the bound used here.
- Fenwick tree — the O(log n) prefix sum that uses the reserved heights.