Skip to main content

ftui_runtime/
program.rs

1#![forbid(unsafe_code)]
2
3//! Bubbletea/Elm-style runtime for terminal applications.
4//!
5//! The program runtime manages the update/view loop, handling events and
6//! rendering frames. It separates state (Model) from rendering (View) and
7//! provides a command pattern for side effects.
8//!
9//! # Example
10//!
11//! ```ignore
12//! use ftui_runtime::program::{Model, Cmd};
13//! use ftui_core::event::Event;
14//! use ftui_render::frame::Frame;
15//!
16//! struct Counter {
17//!     count: i32,
18//! }
19//!
20//! enum Msg {
21//!     Increment,
22//!     Decrement,
23//!     Quit,
24//! }
25//!
26//! impl From<Event> for Msg {
27//!     fn from(event: Event) -> Self {
28//!         match event {
29//!             Event::Key(k) if k.is_char('q') => Msg::Quit,
30//!             Event::Key(k) if k.is_char('+') => Msg::Increment,
31//!             Event::Key(k) if k.is_char('-') => Msg::Decrement,
32//!             _ => Msg::Increment, // Default
33//!         }
34//!     }
35//! }
36//!
37//! impl Model for Counter {
38//!     type Message = Msg;
39//!
40//!     fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
41//!         match msg {
42//!             Msg::Increment => { self.count += 1; Cmd::none() }
43//!             Msg::Decrement => { self.count -= 1; Cmd::none() }
44//!             Msg::Quit => Cmd::quit(),
45//!         }
46//!     }
47//!
48//!     fn view(&self, frame: &mut Frame) {
49//!         // Render counter value to frame
50//!     }
51//! }
52//! ```
53
54use crate::StorageResult;
55use crate::evidence_sink::{EvidenceSink, EvidenceSinkConfig};
56use crate::evidence_telemetry::{
57    BudgetDecisionSnapshot, ConformalSnapshot, ResizeDecisionSnapshot, set_budget_snapshot,
58    set_resize_snapshot,
59};
60use crate::input_fairness::{FairnessDecision, FairnessEventType, InputFairnessGuard};
61use crate::input_macro::{EventRecorder, InputMacro};
62use crate::locale::LocaleContext;
63use crate::queueing_scheduler::{EstimateSource, QueueingScheduler, SchedulerConfig, WeightSource};
64use crate::render_trace::RenderTraceConfig;
65use crate::resize_coalescer::{CoalesceAction, CoalescerConfig, ResizeCoalescer};
66use crate::state_persistence::StateRegistry;
67use crate::subscription::SubscriptionManager;
68use crate::terminal_writer::{RuntimeDiffConfig, ScreenMode, TerminalWriter, UiAnchor};
69use crate::voi_sampling::{VoiConfig, VoiSampler};
70use crate::{BucketKey, ConformalConfig, ConformalPrediction, ConformalPredictor};
71use ftui_core::event::Event;
72use ftui_core::terminal_capabilities::TerminalCapabilities;
73use ftui_core::terminal_session::{SessionOptions, TerminalSession};
74use ftui_render::budget::{BudgetDecision, DegradationLevel, FrameBudgetConfig, RenderBudget};
75use ftui_render::buffer::Buffer;
76use ftui_render::diff_strategy::DiffStrategy;
77use ftui_render::frame::{Frame, WidgetBudget, WidgetSignal};
78use ftui_render::sanitize::sanitize;
79use std::collections::HashMap;
80use std::io::{self, Stdout, Write};
81use std::sync::Arc;
82use std::sync::mpsc;
83use std::thread::{self, JoinHandle};
84use std::time::{Duration, Instant};
85use tracing::{debug, debug_span, info, info_span};
86
87/// The Model trait defines application state and behavior.
88///
89/// Implementations define how the application responds to events
90/// and renders its current state.
91pub trait Model: Sized {
92    /// The message type for this model.
93    ///
94    /// Messages represent actions that update the model state.
95    /// Must be convertible from terminal events.
96    type Message: From<Event> + Send + 'static;
97
98    /// Initialize the model with startup commands.
99    ///
100    /// Called once when the program starts. Return commands to execute
101    /// initial side effects like loading data.
102    fn init(&mut self) -> Cmd<Self::Message> {
103        Cmd::none()
104    }
105
106    /// Update the model in response to a message.
107    ///
108    /// This is the core state transition function. Returns commands
109    /// for any side effects that should be executed.
110    fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message>;
111
112    /// Render the current state to a frame.
113    ///
114    /// Called after updates when the UI needs to be redrawn.
115    fn view(&self, frame: &mut Frame);
116
117    /// Declare active subscriptions.
118    ///
119    /// Called after each `update()`. The runtime compares the returned set
120    /// (by `SubId`) against currently running subscriptions and starts/stops
121    /// as needed. Returning an empty vec stops all subscriptions.
122    ///
123    /// # Default
124    ///
125    /// Returns an empty vec (no subscriptions).
126    fn subscriptions(&self) -> Vec<Box<dyn crate::subscription::Subscription<Self::Message>>> {
127        vec![]
128    }
129}
130
131/// Default weight assigned to background tasks.
132const DEFAULT_TASK_WEIGHT: f64 = 1.0;
133
134/// Default estimated task cost (ms) used for scheduling.
135const DEFAULT_TASK_ESTIMATE_MS: f64 = 10.0;
136
137/// Scheduling metadata for background tasks.
138#[derive(Debug, Clone)]
139pub struct TaskSpec {
140    /// Task weight (importance). Higher = more priority.
141    pub weight: f64,
142    /// Estimated task cost in milliseconds.
143    pub estimate_ms: f64,
144    /// Optional task name for evidence logging.
145    pub name: Option<String>,
146}
147
148impl Default for TaskSpec {
149    fn default() -> Self {
150        Self {
151            weight: DEFAULT_TASK_WEIGHT,
152            estimate_ms: DEFAULT_TASK_ESTIMATE_MS,
153            name: None,
154        }
155    }
156}
157
158impl TaskSpec {
159    /// Create a task spec with an explicit weight and estimate.
160    #[must_use]
161    pub fn new(weight: f64, estimate_ms: f64) -> Self {
162        Self {
163            weight,
164            estimate_ms,
165            name: None,
166        }
167    }
168
169    /// Attach a task name for diagnostics.
170    #[must_use]
171    pub fn with_name(mut self, name: impl Into<String>) -> Self {
172        self.name = Some(name.into());
173        self
174    }
175}
176
177/// Per-frame timing data for profiling.
178#[derive(Debug, Clone, Copy)]
179pub struct FrameTiming {
180    pub frame_idx: u64,
181    pub update_us: u64,
182    pub render_us: u64,
183    pub diff_us: u64,
184    pub present_us: u64,
185    pub total_us: u64,
186}
187
188/// Sink for frame timing events.
189pub trait FrameTimingSink: Send + Sync {
190    fn record_frame(&self, timing: &FrameTiming);
191}
192
193/// Configuration for frame timing capture.
194#[derive(Clone)]
195pub struct FrameTimingConfig {
196    pub sink: Arc<dyn FrameTimingSink>,
197}
198
199impl FrameTimingConfig {
200    #[must_use]
201    pub fn new(sink: Arc<dyn FrameTimingSink>) -> Self {
202        Self { sink }
203    }
204}
205
206impl std::fmt::Debug for FrameTimingConfig {
207    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
208        f.debug_struct("FrameTimingConfig")
209            .field("sink", &"<dyn FrameTimingSink>")
210            .finish()
211    }
212}
213
214/// Commands represent side effects to be executed by the runtime.
215///
216/// Commands are returned from `init()` and `update()` to trigger
217/// actions like quitting, sending messages, or scheduling ticks.
218#[derive(Default)]
219pub enum Cmd<M> {
220    /// No operation.
221    #[default]
222    None,
223    /// Quit the application.
224    Quit,
225    /// Execute multiple commands as a batch (currently sequential).
226    Batch(Vec<Cmd<M>>),
227    /// Execute commands sequentially.
228    Sequence(Vec<Cmd<M>>),
229    /// Send a message to the model.
230    Msg(M),
231    /// Schedule a tick after a duration.
232    Tick(Duration),
233    /// Write a log message to the terminal output.
234    ///
235    /// This writes to the scrollback region in inline mode, or is ignored/handled
236    /// appropriately in alternate screen mode. Safe to use with the One-Writer Rule.
237    Log(String),
238    /// Execute a blocking operation on a background thread.
239    ///
240    /// When effect queue scheduling is enabled, tasks are enqueued and executed
241    /// in Smith-rule order on a dedicated worker thread. Otherwise the closure
242    /// runs on a spawned thread immediately. The return value is sent back
243    /// as a message to the model.
244    Task(TaskSpec, Box<dyn FnOnce() -> M + Send>),
245    /// Save widget state to the persistence registry.
246    ///
247    /// Triggers a flush of the state registry to the storage backend.
248    /// No-op if persistence is not configured.
249    SaveState,
250    /// Restore widget state from the persistence registry.
251    ///
252    /// Triggers a load from the storage backend and updates the cache.
253    /// No-op if persistence is not configured. Returns a message via
254    /// callback if state was successfully restored.
255    RestoreState,
256}
257
258impl<M: std::fmt::Debug> std::fmt::Debug for Cmd<M> {
259    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
260        match self {
261            Self::None => write!(f, "None"),
262            Self::Quit => write!(f, "Quit"),
263            Self::Batch(cmds) => f.debug_tuple("Batch").field(cmds).finish(),
264            Self::Sequence(cmds) => f.debug_tuple("Sequence").field(cmds).finish(),
265            Self::Msg(m) => f.debug_tuple("Msg").field(m).finish(),
266            Self::Tick(d) => f.debug_tuple("Tick").field(d).finish(),
267            Self::Log(s) => f.debug_tuple("Log").field(s).finish(),
268            Self::Task(spec, _) => f.debug_struct("Task").field("spec", spec).finish(),
269            Self::SaveState => write!(f, "SaveState"),
270            Self::RestoreState => write!(f, "RestoreState"),
271        }
272    }
273}
274
275impl<M> Cmd<M> {
276    /// Create a no-op command.
277    #[inline]
278    pub fn none() -> Self {
279        Self::None
280    }
281
282    /// Create a quit command.
283    #[inline]
284    pub fn quit() -> Self {
285        Self::Quit
286    }
287
288    /// Create a message command.
289    #[inline]
290    pub fn msg(m: M) -> Self {
291        Self::Msg(m)
292    }
293
294    /// Create a log command.
295    ///
296    /// The message will be sanitized and written to the terminal log (scrollback).
297    /// A newline is appended if not present.
298    #[inline]
299    pub fn log(msg: impl Into<String>) -> Self {
300        Self::Log(msg.into())
301    }
302
303    /// Create a batch of commands.
304    pub fn batch(cmds: Vec<Self>) -> Self {
305        if cmds.is_empty() {
306            Self::None
307        } else if cmds.len() == 1 {
308            cmds.into_iter().next().unwrap()
309        } else {
310            Self::Batch(cmds)
311        }
312    }
313
314    /// Create a sequence of commands.
315    pub fn sequence(cmds: Vec<Self>) -> Self {
316        if cmds.is_empty() {
317            Self::None
318        } else if cmds.len() == 1 {
319            cmds.into_iter().next().unwrap()
320        } else {
321            Self::Sequence(cmds)
322        }
323    }
324
325    /// Return a stable name for telemetry and tracing.
326    #[inline]
327    pub fn type_name(&self) -> &'static str {
328        match self {
329            Self::None => "None",
330            Self::Quit => "Quit",
331            Self::Batch(_) => "Batch",
332            Self::Sequence(_) => "Sequence",
333            Self::Msg(_) => "Msg",
334            Self::Tick(_) => "Tick",
335            Self::Log(_) => "Log",
336            Self::Task(..) => "Task",
337            Self::SaveState => "SaveState",
338            Self::RestoreState => "RestoreState",
339        }
340    }
341
342    /// Create a tick command.
343    #[inline]
344    pub fn tick(duration: Duration) -> Self {
345        Self::Tick(duration)
346    }
347
348    /// Create a background task command.
349    ///
350    /// The closure runs on a spawned thread (or the effect queue worker when
351    /// scheduling is enabled). When it completes, the returned message is
352    /// sent back to the model's `update()`.
353    pub fn task<F>(f: F) -> Self
354    where
355        F: FnOnce() -> M + Send + 'static,
356    {
357        Self::Task(TaskSpec::default(), Box::new(f))
358    }
359
360    /// Create a background task command with explicit scheduling metadata.
361    pub fn task_with_spec<F>(spec: TaskSpec, f: F) -> Self
362    where
363        F: FnOnce() -> M + Send + 'static,
364    {
365        Self::Task(spec, Box::new(f))
366    }
367
368    /// Create a background task command with explicit weight and estimate.
369    pub fn task_weighted<F>(weight: f64, estimate_ms: f64, f: F) -> Self
370    where
371        F: FnOnce() -> M + Send + 'static,
372    {
373        Self::Task(TaskSpec::new(weight, estimate_ms), Box::new(f))
374    }
375
376    /// Create a named background task command.
377    pub fn task_named<F>(name: impl Into<String>, f: F) -> Self
378    where
379        F: FnOnce() -> M + Send + 'static,
380    {
381        Self::Task(TaskSpec::default().with_name(name), Box::new(f))
382    }
383
384    /// Create a save state command.
385    ///
386    /// Triggers a flush of the state registry to the storage backend.
387    /// No-op if persistence is not configured.
388    #[inline]
389    pub fn save_state() -> Self {
390        Self::SaveState
391    }
392
393    /// Create a restore state command.
394    ///
395    /// Triggers a load from the storage backend.
396    /// No-op if persistence is not configured.
397    #[inline]
398    pub fn restore_state() -> Self {
399        Self::RestoreState
400    }
401
402    /// Count the number of atomic commands in this command.
403    ///
404    /// Returns 0 for None, 1 for atomic commands, and recursively counts for Batch/Sequence.
405    pub fn count(&self) -> usize {
406        match self {
407            Self::None => 0,
408            Self::Batch(cmds) | Self::Sequence(cmds) => cmds.iter().map(Self::count).sum(),
409            _ => 1,
410        }
411    }
412}
413
414/// Resize handling behavior for the runtime.
415#[derive(Debug, Clone, Copy, PartialEq, Eq)]
416pub enum ResizeBehavior {
417    /// Apply resize immediately (no debounce, no placeholder).
418    Immediate,
419    /// Coalesce resize events for continuous reflow.
420    Throttled,
421}
422
423impl ResizeBehavior {
424    const fn uses_coalescer(self) -> bool {
425        matches!(self, ResizeBehavior::Throttled)
426    }
427}
428
429/// Configuration for state persistence in the program runtime.
430///
431/// Controls when and how widget state is saved/restored.
432#[derive(Clone)]
433pub struct PersistenceConfig {
434    /// State registry for persistence. If None, persistence is disabled.
435    pub registry: Option<std::sync::Arc<StateRegistry>>,
436    /// Interval for periodic checkpoint saves. None disables checkpoints.
437    pub checkpoint_interval: Option<Duration>,
438    /// Automatically load state on program start.
439    pub auto_load: bool,
440    /// Automatically save state on program exit.
441    pub auto_save: bool,
442}
443
444impl Default for PersistenceConfig {
445    fn default() -> Self {
446        Self {
447            registry: None,
448            checkpoint_interval: None,
449            auto_load: true,
450            auto_save: true,
451        }
452    }
453}
454
455impl std::fmt::Debug for PersistenceConfig {
456    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
457        f.debug_struct("PersistenceConfig")
458            .field(
459                "registry",
460                &self.registry.as_ref().map(|r| r.backend_name()),
461            )
462            .field("checkpoint_interval", &self.checkpoint_interval)
463            .field("auto_load", &self.auto_load)
464            .field("auto_save", &self.auto_save)
465            .finish()
466    }
467}
468
469impl PersistenceConfig {
470    /// Create a disabled persistence config.
471    #[must_use]
472    pub fn disabled() -> Self {
473        Self::default()
474    }
475
476    /// Create a persistence config with the given registry.
477    #[must_use]
478    pub fn with_registry(registry: std::sync::Arc<StateRegistry>) -> Self {
479        Self {
480            registry: Some(registry),
481            ..Default::default()
482        }
483    }
484
485    /// Set the checkpoint interval.
486    #[must_use]
487    pub fn checkpoint_every(mut self, interval: Duration) -> Self {
488        self.checkpoint_interval = Some(interval);
489        self
490    }
491
492    /// Enable or disable auto-load on start.
493    #[must_use]
494    pub fn auto_load(mut self, enabled: bool) -> Self {
495        self.auto_load = enabled;
496        self
497    }
498
499    /// Enable or disable auto-save on exit.
500    #[must_use]
501    pub fn auto_save(mut self, enabled: bool) -> Self {
502        self.auto_save = enabled;
503        self
504    }
505}
506
507/// Configuration for widget refresh selection under render budget.
508///
509/// Defaults are conservative and deterministic:
510/// - enabled: true
511/// - staleness_window_ms: 1_000
512/// - starve_ms: 3_000
513/// - max_starved_per_frame: 2
514/// - max_drop_fraction: 1.0 (disabled)
515/// - weights: priority 1.0, staleness 0.5, focus 0.75, interaction 0.5
516/// - starve_boost: 1.5
517/// - min_cost_us: 1.0
518#[derive(Debug, Clone)]
519pub struct WidgetRefreshConfig {
520    /// Enable budgeted widget refresh selection.
521    pub enabled: bool,
522    /// Staleness decay window (ms) used to normalize staleness scores.
523    pub staleness_window_ms: u64,
524    /// Staleness threshold that triggers starvation guard (ms).
525    pub starve_ms: u64,
526    /// Maximum number of starved widgets to force in per frame.
527    pub max_starved_per_frame: usize,
528    /// Maximum fraction of non-essential widgets that may be dropped.
529    /// Set to 1.0 to disable the guardrail.
530    pub max_drop_fraction: f32,
531    /// Weight for base priority signal.
532    pub weight_priority: f32,
533    /// Weight for staleness signal.
534    pub weight_staleness: f32,
535    /// Weight for focus boost.
536    pub weight_focus: f32,
537    /// Weight for interaction boost.
538    pub weight_interaction: f32,
539    /// Additive boost to value for starved widgets.
540    pub starve_boost: f32,
541    /// Minimum cost (us) to avoid divide-by-zero.
542    pub min_cost_us: f32,
543}
544
545impl Default for WidgetRefreshConfig {
546    fn default() -> Self {
547        Self {
548            enabled: true,
549            staleness_window_ms: 1_000,
550            starve_ms: 3_000,
551            max_starved_per_frame: 2,
552            max_drop_fraction: 1.0,
553            weight_priority: 1.0,
554            weight_staleness: 0.5,
555            weight_focus: 0.75,
556            weight_interaction: 0.5,
557            starve_boost: 1.5,
558            min_cost_us: 1.0,
559        }
560    }
561}
562
563/// Configuration for effect queue scheduling.
564#[derive(Debug, Clone)]
565pub struct EffectQueueConfig {
566    /// Whether effect queue scheduling is enabled.
567    pub enabled: bool,
568    /// Scheduler configuration (Smith's rule by default).
569    pub scheduler: SchedulerConfig,
570}
571
572impl Default for EffectQueueConfig {
573    fn default() -> Self {
574        let scheduler = SchedulerConfig {
575            smith_enabled: true,
576            force_fifo: false,
577            preemptive: false,
578            aging_factor: 0.0,
579            wait_starve_ms: 0.0,
580            enable_logging: false,
581            ..Default::default()
582        };
583        Self {
584            enabled: false,
585            scheduler,
586        }
587    }
588}
589
590impl EffectQueueConfig {
591    /// Enable effect queue scheduling with the provided scheduler config.
592    #[must_use]
593    pub fn with_enabled(mut self, enabled: bool) -> Self {
594        self.enabled = enabled;
595        self
596    }
597
598    /// Override the scheduler configuration.
599    #[must_use]
600    pub fn with_scheduler(mut self, scheduler: SchedulerConfig) -> Self {
601        self.scheduler = scheduler;
602        self
603    }
604}
605
606/// Configuration for the program runtime.
607#[derive(Debug, Clone)]
608pub struct ProgramConfig {
609    /// Screen mode (inline or alternate screen).
610    pub screen_mode: ScreenMode,
611    /// UI anchor for inline mode.
612    pub ui_anchor: UiAnchor,
613    /// Frame budget configuration.
614    pub budget: FrameBudgetConfig,
615    /// Diff strategy configuration for the terminal writer.
616    pub diff_config: RuntimeDiffConfig,
617    /// Evidence JSONL sink configuration.
618    pub evidence_sink: EvidenceSinkConfig,
619    /// Render-trace recorder configuration.
620    pub render_trace: RenderTraceConfig,
621    /// Optional frame timing sink.
622    pub frame_timing: Option<FrameTimingConfig>,
623    /// Conformal predictor configuration for frame-time risk gating.
624    pub conformal_config: Option<ConformalConfig>,
625    /// Locale context used for rendering.
626    pub locale_context: LocaleContext,
627    /// Input poll timeout.
628    pub poll_timeout: Duration,
629    /// Resize coalescer configuration.
630    pub resize_coalescer: CoalescerConfig,
631    /// Resize handling behavior (immediate/throttled).
632    pub resize_behavior: ResizeBehavior,
633    /// Forced terminal size override (when set, resize events are ignored).
634    pub forced_size: Option<(u16, u16)>,
635    /// Enable mouse support.
636    pub mouse: bool,
637    /// Enable bracketed paste.
638    pub bracketed_paste: bool,
639    /// Enable focus reporting.
640    pub focus_reporting: bool,
641    /// Enable Kitty keyboard protocol (repeat/release events).
642    pub kitty_keyboard: bool,
643    /// State persistence configuration.
644    pub persistence: PersistenceConfig,
645    /// Inline auto UI height remeasurement policy.
646    pub inline_auto_remeasure: Option<InlineAutoRemeasureConfig>,
647    /// Widget refresh selection configuration.
648    pub widget_refresh: WidgetRefreshConfig,
649    /// Effect queue scheduling configuration.
650    pub effect_queue: EffectQueueConfig,
651}
652
653impl Default for ProgramConfig {
654    fn default() -> Self {
655        Self {
656            screen_mode: ScreenMode::Inline { ui_height: 4 },
657            ui_anchor: UiAnchor::Bottom,
658            budget: FrameBudgetConfig::default(),
659            diff_config: RuntimeDiffConfig::default(),
660            evidence_sink: EvidenceSinkConfig::default(),
661            render_trace: RenderTraceConfig::default(),
662            frame_timing: None,
663            conformal_config: None,
664            locale_context: LocaleContext::global(),
665            poll_timeout: Duration::from_millis(100),
666            resize_coalescer: CoalescerConfig::default(),
667            resize_behavior: ResizeBehavior::Throttled,
668            forced_size: None,
669            mouse: false,
670            bracketed_paste: true,
671            focus_reporting: false,
672            kitty_keyboard: false,
673            persistence: PersistenceConfig::default(),
674            inline_auto_remeasure: None,
675            widget_refresh: WidgetRefreshConfig::default(),
676            effect_queue: EffectQueueConfig::default(),
677        }
678    }
679}
680
681impl ProgramConfig {
682    /// Create config for fullscreen applications.
683    pub fn fullscreen() -> Self {
684        Self {
685            screen_mode: ScreenMode::AltScreen,
686            ..Default::default()
687        }
688    }
689
690    /// Create config for inline mode with specified height.
691    pub fn inline(height: u16) -> Self {
692        Self {
693            screen_mode: ScreenMode::Inline { ui_height: height },
694            ..Default::default()
695        }
696    }
697
698    /// Create config for inline mode with automatic UI height.
699    pub fn inline_auto(min_height: u16, max_height: u16) -> Self {
700        Self {
701            screen_mode: ScreenMode::InlineAuto {
702                min_height,
703                max_height,
704            },
705            inline_auto_remeasure: Some(InlineAutoRemeasureConfig::default()),
706            ..Default::default()
707        }
708    }
709
710    /// Enable mouse support.
711    pub fn with_mouse(mut self) -> Self {
712        self.mouse = true;
713        self
714    }
715
716    /// Set the budget configuration.
717    pub fn with_budget(mut self, budget: FrameBudgetConfig) -> Self {
718        self.budget = budget;
719        self
720    }
721
722    /// Set the diff strategy configuration for the terminal writer.
723    pub fn with_diff_config(mut self, diff_config: RuntimeDiffConfig) -> Self {
724        self.diff_config = diff_config;
725        self
726    }
727
728    /// Set the evidence JSONL sink configuration.
729    pub fn with_evidence_sink(mut self, config: EvidenceSinkConfig) -> Self {
730        self.evidence_sink = config;
731        self
732    }
733
734    /// Set the render-trace recorder configuration.
735    pub fn with_render_trace(mut self, config: RenderTraceConfig) -> Self {
736        self.render_trace = config;
737        self
738    }
739
740    /// Set a frame timing sink for per-frame profiling.
741    pub fn with_frame_timing(mut self, config: FrameTimingConfig) -> Self {
742        self.frame_timing = Some(config);
743        self
744    }
745
746    /// Enable conformal frame-time risk gating with the given config.
747    pub fn with_conformal_config(mut self, config: ConformalConfig) -> Self {
748        self.conformal_config = Some(config);
749        self
750    }
751
752    /// Disable conformal frame-time risk gating.
753    pub fn without_conformal(mut self) -> Self {
754        self.conformal_config = None;
755        self
756    }
757
758    /// Set the locale context used for rendering.
759    pub fn with_locale_context(mut self, locale_context: LocaleContext) -> Self {
760        self.locale_context = locale_context;
761        self
762    }
763
764    /// Set the base locale used for rendering.
765    pub fn with_locale(mut self, locale: impl Into<crate::locale::Locale>) -> Self {
766        self.locale_context = LocaleContext::new(locale);
767        self
768    }
769
770    /// Set the widget refresh selection configuration.
771    pub fn with_widget_refresh(mut self, config: WidgetRefreshConfig) -> Self {
772        self.widget_refresh = config;
773        self
774    }
775
776    /// Set the effect queue scheduling configuration.
777    pub fn with_effect_queue(mut self, config: EffectQueueConfig) -> Self {
778        self.effect_queue = config;
779        self
780    }
781
782    /// Set the resize coalescer configuration.
783    pub fn with_resize_coalescer(mut self, config: CoalescerConfig) -> Self {
784        self.resize_coalescer = config;
785        self
786    }
787
788    /// Set the resize handling behavior.
789    pub fn with_resize_behavior(mut self, behavior: ResizeBehavior) -> Self {
790        self.resize_behavior = behavior;
791        self
792    }
793
794    /// Force a fixed terminal size (cols, rows). Resize events are ignored.
795    pub fn with_forced_size(mut self, width: u16, height: u16) -> Self {
796        let width = width.max(1);
797        let height = height.max(1);
798        self.forced_size = Some((width, height));
799        self
800    }
801
802    /// Clear any forced terminal size override.
803    pub fn without_forced_size(mut self) -> Self {
804        self.forced_size = None;
805        self
806    }
807
808    /// Toggle legacy immediate-resize behavior for migration.
809    pub fn with_legacy_resize(mut self, enabled: bool) -> Self {
810        if enabled {
811            self.resize_behavior = ResizeBehavior::Immediate;
812        }
813        self
814    }
815
816    /// Set the persistence configuration.
817    pub fn with_persistence(mut self, persistence: PersistenceConfig) -> Self {
818        self.persistence = persistence;
819        self
820    }
821
822    /// Enable persistence with the given registry.
823    pub fn with_registry(mut self, registry: std::sync::Arc<StateRegistry>) -> Self {
824        self.persistence = PersistenceConfig::with_registry(registry);
825        self
826    }
827
828    /// Enable inline auto UI height remeasurement with the given policy.
829    pub fn with_inline_auto_remeasure(mut self, config: InlineAutoRemeasureConfig) -> Self {
830        self.inline_auto_remeasure = Some(config);
831        self
832    }
833
834    /// Disable inline auto UI height remeasurement.
835    pub fn without_inline_auto_remeasure(mut self) -> Self {
836        self.inline_auto_remeasure = None;
837        self
838    }
839}
840
841enum EffectCommand<M> {
842    Enqueue(TaskSpec, Box<dyn FnOnce() -> M + Send>),
843    Shutdown,
844}
845
846struct EffectQueue<M: Send + 'static> {
847    sender: mpsc::Sender<EffectCommand<M>>,
848    handle: Option<JoinHandle<()>>,
849}
850
851impl<M: Send + 'static> EffectQueue<M> {
852    fn start(
853        config: EffectQueueConfig,
854        result_sender: mpsc::Sender<M>,
855        evidence_sink: Option<EvidenceSink>,
856    ) -> Self {
857        let (tx, rx) = mpsc::channel::<EffectCommand<M>>();
858        let handle = thread::Builder::new()
859            .name("ftui-effects".into())
860            .spawn(move || effect_queue_loop(config, rx, result_sender, evidence_sink))
861            .expect("failed to spawn effect queue");
862
863        Self {
864            sender: tx,
865            handle: Some(handle),
866        }
867    }
868
869    fn enqueue(&self, spec: TaskSpec, task: Box<dyn FnOnce() -> M + Send>) {
870        let _ = self.sender.send(EffectCommand::Enqueue(spec, task));
871    }
872
873    fn shutdown(&mut self) {
874        let _ = self.sender.send(EffectCommand::Shutdown);
875        if let Some(handle) = self.handle.take() {
876            let _ = handle.join();
877        }
878    }
879}
880
881impl<M: Send + 'static> Drop for EffectQueue<M> {
882    fn drop(&mut self) {
883        self.shutdown();
884    }
885}
886
887fn effect_queue_loop<M: Send + 'static>(
888    config: EffectQueueConfig,
889    rx: mpsc::Receiver<EffectCommand<M>>,
890    result_sender: mpsc::Sender<M>,
891    evidence_sink: Option<EvidenceSink>,
892) {
893    let mut scheduler = QueueingScheduler::new(config.scheduler);
894    let mut tasks: HashMap<u64, Box<dyn FnOnce() -> M + Send>> = HashMap::new();
895
896    loop {
897        if tasks.is_empty() {
898            match rx.recv() {
899                Ok(cmd) => {
900                    if handle_effect_command(cmd, &mut scheduler, &mut tasks, &result_sender) {
901                        return;
902                    }
903                }
904                Err(_) => return,
905            }
906        }
907
908        while let Ok(cmd) = rx.try_recv() {
909            if handle_effect_command(cmd, &mut scheduler, &mut tasks, &result_sender) {
910                return;
911            }
912        }
913
914        if tasks.is_empty() {
915            continue;
916        }
917
918        let Some(job) = scheduler.peek_next().cloned() else {
919            continue;
920        };
921
922        if let Some(ref sink) = evidence_sink {
923            let evidence = scheduler.evidence();
924            let _ = sink.write_jsonl(&evidence.to_jsonl("effect_queue_select"));
925        }
926
927        let completed = scheduler.tick(job.remaining_time);
928        for job_id in completed {
929            if let Some(task) = tasks.remove(&job_id) {
930                let msg = task();
931                let _ = result_sender.send(msg);
932            }
933        }
934    }
935}
936
937fn handle_effect_command<M: Send + 'static>(
938    cmd: EffectCommand<M>,
939    scheduler: &mut QueueingScheduler,
940    tasks: &mut HashMap<u64, Box<dyn FnOnce() -> M + Send>>,
941    result_sender: &mpsc::Sender<M>,
942) -> bool {
943    match cmd {
944        EffectCommand::Enqueue(spec, task) => {
945            let weight_source = if spec.weight == DEFAULT_TASK_WEIGHT {
946                WeightSource::Default
947            } else {
948                WeightSource::Explicit
949            };
950            let estimate_source = if spec.estimate_ms == DEFAULT_TASK_ESTIMATE_MS {
951                EstimateSource::Default
952            } else {
953                EstimateSource::Explicit
954            };
955            let id = scheduler.submit_with_sources(
956                spec.weight,
957                spec.estimate_ms,
958                weight_source,
959                estimate_source,
960                spec.name,
961            );
962            if let Some(id) = id {
963                tasks.insert(id, task);
964            } else {
965                let msg = task();
966                let _ = result_sender.send(msg);
967            }
968            false
969        }
970        EffectCommand::Shutdown => true,
971    }
972}
973
974// removed: legacy ResizeDebouncer (superseded by ResizeCoalescer)
975
976/// Policy for remeasuring inline auto UI height.
977///
978/// Uses VOI (value-of-information) sampling to decide when to perform
979/// a costly full-height measurement, with any-time valid guarantees via
980/// the embedded e-process in `VoiSampler`.
981#[derive(Debug, Clone)]
982pub struct InlineAutoRemeasureConfig {
983    /// VOI sampling configuration.
984    pub voi: VoiConfig,
985    /// Minimum row delta to count as a "violation".
986    pub change_threshold_rows: u16,
987}
988
989impl Default for InlineAutoRemeasureConfig {
990    fn default() -> Self {
991        Self {
992            voi: VoiConfig {
993                // Height changes are expected to be rare; bias toward fewer samples.
994                prior_alpha: 1.0,
995                prior_beta: 9.0,
996                // Allow ~1s max latency to adapt to growth/shrink.
997                max_interval_ms: 1000,
998                // Avoid over-sampling in high-FPS loops.
999                min_interval_ms: 100,
1000                // Disable event forcing; use time-based gating.
1001                max_interval_events: 0,
1002                min_interval_events: 0,
1003                // Treat sampling as moderately expensive.
1004                sample_cost: 0.08,
1005                ..VoiConfig::default()
1006            },
1007            change_threshold_rows: 1,
1008        }
1009    }
1010}
1011
1012#[derive(Debug)]
1013struct InlineAutoRemeasureState {
1014    config: InlineAutoRemeasureConfig,
1015    sampler: VoiSampler,
1016}
1017
1018impl InlineAutoRemeasureState {
1019    fn new(config: InlineAutoRemeasureConfig) -> Self {
1020        let sampler = VoiSampler::new(config.voi.clone());
1021        Self { config, sampler }
1022    }
1023
1024    fn reset(&mut self) {
1025        self.sampler = VoiSampler::new(self.config.voi.clone());
1026    }
1027}
1028
1029#[derive(Debug, Clone)]
1030struct ConformalEvidence {
1031    bucket_key: String,
1032    n_b: usize,
1033    alpha: f64,
1034    q_b: f64,
1035    y_hat: f64,
1036    upper_us: f64,
1037    risk: bool,
1038    fallback_level: u8,
1039    window_size: usize,
1040    reset_count: u64,
1041}
1042
1043impl ConformalEvidence {
1044    fn from_prediction(prediction: &ConformalPrediction) -> Self {
1045        let alpha = (1.0 - prediction.confidence).clamp(0.0, 1.0);
1046        Self {
1047            bucket_key: prediction.bucket.to_string(),
1048            n_b: prediction.sample_count,
1049            alpha,
1050            q_b: prediction.quantile,
1051            y_hat: prediction.y_hat,
1052            upper_us: prediction.upper_us,
1053            risk: prediction.risk,
1054            fallback_level: prediction.fallback_level,
1055            window_size: prediction.window_size,
1056            reset_count: prediction.reset_count,
1057        }
1058    }
1059}
1060
1061#[derive(Debug, Clone)]
1062struct BudgetDecisionEvidence {
1063    frame_idx: u64,
1064    decision: BudgetDecision,
1065    controller_decision: BudgetDecision,
1066    degradation_before: DegradationLevel,
1067    degradation_after: DegradationLevel,
1068    frame_time_us: f64,
1069    budget_us: f64,
1070    pid_output: f64,
1071    pid_p: f64,
1072    pid_i: f64,
1073    pid_d: f64,
1074    e_value: f64,
1075    frames_observed: u32,
1076    frames_since_change: u32,
1077    in_warmup: bool,
1078    conformal: Option<ConformalEvidence>,
1079}
1080
1081impl BudgetDecisionEvidence {
1082    fn decision_from_levels(before: DegradationLevel, after: DegradationLevel) -> BudgetDecision {
1083        if after > before {
1084            BudgetDecision::Degrade
1085        } else if after < before {
1086            BudgetDecision::Upgrade
1087        } else {
1088            BudgetDecision::Hold
1089        }
1090    }
1091
1092    #[must_use]
1093    fn to_jsonl(&self) -> String {
1094        let conformal = self.conformal.as_ref();
1095        let bucket_key = Self::opt_str(conformal.map(|c| c.bucket_key.as_str()));
1096        let n_b = Self::opt_usize(conformal.map(|c| c.n_b));
1097        let alpha = Self::opt_f64(conformal.map(|c| c.alpha));
1098        let q_b = Self::opt_f64(conformal.map(|c| c.q_b));
1099        let y_hat = Self::opt_f64(conformal.map(|c| c.y_hat));
1100        let upper_us = Self::opt_f64(conformal.map(|c| c.upper_us));
1101        let risk = Self::opt_bool(conformal.map(|c| c.risk));
1102        let fallback_level = Self::opt_u8(conformal.map(|c| c.fallback_level));
1103        let window_size = Self::opt_usize(conformal.map(|c| c.window_size));
1104        let reset_count = Self::opt_u64(conformal.map(|c| c.reset_count));
1105
1106        format!(
1107            r#"{{"event":"budget_decision","frame_idx":{},"decision":"{}","decision_controller":"{}","degradation_before":"{}","degradation_after":"{}","frame_time_us":{:.6},"budget_us":{:.6},"pid_output":{:.6},"pid_p":{:.6},"pid_i":{:.6},"pid_d":{:.6},"e_value":{:.6},"frames_observed":{},"frames_since_change":{},"in_warmup":{},"bucket_key":{},"n_b":{},"alpha":{},"q_b":{},"y_hat":{},"upper_us":{},"risk":{},"fallback_level":{},"window_size":{},"reset_count":{}}}"#,
1108            self.frame_idx,
1109            self.decision.as_str(),
1110            self.controller_decision.as_str(),
1111            self.degradation_before.as_str(),
1112            self.degradation_after.as_str(),
1113            self.frame_time_us,
1114            self.budget_us,
1115            self.pid_output,
1116            self.pid_p,
1117            self.pid_i,
1118            self.pid_d,
1119            self.e_value,
1120            self.frames_observed,
1121            self.frames_since_change,
1122            self.in_warmup,
1123            bucket_key,
1124            n_b,
1125            alpha,
1126            q_b,
1127            y_hat,
1128            upper_us,
1129            risk,
1130            fallback_level,
1131            window_size,
1132            reset_count
1133        )
1134    }
1135
1136    fn opt_f64(value: Option<f64>) -> String {
1137        value
1138            .map(|v| format!("{v:.6}"))
1139            .unwrap_or_else(|| "null".to_string())
1140    }
1141
1142    fn opt_u64(value: Option<u64>) -> String {
1143        value
1144            .map(|v| v.to_string())
1145            .unwrap_or_else(|| "null".to_string())
1146    }
1147
1148    fn opt_u8(value: Option<u8>) -> String {
1149        value
1150            .map(|v| v.to_string())
1151            .unwrap_or_else(|| "null".to_string())
1152    }
1153
1154    fn opt_usize(value: Option<usize>) -> String {
1155        value
1156            .map(|v| v.to_string())
1157            .unwrap_or_else(|| "null".to_string())
1158    }
1159
1160    fn opt_bool(value: Option<bool>) -> String {
1161        value
1162            .map(|v| v.to_string())
1163            .unwrap_or_else(|| "null".to_string())
1164    }
1165
1166    fn opt_str(value: Option<&str>) -> String {
1167        value
1168            .map(|v| format!("\"{}\"", v.replace('"', "\\\"")))
1169            .unwrap_or_else(|| "null".to_string())
1170    }
1171}
1172
1173#[derive(Debug, Clone)]
1174struct FairnessConfigEvidence {
1175    enabled: bool,
1176    input_priority_threshold_ms: u64,
1177    dominance_threshold: u32,
1178    fairness_threshold: f64,
1179}
1180
1181impl FairnessConfigEvidence {
1182    #[must_use]
1183    fn to_jsonl(&self) -> String {
1184        format!(
1185            r#"{{"event":"fairness_config","enabled":{},"input_priority_threshold_ms":{},"dominance_threshold":{},"fairness_threshold":{:.6}}}"#,
1186            self.enabled,
1187            self.input_priority_threshold_ms,
1188            self.dominance_threshold,
1189            self.fairness_threshold
1190        )
1191    }
1192}
1193
1194#[derive(Debug, Clone)]
1195struct FairnessDecisionEvidence {
1196    frame_idx: u64,
1197    decision: &'static str,
1198    reason: &'static str,
1199    pending_input_latency_ms: Option<u64>,
1200    jain_index: f64,
1201    resize_dominance_count: u32,
1202    dominance_threshold: u32,
1203    fairness_threshold: f64,
1204    input_priority_threshold_ms: u64,
1205}
1206
1207impl FairnessDecisionEvidence {
1208    #[must_use]
1209    fn to_jsonl(&self) -> String {
1210        let pending_latency = self
1211            .pending_input_latency_ms
1212            .map(|v| v.to_string())
1213            .unwrap_or_else(|| "null".to_string());
1214        format!(
1215            r#"{{"event":"fairness_decision","frame_idx":{},"decision":"{}","reason":"{}","pending_input_latency_ms":{},"jain_index":{:.6},"resize_dominance_count":{},"dominance_threshold":{},"fairness_threshold":{:.6},"input_priority_threshold_ms":{}}}"#,
1216            self.frame_idx,
1217            self.decision,
1218            self.reason,
1219            pending_latency,
1220            self.jain_index,
1221            self.resize_dominance_count,
1222            self.dominance_threshold,
1223            self.fairness_threshold,
1224            self.input_priority_threshold_ms
1225        )
1226    }
1227}
1228
1229#[derive(Debug, Clone)]
1230struct WidgetRefreshEntry {
1231    widget_id: u64,
1232    essential: bool,
1233    starved: bool,
1234    value: f32,
1235    cost_us: f32,
1236    score: f32,
1237    staleness_ms: u64,
1238}
1239
1240impl WidgetRefreshEntry {
1241    fn to_json(&self) -> String {
1242        format!(
1243            r#"{{"id":{},"cost_us":{:.3},"value":{:.4},"score":{:.4},"essential":{},"starved":{},"staleness_ms":{}}}"#,
1244            self.widget_id,
1245            self.cost_us,
1246            self.value,
1247            self.score,
1248            self.essential,
1249            self.starved,
1250            self.staleness_ms
1251        )
1252    }
1253}
1254
1255#[derive(Debug, Clone)]
1256struct WidgetRefreshPlan {
1257    frame_idx: u64,
1258    budget_us: f64,
1259    degradation: DegradationLevel,
1260    essentials_cost_us: f64,
1261    selected_cost_us: f64,
1262    selected_value: f64,
1263    signal_count: usize,
1264    selected: Vec<WidgetRefreshEntry>,
1265    skipped_count: usize,
1266    skipped_starved: usize,
1267    starved_selected: usize,
1268    over_budget: bool,
1269}
1270
1271impl WidgetRefreshPlan {
1272    fn new() -> Self {
1273        Self {
1274            frame_idx: 0,
1275            budget_us: 0.0,
1276            degradation: DegradationLevel::Full,
1277            essentials_cost_us: 0.0,
1278            selected_cost_us: 0.0,
1279            selected_value: 0.0,
1280            signal_count: 0,
1281            selected: Vec::new(),
1282            skipped_count: 0,
1283            skipped_starved: 0,
1284            starved_selected: 0,
1285            over_budget: false,
1286        }
1287    }
1288
1289    fn clear(&mut self) {
1290        self.frame_idx = 0;
1291        self.budget_us = 0.0;
1292        self.degradation = DegradationLevel::Full;
1293        self.essentials_cost_us = 0.0;
1294        self.selected_cost_us = 0.0;
1295        self.selected_value = 0.0;
1296        self.signal_count = 0;
1297        self.selected.clear();
1298        self.skipped_count = 0;
1299        self.skipped_starved = 0;
1300        self.starved_selected = 0;
1301        self.over_budget = false;
1302    }
1303
1304    fn as_budget(&self) -> WidgetBudget {
1305        if self.signal_count == 0 {
1306            return WidgetBudget::allow_all();
1307        }
1308        let ids = self.selected.iter().map(|entry| entry.widget_id).collect();
1309        WidgetBudget::allow_only(ids)
1310    }
1311
1312    fn recompute(
1313        &mut self,
1314        frame_idx: u64,
1315        budget_us: f64,
1316        degradation: DegradationLevel,
1317        signals: &[WidgetSignal],
1318        config: &WidgetRefreshConfig,
1319    ) {
1320        self.clear();
1321        self.frame_idx = frame_idx;
1322        self.budget_us = budget_us;
1323        self.degradation = degradation;
1324
1325        if !config.enabled || signals.is_empty() {
1326            return;
1327        }
1328
1329        self.signal_count = signals.len();
1330        let mut essentials_cost = 0.0f64;
1331        let mut selected_cost = 0.0f64;
1332        let mut selected_value = 0.0f64;
1333
1334        let staleness_window = config.staleness_window_ms.max(1) as f32;
1335        let mut candidates: Vec<WidgetRefreshEntry> = Vec::with_capacity(signals.len());
1336
1337        for signal in signals {
1338            let starved = config.starve_ms > 0 && signal.staleness_ms >= config.starve_ms;
1339            let staleness_score = (signal.staleness_ms as f32 / staleness_window).min(1.0);
1340            let mut value = config.weight_priority * signal.priority
1341                + config.weight_staleness * staleness_score
1342                + config.weight_focus * signal.focus_boost
1343                + config.weight_interaction * signal.interaction_boost;
1344            if starved {
1345                value += config.starve_boost;
1346            }
1347            let raw_cost = if signal.recent_cost_us > 0.0 {
1348                signal.recent_cost_us
1349            } else {
1350                signal.cost_estimate_us
1351            };
1352            let cost_us = raw_cost.max(config.min_cost_us);
1353            let score = if cost_us > 0.0 {
1354                value / cost_us
1355            } else {
1356                value
1357            };
1358
1359            let entry = WidgetRefreshEntry {
1360                widget_id: signal.widget_id,
1361                essential: signal.essential,
1362                starved,
1363                value,
1364                cost_us,
1365                score,
1366                staleness_ms: signal.staleness_ms,
1367            };
1368
1369            if degradation >= DegradationLevel::EssentialOnly && !signal.essential {
1370                self.skipped_count += 1;
1371                if starved {
1372                    self.skipped_starved = self.skipped_starved.saturating_add(1);
1373                }
1374                continue;
1375            }
1376
1377            if signal.essential {
1378                essentials_cost += cost_us as f64;
1379                selected_cost += cost_us as f64;
1380                selected_value += value as f64;
1381                if starved {
1382                    self.starved_selected = self.starved_selected.saturating_add(1);
1383                }
1384                self.selected.push(entry);
1385            } else {
1386                candidates.push(entry);
1387            }
1388        }
1389
1390        let mut remaining = budget_us - selected_cost;
1391
1392        if degradation < DegradationLevel::EssentialOnly {
1393            let nonessential_total = candidates.len();
1394            let max_drop_fraction = config.max_drop_fraction.clamp(0.0, 1.0);
1395            let enforce_drop_rate = max_drop_fraction < 1.0 && nonessential_total > 0;
1396            let min_nonessential_selected = if enforce_drop_rate {
1397                let min_fraction = (1.0 - max_drop_fraction).max(0.0);
1398                ((min_fraction * nonessential_total as f32).ceil() as usize).min(nonessential_total)
1399            } else {
1400                0
1401            };
1402
1403            candidates.sort_by(|a, b| {
1404                b.starved
1405                    .cmp(&a.starved)
1406                    .then_with(|| b.score.total_cmp(&a.score))
1407                    .then_with(|| b.value.total_cmp(&a.value))
1408                    .then_with(|| a.cost_us.total_cmp(&b.cost_us))
1409                    .then_with(|| a.widget_id.cmp(&b.widget_id))
1410            });
1411
1412            let mut forced_starved = 0usize;
1413            let mut nonessential_selected = 0usize;
1414            let mut skipped_candidates = if enforce_drop_rate {
1415                Vec::with_capacity(candidates.len())
1416            } else {
1417                Vec::new()
1418            };
1419
1420            for entry in candidates.into_iter() {
1421                if entry.starved && forced_starved >= config.max_starved_per_frame {
1422                    self.skipped_count += 1;
1423                    self.skipped_starved = self.skipped_starved.saturating_add(1);
1424                    if enforce_drop_rate {
1425                        skipped_candidates.push(entry);
1426                    }
1427                    continue;
1428                }
1429
1430                if remaining >= entry.cost_us as f64 {
1431                    remaining -= entry.cost_us as f64;
1432                    selected_cost += entry.cost_us as f64;
1433                    selected_value += entry.value as f64;
1434                    if entry.starved {
1435                        self.starved_selected = self.starved_selected.saturating_add(1);
1436                        forced_starved += 1;
1437                    }
1438                    nonessential_selected += 1;
1439                    self.selected.push(entry);
1440                } else if entry.starved
1441                    && forced_starved < config.max_starved_per_frame
1442                    && nonessential_selected == 0
1443                {
1444                    // Starvation guard: ensure at least one starved widget can refresh.
1445                    selected_cost += entry.cost_us as f64;
1446                    selected_value += entry.value as f64;
1447                    self.starved_selected = self.starved_selected.saturating_add(1);
1448                    forced_starved += 1;
1449                    nonessential_selected += 1;
1450                    self.selected.push(entry);
1451                } else {
1452                    self.skipped_count += 1;
1453                    if entry.starved {
1454                        self.skipped_starved = self.skipped_starved.saturating_add(1);
1455                    }
1456                    if enforce_drop_rate {
1457                        skipped_candidates.push(entry);
1458                    }
1459                }
1460            }
1461
1462            if enforce_drop_rate && nonessential_selected < min_nonessential_selected {
1463                for entry in skipped_candidates.into_iter() {
1464                    if nonessential_selected >= min_nonessential_selected {
1465                        break;
1466                    }
1467                    if entry.starved && forced_starved >= config.max_starved_per_frame {
1468                        continue;
1469                    }
1470                    selected_cost += entry.cost_us as f64;
1471                    selected_value += entry.value as f64;
1472                    if entry.starved {
1473                        self.starved_selected = self.starved_selected.saturating_add(1);
1474                        forced_starved += 1;
1475                        self.skipped_starved = self.skipped_starved.saturating_sub(1);
1476                    }
1477                    self.skipped_count = self.skipped_count.saturating_sub(1);
1478                    nonessential_selected += 1;
1479                    self.selected.push(entry);
1480                }
1481            }
1482        }
1483
1484        self.essentials_cost_us = essentials_cost;
1485        self.selected_cost_us = selected_cost;
1486        self.selected_value = selected_value;
1487        self.over_budget = selected_cost > budget_us;
1488    }
1489
1490    #[must_use]
1491    fn to_jsonl(&self) -> String {
1492        let mut out = String::with_capacity(256 + self.selected.len() * 96);
1493        out.push_str(r#"{"event":"widget_refresh""#);
1494        out.push_str(&format!(
1495            r#","frame_idx":{},"budget_us":{:.3},"degradation":"{}","essentials_cost_us":{:.3},"selected_cost_us":{:.3},"selected_value":{:.3},"selected_count":{},"skipped_count":{},"starved_selected":{},"starved_skipped":{},"over_budget":{}"#,
1496            self.frame_idx,
1497            self.budget_us,
1498            self.degradation.as_str(),
1499            self.essentials_cost_us,
1500            self.selected_cost_us,
1501            self.selected_value,
1502            self.selected.len(),
1503            self.skipped_count,
1504            self.starved_selected,
1505            self.skipped_starved,
1506            self.over_budget
1507        ));
1508        out.push_str(r#","selected":["#);
1509        for (i, entry) in self.selected.iter().enumerate() {
1510            if i > 0 {
1511                out.push(',');
1512            }
1513            out.push_str(&entry.to_json());
1514        }
1515        out.push_str("]}");
1516        out
1517    }
1518}
1519
1520/// The program runtime that manages the update/view loop.
1521pub struct Program<M: Model, W: Write + Send = Stdout> {
1522    /// The application model.
1523    model: M,
1524    /// Terminal output coordinator.
1525    writer: TerminalWriter<W>,
1526    /// Terminal lifecycle guard (raw mode, mouse, paste, focus).
1527    session: TerminalSession,
1528    /// Whether the program is running.
1529    running: bool,
1530    /// Current tick rate (if any).
1531    tick_rate: Option<Duration>,
1532    /// Last tick time.
1533    last_tick: Instant,
1534    /// Whether the UI needs to be redrawn.
1535    dirty: bool,
1536    /// Monotonic frame index for evidence logging.
1537    frame_idx: u64,
1538    /// Widget scheduling signals captured during the last render.
1539    widget_signals: Vec<WidgetSignal>,
1540    /// Widget refresh selection configuration.
1541    widget_refresh_config: WidgetRefreshConfig,
1542    /// Last computed widget refresh plan.
1543    widget_refresh_plan: WidgetRefreshPlan,
1544    /// Current terminal width.
1545    width: u16,
1546    /// Current terminal height.
1547    height: u16,
1548    /// Forced terminal size override (when set, resize events are ignored).
1549    forced_size: Option<(u16, u16)>,
1550    /// Poll timeout when no tick is scheduled.
1551    poll_timeout: Duration,
1552    /// Frame budget configuration.
1553    budget: RenderBudget,
1554    /// Conformal predictor for frame-time risk gating.
1555    conformal_predictor: Option<ConformalPredictor>,
1556    /// Last observed frame time (microseconds), used as a baseline predictor.
1557    last_frame_time_us: Option<f64>,
1558    /// Last observed update duration (microseconds).
1559    last_update_us: Option<u64>,
1560    /// Optional frame timing sink for profiling.
1561    frame_timing: Option<FrameTimingConfig>,
1562    /// Locale context used for rendering.
1563    locale_context: LocaleContext,
1564    /// Last observed locale version.
1565    locale_version: u64,
1566    /// Resize coalescer for rapid resize events.
1567    resize_coalescer: ResizeCoalescer,
1568    /// Shared evidence sink for decision logs (optional).
1569    evidence_sink: Option<EvidenceSink>,
1570    /// Whether fairness config has been logged to evidence sink.
1571    fairness_config_logged: bool,
1572    /// Resize handling behavior.
1573    resize_behavior: ResizeBehavior,
1574    /// Input fairness guard for scheduler integration.
1575    fairness_guard: InputFairnessGuard,
1576    /// Optional event recorder for macro capture.
1577    event_recorder: Option<EventRecorder>,
1578    /// Subscription lifecycle manager.
1579    subscriptions: SubscriptionManager<M::Message>,
1580    /// Channel for receiving messages from background tasks.
1581    task_sender: std::sync::mpsc::Sender<M::Message>,
1582    /// Channel for receiving messages from background tasks.
1583    task_receiver: std::sync::mpsc::Receiver<M::Message>,
1584    /// Join handles for background tasks; reaped opportunistically.
1585    task_handles: Vec<std::thread::JoinHandle<()>>,
1586    /// Optional effect queue scheduler for background tasks.
1587    effect_queue: Option<EffectQueue<M::Message>>,
1588    /// Optional state registry for widget persistence.
1589    state_registry: Option<std::sync::Arc<StateRegistry>>,
1590    /// Persistence configuration.
1591    persistence_config: PersistenceConfig,
1592    /// Last checkpoint save time.
1593    last_checkpoint: Instant,
1594    /// Inline auto UI height remeasurement state.
1595    inline_auto_remeasure: Option<InlineAutoRemeasureState>,
1596}
1597
1598impl<M: Model> Program<M, Stdout> {
1599    /// Create a new program with default configuration.
1600    pub fn new(model: M) -> io::Result<Self>
1601    where
1602        M::Message: Send + 'static,
1603    {
1604        Self::with_config(model, ProgramConfig::default())
1605    }
1606
1607    /// Create a new program with the specified configuration.
1608    pub fn with_config(model: M, config: ProgramConfig) -> io::Result<Self>
1609    where
1610        M::Message: Send + 'static,
1611    {
1612        let capabilities = TerminalCapabilities::with_overrides();
1613        let session = TerminalSession::new(SessionOptions {
1614            alternate_screen: matches!(config.screen_mode, ScreenMode::AltScreen),
1615            mouse_capture: config.mouse,
1616            bracketed_paste: config.bracketed_paste,
1617            focus_events: config.focus_reporting,
1618            kitty_keyboard: config.kitty_keyboard,
1619        })?;
1620
1621        let mut writer = TerminalWriter::with_diff_config(
1622            io::stdout(),
1623            config.screen_mode,
1624            config.ui_anchor,
1625            capabilities,
1626            config.diff_config.clone(),
1627        );
1628
1629        let frame_timing = config.frame_timing.clone();
1630        writer.set_timing_enabled(frame_timing.is_some());
1631
1632        let evidence_sink = EvidenceSink::from_config(&config.evidence_sink)?;
1633        if let Some(ref sink) = evidence_sink {
1634            writer = writer.with_evidence_sink(sink.clone());
1635        }
1636
1637        let render_trace = crate::RenderTraceRecorder::from_config(
1638            &config.render_trace,
1639            crate::RenderTraceContext {
1640                capabilities: writer.capabilities(),
1641                diff_config: config.diff_config.clone(),
1642                resize_config: config.resize_coalescer.clone(),
1643                conformal_config: config.conformal_config.clone(),
1644            },
1645        )?;
1646        if let Some(recorder) = render_trace {
1647            writer = writer.with_render_trace(recorder);
1648        }
1649
1650        // Get terminal size for initial frame (or forced size override).
1651        let (w, h) = config
1652            .forced_size
1653            .unwrap_or_else(|| session.size().unwrap_or((80, 24)));
1654        let width = w.max(1);
1655        let height = h.max(1);
1656        writer.set_size(width, height);
1657
1658        let budget = RenderBudget::from_config(&config.budget);
1659        let conformal_predictor = config.conformal_config.clone().map(ConformalPredictor::new);
1660        let locale_context = config.locale_context.clone();
1661        let locale_version = locale_context.version();
1662        let mut resize_coalescer =
1663            ResizeCoalescer::new(config.resize_coalescer.clone(), (width, height))
1664                .with_screen_mode(config.screen_mode);
1665        if let Some(ref sink) = evidence_sink {
1666            resize_coalescer = resize_coalescer.with_evidence_sink(sink.clone());
1667        }
1668        let subscriptions = SubscriptionManager::new();
1669        let (task_sender, task_receiver) = std::sync::mpsc::channel();
1670        let inline_auto_remeasure = config
1671            .inline_auto_remeasure
1672            .clone()
1673            .map(InlineAutoRemeasureState::new);
1674        let effect_queue = if config.effect_queue.enabled {
1675            Some(EffectQueue::start(
1676                config.effect_queue.clone(),
1677                task_sender.clone(),
1678                evidence_sink.clone(),
1679            ))
1680        } else {
1681            None
1682        };
1683
1684        Ok(Self {
1685            model,
1686            writer,
1687            session,
1688            running: true,
1689            tick_rate: None,
1690            last_tick: Instant::now(),
1691            dirty: true,
1692            frame_idx: 0,
1693            widget_signals: Vec::new(),
1694            widget_refresh_config: config.widget_refresh,
1695            widget_refresh_plan: WidgetRefreshPlan::new(),
1696            width,
1697            height,
1698            forced_size: config.forced_size,
1699            poll_timeout: config.poll_timeout,
1700            budget,
1701            conformal_predictor,
1702            last_frame_time_us: None,
1703            last_update_us: None,
1704            frame_timing,
1705            locale_context,
1706            locale_version,
1707            resize_coalescer,
1708            evidence_sink,
1709            fairness_config_logged: false,
1710            resize_behavior: config.resize_behavior,
1711            fairness_guard: InputFairnessGuard::new(),
1712            event_recorder: None,
1713            subscriptions,
1714            task_sender,
1715            task_receiver,
1716            task_handles: Vec::new(),
1717            effect_queue,
1718            state_registry: config.persistence.registry.clone(),
1719            persistence_config: config.persistence,
1720            last_checkpoint: Instant::now(),
1721            inline_auto_remeasure,
1722        })
1723    }
1724}
1725
1726impl<M: Model, W: Write + Send> Program<M, W> {
1727    /// Run the main event loop.
1728    ///
1729    /// This is the main entry point. It handles:
1730    /// 1. Initialization (terminal setup, raw mode)
1731    /// 2. Event polling and message dispatch
1732    /// 3. Frame rendering
1733    /// 4. Shutdown (terminal cleanup)
1734    pub fn run(&mut self) -> io::Result<()> {
1735        self.run_event_loop()
1736    }
1737
1738    /// Access widget scheduling signals captured on the last render.
1739    #[inline]
1740    pub fn last_widget_signals(&self) -> &[WidgetSignal] {
1741        &self.widget_signals
1742    }
1743
1744    /// The inner event loop, separated for proper cleanup handling.
1745    fn run_event_loop(&mut self) -> io::Result<()> {
1746        // Auto-load state on start
1747        if self.persistence_config.auto_load {
1748            self.load_state();
1749        }
1750
1751        // Initialize
1752        let cmd = {
1753            let _span = info_span!("ftui.program.init").entered();
1754            self.model.init()
1755        };
1756        self.execute_cmd(cmd)?;
1757
1758        // Reconcile initial subscriptions
1759        self.reconcile_subscriptions();
1760
1761        // Initial render
1762        self.render_frame()?;
1763
1764        // Main loop
1765        let mut loop_count: u64 = 0;
1766        while self.running {
1767            loop_count += 1;
1768            // Log heartbeat every 100 iterations to avoid flooding stderr
1769            if loop_count.is_multiple_of(100) {
1770                crate::debug_trace!("main loop heartbeat: iteration {}", loop_count);
1771            }
1772
1773            // Poll for input with tick timeout
1774            let timeout = self.effective_timeout();
1775
1776            // Poll for events with timeout
1777            if self.session.poll_event(timeout)? {
1778                // Drain all pending events
1779                loop {
1780                    // read_event returns Option<Event> after converting from crossterm
1781                    if let Some(event) = self.session.read_event()? {
1782                        self.handle_event(event)?;
1783                    }
1784                    if !self.session.poll_event(Duration::from_millis(0))? {
1785                        break;
1786                    }
1787                }
1788            }
1789
1790            // Process subscription messages
1791            self.process_subscription_messages()?;
1792
1793            // Process background task results
1794            self.process_task_results()?;
1795            self.reap_finished_tasks();
1796
1797            self.process_resize_coalescer()?;
1798
1799            // Check for tick - deliver to model so periodic logic can run
1800            if self.should_tick() {
1801                let msg = M::Message::from(Event::Tick);
1802                let cmd = {
1803                    let _span = debug_span!(
1804                        "ftui.program.update",
1805                        msg_type = "Tick",
1806                        duration_us = tracing::field::Empty,
1807                        cmd_type = tracing::field::Empty
1808                    )
1809                    .entered();
1810                    let start = Instant::now();
1811                    let cmd = self.model.update(msg);
1812                    tracing::Span::current()
1813                        .record("duration_us", start.elapsed().as_micros() as u64);
1814                    tracing::Span::current()
1815                        .record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
1816                    cmd
1817                };
1818                self.mark_dirty();
1819                self.execute_cmd(cmd)?;
1820                self.reconcile_subscriptions();
1821            }
1822
1823            // Check for periodic checkpoint save
1824            self.check_checkpoint_save();
1825
1826            // Detect locale changes outside the event loop.
1827            self.check_locale_change();
1828
1829            // Render if dirty
1830            if self.dirty {
1831                self.render_frame()?;
1832            }
1833
1834            // Periodic grapheme pool GC
1835            if loop_count.is_multiple_of(1000) {
1836                self.writer.gc();
1837            }
1838        }
1839
1840        // Auto-save state on exit
1841        if self.persistence_config.auto_save {
1842            self.save_state();
1843        }
1844
1845        // Stop all subscriptions on exit
1846        self.subscriptions.stop_all();
1847        self.reap_finished_tasks();
1848
1849        Ok(())
1850    }
1851
1852    /// Load state from the persistence registry.
1853    fn load_state(&mut self) {
1854        if let Some(registry) = &self.state_registry {
1855            match registry.load() {
1856                Ok(count) => {
1857                    info!(count, "loaded widget state from persistence");
1858                }
1859                Err(e) => {
1860                    tracing::warn!(error = %e, "failed to load widget state");
1861                }
1862            }
1863        }
1864    }
1865
1866    /// Save state to the persistence registry.
1867    fn save_state(&mut self) {
1868        if let Some(registry) = &self.state_registry {
1869            match registry.flush() {
1870                Ok(true) => {
1871                    debug!("saved widget state to persistence");
1872                }
1873                Ok(false) => {
1874                    // No changes to save
1875                }
1876                Err(e) => {
1877                    tracing::warn!(error = %e, "failed to save widget state");
1878                }
1879            }
1880        }
1881    }
1882
1883    /// Check if it's time for a periodic checkpoint save.
1884    fn check_checkpoint_save(&mut self) {
1885        if let Some(interval) = self.persistence_config.checkpoint_interval
1886            && self.last_checkpoint.elapsed() >= interval
1887        {
1888            self.save_state();
1889            self.last_checkpoint = Instant::now();
1890        }
1891    }
1892
1893    fn handle_event(&mut self, event: Event) -> io::Result<()> {
1894        // Track event start time and type for fairness scheduling.
1895        let event_start = Instant::now();
1896        let fairness_event_type = Self::classify_event_for_fairness(&event);
1897        if fairness_event_type == FairnessEventType::Input {
1898            self.fairness_guard.input_arrived(event_start);
1899        }
1900
1901        // Record event before processing (no-op when recorder is None or idle).
1902        if let Some(recorder) = &mut self.event_recorder {
1903            recorder.record(&event);
1904        }
1905
1906        let event = match event {
1907            Event::Resize { width, height } => {
1908                debug!(
1909                    width,
1910                    height,
1911                    behavior = ?self.resize_behavior,
1912                    "Resize event received"
1913                );
1914                if let Some((forced_width, forced_height)) = self.forced_size {
1915                    debug!(
1916                        forced_width,
1917                        forced_height, "Resize ignored due to forced size override"
1918                    );
1919                    self.fairness_guard.event_processed(
1920                        fairness_event_type,
1921                        event_start.elapsed(),
1922                        Instant::now(),
1923                    );
1924                    return Ok(());
1925                }
1926                // Clamp to minimum 1 to prevent Buffer::new panic on zero dimensions
1927                let width = width.max(1);
1928                let height = height.max(1);
1929                match self.resize_behavior {
1930                    ResizeBehavior::Immediate => {
1931                        self.resize_coalescer
1932                            .record_external_apply(width, height, Instant::now());
1933                        let result = self.apply_resize(width, height, Duration::ZERO, false);
1934                        self.fairness_guard.event_processed(
1935                            fairness_event_type,
1936                            event_start.elapsed(),
1937                            Instant::now(),
1938                        );
1939                        return result;
1940                    }
1941                    ResizeBehavior::Throttled => {
1942                        let action = self.resize_coalescer.handle_resize(width, height);
1943                        if let CoalesceAction::ApplyResize {
1944                            width,
1945                            height,
1946                            coalesce_time,
1947                            forced_by_deadline,
1948                        } = action
1949                        {
1950                            let result =
1951                                self.apply_resize(width, height, coalesce_time, forced_by_deadline);
1952                            self.fairness_guard.event_processed(
1953                                fairness_event_type,
1954                                event_start.elapsed(),
1955                                Instant::now(),
1956                            );
1957                            return result;
1958                        }
1959
1960                        self.fairness_guard.event_processed(
1961                            fairness_event_type,
1962                            event_start.elapsed(),
1963                            Instant::now(),
1964                        );
1965                        return Ok(());
1966                    }
1967                }
1968            }
1969            other => other,
1970        };
1971
1972        let msg = M::Message::from(event);
1973        let cmd = {
1974            let _span = debug_span!(
1975                "ftui.program.update",
1976                msg_type = "event",
1977                duration_us = tracing::field::Empty,
1978                cmd_type = tracing::field::Empty
1979            )
1980            .entered();
1981            let start = Instant::now();
1982            let cmd = self.model.update(msg);
1983            let elapsed_us = start.elapsed().as_micros() as u64;
1984            self.last_update_us = Some(elapsed_us);
1985            tracing::Span::current().record("duration_us", elapsed_us);
1986            tracing::Span::current()
1987                .record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
1988            cmd
1989        };
1990        self.mark_dirty();
1991        self.execute_cmd(cmd)?;
1992        self.reconcile_subscriptions();
1993
1994        // Track input event processing for fairness.
1995        self.fairness_guard.event_processed(
1996            fairness_event_type,
1997            event_start.elapsed(),
1998            Instant::now(),
1999        );
2000
2001        Ok(())
2002    }
2003
2004    /// Classify an event for fairness tracking.
2005    fn classify_event_for_fairness(event: &Event) -> FairnessEventType {
2006        match event {
2007            Event::Key(_)
2008            | Event::Mouse(_)
2009            | Event::Paste(_)
2010            | Event::Focus(_)
2011            | Event::Clipboard(_) => FairnessEventType::Input,
2012            Event::Resize { .. } => FairnessEventType::Resize,
2013            Event::Tick => FairnessEventType::Tick,
2014        }
2015    }
2016
2017    /// Reconcile the model's declared subscriptions with running ones.
2018    fn reconcile_subscriptions(&mut self) {
2019        let _span = debug_span!(
2020            "ftui.program.subscriptions",
2021            active_count = tracing::field::Empty,
2022            started = tracing::field::Empty,
2023            stopped = tracing::field::Empty
2024        )
2025        .entered();
2026        let subs = self.model.subscriptions();
2027        let before_count = self.subscriptions.active_count();
2028        self.subscriptions.reconcile(subs);
2029        let after_count = self.subscriptions.active_count();
2030        let started = after_count.saturating_sub(before_count);
2031        let stopped = before_count.saturating_sub(after_count);
2032        crate::debug_trace!(
2033            "subscriptions reconcile: before={}, after={}, started={}, stopped={}",
2034            before_count,
2035            after_count,
2036            started,
2037            stopped
2038        );
2039        if after_count == 0 {
2040            crate::debug_trace!("subscriptions reconcile: no active subscriptions");
2041        }
2042        let current = tracing::Span::current();
2043        current.record("active_count", after_count);
2044        // started/stopped would require tracking in SubscriptionManager
2045        current.record("started", started);
2046        current.record("stopped", stopped);
2047    }
2048
2049    /// Process pending messages from subscriptions.
2050    fn process_subscription_messages(&mut self) -> io::Result<()> {
2051        let messages = self.subscriptions.drain_messages();
2052        let msg_count = messages.len();
2053        if msg_count > 0 {
2054            crate::debug_trace!("processing {} subscription message(s)", msg_count);
2055        }
2056        for msg in messages {
2057            let cmd = {
2058                let _span = debug_span!(
2059                    "ftui.program.update",
2060                    msg_type = "subscription",
2061                    duration_us = tracing::field::Empty,
2062                    cmd_type = tracing::field::Empty
2063                )
2064                .entered();
2065                let start = Instant::now();
2066                let cmd = self.model.update(msg);
2067                let elapsed_us = start.elapsed().as_micros() as u64;
2068                self.last_update_us = Some(elapsed_us);
2069                tracing::Span::current().record("duration_us", elapsed_us);
2070                tracing::Span::current()
2071                    .record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
2072                cmd
2073            };
2074            self.mark_dirty();
2075            self.execute_cmd(cmd)?;
2076        }
2077        if self.dirty {
2078            self.reconcile_subscriptions();
2079        }
2080        Ok(())
2081    }
2082
2083    /// Process results from background tasks.
2084    fn process_task_results(&mut self) -> io::Result<()> {
2085        while let Ok(msg) = self.task_receiver.try_recv() {
2086            let cmd = {
2087                let _span = debug_span!(
2088                    "ftui.program.update",
2089                    msg_type = "task",
2090                    duration_us = tracing::field::Empty,
2091                    cmd_type = tracing::field::Empty
2092                )
2093                .entered();
2094                let start = Instant::now();
2095                let cmd = self.model.update(msg);
2096                let elapsed_us = start.elapsed().as_micros() as u64;
2097                self.last_update_us = Some(elapsed_us);
2098                tracing::Span::current().record("duration_us", elapsed_us);
2099                tracing::Span::current()
2100                    .record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
2101                cmd
2102            };
2103            self.mark_dirty();
2104            self.execute_cmd(cmd)?;
2105        }
2106        if self.dirty {
2107            self.reconcile_subscriptions();
2108        }
2109        Ok(())
2110    }
2111
2112    /// Execute a command.
2113    fn execute_cmd(&mut self, cmd: Cmd<M::Message>) -> io::Result<()> {
2114        match cmd {
2115            Cmd::None => {}
2116            Cmd::Quit => self.running = false,
2117            Cmd::Msg(m) => {
2118                let start = Instant::now();
2119                let cmd = self.model.update(m);
2120                let elapsed_us = start.elapsed().as_micros() as u64;
2121                self.last_update_us = Some(elapsed_us);
2122                self.mark_dirty();
2123                self.execute_cmd(cmd)?;
2124            }
2125            Cmd::Batch(cmds) => {
2126                // Batch currently executes sequentially. This is intentional
2127                // until an async runtime or task scheduler is added.
2128                for c in cmds {
2129                    self.execute_cmd(c)?;
2130                    if !self.running {
2131                        break;
2132                    }
2133                }
2134            }
2135            Cmd::Sequence(cmds) => {
2136                for c in cmds {
2137                    self.execute_cmd(c)?;
2138                    if !self.running {
2139                        break;
2140                    }
2141                }
2142            }
2143            Cmd::Tick(duration) => {
2144                self.tick_rate = Some(duration);
2145                self.last_tick = Instant::now();
2146            }
2147            Cmd::Log(text) => {
2148                let sanitized = sanitize(&text);
2149                if sanitized.ends_with('\n') {
2150                    self.writer.write_log(&sanitized)?;
2151                } else {
2152                    let mut owned = sanitized.into_owned();
2153                    owned.push('\n');
2154                    self.writer.write_log(&owned)?;
2155                }
2156            }
2157            Cmd::Task(spec, f) => {
2158                if let Some(ref queue) = self.effect_queue {
2159                    queue.enqueue(spec, f);
2160                } else {
2161                    let sender = self.task_sender.clone();
2162                    let handle = std::thread::spawn(move || {
2163                        let msg = f();
2164                        let _ = sender.send(msg);
2165                    });
2166                    self.task_handles.push(handle);
2167                }
2168            }
2169            Cmd::SaveState => {
2170                self.save_state();
2171            }
2172            Cmd::RestoreState => {
2173                self.load_state();
2174            }
2175        }
2176        Ok(())
2177    }
2178
2179    fn reap_finished_tasks(&mut self) {
2180        if self.task_handles.is_empty() {
2181            return;
2182        }
2183
2184        let mut remaining = Vec::with_capacity(self.task_handles.len());
2185        for handle in self.task_handles.drain(..) {
2186            if handle.is_finished() {
2187                let _ = handle.join();
2188            } else {
2189                remaining.push(handle);
2190            }
2191        }
2192        self.task_handles = remaining;
2193    }
2194
2195    /// Render a frame with budget tracking.
2196    fn render_frame(&mut self) -> io::Result<()> {
2197        crate::debug_trace!("render_frame: {}x{}", self.width, self.height);
2198
2199        self.frame_idx = self.frame_idx.wrapping_add(1);
2200        let frame_idx = self.frame_idx;
2201        let degradation_start = self.budget.degradation();
2202
2203        // Reset budget for new frame, potentially upgrading quality
2204        self.budget.next_frame();
2205
2206        // Apply conformal risk gate before rendering (if enabled)
2207        let mut conformal_prediction = None;
2208        if let Some(predictor) = self.conformal_predictor.as_ref() {
2209            let baseline_us = self
2210                .last_frame_time_us
2211                .unwrap_or_else(|| self.budget.total().as_secs_f64() * 1_000_000.0);
2212            let diff_strategy = self
2213                .writer
2214                .last_diff_strategy()
2215                .unwrap_or(DiffStrategy::Full);
2216            let frame_height_hint = self.writer.render_height_hint().max(1);
2217            let key = BucketKey::from_context(
2218                self.writer.screen_mode(),
2219                diff_strategy,
2220                self.width,
2221                frame_height_hint,
2222            );
2223            let budget_us = self.budget.total().as_secs_f64() * 1_000_000.0;
2224            let prediction = predictor.predict(key, baseline_us, budget_us);
2225            if prediction.risk {
2226                self.budget.degrade();
2227            }
2228            debug!(
2229                bucket = %prediction.bucket,
2230                upper_us = prediction.upper_us,
2231                budget_us = prediction.budget_us,
2232                fallback = prediction.fallback_level,
2233                risk = prediction.risk,
2234                "conformal risk gate"
2235            );
2236            conformal_prediction = Some(prediction);
2237        }
2238
2239        // Early skip if budget says to skip this frame entirely
2240        if self.budget.exhausted() {
2241            self.budget.record_frame_time(Duration::ZERO);
2242            self.emit_budget_evidence(
2243                frame_idx,
2244                degradation_start,
2245                0.0,
2246                conformal_prediction.as_ref(),
2247            );
2248            crate::debug_trace!(
2249                "frame skipped: budget exhausted (degradation={})",
2250                self.budget.degradation().as_str()
2251            );
2252            debug!(
2253                degradation = self.budget.degradation().as_str(),
2254                "frame skipped: budget exhausted before render"
2255            );
2256            self.dirty = false;
2257            return Ok(());
2258        }
2259
2260        let auto_bounds = self.writer.inline_auto_bounds();
2261        let needs_measure = auto_bounds.is_some() && self.writer.auto_ui_height().is_none();
2262        let mut should_measure = needs_measure;
2263        if auto_bounds.is_some()
2264            && let Some(state) = self.inline_auto_remeasure.as_mut()
2265        {
2266            let decision = state.sampler.decide(Instant::now());
2267            if decision.should_sample {
2268                should_measure = true;
2269            }
2270        } else {
2271            crate::voi_telemetry::clear_inline_auto_voi_snapshot();
2272        }
2273
2274        // --- Render phase ---
2275        let render_start = Instant::now();
2276        if let (Some((min_height, max_height)), true) = (auto_bounds, should_measure) {
2277            let measure_height = if needs_measure {
2278                self.writer.render_height_hint().max(1)
2279            } else {
2280                max_height.max(1)
2281            };
2282            let (measure_buffer, _) = self.render_measure_buffer(measure_height);
2283            let measured_height = measure_buffer.content_height();
2284            let clamped = measured_height.clamp(min_height, max_height);
2285            let previous_height = self.writer.auto_ui_height();
2286            self.writer.set_auto_ui_height(clamped);
2287            if let Some(state) = self.inline_auto_remeasure.as_mut() {
2288                let threshold = state.config.change_threshold_rows;
2289                let violated = previous_height
2290                    .map(|prev| prev.abs_diff(clamped) >= threshold)
2291                    .unwrap_or(false);
2292                state.sampler.observe(violated);
2293            }
2294        }
2295        if auto_bounds.is_some()
2296            && let Some(state) = self.inline_auto_remeasure.as_ref()
2297        {
2298            let snapshot = state.sampler.snapshot(8, crate::debug_trace::elapsed_ms());
2299            crate::voi_telemetry::set_inline_auto_voi_snapshot(Some(snapshot));
2300        }
2301
2302        let frame_height = self.writer.render_height_hint().max(1);
2303        let _frame_span = info_span!(
2304            "ftui.render.frame",
2305            width = self.width,
2306            height = frame_height,
2307            duration_us = tracing::field::Empty
2308        )
2309        .entered();
2310        let (buffer, cursor, cursor_visible) = self.render_buffer(frame_height);
2311        self.update_widget_refresh_plan(frame_idx);
2312        let render_elapsed = render_start.elapsed();
2313        let mut present_elapsed = Duration::ZERO;
2314        let mut presented = false;
2315
2316        // Check if render phase overspent its budget
2317        let render_budget = self.budget.phase_budgets().render;
2318        if render_elapsed > render_budget {
2319            debug!(
2320                render_ms = render_elapsed.as_millis() as u32,
2321                budget_ms = render_budget.as_millis() as u32,
2322                "render phase exceeded budget"
2323            );
2324            // Trigger degradation if we're consistently over budget
2325            if self.budget.should_degrade(render_budget) {
2326                self.budget.degrade();
2327            }
2328        }
2329
2330        // --- Present phase ---
2331        if !self.budget.exhausted() {
2332            let present_start = Instant::now();
2333            {
2334                let _present_span = debug_span!("ftui.render.present").entered();
2335                self.writer
2336                    .present_ui_owned(buffer, cursor, cursor_visible)?;
2337            }
2338            presented = true;
2339            present_elapsed = present_start.elapsed();
2340
2341            let present_budget = self.budget.phase_budgets().present;
2342            if present_elapsed > present_budget {
2343                debug!(
2344                    present_ms = present_elapsed.as_millis() as u32,
2345                    budget_ms = present_budget.as_millis() as u32,
2346                    "present phase exceeded budget"
2347                );
2348            }
2349        } else {
2350            debug!(
2351                degradation = self.budget.degradation().as_str(),
2352                elapsed_ms = self.budget.elapsed().as_millis() as u32,
2353                "frame present skipped: budget exhausted after render"
2354            );
2355        }
2356
2357        if let Some(ref frame_timing) = self.frame_timing {
2358            let update_us = self.last_update_us.unwrap_or(0);
2359            let render_us = render_elapsed.as_micros() as u64;
2360            let present_us = present_elapsed.as_micros() as u64;
2361            let diff_us = if presented {
2362                self.writer
2363                    .take_last_present_timings()
2364                    .map(|timings| timings.diff_us)
2365                    .unwrap_or(0)
2366            } else {
2367                let _ = self.writer.take_last_present_timings();
2368                0
2369            };
2370            let total_us = update_us
2371                .saturating_add(render_us)
2372                .saturating_add(present_us);
2373            let timing = FrameTiming {
2374                frame_idx,
2375                update_us,
2376                render_us,
2377                diff_us,
2378                present_us,
2379                total_us,
2380            };
2381            frame_timing.sink.record_frame(&timing);
2382        }
2383
2384        let frame_time = render_elapsed.saturating_add(present_elapsed);
2385        self.budget.record_frame_time(frame_time);
2386        let frame_time_us = frame_time.as_secs_f64() * 1_000_000.0;
2387
2388        if let (Some(predictor), Some(prediction)) = (
2389            self.conformal_predictor.as_mut(),
2390            conformal_prediction.as_ref(),
2391        ) {
2392            let diff_strategy = self
2393                .writer
2394                .last_diff_strategy()
2395                .unwrap_or(DiffStrategy::Full);
2396            let key = BucketKey::from_context(
2397                self.writer.screen_mode(),
2398                diff_strategy,
2399                self.width,
2400                frame_height,
2401            );
2402            predictor.observe(key, prediction.y_hat, frame_time_us);
2403        }
2404        self.last_frame_time_us = Some(frame_time_us);
2405        self.emit_budget_evidence(
2406            frame_idx,
2407            degradation_start,
2408            frame_time_us,
2409            conformal_prediction.as_ref(),
2410        );
2411        self.dirty = false;
2412
2413        Ok(())
2414    }
2415
2416    fn emit_budget_evidence(
2417        &self,
2418        frame_idx: u64,
2419        degradation_start: DegradationLevel,
2420        frame_time_us: f64,
2421        conformal_prediction: Option<&ConformalPrediction>,
2422    ) {
2423        let Some(telemetry) = self.budget.telemetry() else {
2424            set_budget_snapshot(None);
2425            return;
2426        };
2427
2428        let budget_us = conformal_prediction
2429            .map(|prediction| prediction.budget_us)
2430            .unwrap_or_else(|| self.budget.total().as_secs_f64() * 1_000_000.0);
2431        let conformal = conformal_prediction.map(ConformalEvidence::from_prediction);
2432        let degradation_after = self.budget.degradation();
2433
2434        let evidence = BudgetDecisionEvidence {
2435            frame_idx,
2436            decision: BudgetDecisionEvidence::decision_from_levels(
2437                degradation_start,
2438                degradation_after,
2439            ),
2440            controller_decision: telemetry.last_decision,
2441            degradation_before: degradation_start,
2442            degradation_after,
2443            frame_time_us,
2444            budget_us,
2445            pid_output: telemetry.pid_output,
2446            pid_p: telemetry.pid_p,
2447            pid_i: telemetry.pid_i,
2448            pid_d: telemetry.pid_d,
2449            e_value: telemetry.e_value,
2450            frames_observed: telemetry.frames_observed,
2451            frames_since_change: telemetry.frames_since_change,
2452            in_warmup: telemetry.in_warmup,
2453            conformal,
2454        };
2455
2456        let conformal_snapshot = evidence
2457            .conformal
2458            .as_ref()
2459            .map(|snapshot| ConformalSnapshot {
2460                bucket_key: snapshot.bucket_key.clone(),
2461                sample_count: snapshot.n_b,
2462                upper_us: snapshot.upper_us,
2463                risk: snapshot.risk,
2464            });
2465        set_budget_snapshot(Some(BudgetDecisionSnapshot {
2466            frame_idx: evidence.frame_idx,
2467            decision: evidence.decision,
2468            controller_decision: evidence.controller_decision,
2469            degradation_before: evidence.degradation_before,
2470            degradation_after: evidence.degradation_after,
2471            frame_time_us: evidence.frame_time_us,
2472            budget_us: evidence.budget_us,
2473            pid_output: evidence.pid_output,
2474            e_value: evidence.e_value,
2475            frames_observed: evidence.frames_observed,
2476            frames_since_change: evidence.frames_since_change,
2477            in_warmup: evidence.in_warmup,
2478            conformal: conformal_snapshot,
2479        }));
2480
2481        if let Some(ref sink) = self.evidence_sink {
2482            let _ = sink.write_jsonl(&evidence.to_jsonl());
2483        }
2484    }
2485
2486    fn update_widget_refresh_plan(&mut self, frame_idx: u64) {
2487        if !self.widget_refresh_config.enabled {
2488            self.widget_refresh_plan.clear();
2489            return;
2490        }
2491
2492        let budget_us = self.budget.phase_budgets().render.as_secs_f64() * 1_000_000.0;
2493        let degradation = self.budget.degradation();
2494        self.widget_refresh_plan.recompute(
2495            frame_idx,
2496            budget_us,
2497            degradation,
2498            &self.widget_signals,
2499            &self.widget_refresh_config,
2500        );
2501
2502        if let Some(ref sink) = self.evidence_sink {
2503            let _ = sink.write_jsonl(&self.widget_refresh_plan.to_jsonl());
2504        }
2505    }
2506
2507    fn render_buffer(&mut self, frame_height: u16) -> (Buffer, Option<(u16, u16)>, bool) {
2508        // Note: Frame borrows the pool and links from writer.
2509        // We scope it so it drops before we call present_ui (which needs exclusive writer access).
2510        let buffer = self.writer.take_render_buffer(self.width, frame_height);
2511        let (pool, links) = self.writer.pool_and_links_mut();
2512        let mut frame = Frame::from_buffer(buffer, pool);
2513        frame.set_degradation(self.budget.degradation());
2514        frame.set_links(links);
2515        frame.set_widget_budget(self.widget_refresh_plan.as_budget());
2516
2517        let view_start = Instant::now();
2518        let _view_span = debug_span!(
2519            "ftui.program.view",
2520            duration_us = tracing::field::Empty,
2521            widget_count = tracing::field::Empty
2522        )
2523        .entered();
2524        self.model.view(&mut frame);
2525        self.widget_signals = frame.take_widget_signals();
2526        tracing::Span::current().record("duration_us", view_start.elapsed().as_micros() as u64);
2527        // widget_count would require tracking in Frame
2528
2529        (frame.buffer, frame.cursor_position, frame.cursor_visible)
2530    }
2531
2532    fn emit_fairness_evidence(&mut self, decision: &FairnessDecision, dominance_count: u32) {
2533        let Some(ref sink) = self.evidence_sink else {
2534            return;
2535        };
2536
2537        let config = self.fairness_guard.config();
2538        if !self.fairness_config_logged {
2539            let config_entry = FairnessConfigEvidence {
2540                enabled: config.enabled,
2541                input_priority_threshold_ms: config.input_priority_threshold.as_millis() as u64,
2542                dominance_threshold: config.dominance_threshold,
2543                fairness_threshold: config.fairness_threshold,
2544            };
2545            let _ = sink.write_jsonl(&config_entry.to_jsonl());
2546            self.fairness_config_logged = true;
2547        }
2548
2549        let evidence = FairnessDecisionEvidence {
2550            frame_idx: self.frame_idx,
2551            decision: if decision.should_process {
2552                "allow"
2553            } else {
2554                "yield"
2555            },
2556            reason: decision.reason.as_str(),
2557            pending_input_latency_ms: decision
2558                .pending_input_latency
2559                .map(|latency| latency.as_millis() as u64),
2560            jain_index: decision.jain_index,
2561            resize_dominance_count: dominance_count,
2562            dominance_threshold: config.dominance_threshold,
2563            fairness_threshold: config.fairness_threshold,
2564            input_priority_threshold_ms: config.input_priority_threshold.as_millis() as u64,
2565        };
2566
2567        let _ = sink.write_jsonl(&evidence.to_jsonl());
2568    }
2569
2570    fn render_measure_buffer(&mut self, frame_height: u16) -> (Buffer, Option<(u16, u16)>) {
2571        let pool = self.writer.pool_mut();
2572        let mut frame = Frame::new(self.width, frame_height, pool);
2573        frame.set_degradation(self.budget.degradation());
2574
2575        let view_start = Instant::now();
2576        let _view_span = debug_span!(
2577            "ftui.program.view",
2578            duration_us = tracing::field::Empty,
2579            widget_count = tracing::field::Empty
2580        )
2581        .entered();
2582        self.model.view(&mut frame);
2583        tracing::Span::current().record("duration_us", view_start.elapsed().as_micros() as u64);
2584
2585        (frame.buffer, frame.cursor_position)
2586    }
2587
2588    /// Calculate the effective poll timeout.
2589    fn effective_timeout(&self) -> Duration {
2590        if let Some(tick_rate) = self.tick_rate {
2591            let elapsed = self.last_tick.elapsed();
2592            let mut timeout = tick_rate.saturating_sub(elapsed);
2593            if self.resize_behavior.uses_coalescer()
2594                && let Some(resize_timeout) = self.resize_coalescer.time_until_apply(Instant::now())
2595            {
2596                timeout = timeout.min(resize_timeout);
2597            }
2598            timeout
2599        } else {
2600            let mut timeout = self.poll_timeout;
2601            if self.resize_behavior.uses_coalescer()
2602                && let Some(resize_timeout) = self.resize_coalescer.time_until_apply(Instant::now())
2603            {
2604                timeout = timeout.min(resize_timeout);
2605            }
2606            timeout
2607        }
2608    }
2609
2610    /// Check if we should send a tick.
2611    fn should_tick(&mut self) -> bool {
2612        if let Some(tick_rate) = self.tick_rate
2613            && self.last_tick.elapsed() >= tick_rate
2614        {
2615            self.last_tick = Instant::now();
2616            return true;
2617        }
2618        false
2619    }
2620
2621    fn process_resize_coalescer(&mut self) -> io::Result<()> {
2622        if !self.resize_behavior.uses_coalescer() {
2623            return Ok(());
2624        }
2625
2626        // Check fairness: if input is starving, skip resize application this cycle.
2627        // This ensures input events are processed before resize is finalized.
2628        let dominance_count = self.fairness_guard.resize_dominance_count();
2629        let fairness_decision = self.fairness_guard.check_fairness(Instant::now());
2630        self.emit_fairness_evidence(&fairness_decision, dominance_count);
2631        if !fairness_decision.should_process {
2632            debug!(
2633                reason = ?fairness_decision.reason,
2634                pending_latency_ms = fairness_decision.pending_input_latency.map(|d| d.as_millis() as u64),
2635                "Resize yielding to input for fairness"
2636            );
2637            // Skip resize application this cycle to allow input processing.
2638            return Ok(());
2639        }
2640
2641        let action = self.resize_coalescer.tick();
2642        let resize_snapshot =
2643            self.resize_coalescer
2644                .logs()
2645                .last()
2646                .map(|entry| ResizeDecisionSnapshot {
2647                    event_idx: entry.event_idx,
2648                    action: entry.action,
2649                    dt_ms: entry.dt_ms,
2650                    event_rate: entry.event_rate,
2651                    regime: entry.regime,
2652                    pending_size: entry.pending_size,
2653                    applied_size: entry.applied_size,
2654                    time_since_render_ms: entry.time_since_render_ms,
2655                    bocpd: self
2656                        .resize_coalescer
2657                        .bocpd()
2658                        .and_then(|detector| detector.last_evidence().cloned()),
2659                });
2660        set_resize_snapshot(resize_snapshot);
2661
2662        match action {
2663            CoalesceAction::ApplyResize {
2664                width,
2665                height,
2666                coalesce_time,
2667                forced_by_deadline,
2668            } => self.apply_resize(width, height, coalesce_time, forced_by_deadline),
2669            _ => Ok(()),
2670        }
2671    }
2672
2673    fn apply_resize(
2674        &mut self,
2675        width: u16,
2676        height: u16,
2677        coalesce_time: Duration,
2678        forced_by_deadline: bool,
2679    ) -> io::Result<()> {
2680        // Clamp to minimum 1 to prevent Buffer::new panic on zero dimensions
2681        let width = width.max(1);
2682        let height = height.max(1);
2683        self.width = width;
2684        self.height = height;
2685        self.writer.set_size(width, height);
2686        info!(
2687            width = width,
2688            height = height,
2689            coalesce_ms = coalesce_time.as_millis() as u64,
2690            forced = forced_by_deadline,
2691            "Resize applied"
2692        );
2693
2694        let msg = M::Message::from(Event::Resize { width, height });
2695        let start = Instant::now();
2696        let cmd = self.model.update(msg);
2697        let elapsed_us = start.elapsed().as_micros() as u64;
2698        self.last_update_us = Some(elapsed_us);
2699        self.mark_dirty();
2700        self.execute_cmd(cmd)
2701    }
2702
2703    // removed: resize placeholder rendering (continuous reflow preferred)
2704
2705    /// Get a reference to the model.
2706    pub fn model(&self) -> &M {
2707        &self.model
2708    }
2709
2710    /// Get a mutable reference to the model.
2711    pub fn model_mut(&mut self) -> &mut M {
2712        &mut self.model
2713    }
2714
2715    /// Check if the program is running.
2716    pub fn is_running(&self) -> bool {
2717        self.running
2718    }
2719
2720    /// Request a quit.
2721    pub fn quit(&mut self) {
2722        self.running = false;
2723    }
2724
2725    /// Get a reference to the state registry, if configured.
2726    pub fn state_registry(&self) -> Option<&std::sync::Arc<StateRegistry>> {
2727        self.state_registry.as_ref()
2728    }
2729
2730    /// Check if state persistence is enabled.
2731    pub fn has_persistence(&self) -> bool {
2732        self.state_registry.is_some()
2733    }
2734
2735    /// Trigger a manual save of widget state.
2736    ///
2737    /// Returns the result of the flush operation, or `Ok(false)` if
2738    /// persistence is not configured.
2739    pub fn trigger_save(&mut self) -> StorageResult<bool> {
2740        if let Some(registry) = &self.state_registry {
2741            registry.flush()
2742        } else {
2743            Ok(false)
2744        }
2745    }
2746
2747    /// Trigger a manual load of widget state.
2748    ///
2749    /// Returns the number of entries loaded, or `Ok(0)` if persistence
2750    /// is not configured.
2751    pub fn trigger_load(&mut self) -> StorageResult<usize> {
2752        if let Some(registry) = &self.state_registry {
2753            registry.load()
2754        } else {
2755            Ok(0)
2756        }
2757    }
2758
2759    fn mark_dirty(&mut self) {
2760        self.dirty = true;
2761    }
2762
2763    fn check_locale_change(&mut self) {
2764        let version = self.locale_context.version();
2765        if version != self.locale_version {
2766            self.locale_version = version;
2767            self.mark_dirty();
2768        }
2769    }
2770
2771    /// Mark the UI as needing redraw.
2772    pub fn request_redraw(&mut self) {
2773        self.mark_dirty();
2774    }
2775
2776    /// Request a re-measure of inline auto UI height on next render.
2777    pub fn request_ui_height_remeasure(&mut self) {
2778        if self.writer.inline_auto_bounds().is_some() {
2779            self.writer.clear_auto_ui_height();
2780            if let Some(state) = self.inline_auto_remeasure.as_mut() {
2781                state.reset();
2782            }
2783            crate::voi_telemetry::clear_inline_auto_voi_snapshot();
2784            self.mark_dirty();
2785        }
2786    }
2787
2788    /// Start recording events into a macro.
2789    ///
2790    /// If already recording, the current recording is discarded and a new one starts.
2791    /// The current terminal size is captured as metadata.
2792    pub fn start_recording(&mut self, name: impl Into<String>) {
2793        let mut recorder = EventRecorder::new(name).with_terminal_size(self.width, self.height);
2794        recorder.start();
2795        self.event_recorder = Some(recorder);
2796    }
2797
2798    /// Stop recording and return the recorded macro, if any.
2799    ///
2800    /// Returns `None` if not currently recording.
2801    pub fn stop_recording(&mut self) -> Option<InputMacro> {
2802        self.event_recorder.take().map(EventRecorder::finish)
2803    }
2804
2805    /// Check if event recording is active.
2806    pub fn is_recording(&self) -> bool {
2807        self.event_recorder
2808            .as_ref()
2809            .is_some_and(EventRecorder::is_recording)
2810    }
2811}
2812
2813/// Builder for creating and running programs.
2814pub struct App;
2815
2816impl App {
2817    /// Create a new app builder with the given model.
2818    #[allow(clippy::new_ret_no_self)] // App is a namespace for builder methods
2819    pub fn new<M: Model>(model: M) -> AppBuilder<M> {
2820        AppBuilder {
2821            model,
2822            config: ProgramConfig::default(),
2823        }
2824    }
2825
2826    /// Create a fullscreen app.
2827    pub fn fullscreen<M: Model>(model: M) -> AppBuilder<M> {
2828        AppBuilder {
2829            model,
2830            config: ProgramConfig::fullscreen(),
2831        }
2832    }
2833
2834    /// Create an inline app with the given height.
2835    pub fn inline<M: Model>(model: M, height: u16) -> AppBuilder<M> {
2836        AppBuilder {
2837            model,
2838            config: ProgramConfig::inline(height),
2839        }
2840    }
2841
2842    /// Create an inline app with automatic UI height.
2843    pub fn inline_auto<M: Model>(model: M, min_height: u16, max_height: u16) -> AppBuilder<M> {
2844        AppBuilder {
2845            model,
2846            config: ProgramConfig::inline_auto(min_height, max_height),
2847        }
2848    }
2849
2850    /// Create a fullscreen app from a [`StringModel`](crate::string_model::StringModel).
2851    ///
2852    /// This wraps the string model in a [`StringModelAdapter`](crate::string_model::StringModelAdapter)
2853    /// so that `view_string()` output is rendered through the standard kernel pipeline.
2854    pub fn string_model<S: crate::string_model::StringModel>(
2855        model: S,
2856    ) -> AppBuilder<crate::string_model::StringModelAdapter<S>> {
2857        AppBuilder {
2858            model: crate::string_model::StringModelAdapter::new(model),
2859            config: ProgramConfig::fullscreen(),
2860        }
2861    }
2862}
2863
2864/// Builder for configuring and running programs.
2865pub struct AppBuilder<M: Model> {
2866    model: M,
2867    config: ProgramConfig,
2868}
2869
2870impl<M: Model> AppBuilder<M> {
2871    /// Set the screen mode.
2872    pub fn screen_mode(mut self, mode: ScreenMode) -> Self {
2873        self.config.screen_mode = mode;
2874        self
2875    }
2876
2877    /// Set the UI anchor.
2878    pub fn anchor(mut self, anchor: UiAnchor) -> Self {
2879        self.config.ui_anchor = anchor;
2880        self
2881    }
2882
2883    /// Enable mouse support.
2884    pub fn with_mouse(mut self) -> Self {
2885        self.config.mouse = true;
2886        self
2887    }
2888
2889    /// Set the frame budget configuration.
2890    pub fn with_budget(mut self, budget: FrameBudgetConfig) -> Self {
2891        self.config.budget = budget;
2892        self
2893    }
2894
2895    /// Set the evidence JSONL sink configuration.
2896    pub fn with_evidence_sink(mut self, config: EvidenceSinkConfig) -> Self {
2897        self.config.evidence_sink = config;
2898        self
2899    }
2900
2901    /// Set the render-trace recorder configuration.
2902    pub fn with_render_trace(mut self, config: RenderTraceConfig) -> Self {
2903        self.config.render_trace = config;
2904        self
2905    }
2906
2907    /// Set the widget refresh selection configuration.
2908    pub fn with_widget_refresh(mut self, config: WidgetRefreshConfig) -> Self {
2909        self.config.widget_refresh = config;
2910        self
2911    }
2912
2913    /// Set the effect queue scheduling configuration.
2914    pub fn with_effect_queue(mut self, config: EffectQueueConfig) -> Self {
2915        self.config.effect_queue = config;
2916        self
2917    }
2918
2919    /// Enable inline auto UI height remeasurement.
2920    pub fn with_inline_auto_remeasure(mut self, config: InlineAutoRemeasureConfig) -> Self {
2921        self.config.inline_auto_remeasure = Some(config);
2922        self
2923    }
2924
2925    /// Disable inline auto UI height remeasurement.
2926    pub fn without_inline_auto_remeasure(mut self) -> Self {
2927        self.config.inline_auto_remeasure = None;
2928        self
2929    }
2930
2931    /// Set the locale context used for rendering.
2932    pub fn with_locale_context(mut self, locale_context: LocaleContext) -> Self {
2933        self.config.locale_context = locale_context;
2934        self
2935    }
2936
2937    /// Set the base locale used for rendering.
2938    pub fn with_locale(mut self, locale: impl Into<crate::locale::Locale>) -> Self {
2939        self.config.locale_context = LocaleContext::new(locale);
2940        self
2941    }
2942
2943    /// Set the resize coalescer configuration.
2944    pub fn resize_coalescer(mut self, config: CoalescerConfig) -> Self {
2945        self.config.resize_coalescer = config;
2946        self
2947    }
2948
2949    /// Set the resize handling behavior.
2950    pub fn resize_behavior(mut self, behavior: ResizeBehavior) -> Self {
2951        self.config.resize_behavior = behavior;
2952        self
2953    }
2954
2955    /// Toggle legacy immediate-resize behavior for migration.
2956    pub fn legacy_resize(mut self, enabled: bool) -> Self {
2957        if enabled {
2958            self.config.resize_behavior = ResizeBehavior::Immediate;
2959        }
2960        self
2961    }
2962
2963    /// Run the application.
2964    pub fn run(self) -> io::Result<()>
2965    where
2966        M::Message: Send + 'static,
2967    {
2968        let mut program = Program::with_config(self.model, self.config)?;
2969        program.run()
2970    }
2971}
2972
2973// =============================================================================
2974// Adaptive Batch Window: Queueing Model (bd-4kq0.8.1)
2975// =============================================================================
2976//
2977// # M/G/1 Queueing Model for Event Batching
2978//
2979// ## Problem
2980//
2981// The event loop must balance two objectives:
2982// 1. **Low latency**: Process events quickly (small batch window τ).
2983// 2. **Efficiency**: Batch multiple events to amortize render cost (large τ).
2984//
2985// ## Model
2986//
2987// We model the event loop as an M/G/1 queue:
2988// - Events arrive at rate λ (Poisson process, reasonable for human input).
2989// - Service time S has mean E[S] and variance Var[S] (render + present).
2990// - Utilization ρ = λ·E[S] must be < 1 for stability.
2991//
2992// ## Pollaczek–Khinchine Mean Waiting Time
2993//
2994// For M/G/1: E[W] = (λ·E[S²]) / (2·(1 − ρ))
2995// where E[S²] = Var[S] + E[S]².
2996//
2997// ## Optimal Batch Window τ
2998//
2999// With batching window τ, we collect ~(λ·τ) events per batch, amortizing
3000// the per-frame render cost. The effective per-event latency is:
3001//
3002//   L(τ) = τ/2 + E[S]
3003//         (waiting in batch)  (service)
3004//
3005// The batch reduces arrival rate to λ_eff = 1/τ (one batch per window),
3006// giving utilization ρ_eff = E[S]/τ.
3007//
3008// Minimizing L(τ) subject to ρ_eff < 1:
3009//   L(τ) = τ/2 + E[S]
3010//   dL/dτ = 1/2  (always positive, so smaller τ is always better for latency)
3011//
3012// But we need ρ_eff < 1, so τ > E[S].
3013//
3014// The practical rule: τ = max(E[S] · headroom_factor, τ_min)
3015// where headroom_factor provides margin (typically 1.5–2.0).
3016//
3017// For high arrival rates: τ = max(E[S] · headroom, 1/λ_target)
3018// where λ_target is the max frame rate we want to sustain.
3019//
3020// ## Failure Modes
3021//
3022// 1. **Overload (ρ ≥ 1)**: Queue grows unbounded. Mitigation: increase τ
3023//    (degrade to lower frame rate), or drop stale events.
3024// 2. **Bursty arrivals**: Real input is bursty (typing, mouse drag). The
3025//    exponential moving average of λ smooths this; high burst periods
3026//    temporarily increase τ.
3027// 3. **Variable service time**: Render complexity varies per frame. Using
3028//    EMA of E[S] tracks this adaptively.
3029//
3030// ## Observable Telemetry
3031//
3032// - λ_est: Exponential moving average of inter-arrival times.
3033// - es_est: Exponential moving average of service (render) times.
3034// - ρ_est: λ_est × es_est (estimated utilization).
3035
3036/// Adaptive batch window controller based on M/G/1 queueing model.
3037///
3038/// Estimates arrival rate λ and service time E[S] from observations,
3039/// then computes the optimal batch window τ to maintain stability
3040/// (ρ < 1) while minimizing latency.
3041#[derive(Debug, Clone)]
3042pub struct BatchController {
3043    /// Exponential moving average of inter-arrival time (seconds).
3044    ema_inter_arrival_s: f64,
3045    /// Exponential moving average of service time (seconds).
3046    ema_service_s: f64,
3047    /// EMA smoothing factor (0..1). Higher = more responsive.
3048    alpha: f64,
3049    /// Minimum batch window (floor).
3050    tau_min_s: f64,
3051    /// Maximum batch window (cap for responsiveness).
3052    tau_max_s: f64,
3053    /// Headroom factor: τ >= E[S] × headroom to keep ρ < 1.
3054    headroom: f64,
3055    /// Last event arrival timestamp.
3056    last_arrival: Option<std::time::Instant>,
3057    /// Number of observations.
3058    observations: u64,
3059}
3060
3061impl BatchController {
3062    /// Create a new controller with sensible defaults.
3063    ///
3064    /// - `alpha`: EMA smoothing (default 0.2)
3065    /// - `tau_min`: minimum batch window (default 1ms)
3066    /// - `tau_max`: maximum batch window (default 50ms)
3067    /// - `headroom`: stability margin (default 2.0, keeps ρ ≤ 0.5)
3068    pub fn new() -> Self {
3069        Self {
3070            ema_inter_arrival_s: 0.1, // assume 10 events/sec initially
3071            ema_service_s: 0.002,     // assume 2ms render initially
3072            alpha: 0.2,
3073            tau_min_s: 0.001, // 1ms floor
3074            tau_max_s: 0.050, // 50ms cap
3075            headroom: 2.0,
3076            last_arrival: None,
3077            observations: 0,
3078        }
3079    }
3080
3081    /// Record an event arrival, updating the inter-arrival estimate.
3082    pub fn observe_arrival(&mut self, now: std::time::Instant) {
3083        if let Some(last) = self.last_arrival {
3084            let dt = now.duration_since(last).as_secs_f64();
3085            if dt > 0.0 && dt < 10.0 {
3086                // Guard against stale gaps (e.g., app was suspended)
3087                self.ema_inter_arrival_s =
3088                    self.alpha * dt + (1.0 - self.alpha) * self.ema_inter_arrival_s;
3089                self.observations += 1;
3090            }
3091        }
3092        self.last_arrival = Some(now);
3093    }
3094
3095    /// Record a service (render) time observation.
3096    pub fn observe_service(&mut self, duration: std::time::Duration) {
3097        let dt = duration.as_secs_f64();
3098        if (0.0..10.0).contains(&dt) {
3099            self.ema_service_s = self.alpha * dt + (1.0 - self.alpha) * self.ema_service_s;
3100        }
3101    }
3102
3103    /// Estimated arrival rate λ (events/second).
3104    #[inline]
3105    pub fn lambda_est(&self) -> f64 {
3106        if self.ema_inter_arrival_s > 0.0 {
3107            1.0 / self.ema_inter_arrival_s
3108        } else {
3109            0.0
3110        }
3111    }
3112
3113    /// Estimated service time E[S] (seconds).
3114    #[inline]
3115    pub fn service_est_s(&self) -> f64 {
3116        self.ema_service_s
3117    }
3118
3119    /// Estimated utilization ρ = λ × E[S].
3120    #[inline]
3121    pub fn rho_est(&self) -> f64 {
3122        self.lambda_est() * self.ema_service_s
3123    }
3124
3125    /// Compute the optimal batch window τ (seconds).
3126    ///
3127    /// τ = clamp(E[S] × headroom, τ_min, τ_max)
3128    ///
3129    /// When ρ approaches 1, τ increases to maintain stability.
3130    pub fn tau_s(&self) -> f64 {
3131        let base = self.ema_service_s * self.headroom;
3132        base.clamp(self.tau_min_s, self.tau_max_s)
3133    }
3134
3135    /// Compute the optimal batch window as a Duration.
3136    pub fn tau(&self) -> std::time::Duration {
3137        std::time::Duration::from_secs_f64(self.tau_s())
3138    }
3139
3140    /// Check if the system is stable (ρ < 1).
3141    #[inline]
3142    pub fn is_stable(&self) -> bool {
3143        self.rho_est() < 1.0
3144    }
3145
3146    /// Number of observations recorded.
3147    #[inline]
3148    pub fn observations(&self) -> u64 {
3149        self.observations
3150    }
3151}
3152
3153impl Default for BatchController {
3154    fn default() -> Self {
3155        Self::new()
3156    }
3157}
3158
3159#[cfg(test)]
3160mod tests {
3161    use super::*;
3162    use ftui_core::terminal_capabilities::TerminalCapabilities;
3163    use ftui_core::terminal_session::{SessionOptions, TerminalSession};
3164    use ftui_render::buffer::Buffer;
3165    use ftui_render::cell::Cell;
3166    use ftui_render::diff_strategy::DiffStrategy;
3167    use ftui_render::frame::CostEstimateSource;
3168    use serde_json::Value;
3169    use std::collections::HashMap;
3170    use std::path::PathBuf;
3171    use std::sync::mpsc;
3172    use std::sync::{
3173        Arc,
3174        atomic::{AtomicUsize, Ordering},
3175    };
3176
3177    // Simple test model
3178    struct TestModel {
3179        value: i32,
3180    }
3181
3182    #[derive(Debug)]
3183    enum TestMsg {
3184        Increment,
3185        Decrement,
3186        Quit,
3187    }
3188
3189    impl From<Event> for TestMsg {
3190        fn from(_event: Event) -> Self {
3191            TestMsg::Increment
3192        }
3193    }
3194
3195    impl Model for TestModel {
3196        type Message = TestMsg;
3197
3198        fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
3199            match msg {
3200                TestMsg::Increment => {
3201                    self.value += 1;
3202                    Cmd::none()
3203                }
3204                TestMsg::Decrement => {
3205                    self.value -= 1;
3206                    Cmd::none()
3207                }
3208                TestMsg::Quit => Cmd::quit(),
3209            }
3210        }
3211
3212        fn view(&self, _frame: &mut Frame) {
3213            // No-op for tests
3214        }
3215    }
3216
3217    #[test]
3218    fn cmd_none() {
3219        let cmd: Cmd<TestMsg> = Cmd::none();
3220        assert!(matches!(cmd, Cmd::None));
3221    }
3222
3223    #[test]
3224    fn cmd_quit() {
3225        let cmd: Cmd<TestMsg> = Cmd::quit();
3226        assert!(matches!(cmd, Cmd::Quit));
3227    }
3228
3229    #[test]
3230    fn cmd_msg() {
3231        let cmd: Cmd<TestMsg> = Cmd::msg(TestMsg::Increment);
3232        assert!(matches!(cmd, Cmd::Msg(TestMsg::Increment)));
3233    }
3234
3235    #[test]
3236    fn cmd_batch_empty() {
3237        let cmd: Cmd<TestMsg> = Cmd::batch(vec![]);
3238        assert!(matches!(cmd, Cmd::None));
3239    }
3240
3241    #[test]
3242    fn cmd_batch_single() {
3243        let cmd: Cmd<TestMsg> = Cmd::batch(vec![Cmd::quit()]);
3244        assert!(matches!(cmd, Cmd::Quit));
3245    }
3246
3247    #[test]
3248    fn cmd_batch_multiple() {
3249        let cmd: Cmd<TestMsg> = Cmd::batch(vec![Cmd::none(), Cmd::quit()]);
3250        assert!(matches!(cmd, Cmd::Batch(_)));
3251    }
3252
3253    #[test]
3254    fn cmd_sequence_empty() {
3255        let cmd: Cmd<TestMsg> = Cmd::sequence(vec![]);
3256        assert!(matches!(cmd, Cmd::None));
3257    }
3258
3259    #[test]
3260    fn cmd_tick() {
3261        let cmd: Cmd<TestMsg> = Cmd::tick(Duration::from_millis(100));
3262        assert!(matches!(cmd, Cmd::Tick(_)));
3263    }
3264
3265    #[test]
3266    fn cmd_task() {
3267        let cmd: Cmd<TestMsg> = Cmd::task(|| TestMsg::Increment);
3268        assert!(matches!(cmd, Cmd::Task(..)));
3269    }
3270
3271    #[test]
3272    fn cmd_debug_format() {
3273        let cmd: Cmd<TestMsg> = Cmd::task(|| TestMsg::Increment);
3274        let debug = format!("{cmd:?}");
3275        assert_eq!(
3276            debug,
3277            "Task { spec: TaskSpec { weight: 1.0, estimate_ms: 10.0, name: None } }"
3278        );
3279    }
3280
3281    #[test]
3282    fn model_subscriptions_default_empty() {
3283        let model = TestModel { value: 0 };
3284        let subs = model.subscriptions();
3285        assert!(subs.is_empty());
3286    }
3287
3288    #[test]
3289    fn program_config_default() {
3290        let config = ProgramConfig::default();
3291        assert!(matches!(config.screen_mode, ScreenMode::Inline { .. }));
3292        assert!(!config.mouse);
3293        assert!(config.bracketed_paste);
3294        assert_eq!(config.resize_behavior, ResizeBehavior::Throttled);
3295        assert!(config.inline_auto_remeasure.is_none());
3296        assert!(config.conformal_config.is_none());
3297        assert!(config.diff_config.bayesian_enabled);
3298        assert!(config.diff_config.dirty_rows_enabled);
3299        assert!(!config.resize_coalescer.enable_bocpd);
3300        assert!(!config.effect_queue.enabled);
3301        assert_eq!(
3302            config.resize_coalescer.steady_delay_ms,
3303            CoalescerConfig::default().steady_delay_ms
3304        );
3305    }
3306
3307    #[test]
3308    fn program_config_fullscreen() {
3309        let config = ProgramConfig::fullscreen();
3310        assert!(matches!(config.screen_mode, ScreenMode::AltScreen));
3311    }
3312
3313    #[test]
3314    fn program_config_inline() {
3315        let config = ProgramConfig::inline(10);
3316        assert!(matches!(
3317            config.screen_mode,
3318            ScreenMode::Inline { ui_height: 10 }
3319        ));
3320    }
3321
3322    #[test]
3323    fn program_config_inline_auto() {
3324        let config = ProgramConfig::inline_auto(3, 9);
3325        assert!(matches!(
3326            config.screen_mode,
3327            ScreenMode::InlineAuto {
3328                min_height: 3,
3329                max_height: 9
3330            }
3331        ));
3332        assert!(config.inline_auto_remeasure.is_some());
3333    }
3334
3335    #[test]
3336    fn program_config_with_mouse() {
3337        let config = ProgramConfig::default().with_mouse();
3338        assert!(config.mouse);
3339    }
3340
3341    #[test]
3342    fn model_update() {
3343        let mut model = TestModel { value: 0 };
3344        model.update(TestMsg::Increment);
3345        assert_eq!(model.value, 1);
3346        model.update(TestMsg::Decrement);
3347        assert_eq!(model.value, 0);
3348        assert!(matches!(model.update(TestMsg::Quit), Cmd::Quit));
3349    }
3350
3351    #[test]
3352    fn model_init_default() {
3353        let mut model = TestModel { value: 0 };
3354        let cmd = model.init();
3355        assert!(matches!(cmd, Cmd::None));
3356    }
3357
3358    // Resize coalescer behavior is covered by resize_coalescer.rs tests.
3359
3360    // =========================================================================
3361    // DETERMINISM TESTS - Program loop determinism (bd-2nu8.10.1)
3362    // =========================================================================
3363
3364    #[test]
3365    fn cmd_sequence_executes_in_order() {
3366        // Verify that Cmd::Sequence executes commands in declared order
3367        use crate::simulator::ProgramSimulator;
3368
3369        struct SeqModel {
3370            trace: Vec<i32>,
3371        }
3372
3373        #[derive(Debug)]
3374        enum SeqMsg {
3375            Append(i32),
3376            TriggerSequence,
3377        }
3378
3379        impl From<Event> for SeqMsg {
3380            fn from(_: Event) -> Self {
3381                SeqMsg::Append(0)
3382            }
3383        }
3384
3385        impl Model for SeqModel {
3386            type Message = SeqMsg;
3387
3388            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
3389                match msg {
3390                    SeqMsg::Append(n) => {
3391                        self.trace.push(n);
3392                        Cmd::none()
3393                    }
3394                    SeqMsg::TriggerSequence => Cmd::sequence(vec![
3395                        Cmd::msg(SeqMsg::Append(1)),
3396                        Cmd::msg(SeqMsg::Append(2)),
3397                        Cmd::msg(SeqMsg::Append(3)),
3398                    ]),
3399                }
3400            }
3401
3402            fn view(&self, _frame: &mut Frame) {}
3403        }
3404
3405        let mut sim = ProgramSimulator::new(SeqModel { trace: vec![] });
3406        sim.init();
3407        sim.send(SeqMsg::TriggerSequence);
3408
3409        assert_eq!(sim.model().trace, vec![1, 2, 3]);
3410    }
3411
3412    #[test]
3413    fn cmd_batch_executes_all_regardless_of_order() {
3414        // Verify that Cmd::Batch executes all commands
3415        use crate::simulator::ProgramSimulator;
3416
3417        struct BatchModel {
3418            values: Vec<i32>,
3419        }
3420
3421        #[derive(Debug)]
3422        enum BatchMsg {
3423            Add(i32),
3424            TriggerBatch,
3425        }
3426
3427        impl From<Event> for BatchMsg {
3428            fn from(_: Event) -> Self {
3429                BatchMsg::Add(0)
3430            }
3431        }
3432
3433        impl Model for BatchModel {
3434            type Message = BatchMsg;
3435
3436            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
3437                match msg {
3438                    BatchMsg::Add(n) => {
3439                        self.values.push(n);
3440                        Cmd::none()
3441                    }
3442                    BatchMsg::TriggerBatch => Cmd::batch(vec![
3443                        Cmd::msg(BatchMsg::Add(10)),
3444                        Cmd::msg(BatchMsg::Add(20)),
3445                        Cmd::msg(BatchMsg::Add(30)),
3446                    ]),
3447                }
3448            }
3449
3450            fn view(&self, _frame: &mut Frame) {}
3451        }
3452
3453        let mut sim = ProgramSimulator::new(BatchModel { values: vec![] });
3454        sim.init();
3455        sim.send(BatchMsg::TriggerBatch);
3456
3457        // All values should be present
3458        assert_eq!(sim.model().values.len(), 3);
3459        assert!(sim.model().values.contains(&10));
3460        assert!(sim.model().values.contains(&20));
3461        assert!(sim.model().values.contains(&30));
3462    }
3463
3464    #[test]
3465    fn cmd_sequence_stops_on_quit() {
3466        // Verify that Cmd::Sequence stops processing after Quit
3467        use crate::simulator::ProgramSimulator;
3468
3469        struct SeqQuitModel {
3470            trace: Vec<i32>,
3471        }
3472
3473        #[derive(Debug)]
3474        enum SeqQuitMsg {
3475            Append(i32),
3476            TriggerSequenceWithQuit,
3477        }
3478
3479        impl From<Event> for SeqQuitMsg {
3480            fn from(_: Event) -> Self {
3481                SeqQuitMsg::Append(0)
3482            }
3483        }
3484
3485        impl Model for SeqQuitModel {
3486            type Message = SeqQuitMsg;
3487
3488            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
3489                match msg {
3490                    SeqQuitMsg::Append(n) => {
3491                        self.trace.push(n);
3492                        Cmd::none()
3493                    }
3494                    SeqQuitMsg::TriggerSequenceWithQuit => Cmd::sequence(vec![
3495                        Cmd::msg(SeqQuitMsg::Append(1)),
3496                        Cmd::quit(),
3497                        Cmd::msg(SeqQuitMsg::Append(2)), // Should not execute
3498                    ]),
3499                }
3500            }
3501
3502            fn view(&self, _frame: &mut Frame) {}
3503        }
3504
3505        let mut sim = ProgramSimulator::new(SeqQuitModel { trace: vec![] });
3506        sim.init();
3507        sim.send(SeqQuitMsg::TriggerSequenceWithQuit);
3508
3509        assert_eq!(sim.model().trace, vec![1]);
3510        assert!(!sim.is_running());
3511    }
3512
3513    #[test]
3514    fn identical_input_produces_identical_state() {
3515        // Verify deterministic state transitions
3516        use crate::simulator::ProgramSimulator;
3517
3518        fn run_scenario() -> Vec<i32> {
3519            struct DetModel {
3520                values: Vec<i32>,
3521            }
3522
3523            #[derive(Debug, Clone)]
3524            enum DetMsg {
3525                Add(i32),
3526                Double,
3527            }
3528
3529            impl From<Event> for DetMsg {
3530                fn from(_: Event) -> Self {
3531                    DetMsg::Add(1)
3532                }
3533            }
3534
3535            impl Model for DetModel {
3536                type Message = DetMsg;
3537
3538                fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
3539                    match msg {
3540                        DetMsg::Add(n) => {
3541                            self.values.push(n);
3542                            Cmd::none()
3543                        }
3544                        DetMsg::Double => {
3545                            if let Some(&last) = self.values.last() {
3546                                self.values.push(last * 2);
3547                            }
3548                            Cmd::none()
3549                        }
3550                    }
3551                }
3552
3553                fn view(&self, _frame: &mut Frame) {}
3554            }
3555
3556            let mut sim = ProgramSimulator::new(DetModel { values: vec![] });
3557            sim.init();
3558            sim.send(DetMsg::Add(5));
3559            sim.send(DetMsg::Double);
3560            sim.send(DetMsg::Add(3));
3561            sim.send(DetMsg::Double);
3562
3563            sim.model().values.clone()
3564        }
3565
3566        // Run the same scenario multiple times
3567        let run1 = run_scenario();
3568        let run2 = run_scenario();
3569        let run3 = run_scenario();
3570
3571        assert_eq!(run1, run2);
3572        assert_eq!(run2, run3);
3573        assert_eq!(run1, vec![5, 10, 3, 6]);
3574    }
3575
3576    #[test]
3577    fn identical_state_produces_identical_render() {
3578        // Verify consistent render outputs for identical inputs
3579        use crate::simulator::ProgramSimulator;
3580
3581        struct RenderModel {
3582            counter: i32,
3583        }
3584
3585        #[derive(Debug)]
3586        enum RenderMsg {
3587            Set(i32),
3588        }
3589
3590        impl From<Event> for RenderMsg {
3591            fn from(_: Event) -> Self {
3592                RenderMsg::Set(0)
3593            }
3594        }
3595
3596        impl Model for RenderModel {
3597            type Message = RenderMsg;
3598
3599            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
3600                match msg {
3601                    RenderMsg::Set(n) => {
3602                        self.counter = n;
3603                        Cmd::none()
3604                    }
3605                }
3606            }
3607
3608            fn view(&self, frame: &mut Frame) {
3609                let text = format!("Value: {}", self.counter);
3610                for (i, c) in text.chars().enumerate() {
3611                    if (i as u16) < frame.width() {
3612                        use ftui_render::cell::Cell;
3613                        frame.buffer.set_raw(i as u16, 0, Cell::from_char(c));
3614                    }
3615                }
3616            }
3617        }
3618
3619        // Create two simulators with the same state
3620        let mut sim1 = ProgramSimulator::new(RenderModel { counter: 42 });
3621        let mut sim2 = ProgramSimulator::new(RenderModel { counter: 42 });
3622
3623        let buf1 = sim1.capture_frame(80, 24);
3624        let buf2 = sim2.capture_frame(80, 24);
3625
3626        // Compare buffer contents
3627        for y in 0..24 {
3628            for x in 0..80 {
3629                let cell1 = buf1.get(x, y).unwrap();
3630                let cell2 = buf2.get(x, y).unwrap();
3631                assert_eq!(
3632                    cell1.content.as_char(),
3633                    cell2.content.as_char(),
3634                    "Mismatch at ({}, {})",
3635                    x,
3636                    y
3637                );
3638            }
3639        }
3640    }
3641
3642    // Resize coalescer timing invariants are covered in resize_coalescer.rs tests.
3643
3644    #[test]
3645    fn cmd_log_creates_log_command() {
3646        let cmd: Cmd<TestMsg> = Cmd::log("test message");
3647        assert!(matches!(cmd, Cmd::Log(s) if s == "test message"));
3648    }
3649
3650    #[test]
3651    fn cmd_log_from_string() {
3652        let msg = String::from("dynamic message");
3653        let cmd: Cmd<TestMsg> = Cmd::log(msg);
3654        assert!(matches!(cmd, Cmd::Log(s) if s == "dynamic message"));
3655    }
3656
3657    #[test]
3658    fn program_simulator_logs_jsonl_with_seed_and_run_id() {
3659        // Ensure ProgramSimulator captures JSONL log lines with run_id/seed.
3660        use crate::simulator::ProgramSimulator;
3661
3662        struct LogModel {
3663            run_id: &'static str,
3664            seed: u64,
3665        }
3666
3667        #[derive(Debug)]
3668        enum LogMsg {
3669            Emit,
3670        }
3671
3672        impl From<Event> for LogMsg {
3673            fn from(_: Event) -> Self {
3674                LogMsg::Emit
3675            }
3676        }
3677
3678        impl Model for LogModel {
3679            type Message = LogMsg;
3680
3681            fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
3682                let line = format!(
3683                    r#"{{"event":"test","run_id":"{}","seed":{}}}"#,
3684                    self.run_id, self.seed
3685                );
3686                Cmd::log(line)
3687            }
3688
3689            fn view(&self, _frame: &mut Frame) {}
3690        }
3691
3692        let mut sim = ProgramSimulator::new(LogModel {
3693            run_id: "test-run-001",
3694            seed: 4242,
3695        });
3696        sim.init();
3697        sim.send(LogMsg::Emit);
3698
3699        let logs = sim.logs();
3700        assert_eq!(logs.len(), 1);
3701        assert!(logs[0].contains(r#""run_id":"test-run-001""#));
3702        assert!(logs[0].contains(r#""seed":4242"#));
3703    }
3704
3705    #[test]
3706    fn cmd_sequence_single_unwraps() {
3707        let cmd: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::quit()]);
3708        // Single element sequence should unwrap to the inner command
3709        assert!(matches!(cmd, Cmd::Quit));
3710    }
3711
3712    #[test]
3713    fn cmd_sequence_multiple() {
3714        let cmd: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::none(), Cmd::quit()]);
3715        assert!(matches!(cmd, Cmd::Sequence(_)));
3716    }
3717
3718    #[test]
3719    fn cmd_default_is_none() {
3720        let cmd: Cmd<TestMsg> = Cmd::default();
3721        assert!(matches!(cmd, Cmd::None));
3722    }
3723
3724    #[test]
3725    fn cmd_debug_all_variants() {
3726        // Test Debug impl for all variants
3727        let none: Cmd<TestMsg> = Cmd::none();
3728        assert_eq!(format!("{none:?}"), "None");
3729
3730        let quit: Cmd<TestMsg> = Cmd::quit();
3731        assert_eq!(format!("{quit:?}"), "Quit");
3732
3733        let msg: Cmd<TestMsg> = Cmd::msg(TestMsg::Increment);
3734        assert!(format!("{msg:?}").starts_with("Msg("));
3735
3736        let batch: Cmd<TestMsg> = Cmd::batch(vec![Cmd::none(), Cmd::none()]);
3737        assert!(format!("{batch:?}").starts_with("Batch("));
3738
3739        let seq: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::none(), Cmd::none()]);
3740        assert!(format!("{seq:?}").starts_with("Sequence("));
3741
3742        let tick: Cmd<TestMsg> = Cmd::tick(Duration::from_secs(1));
3743        assert!(format!("{tick:?}").starts_with("Tick("));
3744
3745        let log: Cmd<TestMsg> = Cmd::log("test");
3746        assert!(format!("{log:?}").starts_with("Log("));
3747    }
3748
3749    #[test]
3750    fn program_config_with_budget() {
3751        let budget = FrameBudgetConfig {
3752            total: Duration::from_millis(50),
3753            ..Default::default()
3754        };
3755        let config = ProgramConfig::default().with_budget(budget);
3756        assert_eq!(config.budget.total, Duration::from_millis(50));
3757    }
3758
3759    #[test]
3760    fn program_config_with_conformal() {
3761        let config = ProgramConfig::default().with_conformal_config(ConformalConfig {
3762            alpha: 0.2,
3763            ..Default::default()
3764        });
3765        assert!(config.conformal_config.is_some());
3766        assert!((config.conformal_config.as_ref().unwrap().alpha - 0.2).abs() < 1e-6);
3767    }
3768
3769    #[test]
3770    fn program_config_forced_size_clamps_minimums() {
3771        let config = ProgramConfig::default().with_forced_size(0, 0);
3772        assert_eq!(config.forced_size, Some((1, 1)));
3773
3774        let cleared = config.without_forced_size();
3775        assert!(cleared.forced_size.is_none());
3776    }
3777
3778    #[test]
3779    fn effect_queue_config_defaults_are_safe() {
3780        let config = EffectQueueConfig::default();
3781        assert!(!config.enabled);
3782        assert!(config.scheduler.smith_enabled);
3783        assert!(!config.scheduler.preemptive);
3784        assert_eq!(config.scheduler.aging_factor, 0.0);
3785        assert_eq!(config.scheduler.wait_starve_ms, 0.0);
3786    }
3787
3788    #[test]
3789    fn handle_effect_command_enqueues_or_executes_inline() {
3790        let (result_tx, result_rx) = mpsc::channel::<u32>();
3791        let mut scheduler = QueueingScheduler::new(EffectQueueConfig::default().scheduler);
3792        let mut tasks: HashMap<u64, Box<dyn FnOnce() -> u32 + Send>> = HashMap::new();
3793
3794        let ran = Arc::new(AtomicUsize::new(0));
3795        let ran_task = ran.clone();
3796        let cmd = EffectCommand::Enqueue(
3797            TaskSpec::default(),
3798            Box::new(move || {
3799                ran_task.fetch_add(1, Ordering::SeqCst);
3800                7
3801            }),
3802        );
3803
3804        let shutdown = handle_effect_command(cmd, &mut scheduler, &mut tasks, &result_tx);
3805        assert!(!shutdown);
3806        assert_eq!(ran.load(Ordering::SeqCst), 0);
3807        assert_eq!(tasks.len(), 1);
3808        assert!(result_rx.try_recv().is_err());
3809
3810        let mut full_scheduler = QueueingScheduler::new(SchedulerConfig {
3811            max_queue_size: 0,
3812            ..Default::default()
3813        });
3814        let mut full_tasks: HashMap<u64, Box<dyn FnOnce() -> u32 + Send>> = HashMap::new();
3815        let ran_full = Arc::new(AtomicUsize::new(0));
3816        let ran_full_task = ran_full.clone();
3817        let cmd_full = EffectCommand::Enqueue(
3818            TaskSpec::default(),
3819            Box::new(move || {
3820                ran_full_task.fetch_add(1, Ordering::SeqCst);
3821                42
3822            }),
3823        );
3824
3825        let shutdown_full =
3826            handle_effect_command(cmd_full, &mut full_scheduler, &mut full_tasks, &result_tx);
3827        assert!(!shutdown_full);
3828        assert!(full_tasks.is_empty());
3829        assert_eq!(ran_full.load(Ordering::SeqCst), 1);
3830        assert_eq!(
3831            result_rx.recv_timeout(Duration::from_millis(200)).unwrap(),
3832            42
3833        );
3834
3835        let shutdown = handle_effect_command(
3836            EffectCommand::Shutdown,
3837            &mut full_scheduler,
3838            &mut full_tasks,
3839            &result_tx,
3840        );
3841        assert!(shutdown);
3842    }
3843
3844    #[test]
3845    fn effect_queue_loop_executes_tasks_and_shutdowns() {
3846        let (cmd_tx, cmd_rx) = mpsc::channel::<EffectCommand<u32>>();
3847        let (result_tx, result_rx) = mpsc::channel::<u32>();
3848        let config = EffectQueueConfig {
3849            enabled: true,
3850            scheduler: SchedulerConfig {
3851                preemptive: false,
3852                ..Default::default()
3853            },
3854        };
3855
3856        let handle = std::thread::spawn(move || {
3857            effect_queue_loop(config, cmd_rx, result_tx, None);
3858        });
3859
3860        cmd_tx
3861            .send(EffectCommand::Enqueue(TaskSpec::default(), Box::new(|| 10)))
3862            .unwrap();
3863        cmd_tx
3864            .send(EffectCommand::Enqueue(
3865                TaskSpec::new(2.0, 5.0).with_name("second"),
3866                Box::new(|| 20),
3867            ))
3868            .unwrap();
3869
3870        let mut results = vec![
3871            result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
3872            result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
3873        ];
3874        results.sort_unstable();
3875        assert_eq!(results, vec![10, 20]);
3876
3877        cmd_tx.send(EffectCommand::Shutdown).unwrap();
3878        let _ = handle.join();
3879    }
3880
3881    #[test]
3882    fn inline_auto_remeasure_reset_clears_decision() {
3883        let mut state = InlineAutoRemeasureState::new(InlineAutoRemeasureConfig::default());
3884        state.sampler.decide(Instant::now());
3885        assert!(state.sampler.last_decision().is_some());
3886
3887        state.reset();
3888        assert!(state.sampler.last_decision().is_none());
3889    }
3890
3891    #[test]
3892    fn budget_decision_jsonl_contains_required_fields() {
3893        let evidence = BudgetDecisionEvidence {
3894            frame_idx: 7,
3895            decision: BudgetDecision::Degrade,
3896            controller_decision: BudgetDecision::Hold,
3897            degradation_before: DegradationLevel::Full,
3898            degradation_after: DegradationLevel::NoStyling,
3899            frame_time_us: 12_345.678,
3900            budget_us: 16_000.0,
3901            pid_output: 1.25,
3902            pid_p: 0.5,
3903            pid_i: 0.25,
3904            pid_d: 0.5,
3905            e_value: 2.0,
3906            frames_observed: 42,
3907            frames_since_change: 3,
3908            in_warmup: false,
3909            conformal: Some(ConformalEvidence {
3910                bucket_key: "inline:dirty:10".to_string(),
3911                n_b: 32,
3912                alpha: 0.05,
3913                q_b: 1000.0,
3914                y_hat: 12_000.0,
3915                upper_us: 13_000.0,
3916                risk: true,
3917                fallback_level: 1,
3918                window_size: 256,
3919                reset_count: 2,
3920            }),
3921        };
3922
3923        let jsonl = evidence.to_jsonl();
3924        assert!(jsonl.contains("\"event\":\"budget_decision\""));
3925        assert!(jsonl.contains("\"decision\":\"degrade\""));
3926        assert!(jsonl.contains("\"decision_controller\":\"stay\""));
3927        assert!(jsonl.contains("\"degradation_before\":\"Full\""));
3928        assert!(jsonl.contains("\"degradation_after\":\"NoStyling\""));
3929        assert!(jsonl.contains("\"frame_time_us\":12345.678000"));
3930        assert!(jsonl.contains("\"budget_us\":16000.000000"));
3931        assert!(jsonl.contains("\"pid_output\":1.250000"));
3932        assert!(jsonl.contains("\"e_value\":2.000000"));
3933        assert!(jsonl.contains("\"bucket_key\":\"inline:dirty:10\""));
3934        assert!(jsonl.contains("\"n_b\":32"));
3935        assert!(jsonl.contains("\"alpha\":0.050000"));
3936        assert!(jsonl.contains("\"q_b\":1000.000000"));
3937        assert!(jsonl.contains("\"y_hat\":12000.000000"));
3938        assert!(jsonl.contains("\"upper_us\":13000.000000"));
3939        assert!(jsonl.contains("\"risk\":true"));
3940        assert!(jsonl.contains("\"fallback_level\":1"));
3941        assert!(jsonl.contains("\"window_size\":256"));
3942        assert!(jsonl.contains("\"reset_count\":2"));
3943    }
3944
3945    fn make_signal(
3946        widget_id: u64,
3947        essential: bool,
3948        priority: f32,
3949        staleness_ms: u64,
3950        cost_us: f32,
3951    ) -> WidgetSignal {
3952        WidgetSignal {
3953            widget_id,
3954            essential,
3955            priority,
3956            staleness_ms,
3957            focus_boost: 0.0,
3958            interaction_boost: 0.0,
3959            area_cells: 1,
3960            cost_estimate_us: cost_us,
3961            recent_cost_us: 0.0,
3962            estimate_source: CostEstimateSource::FixedDefault,
3963        }
3964    }
3965
3966    fn signal_value_cost(signal: &WidgetSignal, config: &WidgetRefreshConfig) -> (f32, f32, bool) {
3967        let starved = config.starve_ms > 0 && signal.staleness_ms >= config.starve_ms;
3968        let staleness_window = config.staleness_window_ms.max(1) as f32;
3969        let staleness_score = (signal.staleness_ms as f32 / staleness_window).min(1.0);
3970        let mut value = config.weight_priority * signal.priority
3971            + config.weight_staleness * staleness_score
3972            + config.weight_focus * signal.focus_boost
3973            + config.weight_interaction * signal.interaction_boost;
3974        if starved {
3975            value += config.starve_boost;
3976        }
3977        let raw_cost = if signal.recent_cost_us > 0.0 {
3978            signal.recent_cost_us
3979        } else {
3980            signal.cost_estimate_us
3981        };
3982        let cost_us = raw_cost.max(config.min_cost_us);
3983        (value, cost_us, starved)
3984    }
3985
3986    fn fifo_select(
3987        signals: &[WidgetSignal],
3988        budget_us: f64,
3989        config: &WidgetRefreshConfig,
3990    ) -> (Vec<u64>, f64, usize) {
3991        let mut selected = Vec::new();
3992        let mut total_value = 0.0f64;
3993        let mut starved_selected = 0usize;
3994        let mut remaining = budget_us;
3995
3996        for signal in signals {
3997            if !signal.essential {
3998                continue;
3999            }
4000            let (value, cost_us, starved) = signal_value_cost(signal, config);
4001            remaining -= cost_us as f64;
4002            total_value += value as f64;
4003            if starved {
4004                starved_selected = starved_selected.saturating_add(1);
4005            }
4006            selected.push(signal.widget_id);
4007        }
4008        for signal in signals {
4009            if signal.essential {
4010                continue;
4011            }
4012            let (value, cost_us, starved) = signal_value_cost(signal, config);
4013            if remaining >= cost_us as f64 {
4014                remaining -= cost_us as f64;
4015                total_value += value as f64;
4016                if starved {
4017                    starved_selected = starved_selected.saturating_add(1);
4018                }
4019                selected.push(signal.widget_id);
4020            }
4021        }
4022
4023        (selected, total_value, starved_selected)
4024    }
4025
4026    fn rotate_signals(signals: &[WidgetSignal], offset: usize) -> Vec<WidgetSignal> {
4027        if signals.is_empty() {
4028            return Vec::new();
4029        }
4030        let mut rotated = Vec::with_capacity(signals.len());
4031        for idx in 0..signals.len() {
4032            rotated.push(signals[(idx + offset) % signals.len()].clone());
4033        }
4034        rotated
4035    }
4036
4037    #[test]
4038    fn widget_refresh_selects_essentials_first() {
4039        let signals = vec![
4040            make_signal(1, true, 0.6, 0, 5.0),
4041            make_signal(2, false, 0.9, 0, 4.0),
4042        ];
4043        let mut plan = WidgetRefreshPlan::new();
4044        let config = WidgetRefreshConfig::default();
4045        plan.recompute(1, 6.0, DegradationLevel::Full, &signals, &config);
4046        let selected: Vec<u64> = plan.selected.iter().map(|e| e.widget_id).collect();
4047        assert_eq!(selected, vec![1]);
4048        assert!(!plan.over_budget);
4049    }
4050
4051    #[test]
4052    fn widget_refresh_degradation_essential_only_skips_nonessential() {
4053        let signals = vec![
4054            make_signal(1, true, 0.5, 0, 2.0),
4055            make_signal(2, false, 1.0, 0, 1.0),
4056        ];
4057        let mut plan = WidgetRefreshPlan::new();
4058        let config = WidgetRefreshConfig::default();
4059        plan.recompute(3, 10.0, DegradationLevel::EssentialOnly, &signals, &config);
4060        let selected: Vec<u64> = plan.selected.iter().map(|e| e.widget_id).collect();
4061        assert_eq!(selected, vec![1]);
4062        assert_eq!(plan.skipped_count, 1);
4063    }
4064
4065    #[test]
4066    fn widget_refresh_starvation_guard_forces_one_starved() {
4067        let signals = vec![make_signal(7, false, 0.1, 10_000, 8.0)];
4068        let mut plan = WidgetRefreshPlan::new();
4069        let config = WidgetRefreshConfig {
4070            starve_ms: 1_000,
4071            max_starved_per_frame: 1,
4072            ..Default::default()
4073        };
4074        plan.recompute(5, 0.0, DegradationLevel::Full, &signals, &config);
4075        assert_eq!(plan.selected.len(), 1);
4076        assert!(plan.selected[0].starved);
4077        assert!(plan.over_budget);
4078    }
4079
4080    #[test]
4081    fn widget_refresh_budget_blocks_when_no_selection() {
4082        let signals = vec![make_signal(42, false, 0.2, 0, 10.0)];
4083        let mut plan = WidgetRefreshPlan::new();
4084        let config = WidgetRefreshConfig {
4085            starve_ms: 0,
4086            max_starved_per_frame: 0,
4087            ..Default::default()
4088        };
4089        plan.recompute(8, 0.0, DegradationLevel::Full, &signals, &config);
4090        let budget = plan.as_budget();
4091        assert!(!budget.allows(42, false));
4092    }
4093
4094    #[test]
4095    fn widget_refresh_max_drop_fraction_forces_minimum_refresh() {
4096        let signals = vec![
4097            make_signal(1, false, 0.4, 0, 10.0),
4098            make_signal(2, false, 0.4, 0, 10.0),
4099            make_signal(3, false, 0.4, 0, 10.0),
4100            make_signal(4, false, 0.4, 0, 10.0),
4101        ];
4102        let mut plan = WidgetRefreshPlan::new();
4103        let config = WidgetRefreshConfig {
4104            starve_ms: 0,
4105            max_starved_per_frame: 0,
4106            max_drop_fraction: 0.5,
4107            ..Default::default()
4108        };
4109        plan.recompute(12, 0.0, DegradationLevel::Full, &signals, &config);
4110        let selected: Vec<u64> = plan.selected.iter().map(|e| e.widget_id).collect();
4111        assert_eq!(selected, vec![1, 2]);
4112    }
4113
4114    #[test]
4115    fn widget_refresh_greedy_beats_fifo_and_round_robin() {
4116        let signals = vec![
4117            make_signal(1, false, 0.1, 0, 6.0),
4118            make_signal(2, false, 0.2, 0, 6.0),
4119            make_signal(3, false, 1.0, 0, 4.0),
4120            make_signal(4, false, 0.9, 0, 3.0),
4121            make_signal(5, false, 0.8, 0, 3.0),
4122            make_signal(6, false, 0.1, 4_000, 2.0),
4123        ];
4124        let budget_us = 10.0;
4125        let config = WidgetRefreshConfig::default();
4126
4127        let mut plan = WidgetRefreshPlan::new();
4128        plan.recompute(21, budget_us, DegradationLevel::Full, &signals, &config);
4129        let greedy_value = plan.selected_value;
4130        let greedy_selected: Vec<u64> = plan.selected.iter().map(|e| e.widget_id).collect();
4131
4132        let (fifo_selected, fifo_value, _fifo_starved) = fifo_select(&signals, budget_us, &config);
4133        let rotated = rotate_signals(&signals, 2);
4134        let (rr_selected, rr_value, _rr_starved) = fifo_select(&rotated, budget_us, &config);
4135
4136        assert!(
4137            greedy_value > fifo_value,
4138            "greedy_value={greedy_value:.3} <= fifo_value={fifo_value:.3}; greedy={:?}, fifo={:?}",
4139            greedy_selected,
4140            fifo_selected
4141        );
4142        assert!(
4143            greedy_value > rr_value,
4144            "greedy_value={greedy_value:.3} <= rr_value={rr_value:.3}; greedy={:?}, rr={:?}",
4145            greedy_selected,
4146            rr_selected
4147        );
4148        assert!(
4149            plan.starved_selected > 0,
4150            "greedy did not select starved widget; greedy={:?}",
4151            greedy_selected
4152        );
4153    }
4154
4155    #[test]
4156    fn widget_refresh_jsonl_contains_required_fields() {
4157        let signals = vec![make_signal(7, true, 0.2, 0, 2.0)];
4158        let mut plan = WidgetRefreshPlan::new();
4159        let config = WidgetRefreshConfig::default();
4160        plan.recompute(9, 4.0, DegradationLevel::Full, &signals, &config);
4161        let jsonl = plan.to_jsonl();
4162        assert!(jsonl.contains("\"event\":\"widget_refresh\""));
4163        assert!(jsonl.contains("\"frame_idx\":9"));
4164        assert!(jsonl.contains("\"selected_count\":1"));
4165        assert!(jsonl.contains("\"id\":7"));
4166    }
4167
4168    #[test]
4169    fn program_config_with_resize_coalescer() {
4170        let config = ProgramConfig::default().with_resize_coalescer(CoalescerConfig {
4171            steady_delay_ms: 8,
4172            burst_delay_ms: 20,
4173            hard_deadline_ms: 80,
4174            burst_enter_rate: 12.0,
4175            burst_exit_rate: 6.0,
4176            cooldown_frames: 2,
4177            rate_window_size: 6,
4178            enable_logging: true,
4179            enable_bocpd: false,
4180            bocpd_config: None,
4181        });
4182        assert_eq!(config.resize_coalescer.steady_delay_ms, 8);
4183        assert!(config.resize_coalescer.enable_logging);
4184    }
4185
4186    #[test]
4187    fn program_config_with_resize_behavior() {
4188        let config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Immediate);
4189        assert_eq!(config.resize_behavior, ResizeBehavior::Immediate);
4190    }
4191
4192    #[test]
4193    fn program_config_with_legacy_resize_enabled() {
4194        let config = ProgramConfig::default().with_legacy_resize(true);
4195        assert_eq!(config.resize_behavior, ResizeBehavior::Immediate);
4196    }
4197
4198    #[test]
4199    fn program_config_with_legacy_resize_disabled_keeps_default() {
4200        let config = ProgramConfig::default().with_legacy_resize(false);
4201        assert_eq!(config.resize_behavior, ResizeBehavior::Throttled);
4202    }
4203
4204    fn diff_strategy_trace(bayesian_enabled: bool) -> Vec<DiffStrategy> {
4205        let config = RuntimeDiffConfig::default().with_bayesian_enabled(bayesian_enabled);
4206        let mut writer = TerminalWriter::with_diff_config(
4207            Vec::<u8>::new(),
4208            ScreenMode::AltScreen,
4209            UiAnchor::Bottom,
4210            TerminalCapabilities::basic(),
4211            config,
4212        );
4213        writer.set_size(8, 4);
4214
4215        let mut buffer = Buffer::new(8, 4);
4216        let mut trace = Vec::new();
4217
4218        writer.present_ui(&buffer, None, false).unwrap();
4219        trace.push(
4220            writer
4221                .last_diff_strategy()
4222                .unwrap_or(DiffStrategy::FullRedraw),
4223        );
4224
4225        buffer.set_raw(0, 0, Cell::from_char('A'));
4226        writer.present_ui(&buffer, None, false).unwrap();
4227        trace.push(
4228            writer
4229                .last_diff_strategy()
4230                .unwrap_or(DiffStrategy::FullRedraw),
4231        );
4232
4233        buffer.set_raw(1, 1, Cell::from_char('B'));
4234        writer.present_ui(&buffer, None, false).unwrap();
4235        trace.push(
4236            writer
4237                .last_diff_strategy()
4238                .unwrap_or(DiffStrategy::FullRedraw),
4239        );
4240
4241        trace
4242    }
4243
4244    fn coalescer_checksum(enable_bocpd: bool) -> String {
4245        let mut config = CoalescerConfig::default().with_logging(true);
4246        if enable_bocpd {
4247            config = config.with_bocpd();
4248        }
4249
4250        let base = Instant::now();
4251        let mut coalescer = ResizeCoalescer::new(config, (80, 24)).with_last_render(base);
4252
4253        let events = [
4254            (0_u64, (82_u16, 24_u16)),
4255            (10, (83, 25)),
4256            (20, (84, 26)),
4257            (35, (90, 28)),
4258            (55, (92, 30)),
4259        ];
4260
4261        let mut idx = 0usize;
4262        for t_ms in (0_u64..=160).step_by(8) {
4263            let now = base + Duration::from_millis(t_ms);
4264            while idx < events.len() && events[idx].0 == t_ms {
4265                let (w, h) = events[idx].1;
4266                coalescer.handle_resize_at(w, h, now);
4267                idx += 1;
4268            }
4269            coalescer.tick_at(now);
4270        }
4271
4272        coalescer.decision_checksum_hex()
4273    }
4274
4275    fn conformal_trace(enabled: bool) -> Vec<(f64, bool)> {
4276        if !enabled {
4277            return Vec::new();
4278        }
4279
4280        let mut predictor = ConformalPredictor::new(ConformalConfig::default());
4281        let key = BucketKey::from_context(ScreenMode::AltScreen, DiffStrategy::Full, 80, 24);
4282        let mut trace = Vec::new();
4283
4284        for i in 0..30 {
4285            let y_hat = 16_000.0 + (i as f64) * 15.0;
4286            let observed = y_hat + (i % 7) as f64 * 120.0;
4287            predictor.observe(key, y_hat, observed);
4288            let prediction = predictor.predict(key, y_hat, 20_000.0);
4289            trace.push((prediction.upper_us, prediction.risk));
4290        }
4291
4292        trace
4293    }
4294
4295    #[test]
4296    fn policy_toggle_matrix_determinism() {
4297        for &bayesian in &[false, true] {
4298            for &bocpd in &[false, true] {
4299                for &conformal in &[false, true] {
4300                    let diff_a = diff_strategy_trace(bayesian);
4301                    let diff_b = diff_strategy_trace(bayesian);
4302                    assert_eq!(diff_a, diff_b, "diff strategy not deterministic");
4303
4304                    let checksum_a = coalescer_checksum(bocpd);
4305                    let checksum_b = coalescer_checksum(bocpd);
4306                    assert_eq!(checksum_a, checksum_b, "coalescer checksum mismatch");
4307
4308                    let conf_a = conformal_trace(conformal);
4309                    let conf_b = conformal_trace(conformal);
4310                    assert_eq!(conf_a, conf_b, "conformal predictor not deterministic");
4311
4312                    if conformal {
4313                        assert!(!conf_a.is_empty(), "conformal trace should be populated");
4314                    } else {
4315                        assert!(conf_a.is_empty(), "conformal trace should be empty");
4316                    }
4317                }
4318            }
4319        }
4320    }
4321
4322    #[test]
4323    fn resize_behavior_uses_coalescer_flag() {
4324        assert!(ResizeBehavior::Throttled.uses_coalescer());
4325        assert!(!ResizeBehavior::Immediate.uses_coalescer());
4326    }
4327
4328    #[test]
4329    fn nested_cmd_msg_executes_recursively() {
4330        // Verify that Cmd::Msg triggers recursive update
4331        use crate::simulator::ProgramSimulator;
4332
4333        struct NestedModel {
4334            depth: usize,
4335        }
4336
4337        #[derive(Debug)]
4338        enum NestedMsg {
4339            Nest(usize),
4340        }
4341
4342        impl From<Event> for NestedMsg {
4343            fn from(_: Event) -> Self {
4344                NestedMsg::Nest(0)
4345            }
4346        }
4347
4348        impl Model for NestedModel {
4349            type Message = NestedMsg;
4350
4351            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
4352                match msg {
4353                    NestedMsg::Nest(n) => {
4354                        self.depth += 1;
4355                        if n > 0 {
4356                            Cmd::msg(NestedMsg::Nest(n - 1))
4357                        } else {
4358                            Cmd::none()
4359                        }
4360                    }
4361                }
4362            }
4363
4364            fn view(&self, _frame: &mut Frame) {}
4365        }
4366
4367        let mut sim = ProgramSimulator::new(NestedModel { depth: 0 });
4368        sim.init();
4369        sim.send(NestedMsg::Nest(3));
4370
4371        // Should have recursed 4 times (3, 2, 1, 0)
4372        assert_eq!(sim.model().depth, 4);
4373    }
4374
4375    #[test]
4376    fn task_executes_synchronously_in_simulator() {
4377        // In simulator, tasks execute synchronously
4378        use crate::simulator::ProgramSimulator;
4379
4380        struct TaskModel {
4381            completed: bool,
4382        }
4383
4384        #[derive(Debug)]
4385        enum TaskMsg {
4386            Complete,
4387            SpawnTask,
4388        }
4389
4390        impl From<Event> for TaskMsg {
4391            fn from(_: Event) -> Self {
4392                TaskMsg::Complete
4393            }
4394        }
4395
4396        impl Model for TaskModel {
4397            type Message = TaskMsg;
4398
4399            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
4400                match msg {
4401                    TaskMsg::Complete => {
4402                        self.completed = true;
4403                        Cmd::none()
4404                    }
4405                    TaskMsg::SpawnTask => Cmd::task(|| TaskMsg::Complete),
4406                }
4407            }
4408
4409            fn view(&self, _frame: &mut Frame) {}
4410        }
4411
4412        let mut sim = ProgramSimulator::new(TaskModel { completed: false });
4413        sim.init();
4414        sim.send(TaskMsg::SpawnTask);
4415
4416        // Task should have completed synchronously
4417        assert!(sim.model().completed);
4418    }
4419
4420    #[test]
4421    fn multiple_updates_accumulate_correctly() {
4422        // Verify state accumulates correctly across multiple updates
4423        use crate::simulator::ProgramSimulator;
4424
4425        struct AccumModel {
4426            sum: i32,
4427        }
4428
4429        #[derive(Debug)]
4430        enum AccumMsg {
4431            Add(i32),
4432            Multiply(i32),
4433        }
4434
4435        impl From<Event> for AccumMsg {
4436            fn from(_: Event) -> Self {
4437                AccumMsg::Add(1)
4438            }
4439        }
4440
4441        impl Model for AccumModel {
4442            type Message = AccumMsg;
4443
4444            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
4445                match msg {
4446                    AccumMsg::Add(n) => {
4447                        self.sum += n;
4448                        Cmd::none()
4449                    }
4450                    AccumMsg::Multiply(n) => {
4451                        self.sum *= n;
4452                        Cmd::none()
4453                    }
4454                }
4455            }
4456
4457            fn view(&self, _frame: &mut Frame) {}
4458        }
4459
4460        let mut sim = ProgramSimulator::new(AccumModel { sum: 0 });
4461        sim.init();
4462
4463        // (0 + 5) * 2 + 3 = 13
4464        sim.send(AccumMsg::Add(5));
4465        sim.send(AccumMsg::Multiply(2));
4466        sim.send(AccumMsg::Add(3));
4467
4468        assert_eq!(sim.model().sum, 13);
4469    }
4470
4471    #[test]
4472    fn init_command_executes_before_first_update() {
4473        // Verify init() command executes before any update
4474        use crate::simulator::ProgramSimulator;
4475
4476        struct InitModel {
4477            initialized: bool,
4478            updates: usize,
4479        }
4480
4481        #[derive(Debug)]
4482        enum InitMsg {
4483            Update,
4484            MarkInit,
4485        }
4486
4487        impl From<Event> for InitMsg {
4488            fn from(_: Event) -> Self {
4489                InitMsg::Update
4490            }
4491        }
4492
4493        impl Model for InitModel {
4494            type Message = InitMsg;
4495
4496            fn init(&mut self) -> Cmd<Self::Message> {
4497                Cmd::msg(InitMsg::MarkInit)
4498            }
4499
4500            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
4501                match msg {
4502                    InitMsg::MarkInit => {
4503                        self.initialized = true;
4504                        Cmd::none()
4505                    }
4506                    InitMsg::Update => {
4507                        self.updates += 1;
4508                        Cmd::none()
4509                    }
4510                }
4511            }
4512
4513            fn view(&self, _frame: &mut Frame) {}
4514        }
4515
4516        let mut sim = ProgramSimulator::new(InitModel {
4517            initialized: false,
4518            updates: 0,
4519        });
4520        sim.init();
4521
4522        assert!(sim.model().initialized);
4523        sim.send(InitMsg::Update);
4524        assert_eq!(sim.model().updates, 1);
4525    }
4526
4527    // =========================================================================
4528    // INLINE MODE FRAME SIZING TESTS (bd-20vg)
4529    // =========================================================================
4530
4531    #[test]
4532    fn ui_height_returns_correct_value_inline_mode() {
4533        // Verify TerminalWriter.ui_height() returns ui_height in inline mode
4534        use crate::terminal_writer::{ScreenMode, TerminalWriter, UiAnchor};
4535        use ftui_core::terminal_capabilities::TerminalCapabilities;
4536
4537        let output = Vec::new();
4538        let writer = TerminalWriter::new(
4539            output,
4540            ScreenMode::Inline { ui_height: 10 },
4541            UiAnchor::Bottom,
4542            TerminalCapabilities::basic(),
4543        );
4544        assert_eq!(writer.ui_height(), 10);
4545    }
4546
4547    #[test]
4548    fn ui_height_returns_term_height_altscreen_mode() {
4549        // Verify TerminalWriter.ui_height() returns full terminal height in alt-screen mode
4550        use crate::terminal_writer::{ScreenMode, TerminalWriter, UiAnchor};
4551        use ftui_core::terminal_capabilities::TerminalCapabilities;
4552
4553        let output = Vec::new();
4554        let mut writer = TerminalWriter::new(
4555            output,
4556            ScreenMode::AltScreen,
4557            UiAnchor::Bottom,
4558            TerminalCapabilities::basic(),
4559        );
4560        writer.set_size(80, 24);
4561        assert_eq!(writer.ui_height(), 24);
4562    }
4563
4564    #[test]
4565    fn inline_mode_frame_uses_ui_height_not_terminal_height() {
4566        // Verify that in inline mode, the model receives a frame with ui_height,
4567        // not the full terminal height. This is the core fix for bd-20vg.
4568        use crate::simulator::ProgramSimulator;
4569        use std::cell::Cell as StdCell;
4570
4571        thread_local! {
4572            static CAPTURED_HEIGHT: StdCell<u16> = const { StdCell::new(0) };
4573        }
4574
4575        struct FrameSizeTracker;
4576
4577        #[derive(Debug)]
4578        enum SizeMsg {
4579            Check,
4580        }
4581
4582        impl From<Event> for SizeMsg {
4583            fn from(_: Event) -> Self {
4584                SizeMsg::Check
4585            }
4586        }
4587
4588        impl Model for FrameSizeTracker {
4589            type Message = SizeMsg;
4590
4591            fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
4592                Cmd::none()
4593            }
4594
4595            fn view(&self, frame: &mut Frame) {
4596                // Capture the frame height we receive
4597                CAPTURED_HEIGHT.with(|h| h.set(frame.height()));
4598            }
4599        }
4600
4601        // Use simulator to verify frame dimension handling
4602        let mut sim = ProgramSimulator::new(FrameSizeTracker);
4603        sim.init();
4604
4605        // Capture with specific dimensions (simulates inline mode ui_height=10)
4606        let buf = sim.capture_frame(80, 10);
4607        assert_eq!(buf.height(), 10);
4608        assert_eq!(buf.width(), 80);
4609
4610        // Verify the frame has the correct dimensions
4611        // In inline mode with ui_height=10, the frame should be 10 rows tall,
4612        // NOT the full terminal height (e.g., 24).
4613    }
4614
4615    #[test]
4616    fn altscreen_frame_uses_full_terminal_height() {
4617        // Regression test: in alt-screen mode, frame should use full terminal height.
4618        use crate::terminal_writer::{ScreenMode, TerminalWriter, UiAnchor};
4619        use ftui_core::terminal_capabilities::TerminalCapabilities;
4620
4621        let output = Vec::new();
4622        let mut writer = TerminalWriter::new(
4623            output,
4624            ScreenMode::AltScreen,
4625            UiAnchor::Bottom,
4626            TerminalCapabilities::basic(),
4627        );
4628        writer.set_size(80, 40);
4629
4630        // In alt-screen, ui_height equals terminal height
4631        assert_eq!(writer.ui_height(), 40);
4632    }
4633
4634    #[test]
4635    fn ui_height_clamped_to_terminal_height() {
4636        // Verify ui_height doesn't exceed terminal height
4637        // (This is handled in present_inline, but ui_height() returns the configured value)
4638        use crate::terminal_writer::{ScreenMode, TerminalWriter, UiAnchor};
4639        use ftui_core::terminal_capabilities::TerminalCapabilities;
4640
4641        let output = Vec::new();
4642        let mut writer = TerminalWriter::new(
4643            output,
4644            ScreenMode::Inline { ui_height: 100 },
4645            UiAnchor::Bottom,
4646            TerminalCapabilities::basic(),
4647        );
4648        writer.set_size(80, 10);
4649
4650        // ui_height() returns configured value, but present_inline clamps
4651        // The Frame should be created with ui_height (100), which is later
4652        // clamped during presentation. For safety, we should use the min.
4653        // Note: This documents current behavior. A stricter fix might
4654        // have ui_height() return min(ui_height, term_height).
4655        assert_eq!(writer.ui_height(), 100);
4656    }
4657
4658    // =========================================================================
4659    // TICK DELIVERY TESTS (bd-3ufh)
4660    // =========================================================================
4661
4662    #[test]
4663    fn tick_event_delivered_to_model_update() {
4664        // Verify that Event::Tick is delivered to model.update()
4665        // This is the core fix: ticks now flow through the update pipeline.
4666        use crate::simulator::ProgramSimulator;
4667
4668        struct TickTracker {
4669            tick_count: usize,
4670        }
4671
4672        #[derive(Debug)]
4673        enum TickMsg {
4674            Tick,
4675            Other,
4676        }
4677
4678        impl From<Event> for TickMsg {
4679            fn from(event: Event) -> Self {
4680                match event {
4681                    Event::Tick => TickMsg::Tick,
4682                    _ => TickMsg::Other,
4683                }
4684            }
4685        }
4686
4687        impl Model for TickTracker {
4688            type Message = TickMsg;
4689
4690            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
4691                match msg {
4692                    TickMsg::Tick => {
4693                        self.tick_count += 1;
4694                        Cmd::none()
4695                    }
4696                    TickMsg::Other => Cmd::none(),
4697                }
4698            }
4699
4700            fn view(&self, _frame: &mut Frame) {}
4701        }
4702
4703        let mut sim = ProgramSimulator::new(TickTracker { tick_count: 0 });
4704        sim.init();
4705
4706        // Manually inject tick event to simulate what the runtime does
4707        sim.inject_event(Event::Tick);
4708        assert_eq!(sim.model().tick_count, 1);
4709
4710        sim.inject_event(Event::Tick);
4711        sim.inject_event(Event::Tick);
4712        assert_eq!(sim.model().tick_count, 3);
4713    }
4714
4715    #[test]
4716    fn tick_command_sets_tick_rate() {
4717        // Verify Cmd::tick() sets the tick rate in the simulator
4718        use crate::simulator::{CmdRecord, ProgramSimulator};
4719
4720        struct TickModel;
4721
4722        #[derive(Debug)]
4723        enum Msg {
4724            SetTick,
4725            Noop,
4726        }
4727
4728        impl From<Event> for Msg {
4729            fn from(_: Event) -> Self {
4730                Msg::Noop
4731            }
4732        }
4733
4734        impl Model for TickModel {
4735            type Message = Msg;
4736
4737            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
4738                match msg {
4739                    Msg::SetTick => Cmd::tick(Duration::from_millis(100)),
4740                    Msg::Noop => Cmd::none(),
4741                }
4742            }
4743
4744            fn view(&self, _frame: &mut Frame) {}
4745        }
4746
4747        let mut sim = ProgramSimulator::new(TickModel);
4748        sim.init();
4749        sim.send(Msg::SetTick);
4750
4751        // Check that tick was recorded
4752        let commands = sim.command_log();
4753        assert!(
4754            commands
4755                .iter()
4756                .any(|c| matches!(c, CmdRecord::Tick(d) if *d == Duration::from_millis(100)))
4757        );
4758    }
4759
4760    #[test]
4761    fn tick_can_trigger_further_commands() {
4762        // Verify that tick handling can return commands that are executed
4763        use crate::simulator::ProgramSimulator;
4764
4765        struct ChainModel {
4766            stage: usize,
4767        }
4768
4769        #[derive(Debug)]
4770        enum ChainMsg {
4771            Tick,
4772            Advance,
4773            Noop,
4774        }
4775
4776        impl From<Event> for ChainMsg {
4777            fn from(event: Event) -> Self {
4778                match event {
4779                    Event::Tick => ChainMsg::Tick,
4780                    _ => ChainMsg::Noop,
4781                }
4782            }
4783        }
4784
4785        impl Model for ChainModel {
4786            type Message = ChainMsg;
4787
4788            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
4789                match msg {
4790                    ChainMsg::Tick => {
4791                        self.stage += 1;
4792                        // Return another message to be processed
4793                        Cmd::msg(ChainMsg::Advance)
4794                    }
4795                    ChainMsg::Advance => {
4796                        self.stage += 10;
4797                        Cmd::none()
4798                    }
4799                    ChainMsg::Noop => Cmd::none(),
4800                }
4801            }
4802
4803            fn view(&self, _frame: &mut Frame) {}
4804        }
4805
4806        let mut sim = ProgramSimulator::new(ChainModel { stage: 0 });
4807        sim.init();
4808        sim.inject_event(Event::Tick);
4809
4810        // Tick increments by 1, then Advance increments by 10
4811        assert_eq!(sim.model().stage, 11);
4812    }
4813
4814    #[test]
4815    fn tick_disabled_with_zero_duration() {
4816        // Verify that Duration::ZERO disables ticks (no busy loop)
4817        use crate::simulator::ProgramSimulator;
4818
4819        struct ZeroTickModel {
4820            disabled: bool,
4821        }
4822
4823        #[derive(Debug)]
4824        enum ZeroMsg {
4825            DisableTick,
4826            Noop,
4827        }
4828
4829        impl From<Event> for ZeroMsg {
4830            fn from(_: Event) -> Self {
4831                ZeroMsg::Noop
4832            }
4833        }
4834
4835        impl Model for ZeroTickModel {
4836            type Message = ZeroMsg;
4837
4838            fn init(&mut self) -> Cmd<Self::Message> {
4839                // Start with a tick enabled
4840                Cmd::tick(Duration::from_millis(100))
4841            }
4842
4843            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
4844                match msg {
4845                    ZeroMsg::DisableTick => {
4846                        self.disabled = true;
4847                        // Setting tick to ZERO should effectively disable
4848                        Cmd::tick(Duration::ZERO)
4849                    }
4850                    ZeroMsg::Noop => Cmd::none(),
4851                }
4852            }
4853
4854            fn view(&self, _frame: &mut Frame) {}
4855        }
4856
4857        let mut sim = ProgramSimulator::new(ZeroTickModel { disabled: false });
4858        sim.init();
4859
4860        // Verify initial tick rate is set
4861        assert!(sim.tick_rate().is_some());
4862        assert_eq!(sim.tick_rate(), Some(Duration::from_millis(100)));
4863
4864        // Disable ticks
4865        sim.send(ZeroMsg::DisableTick);
4866        assert!(sim.model().disabled);
4867
4868        // Note: The simulator still records the ZERO tick, but the runtime's
4869        // should_tick() handles ZERO duration appropriately
4870        assert_eq!(sim.tick_rate(), Some(Duration::ZERO));
4871    }
4872
4873    #[test]
4874    fn tick_event_distinguishable_from_other_events() {
4875        // Verify Event::Tick can be distinguished in pattern matching
4876        let tick = Event::Tick;
4877        let key = Event::Key(ftui_core::event::KeyEvent::new(
4878            ftui_core::event::KeyCode::Char('a'),
4879        ));
4880
4881        assert!(matches!(tick, Event::Tick));
4882        assert!(!matches!(key, Event::Tick));
4883    }
4884
4885    #[test]
4886    fn tick_event_clone_and_eq() {
4887        // Verify Event::Tick implements Clone and Eq correctly
4888        let tick1 = Event::Tick;
4889        let tick2 = tick1.clone();
4890        assert_eq!(tick1, tick2);
4891    }
4892
4893    #[test]
4894    fn model_receives_tick_and_input_events() {
4895        // Verify model can handle both tick and input events correctly
4896        use crate::simulator::ProgramSimulator;
4897
4898        struct MixedModel {
4899            ticks: usize,
4900            keys: usize,
4901        }
4902
4903        #[derive(Debug)]
4904        enum MixedMsg {
4905            Tick,
4906            Key,
4907        }
4908
4909        impl From<Event> for MixedMsg {
4910            fn from(event: Event) -> Self {
4911                match event {
4912                    Event::Tick => MixedMsg::Tick,
4913                    _ => MixedMsg::Key,
4914                }
4915            }
4916        }
4917
4918        impl Model for MixedModel {
4919            type Message = MixedMsg;
4920
4921            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
4922                match msg {
4923                    MixedMsg::Tick => {
4924                        self.ticks += 1;
4925                        Cmd::none()
4926                    }
4927                    MixedMsg::Key => {
4928                        self.keys += 1;
4929                        Cmd::none()
4930                    }
4931                }
4932            }
4933
4934            fn view(&self, _frame: &mut Frame) {}
4935        }
4936
4937        let mut sim = ProgramSimulator::new(MixedModel { ticks: 0, keys: 0 });
4938        sim.init();
4939
4940        // Interleave tick and input events
4941        sim.inject_event(Event::Tick);
4942        sim.inject_event(Event::Key(ftui_core::event::KeyEvent::new(
4943            ftui_core::event::KeyCode::Char('a'),
4944        )));
4945        sim.inject_event(Event::Tick);
4946        sim.inject_event(Event::Key(ftui_core::event::KeyEvent::new(
4947            ftui_core::event::KeyCode::Char('b'),
4948        )));
4949        sim.inject_event(Event::Tick);
4950
4951        assert_eq!(sim.model().ticks, 3);
4952        assert_eq!(sim.model().keys, 2);
4953    }
4954
4955    // =========================================================================
4956    // HEADLESS PROGRAM TESTS (bd-1av4o.2)
4957    // =========================================================================
4958
4959    fn headless_program_with_config<M: Model>(
4960        model: M,
4961        config: ProgramConfig,
4962    ) -> Program<M, Vec<u8>>
4963    where
4964        M::Message: Send + 'static,
4965    {
4966        let capabilities = TerminalCapabilities::basic();
4967        let mut writer = TerminalWriter::with_diff_config(
4968            Vec::new(),
4969            config.screen_mode,
4970            config.ui_anchor,
4971            capabilities,
4972            config.diff_config.clone(),
4973        );
4974        let frame_timing = config.frame_timing.clone();
4975        writer.set_timing_enabled(frame_timing.is_some());
4976
4977        let (width, height) = config.forced_size.unwrap_or((80, 24));
4978        let width = width.max(1);
4979        let height = height.max(1);
4980        writer.set_size(width, height);
4981
4982        let session = TerminalSession::new_for_tests(SessionOptions {
4983            alternate_screen: matches!(config.screen_mode, ScreenMode::AltScreen),
4984            mouse_capture: config.mouse,
4985            bracketed_paste: config.bracketed_paste,
4986            focus_events: config.focus_reporting,
4987            kitty_keyboard: config.kitty_keyboard,
4988        })
4989        .expect("headless test session");
4990
4991        let budget = RenderBudget::from_config(&config.budget);
4992        let conformal_predictor = config.conformal_config.clone().map(ConformalPredictor::new);
4993        let locale_context = config.locale_context.clone();
4994        let locale_version = locale_context.version();
4995        let resize_coalescer =
4996            ResizeCoalescer::new(config.resize_coalescer.clone(), (width, height));
4997        let subscriptions = SubscriptionManager::new();
4998        let (task_sender, task_receiver) = std::sync::mpsc::channel();
4999        let inline_auto_remeasure = config
5000            .inline_auto_remeasure
5001            .clone()
5002            .map(InlineAutoRemeasureState::new);
5003
5004        Program {
5005            model,
5006            writer,
5007            session,
5008            running: true,
5009            tick_rate: None,
5010            last_tick: Instant::now(),
5011            dirty: true,
5012            frame_idx: 0,
5013            widget_signals: Vec::new(),
5014            widget_refresh_config: config.widget_refresh,
5015            widget_refresh_plan: WidgetRefreshPlan::new(),
5016            width,
5017            height,
5018            forced_size: config.forced_size,
5019            poll_timeout: config.poll_timeout,
5020            budget,
5021            conformal_predictor,
5022            last_frame_time_us: None,
5023            last_update_us: None,
5024            frame_timing,
5025            locale_context,
5026            locale_version,
5027            resize_coalescer,
5028            evidence_sink: None,
5029            fairness_config_logged: false,
5030            resize_behavior: config.resize_behavior,
5031            fairness_guard: InputFairnessGuard::new(),
5032            event_recorder: None,
5033            subscriptions,
5034            task_sender,
5035            task_receiver,
5036            task_handles: Vec::new(),
5037            effect_queue: None,
5038            state_registry: config.persistence.registry.clone(),
5039            persistence_config: config.persistence,
5040            last_checkpoint: Instant::now(),
5041            inline_auto_remeasure,
5042        }
5043    }
5044
5045    fn temp_evidence_path(label: &str) -> PathBuf {
5046        static COUNTER: AtomicUsize = AtomicUsize::new(0);
5047        let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
5048        let pid = std::process::id();
5049        let mut path = std::env::temp_dir();
5050        path.push(format!("ftui_evidence_{label}_{pid}_{seq}.jsonl"));
5051        path
5052    }
5053
5054    fn read_evidence_event(path: &PathBuf, event: &str) -> Value {
5055        let jsonl = std::fs::read_to_string(path).expect("read evidence jsonl");
5056        let needle = format!("\"event\":\"{event}\"");
5057        let line = jsonl
5058            .lines()
5059            .find(|line| line.contains(&needle))
5060            .unwrap_or_else(|| panic!("missing {event} line"));
5061        serde_json::from_str(line).expect("valid evidence json")
5062    }
5063
5064    #[test]
5065    fn headless_apply_resize_updates_model_and_dimensions() {
5066        struct ResizeModel {
5067            last_size: Option<(u16, u16)>,
5068        }
5069
5070        #[derive(Debug)]
5071        enum ResizeMsg {
5072            Resize(u16, u16),
5073            Other,
5074        }
5075
5076        impl From<Event> for ResizeMsg {
5077            fn from(event: Event) -> Self {
5078                match event {
5079                    Event::Resize { width, height } => ResizeMsg::Resize(width, height),
5080                    _ => ResizeMsg::Other,
5081                }
5082            }
5083        }
5084
5085        impl Model for ResizeModel {
5086            type Message = ResizeMsg;
5087
5088            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
5089                if let ResizeMsg::Resize(w, h) = msg {
5090                    self.last_size = Some((w, h));
5091                }
5092                Cmd::none()
5093            }
5094
5095            fn view(&self, _frame: &mut Frame) {}
5096        }
5097
5098        let mut program =
5099            headless_program_with_config(ResizeModel { last_size: None }, ProgramConfig::default());
5100        program.dirty = false;
5101
5102        program
5103            .apply_resize(0, 0, Duration::ZERO, false)
5104            .expect("resize");
5105
5106        assert_eq!(program.width, 1);
5107        assert_eq!(program.height, 1);
5108        assert_eq!(program.model().last_size, Some((1, 1)));
5109        assert!(program.dirty);
5110    }
5111
5112    #[test]
5113    fn headless_execute_cmd_log_writes_output() {
5114        let mut program =
5115            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
5116        program.execute_cmd(Cmd::log("hello world")).expect("log");
5117
5118        let bytes = program.writer.into_inner().expect("writer output");
5119        let output = String::from_utf8_lossy(&bytes);
5120        assert!(output.contains("hello world"));
5121    }
5122
5123    #[test]
5124    fn headless_process_task_results_updates_model() {
5125        struct TaskModel {
5126            updates: usize,
5127        }
5128
5129        #[derive(Debug)]
5130        enum TaskMsg {
5131            Done,
5132        }
5133
5134        impl From<Event> for TaskMsg {
5135            fn from(_: Event) -> Self {
5136                TaskMsg::Done
5137            }
5138        }
5139
5140        impl Model for TaskModel {
5141            type Message = TaskMsg;
5142
5143            fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
5144                self.updates += 1;
5145                Cmd::none()
5146            }
5147
5148            fn view(&self, _frame: &mut Frame) {}
5149        }
5150
5151        let mut program =
5152            headless_program_with_config(TaskModel { updates: 0 }, ProgramConfig::default());
5153        program.dirty = false;
5154        program.task_sender.send(TaskMsg::Done).unwrap();
5155
5156        program
5157            .process_task_results()
5158            .expect("process task results");
5159        assert_eq!(program.model().updates, 1);
5160        assert!(program.dirty);
5161    }
5162
5163    #[test]
5164    fn headless_should_tick_and_timeout_behaviors() {
5165        let mut program =
5166            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
5167        program.tick_rate = Some(Duration::from_millis(5));
5168        program.last_tick = Instant::now() - Duration::from_millis(10);
5169
5170        assert!(program.should_tick());
5171        assert!(!program.should_tick());
5172
5173        let timeout = program.effective_timeout();
5174        assert!(timeout <= Duration::from_millis(5));
5175
5176        program.tick_rate = None;
5177        program.poll_timeout = Duration::from_millis(33);
5178        assert_eq!(program.effective_timeout(), Duration::from_millis(33));
5179    }
5180
5181    #[test]
5182    fn headless_effective_timeout_respects_resize_coalescer() {
5183        let mut config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Throttled);
5184        config.resize_coalescer.steady_delay_ms = 0;
5185        config.resize_coalescer.burst_delay_ms = 0;
5186
5187        let mut program = headless_program_with_config(TestModel { value: 0 }, config);
5188        program.tick_rate = Some(Duration::from_millis(50));
5189
5190        program.resize_coalescer.handle_resize(120, 40);
5191        assert!(program.resize_coalescer.has_pending());
5192
5193        let timeout = program.effective_timeout();
5194        assert_eq!(timeout, Duration::ZERO);
5195    }
5196
5197    #[test]
5198    fn headless_ui_height_remeasure_clears_auto_height() {
5199        let mut config = ProgramConfig::inline_auto(2, 6);
5200        config.inline_auto_remeasure = Some(InlineAutoRemeasureConfig::default());
5201
5202        let mut program = headless_program_with_config(TestModel { value: 0 }, config);
5203        program.dirty = false;
5204        program.writer.set_auto_ui_height(5);
5205
5206        assert_eq!(program.writer.auto_ui_height(), Some(5));
5207        program.request_ui_height_remeasure();
5208
5209        assert_eq!(program.writer.auto_ui_height(), None);
5210        assert!(program.dirty);
5211    }
5212
5213    #[test]
5214    fn headless_recording_lifecycle_and_locale_change() {
5215        let mut program =
5216            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
5217        program.dirty = false;
5218
5219        program.start_recording("demo");
5220        assert!(program.is_recording());
5221        let recorded = program.stop_recording();
5222        assert!(recorded.is_some());
5223        assert!(!program.is_recording());
5224
5225        let prev_dirty = program.dirty;
5226        program.locale_context.set_locale("fr");
5227        program.check_locale_change();
5228        assert!(program.dirty || prev_dirty);
5229    }
5230
5231    #[test]
5232    fn headless_render_frame_marks_clean_and_sets_diff() {
5233        struct RenderModel;
5234
5235        #[derive(Debug)]
5236        enum RenderMsg {
5237            Noop,
5238        }
5239
5240        impl From<Event> for RenderMsg {
5241            fn from(_: Event) -> Self {
5242                RenderMsg::Noop
5243            }
5244        }
5245
5246        impl Model for RenderModel {
5247            type Message = RenderMsg;
5248
5249            fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
5250                Cmd::none()
5251            }
5252
5253            fn view(&self, frame: &mut Frame) {
5254                frame.buffer.set_raw(0, 0, Cell::from_char('X'));
5255            }
5256        }
5257
5258        let mut program = headless_program_with_config(RenderModel, ProgramConfig::default());
5259        program.render_frame().expect("render frame");
5260
5261        assert!(!program.dirty);
5262        assert!(program.writer.last_diff_strategy().is_some());
5263        assert_eq!(program.frame_idx, 1);
5264    }
5265
5266    #[test]
5267    fn headless_render_frame_skips_when_budget_exhausted() {
5268        let config = ProgramConfig {
5269            budget: FrameBudgetConfig::with_total(Duration::ZERO),
5270            ..Default::default()
5271        };
5272
5273        let mut program = headless_program_with_config(TestModel { value: 0 }, config);
5274        program.render_frame().expect("render frame");
5275
5276        assert!(!program.dirty);
5277        assert_eq!(program.frame_idx, 1);
5278    }
5279
5280    #[test]
5281    fn headless_render_frame_emits_budget_evidence_with_controller() {
5282        use ftui_render::budget::BudgetControllerConfig;
5283
5284        struct RenderModel;
5285
5286        #[derive(Debug)]
5287        enum RenderMsg {
5288            Noop,
5289        }
5290
5291        impl From<Event> for RenderMsg {
5292            fn from(_: Event) -> Self {
5293                RenderMsg::Noop
5294            }
5295        }
5296
5297        impl Model for RenderModel {
5298            type Message = RenderMsg;
5299
5300            fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
5301                Cmd::none()
5302            }
5303
5304            fn view(&self, frame: &mut Frame) {
5305                frame.buffer.set_raw(0, 0, Cell::from_char('E'));
5306            }
5307        }
5308
5309        let config =
5310            ProgramConfig::default().with_evidence_sink(EvidenceSinkConfig::enabled_stdout());
5311        let mut program = headless_program_with_config(RenderModel, config);
5312        program.budget = program
5313            .budget
5314            .with_controller(BudgetControllerConfig::default());
5315
5316        program.render_frame().expect("render frame");
5317        assert!(program.budget.telemetry().is_some());
5318        assert_eq!(program.frame_idx, 1);
5319    }
5320
5321    #[test]
5322    fn headless_handle_event_updates_model() {
5323        struct EventModel {
5324            events: usize,
5325            last_resize: Option<(u16, u16)>,
5326        }
5327
5328        #[derive(Debug)]
5329        enum EventMsg {
5330            Resize(u16, u16),
5331            Other,
5332        }
5333
5334        impl From<Event> for EventMsg {
5335            fn from(event: Event) -> Self {
5336                match event {
5337                    Event::Resize { width, height } => EventMsg::Resize(width, height),
5338                    _ => EventMsg::Other,
5339                }
5340            }
5341        }
5342
5343        impl Model for EventModel {
5344            type Message = EventMsg;
5345
5346            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
5347                self.events += 1;
5348                if let EventMsg::Resize(w, h) = msg {
5349                    self.last_resize = Some((w, h));
5350                }
5351                Cmd::none()
5352            }
5353
5354            fn view(&self, _frame: &mut Frame) {}
5355        }
5356
5357        let mut program = headless_program_with_config(
5358            EventModel {
5359                events: 0,
5360                last_resize: None,
5361            },
5362            ProgramConfig::default().with_resize_behavior(ResizeBehavior::Immediate),
5363        );
5364
5365        program
5366            .handle_event(Event::Key(ftui_core::event::KeyEvent::new(
5367                ftui_core::event::KeyCode::Char('x'),
5368            )))
5369            .expect("handle key");
5370        assert_eq!(program.model().events, 1);
5371
5372        program
5373            .handle_event(Event::Resize {
5374                width: 10,
5375                height: 5,
5376            })
5377            .expect("handle resize");
5378        assert_eq!(program.model().events, 2);
5379        assert_eq!(program.model().last_resize, Some((10, 5)));
5380        assert_eq!(program.width, 10);
5381        assert_eq!(program.height, 5);
5382    }
5383
5384    #[test]
5385    fn headless_handle_resize_ignored_when_forced_size() {
5386        struct ResizeModel {
5387            resized: bool,
5388        }
5389
5390        #[derive(Debug)]
5391        enum ResizeMsg {
5392            Resize,
5393            Other,
5394        }
5395
5396        impl From<Event> for ResizeMsg {
5397            fn from(event: Event) -> Self {
5398                match event {
5399                    Event::Resize { .. } => ResizeMsg::Resize,
5400                    _ => ResizeMsg::Other,
5401                }
5402            }
5403        }
5404
5405        impl Model for ResizeModel {
5406            type Message = ResizeMsg;
5407
5408            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
5409                if matches!(msg, ResizeMsg::Resize) {
5410                    self.resized = true;
5411                }
5412                Cmd::none()
5413            }
5414
5415            fn view(&self, _frame: &mut Frame) {}
5416        }
5417
5418        let config = ProgramConfig::default().with_forced_size(80, 24);
5419        let mut program = headless_program_with_config(ResizeModel { resized: false }, config);
5420
5421        program
5422            .handle_event(Event::Resize {
5423                width: 120,
5424                height: 40,
5425            })
5426            .expect("handle resize");
5427
5428        assert_eq!(program.width, 80);
5429        assert_eq!(program.height, 24);
5430        assert!(!program.model().resized);
5431    }
5432
5433    #[test]
5434    fn headless_execute_cmd_batch_sequence_and_quit() {
5435        struct BatchModel {
5436            count: usize,
5437        }
5438
5439        #[derive(Debug)]
5440        enum BatchMsg {
5441            Inc,
5442        }
5443
5444        impl From<Event> for BatchMsg {
5445            fn from(_: Event) -> Self {
5446                BatchMsg::Inc
5447            }
5448        }
5449
5450        impl Model for BatchModel {
5451            type Message = BatchMsg;
5452
5453            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
5454                match msg {
5455                    BatchMsg::Inc => {
5456                        self.count += 1;
5457                        Cmd::none()
5458                    }
5459                }
5460            }
5461
5462            fn view(&self, _frame: &mut Frame) {}
5463        }
5464
5465        let mut program =
5466            headless_program_with_config(BatchModel { count: 0 }, ProgramConfig::default());
5467
5468        program
5469            .execute_cmd(Cmd::Batch(vec![
5470                Cmd::msg(BatchMsg::Inc),
5471                Cmd::Sequence(vec![
5472                    Cmd::msg(BatchMsg::Inc),
5473                    Cmd::quit(),
5474                    Cmd::msg(BatchMsg::Inc),
5475                ]),
5476            ]))
5477            .expect("batch cmd");
5478
5479        assert_eq!(program.model().count, 2);
5480        assert!(!program.running);
5481    }
5482
5483    #[test]
5484    fn headless_process_subscription_messages_updates_model() {
5485        use crate::subscription::{StopSignal, SubId, Subscription};
5486
5487        struct SubModel {
5488            pings: usize,
5489            ready_tx: mpsc::Sender<()>,
5490        }
5491
5492        #[derive(Debug)]
5493        enum SubMsg {
5494            Ping,
5495            Other,
5496        }
5497
5498        impl From<Event> for SubMsg {
5499            fn from(_: Event) -> Self {
5500                SubMsg::Other
5501            }
5502        }
5503
5504        impl Model for SubModel {
5505            type Message = SubMsg;
5506
5507            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
5508                if let SubMsg::Ping = msg {
5509                    self.pings += 1;
5510                }
5511                Cmd::none()
5512            }
5513
5514            fn view(&self, _frame: &mut Frame) {}
5515
5516            fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
5517                vec![Box::new(TestSubscription {
5518                    ready_tx: self.ready_tx.clone(),
5519                })]
5520            }
5521        }
5522
5523        struct TestSubscription {
5524            ready_tx: mpsc::Sender<()>,
5525        }
5526
5527        impl Subscription<SubMsg> for TestSubscription {
5528            fn id(&self) -> SubId {
5529                1
5530            }
5531
5532            fn run(&self, sender: mpsc::Sender<SubMsg>, _stop: StopSignal) {
5533                let _ = sender.send(SubMsg::Ping);
5534                let _ = self.ready_tx.send(());
5535            }
5536        }
5537
5538        let (ready_tx, ready_rx) = mpsc::channel();
5539        let mut program =
5540            headless_program_with_config(SubModel { pings: 0, ready_tx }, ProgramConfig::default());
5541
5542        program.reconcile_subscriptions();
5543        ready_rx
5544            .recv_timeout(Duration::from_millis(200))
5545            .expect("subscription started");
5546        program
5547            .process_subscription_messages()
5548            .expect("process subscriptions");
5549
5550        assert_eq!(program.model().pings, 1);
5551    }
5552
5553    #[test]
5554    fn headless_execute_cmd_task_spawns_and_reaps() {
5555        struct TaskModel {
5556            done: bool,
5557        }
5558
5559        #[derive(Debug)]
5560        enum TaskMsg {
5561            Done,
5562        }
5563
5564        impl From<Event> for TaskMsg {
5565            fn from(_: Event) -> Self {
5566                TaskMsg::Done
5567            }
5568        }
5569
5570        impl Model for TaskModel {
5571            type Message = TaskMsg;
5572
5573            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
5574                match msg {
5575                    TaskMsg::Done => {
5576                        self.done = true;
5577                        Cmd::none()
5578                    }
5579                }
5580            }
5581
5582            fn view(&self, _frame: &mut Frame) {}
5583        }
5584
5585        let mut program =
5586            headless_program_with_config(TaskModel { done: false }, ProgramConfig::default());
5587        program
5588            .execute_cmd(Cmd::task(|| TaskMsg::Done))
5589            .expect("task cmd");
5590
5591        let deadline = Instant::now() + Duration::from_millis(200);
5592        while !program.model().done {
5593            program
5594                .process_task_results()
5595                .expect("process task results");
5596            program.reap_finished_tasks();
5597            if Instant::now() > deadline {
5598                panic!("task result did not arrive in time");
5599            }
5600        }
5601
5602        assert!(program.model().done);
5603    }
5604
5605    #[test]
5606    fn headless_persistence_commands_with_registry() {
5607        use crate::state_persistence::{MemoryStorage, StateRegistry};
5608        use std::sync::Arc;
5609
5610        let registry = Arc::new(StateRegistry::new(Box::new(MemoryStorage::new())));
5611        let config = ProgramConfig::default().with_registry(registry.clone());
5612        let mut program = headless_program_with_config(TestModel { value: 0 }, config);
5613
5614        assert!(program.has_persistence());
5615        assert!(program.state_registry().is_some());
5616
5617        program.execute_cmd(Cmd::save_state()).expect("save");
5618        program.execute_cmd(Cmd::restore_state()).expect("restore");
5619
5620        let saved = program.trigger_save().expect("trigger save");
5621        let loaded = program.trigger_load().expect("trigger load");
5622        assert!(!saved);
5623        assert_eq!(loaded, 0);
5624    }
5625
5626    #[test]
5627    fn headless_process_resize_coalescer_applies_pending_resize() {
5628        struct ResizeModel {
5629            last_size: Option<(u16, u16)>,
5630        }
5631
5632        #[derive(Debug)]
5633        enum ResizeMsg {
5634            Resize(u16, u16),
5635            Other,
5636        }
5637
5638        impl From<Event> for ResizeMsg {
5639            fn from(event: Event) -> Self {
5640                match event {
5641                    Event::Resize { width, height } => ResizeMsg::Resize(width, height),
5642                    _ => ResizeMsg::Other,
5643                }
5644            }
5645        }
5646
5647        impl Model for ResizeModel {
5648            type Message = ResizeMsg;
5649
5650            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
5651                if let ResizeMsg::Resize(w, h) = msg {
5652                    self.last_size = Some((w, h));
5653                }
5654                Cmd::none()
5655            }
5656
5657            fn view(&self, _frame: &mut Frame) {}
5658        }
5659
5660        let evidence_path = temp_evidence_path("fairness_allow");
5661        let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
5662        let mut config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Throttled);
5663        config.resize_coalescer.steady_delay_ms = 0;
5664        config.resize_coalescer.burst_delay_ms = 0;
5665        config.resize_coalescer.hard_deadline_ms = 1_000;
5666        config.evidence_sink = sink_config.clone();
5667
5668        let mut program = headless_program_with_config(ResizeModel { last_size: None }, config);
5669        let sink = EvidenceSink::from_config(&sink_config)
5670            .expect("evidence sink config")
5671            .expect("evidence sink enabled");
5672        program.evidence_sink = Some(sink);
5673
5674        program.resize_coalescer.handle_resize(120, 40);
5675        assert!(program.resize_coalescer.has_pending());
5676
5677        program
5678            .process_resize_coalescer()
5679            .expect("process resize coalescer");
5680
5681        assert_eq!(program.width, 120);
5682        assert_eq!(program.height, 40);
5683        assert_eq!(program.model().last_size, Some((120, 40)));
5684
5685        let config_line = read_evidence_event(&evidence_path, "fairness_config");
5686        assert_eq!(config_line["event"], "fairness_config");
5687        assert!(config_line["enabled"].is_boolean());
5688        assert!(config_line["input_priority_threshold_ms"].is_number());
5689        assert!(config_line["dominance_threshold"].is_number());
5690        assert!(config_line["fairness_threshold"].is_number());
5691
5692        let decision_line = read_evidence_event(&evidence_path, "fairness_decision");
5693        assert_eq!(decision_line["event"], "fairness_decision");
5694        assert_eq!(decision_line["decision"], "allow");
5695        assert_eq!(decision_line["reason"], "none");
5696        assert!(decision_line["pending_input_latency_ms"].is_null());
5697        assert!(decision_line["jain_index"].is_number());
5698        assert!(decision_line["resize_dominance_count"].is_number());
5699        assert!(decision_line["dominance_threshold"].is_number());
5700        assert!(decision_line["fairness_threshold"].is_number());
5701        assert!(decision_line["input_priority_threshold_ms"].is_number());
5702    }
5703
5704    #[test]
5705    fn headless_process_resize_coalescer_yields_to_input() {
5706        struct ResizeModel {
5707            last_size: Option<(u16, u16)>,
5708        }
5709
5710        #[derive(Debug)]
5711        enum ResizeMsg {
5712            Resize(u16, u16),
5713            Other,
5714        }
5715
5716        impl From<Event> for ResizeMsg {
5717            fn from(event: Event) -> Self {
5718                match event {
5719                    Event::Resize { width, height } => ResizeMsg::Resize(width, height),
5720                    _ => ResizeMsg::Other,
5721                }
5722            }
5723        }
5724
5725        impl Model for ResizeModel {
5726            type Message = ResizeMsg;
5727
5728            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
5729                if let ResizeMsg::Resize(w, h) = msg {
5730                    self.last_size = Some((w, h));
5731                }
5732                Cmd::none()
5733            }
5734
5735            fn view(&self, _frame: &mut Frame) {}
5736        }
5737
5738        let evidence_path = temp_evidence_path("fairness_yield");
5739        let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
5740        let mut config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Throttled);
5741        config.resize_coalescer.steady_delay_ms = 0;
5742        config.resize_coalescer.burst_delay_ms = 0;
5743        config.evidence_sink = sink_config.clone();
5744
5745        let mut program = headless_program_with_config(ResizeModel { last_size: None }, config);
5746        let sink = EvidenceSink::from_config(&sink_config)
5747            .expect("evidence sink config")
5748            .expect("evidence sink enabled");
5749        program.evidence_sink = Some(sink);
5750
5751        program.fairness_guard = InputFairnessGuard::with_config(
5752            crate::input_fairness::FairnessConfig::default().with_max_latency(Duration::ZERO),
5753        );
5754        program
5755            .fairness_guard
5756            .input_arrived(Instant::now() - Duration::from_millis(1));
5757
5758        program.resize_coalescer.handle_resize(120, 40);
5759        assert!(program.resize_coalescer.has_pending());
5760
5761        program
5762            .process_resize_coalescer()
5763            .expect("process resize coalescer");
5764
5765        assert_eq!(program.width, 80);
5766        assert_eq!(program.height, 24);
5767        assert_eq!(program.model().last_size, None);
5768        assert!(program.resize_coalescer.has_pending());
5769
5770        let decision_line = read_evidence_event(&evidence_path, "fairness_decision");
5771        assert_eq!(decision_line["event"], "fairness_decision");
5772        assert_eq!(decision_line["decision"], "yield");
5773        assert_eq!(decision_line["reason"], "input_latency");
5774        assert!(decision_line["pending_input_latency_ms"].is_number());
5775        assert!(decision_line["jain_index"].is_number());
5776        assert!(decision_line["resize_dominance_count"].is_number());
5777        assert!(decision_line["dominance_threshold"].is_number());
5778        assert!(decision_line["fairness_threshold"].is_number());
5779        assert!(decision_line["input_priority_threshold_ms"].is_number());
5780    }
5781
5782    #[test]
5783    fn headless_execute_cmd_task_with_effect_queue() {
5784        struct TaskModel {
5785            done: bool,
5786        }
5787
5788        #[derive(Debug)]
5789        enum TaskMsg {
5790            Done,
5791        }
5792
5793        impl From<Event> for TaskMsg {
5794            fn from(_: Event) -> Self {
5795                TaskMsg::Done
5796            }
5797        }
5798
5799        impl Model for TaskModel {
5800            type Message = TaskMsg;
5801
5802            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
5803                match msg {
5804                    TaskMsg::Done => {
5805                        self.done = true;
5806                        Cmd::none()
5807                    }
5808                }
5809            }
5810
5811            fn view(&self, _frame: &mut Frame) {}
5812        }
5813
5814        let effect_queue = EffectQueueConfig {
5815            enabled: true,
5816            scheduler: SchedulerConfig {
5817                max_queue_size: 0,
5818                ..Default::default()
5819            },
5820        };
5821        let config = ProgramConfig::default().with_effect_queue(effect_queue);
5822        let mut program = headless_program_with_config(TaskModel { done: false }, config);
5823
5824        program
5825            .execute_cmd(Cmd::task(|| TaskMsg::Done))
5826            .expect("task cmd");
5827
5828        let deadline = Instant::now() + Duration::from_millis(200);
5829        while !program.model().done {
5830            program
5831                .process_task_results()
5832                .expect("process task results");
5833            if Instant::now() > deadline {
5834                panic!("effect queue task result did not arrive in time");
5835            }
5836        }
5837
5838        assert!(program.model().done);
5839    }
5840
5841    // =========================================================================
5842    // BatchController Tests (bd-4kq0.8.1)
5843    // =========================================================================
5844
5845    #[test]
5846    fn unit_tau_monotone() {
5847        // τ should decrease (or stay constant) as service time decreases,
5848        // since τ = E[S] × headroom.
5849        let mut bc = BatchController::new();
5850
5851        // High service time → high τ
5852        bc.observe_service(Duration::from_millis(20));
5853        bc.observe_service(Duration::from_millis(20));
5854        bc.observe_service(Duration::from_millis(20));
5855        let tau_high = bc.tau_s();
5856
5857        // Low service time → lower τ
5858        for _ in 0..20 {
5859            bc.observe_service(Duration::from_millis(1));
5860        }
5861        let tau_low = bc.tau_s();
5862
5863        assert!(
5864            tau_low <= tau_high,
5865            "τ should decrease with lower service time: tau_low={tau_low:.6}, tau_high={tau_high:.6}"
5866        );
5867    }
5868
5869    #[test]
5870    fn unit_tau_monotone_lambda() {
5871        // As arrival rate λ decreases (longer inter-arrival times),
5872        // τ should not increase (it's based on service time, not λ).
5873        // But ρ should decrease.
5874        let mut bc = BatchController::new();
5875        let base = Instant::now();
5876
5877        // Fast arrivals (λ high)
5878        for i in 0..10 {
5879            bc.observe_arrival(base + Duration::from_millis(i * 10));
5880        }
5881        let rho_fast = bc.rho_est();
5882
5883        // Slow arrivals (λ low)
5884        for i in 10..20 {
5885            bc.observe_arrival(base + Duration::from_millis(100 + i * 100));
5886        }
5887        let rho_slow = bc.rho_est();
5888
5889        assert!(
5890            rho_slow < rho_fast,
5891            "ρ should decrease with slower arrivals: rho_slow={rho_slow:.4}, rho_fast={rho_fast:.4}"
5892        );
5893    }
5894
5895    #[test]
5896    fn unit_stability() {
5897        // With reasonable service times, the controller should keep ρ < 1.
5898        let mut bc = BatchController::new();
5899        let base = Instant::now();
5900
5901        // Moderate arrival rate: 30 events/sec
5902        for i in 0..30 {
5903            bc.observe_arrival(base + Duration::from_millis(i * 33));
5904            bc.observe_service(Duration::from_millis(5)); // 5ms render
5905        }
5906
5907        assert!(
5908            bc.is_stable(),
5909            "should be stable at 30 events/sec with 5ms service: ρ={:.4}",
5910            bc.rho_est()
5911        );
5912        assert!(
5913            bc.rho_est() < 1.0,
5914            "utilization should be < 1: ρ={:.4}",
5915            bc.rho_est()
5916        );
5917
5918        // τ must be > E[S] (stability requirement)
5919        assert!(
5920            bc.tau_s() > bc.service_est_s(),
5921            "τ ({:.6}) must exceed E[S] ({:.6}) for stability",
5922            bc.tau_s(),
5923            bc.service_est_s()
5924        );
5925    }
5926
5927    #[test]
5928    fn unit_stability_high_load() {
5929        // Even under high load, τ keeps the system stable.
5930        let mut bc = BatchController::new();
5931        let base = Instant::now();
5932
5933        // 100 events/sec with 8ms render
5934        for i in 0..50 {
5935            bc.observe_arrival(base + Duration::from_millis(i * 10));
5936            bc.observe_service(Duration::from_millis(8));
5937        }
5938
5939        // τ × ρ_eff = E[S]/τ should be < 1
5940        let tau = bc.tau_s();
5941        let rho_eff = bc.service_est_s() / tau;
5942        assert!(
5943            rho_eff < 1.0,
5944            "effective utilization should be < 1: ρ_eff={rho_eff:.4}, τ={tau:.6}, E[S]={:.6}",
5945            bc.service_est_s()
5946        );
5947    }
5948
5949    #[test]
5950    fn batch_controller_defaults() {
5951        let bc = BatchController::new();
5952        assert!(bc.tau_s() >= bc.tau_min_s);
5953        assert!(bc.tau_s() <= bc.tau_max_s);
5954        assert_eq!(bc.observations(), 0);
5955        assert!(bc.is_stable());
5956    }
5957
5958    #[test]
5959    fn batch_controller_tau_clamped() {
5960        let mut bc = BatchController::new();
5961
5962        // Very fast service → τ clamped to tau_min
5963        for _ in 0..20 {
5964            bc.observe_service(Duration::from_micros(10));
5965        }
5966        assert!(
5967            bc.tau_s() >= bc.tau_min_s,
5968            "τ should be >= tau_min: τ={:.6}, min={:.6}",
5969            bc.tau_s(),
5970            bc.tau_min_s
5971        );
5972
5973        // Very slow service → τ clamped to tau_max
5974        for _ in 0..20 {
5975            bc.observe_service(Duration::from_millis(100));
5976        }
5977        assert!(
5978            bc.tau_s() <= bc.tau_max_s,
5979            "τ should be <= tau_max: τ={:.6}, max={:.6}",
5980            bc.tau_s(),
5981            bc.tau_max_s
5982        );
5983    }
5984
5985    #[test]
5986    fn batch_controller_duration_conversion() {
5987        let bc = BatchController::new();
5988        let tau = bc.tau();
5989        let tau_s = bc.tau_s();
5990        // Duration should match f64 representation
5991        let diff = (tau.as_secs_f64() - tau_s).abs();
5992        assert!(diff < 1e-9, "Duration conversion mismatch: {diff}");
5993    }
5994
5995    #[test]
5996    fn batch_controller_lambda_estimation() {
5997        let mut bc = BatchController::new();
5998        let base = Instant::now();
5999
6000        // 50 events/sec (20ms apart)
6001        for i in 0..20 {
6002            bc.observe_arrival(base + Duration::from_millis(i * 20));
6003        }
6004
6005        // λ should converge near 50
6006        let lambda = bc.lambda_est();
6007        assert!(
6008            lambda > 20.0 && lambda < 100.0,
6009            "λ should be near 50: got {lambda:.1}"
6010        );
6011    }
6012
6013    // ─────────────────────────────────────────────────────────────────────────────
6014    // Persistence Config Tests
6015    // ─────────────────────────────────────────────────────────────────────────────
6016
6017    #[test]
6018    fn cmd_save_state() {
6019        let cmd: Cmd<TestMsg> = Cmd::save_state();
6020        assert!(matches!(cmd, Cmd::SaveState));
6021    }
6022
6023    #[test]
6024    fn cmd_restore_state() {
6025        let cmd: Cmd<TestMsg> = Cmd::restore_state();
6026        assert!(matches!(cmd, Cmd::RestoreState));
6027    }
6028
6029    #[test]
6030    fn persistence_config_default() {
6031        let config = PersistenceConfig::default();
6032        assert!(config.registry.is_none());
6033        assert!(config.checkpoint_interval.is_none());
6034        assert!(config.auto_load);
6035        assert!(config.auto_save);
6036    }
6037
6038    #[test]
6039    fn persistence_config_disabled() {
6040        let config = PersistenceConfig::disabled();
6041        assert!(config.registry.is_none());
6042    }
6043
6044    #[test]
6045    fn persistence_config_with_registry() {
6046        use crate::state_persistence::{MemoryStorage, StateRegistry};
6047        use std::sync::Arc;
6048
6049        let registry = Arc::new(StateRegistry::new(Box::new(MemoryStorage::new())));
6050        let config = PersistenceConfig::with_registry(registry.clone());
6051
6052        assert!(config.registry.is_some());
6053        assert!(config.auto_load);
6054        assert!(config.auto_save);
6055    }
6056
6057    #[test]
6058    fn persistence_config_checkpoint_interval() {
6059        use crate::state_persistence::{MemoryStorage, StateRegistry};
6060        use std::sync::Arc;
6061
6062        let registry = Arc::new(StateRegistry::new(Box::new(MemoryStorage::new())));
6063        let config = PersistenceConfig::with_registry(registry)
6064            .checkpoint_every(Duration::from_secs(30))
6065            .auto_load(false)
6066            .auto_save(true);
6067
6068        assert!(config.checkpoint_interval.is_some());
6069        assert_eq!(config.checkpoint_interval.unwrap(), Duration::from_secs(30));
6070        assert!(!config.auto_load);
6071        assert!(config.auto_save);
6072    }
6073
6074    #[test]
6075    fn program_config_with_persistence() {
6076        use crate::state_persistence::{MemoryStorage, StateRegistry};
6077        use std::sync::Arc;
6078
6079        let registry = Arc::new(StateRegistry::new(Box::new(MemoryStorage::new())));
6080        let config = ProgramConfig::default().with_registry(registry);
6081
6082        assert!(config.persistence.registry.is_some());
6083    }
6084}