Skip to Content
OperationsFrame budget

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 tier

The 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

TierWhat changesApproximate cost
FullEvery widget renders normally. Gradients, shadows, rounded borders, attribute-heavy styling all enabled.1× (baseline)
SimpleBordersRounded / doubled borders collapse to single-line ASCII. Shadows disabled. Gradients become solid fills.~0.6–0.8×
NoColorsAs above, plus SGR emission limited to bold/underline/reverse. Foreground / background colors collapse to default.~0.3–0.5×
TextOnlyWidgets 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: hold

Every 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:

ParameterDefaultPurpose
downgrade_min_frames1Downgrade on first confirmed breach.
upgrade_consecutive_safe_frames30Require 30 consecutive under-budget frames (≈ 0.5 s at 60 Hz) before upgrading.
upgrade_headroom_pct25And 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

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.