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:
-
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).
-
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:
with:
- (setpoint minus measurement).
- tuned to the plant’s dynamics.
FrankenTUI defaults: , , . The 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 , the closed-loop error of a PI controller is:
with settling time . The derivative term only helps if 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:
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 entirelyFloor: SimpleBorders (never drop below visually comprehensible).
Recovery: in-budget frames in a row (default ) before
stepping back up a tier.
Why a streak rather than a threshold?
Threshold recovery
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
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.jsonlIf the cascade oscillates between Full and SimpleBorders, the
streak target is too short — bump to 20 and recheck.
Pitfalls
Don’t tune by intuition. A PI controller is stable for a range of gains but it’s easy to pick values that oscillate. The defaults , 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_budgetsignal source. - SOS barrier — the polynomial barrier certificate that defines the admissible region for PI control.