Skip to Content

Mondrian Conformal Frame-Time Gating

What goes wrong with a naive approach

Vanilla conformal prediction assumes residuals are exchangeable across the whole stream. For frame time, that is blatantly false. A frame that rendered an inline prompt has a completely different cost profile from a frame that rendered a full-screen table. Pooling them gives a quantile dominated by the hard cases — the easy ones are massively over-covered and the hard ones remain barely-covered.

Three failure modes pop up immediately:

  1. Cross-regime pollution. A 20 ms outlier from a scroll burst raises the global quantile forever; steady-state frames are gated far too conservatively.
  2. Insufficient samples per regime. You have thousands of steady frames and ten scroll-burst frames. A per-bucket quantile over 10 points is useless.
  3. Silent mispicks. The diff-strategy picker changes which strategy it uses as the workload drifts; the conformal layer has no way to know that the calibration set just became obsolete.

The right answer is Mondrian conformal prediction (Vovk, 2003): partition the calibration set into buckets that are conditionally exchangeable, quote per-bucket quantiles, and fall back up a hierarchy when a bucket is too sparse.

Mental model

Partition frames by a tuple of context keys:

(M,D,S)=(screen mode,  diff strategy,  size class)(M, D, S) = (\text{screen mode},\; \text{diff strategy},\; \text{size class})

Keep a separate residual window per (M,D,S)(M, D, S). On each frame:

  1. Look up the bucket’s window.
  2. If ∣window∣≥20|\text{window}| \ge 20, use its quantile.
  3. Else fall back to (M,D,⋆)(M, D, \star) — any size class.
  4. Else fall back to (M,⋆,⋆)(M, \star, \star) — any strategy.
  5. Else fall back to (⋆,⋆,⋆)(\star, \star, \star) — global.

The fallback hierarchy is crucial: the system stays calibrated and always has a bound to quote, even in corner buckets that have never been hit.

Mondrian is conformal prediction plus a coarsening operator. Fine-grained buckets give precise bounds where you have data; the coarsening hierarchy gives you a valid bound where you don’t. The same theorem covers both levels.

The math

Per-bucket quantile

For each bucket BkB_k with calibration residuals R1(k),…,Rnk(k)R^{(k)}_1, \ldots, R^{(k)}_{n_k}:

qk=R(⌈(1−α)(nk+1)⌉)(k)if nk≥nmin⁡q_k = R^{(k)}_{(\lceil (1-\alpha)(n_k + 1) \rceil)} \qquad \text{if } n_k \ge n_{\min}

Otherwise fall through to the parent bucket.

Fallback hierarchy

(M,D,S)⟶(M,D,⋆)⟶(M,⋆,⋆)⟶(⋆,⋆,⋆)(M, D, S) \longrightarrow (M, D, \star) \longrightarrow (M, \star, \star) \longrightarrow (\star, \star, \star)

At the root, if even the global window is short, quote the fallback budget (default 16000 µs) — a hard engineering constant beyond which we always degrade.

Upper bound on frame time

For a frame with predicted time y^\hat{y} in bucket kk:

y^+=y^+qk\hat{y}^+ = \hat{y} + q_k

Gating rule: if y^+>budget\hat{y}^+ > \text{budget}, trigger degradation. See control theory for what degradation looks like.

Defaults

ParameterDefault
α\alpha0.05
nmin⁡n_{\min}20
window size per bucket256
fallback budget16 000 µs (60 fps)
hysteresis η\eta1.1

Worked example — a bucket fallback

Suppose you have the following bucket populations after 30 seconds of running the showcase:

BucketSamples
(alt, dirty, medium)512
(alt, full, large)23
(alt, full, small)3
(inline, dirty, medium)40
(inline, dirty, small)1

Per-frame resolution:

  • (alt, dirty, medium) — use bucket’s own qq (plenty of data).
  • (alt, full, large) — 23 ≥ 20, use bucket’s own qq.
  • (alt, full, small) — 3 < 20, fall back to (alt, full, ★), which has 23+3=2623 + 3 = 26 samples. Use that qq.
  • (inline, dirty, small) — 1 < 20 and (inline, dirty, ★) has 40+1=4140 + 1 = 41 samples. Use that qq.

At each fall-back, the theorem still applies because exchangeability only needs to hold within the coarsened bucket.

Rust interface

crates/ftui-runtime/src/conformal_frame_guard.rs
use ftui_runtime::conformal_frame_guard::{ ConformalFrameGuard, FrameGuardConfig, BucketKey, }; let mut guard = ConformalFrameGuard::new(FrameGuardConfig { alpha: 0.05, min_samples: 20, window_size: 256, fallback_budget_us: 16_000, hysteresis: 1.1, }); // After a frame, feed the context + residual: guard.observe( BucketKey { mode, diff_strategy, size_class }, y_hat_us, observed_us, ); // At the next frame, gate on the bound: let decision = guard.decide(BucketKey { mode, diff_strategy, size_class }, y_hat_us); if decision.exceeds_budget { degradation.step_down(); }

How to debug

Every decision emits a conformal_frame_guard line:

{"schema":"conformal_frame_guard","y_hat_us":14200, "upper_us":15800,"budget_us":16000, "exceeds_budget":false, "bucket":{"mode":"alt","diff":"dirty","size":"medium"}, "fallback_level":0,"calibration_size":512}
FTUI_EVIDENCE_SINK=/tmp/ftui.jsonl cargo run -p ftui-demo-showcase # How often did we fall back, and to which level? jq -c 'select(.schema=="conformal_frame_guard") | .fallback_level' /tmp/ftui.jsonl | sort | uniq -c

Large counts at fallback_level=2 or 3 mean your bucketing keys are too fine — consider coarsening size_class so buckets fill up faster.

Pitfalls

Bucket keys must reflect cost regimes, not identifiers. Keying by pane ID or widget name produces dozens of identifier-level buckets that each need 20 samples. Key by what makes frames expensive: screen mode, diff strategy, size class. Leave widget identity out.

fallback_budget_us is a backstop, not a budget. It fires only when even the global window is below nmin⁡n_{\min}. Tune the real budget via the budget_us config and the degradation cascade; don’t lean on the backstop.

Cross-references

  • /operations/frame-budget — the top- level document describing how the guard, degradation, and PI pacing cooperate.
  • Vanilla conformal — the theorem that underpins every bucket’s quantile.
  • Control theory — PI + degradation cascade that consumes the exceeds_budget signal.

Where next