Skip to Content
IntelligenceControl theory

Control Theory — PI Pacing and Degradation Cascade

What goes wrong with a naive approach

Frame pacing without feedback: the renderer sleeps 16 ms after every frame and hopes things stay close to 60 fps. The moment a single frame overruns — because a dashboard tile refreshed, a scroll burst landed, or the diff picker mispicked — the next frame is already late. There is no recovery mechanism; the error just stacks.

Then naive recovery kicks in:

  • “If the frame is late, skip the next one.” Gives stuttery motion and fails to address why frames are late.
  • “If the frame is late, degrade immediately.” Overreacts; a single outlier costs the user their gradients and colours for the rest of the session.

Proper frame pacing is a closed-loop control problem:

  • The plant is the renderer.
  • The controller is an algorithm that looks at frame-time error and emits an adjustment (coalesce more, degrade one tier, extend the tick).
  • The controller needs to be responsive enough to catch regressions and robust enough to ignore noise.

Mental model

Think of the budget loop in two layers:

  1. Fast layer — PI pacing. Watch frame time, emit a smooth “how much headroom do I have?” signal. Feed that signal into both the scheduler (delay background work) and the degradation ladder (adjust richness of output).

  2. Slow layer — degradation cascade. A finite state machine that climbs down a ladder of visual richness when pressure persists: Full → SimpleBorders → NoColors → TextOnly → SkipFrame. Recovery requires a streak of within-budget frames, not just one good frame.

PI control is sufficient because the frame-time disturbance is effectively piecewise-constant (one workload for N frames, then a new workload). MPC would look ahead, but a look-ahead horizon is only helpful if disturbances are predictable — and TUI disturbances are not.

FrankenTUI’s MPC analysis is a proof that you don’t need MPC here. The numeric evaluation in degradation_cascade.rs compares optimal-MPC cost to PI cost under typical TUI disturbance models and finds the gap to be less than 2%. Saves a lot of code.

The math

PID controller

Classical form:

ut=Kpet+Kistes+KdΔetu_t = K_p \cdot e_t + K_i \sum_{s \le t} e_s + K_d \cdot \Delta e_t

with:

  • et=yyte_t = y^\star - y_t (setpoint minus measurement).
  • Kp,Ki,KdK_p, K_i, K_d tuned to the plant’s dynamics.

FrankenTUI defaults: Kp=0.5K_p = 0.5, Ki=0.05K_i = 0.05, Kd=0.2K_d = 0.2. The KdK_d term is present but near-zero because frame-time noise is too high for a useful derivative.

Why PI is sufficient

Under a piecewise-constant disturbance dtd_t, the closed-loop error of a PI controller is:

et0as te_t \to 0 \quad \text{as } t \to \infty

with settling time 1/Ki\sim 1 / K_i. The derivative term only helps if dtd_t is smoothly varying, which it isn’t — TUI disturbances arrive as step changes (a new pane, a scroll burst, a BOCPD regime transition).

MPC benchmark

MPC minimises a finite-horizon cost:

minut:t+Hk=0Hyt+ky2+ρut+k2\min_{u_{t:t+H}} \sum_{k=0}^H \|y_{t+k} - y^\star\|^2 + \rho \|u_{t+k}\|^2

subject to a plant model. Under the TUI disturbance profile, simulation shows MPC beats PI by less than 2% in tracking error — and MPC costs a linear solve per frame. The analytical and numerical evidence is in degradation_cascade.rs design notes.

Degradation tiers

Full – all colors, gradients, rich chrome SimpleBorders – rounded corners off, single-weight borders NoColors – mono except accent TextOnly – plain glyphs only, no icons/bars SkipFrame – yield this frame entirely

Floor: SimpleBorders (never drop below visually comprehensible). Recovery: NN in-budget frames in a row (default N=10N = 10) before stepping back up a tier.

Why a streak rather than a threshold?

if frame_time < budget { tier_up(); } → one in-budget frame bumps tier, next frame is over budget, we drop again. Visual chrome flickers.

Rust interface

crates/ftui-runtime/src/degradation_cascade.rs
use ftui_runtime::degradation_cascade::{DegradationCascade, DegradationConfig, Tier}; let mut cascade = DegradationCascade::new(DegradationConfig { k_p: 0.5, k_i: 0.05, k_d: 0.2, budget_us: 16_000, recovery_streak_target: 10, floor: Tier::SimpleBorders, }); cascade.observe(frame_time_us); let tier = cascade.current_tier(); render.set_tier(tier);

The cascade consumes the conformal frame guard exceeds_budget signal, driving a tier step-down when it fires.

How to debug

Every tier transition emits a degradation_event line:

{"schema":"degradation_event","decision":"step_down", "level_before":"Full","level_after":"SimpleBorders", "p99_upper_us":17500,"budget_us":16000, "recovery_streak":0,"control_u":-1.2}
FTUI_EVIDENCE_SINK=/tmp/ftui.jsonl cargo run -p ftui-demo-showcase # Tier dwell time over a session: jq -c 'select(.schema=="degradation_event") | {to: .level_after, streak: .recovery_streak}' /tmp/ftui.jsonl

If the cascade oscillates between Full and SimpleBorders, the streak target is too short — bump to 20 and recheck.

Pitfalls

Don’t tune KpK_p by intuition. A PI controller is stable for a range of gains but it’s easy to pick values that oscillate. The defaults Kp=0.5K_p = 0.5, Ki=0.05K_i = 0.05 come from Ziegler–Nichols on a recorded frame-time trace; re-run the tuning script if you change the frame budget.

The cascade has a floor. You cannot degrade below SimpleBorders without losing visual coherence, and SkipFrame is an emergency only. If you find yourself at the floor routinely, the problem is upstream — fix the frame-time generator, not the controller.

Cross-references

  • /operations/frame-budget — the operator-facing document that ties PI, the cascade, and the conformal layer together.
  • Mondrian conformal — the exceeds_budget signal source.
  • SOS barrier — the polynomial barrier certificate that defines the admissible region for PI control.

Where next