Frame budget and degradation cascade
Every render has a budget. When FrankenTUI can’t meet it, it doesn’t stutter, drop frames, or hang — it degrades the rendering strategy to something cheaper. This page explains the two pieces that make that work: the conformal frame guard (the prediction) and the degradation cascade (the response).
The promise
The runtime promises frame-render p99 below the
SLO max_value. That promise holds even
when the content is unusually expensive (a full-screen VFX effect, a
resize storm, a thousand-line log viewer scrolling). The way it holds
is by cheapening the output, not by cheating the clock.
Mental model
per-frame prediction (conformal) tier selector (cascade)
──────────────────────────────── ──────────────────────────
measured cost history current tier: Full
│ │
▼ ▼
conformal predict p95 ────▶ budget? ──▶ tier OK? yes ──▶ render Full
│ ▲ │
│ │ no
│ recovery threshold ▼
└──────────── below budget ───── downgrade one tier
│
▼
upgrade one tierThe frame guard predicts, conformally, whether the next render
will fit within the remaining budget. If it predicts a breach, the
cascade downgrades by one tier before the render even starts. If the
guard predicts comfortable headroom for N consecutive frames, the
cascade upgrades one tier back.
The four tiers
| Tier | What changes | Approximate cost |
|---|---|---|
| Full | Every widget renders normally. Gradients, shadows, rounded borders, attribute-heavy styling all enabled. | 1× (baseline) |
| SimpleBorders | Rounded / doubled borders collapse to single-line ASCII. Shadows disabled. Gradients become solid fills. | ~0.6–0.8× |
| NoColors | As above, plus SGR emission limited to bold/underline/reverse. Foreground / background colors collapse to default. | ~0.3–0.5× |
| TextOnly | Widgets render their textual content only. No borders, no styling. Layout still honoured. | ~0.15–0.25× |
Each tier is one step cheaper than the one above in both CPU (fewer style decisions, fewer diff runs) and bytes on the wire (fewer SGR sequences).
All four tiers render the same semantic content. A screen-reader walking the a11y tree sees identical structure regardless of tier.
The conformal frame guard
The guard sits in ftui-runtime and consumes the recent history of
frame-render times. It emits a conformal p95 prediction: with 95%
probability the next frame will cost at most p95_us microseconds,
conditional on the recent distribution.
Why conformal
A plain moving average is fooled by fast → slow transitions. A quantile over a rolling window is fooled by rare spikes. Conformal prediction produces valid coverage under any distribution (modulo exchangeability), which matters here because rendering cost has regime shifts (VFX on, resize storms, a cold cache warming up). See conformal: Mondrian for the full story — the frame guard uses a Mondrian conditional variant so that predictions are calibrated per tier.
What the guard decides
Before every render, the guard runs:
predicted_p95 = conformal_predict(history_at_current_tier)
remaining_budget = slo_ceiling - work_done_this_tick
if predicted_p95 > remaining_budget:
cascade.downgrade()
elif guard_headroom_over(recovery_threshold, consecutive_frames):
cascade.upgrade()
else:
holdEvery guard decision is emitted as a conformal_frame_guard event on
the evidence sink:
{"event":"conformal_frame_guard","verdict":"hold","tier":"Full","predicted_p95_us":1820,"budget_us":4000,"headroom_us":2180}
{"event":"conformal_frame_guard","verdict":"breach","tier":"Full","predicted_p95_us":4250,"budget_us":4000,"headroom_us":-250}The cascade
When the guard signals breach, the cascade issues a degradation_event
and descends one tier. When the guard signals consecutive comfortable
headroom, the cascade ascends one tier. Both transitions emit structured
evidence:
{"event":"degradation_event","from_tier":"Full","to_tier":"SimpleBorders","reason":"conformal_frame_guard_breach"}
{"event":"degradation_event","from_tier":"SimpleBorders","to_tier":"Full","reason":"recovery_threshold_met","consecutive_safe_frames":30}Recovery threshold
The upgrade side is more conservative than the downgrade side. You don’t want to flap. Canonical defaults:
| Parameter | Default | Purpose |
|---|---|---|
downgrade_min_frames | 1 | Downgrade on first confirmed breach. |
upgrade_consecutive_safe_frames | 30 | Require 30 consecutive under-budget frames (≈ 0.5 s at 60 Hz) before upgrading. |
upgrade_headroom_pct | 25 | And the predicted p95 must be ≥ 25% below the budget. |
These are configurable in ProgramConfig::degradation_config. Most
applications use the defaults.
The RATS view from the intelligence layer
The cascade is an instance of the broader control-theory pattern at intelligence/control-theory:
- Observation: recent frame-render times.
- Predictor: conformal p95.
- Controller: cascade (downgrade on breach, upgrade on headroom).
- Actuator: the tier selector flipping strategy flags.
The controller is deliberately hysteretic — the upgrade threshold is stricter than the downgrade threshold — so the system settles instead of flapping.
Observability
Events
Every decision emits an event on the evidence sink:
conformal_frame_guard— per-frame prediction + verdict.degradation_event— tier transition.
See evidence grep patterns for copy-paste recipes.
Interaction with safe_mode_trigger
The cascade is the kernel’s first line of defence. If the guard is
breaching on every frame for dozens of frames in a row — or a
safe_mode_trigger metric from slo.yaml
breaches safe_mode_breach_count times — the runtime enters safe
mode: a persistent TextOnly tier that doesn’t upgrade automatically.
Safe mode has to be cleared explicitly (restart, or an explicit API
call once the underlying pressure is gone).
Pitfalls
Don’t override the tier manually in hot paths. Overriding the
tier inside Model::view defeats the guard’s hysteresis and creates
the exact flapping the cascade is designed to prevent.
Tier is a rendering strategy, not a feature flag. Functional
behaviour (keybindings, focus, command palette) must work identically
at every tier. If you find yourself checking the current tier inside
an update, step back — that almost certainly belongs elsewhere.
Conformal validity depends on exchangeability. If your application has a predictable “hot” vs “cold” cycle (e.g. idle 90% of the time, bursty 10%), consider splitting it into Mondrian conditional strata yourself and feeding the guard the right bucket. See conformal: Mondrian.