Skip to main content

ftui_runtime/
policy_config.rs

1#![forbid(unsafe_code)]
2
3//! Policy-as-data configuration for FrankenTUI decision controllers.
4//!
5//! Captures all tunable parameters across the decision stack as a single
6//! [`PolicyConfig`] that can be loaded from TOML or JSON at startup, removing
7//! the need for compile-time constant changes.
8//!
9//! # Loading
10//!
11//! ```toml
12//! # ftui-policy.toml
13//! [conformal]
14//! alpha = 0.05
15//! min_samples = 20
16//!
17//! [cascade]
18//! recovery_threshold = 10
19//! ```
20//!
21//! ```rust,ignore
22//! let policy = PolicyConfig::from_toml_file("ftui-policy.toml")?;
23//! let policy = PolicyConfig::from_json_str(json)?;
24//! ```
25//!
26//! # Defaults
27//!
28//! Every field has a default that exactly matches the current hardcoded values
29//! in each decision component, so `PolicyConfig::default()` produces the same
30//! behavior as the existing code.
31
32#[cfg(feature = "policy-config")]
33use std::path::Path;
34
35#[cfg(feature = "policy-config")]
36use serde::{Deserialize, Serialize};
37
38use crate::bocpd::BocpdConfig;
39use crate::conformal_frame_guard::ConformalFrameGuardConfig;
40use crate::conformal_predictor::ConformalConfig;
41use crate::degradation_cascade::CascadeConfig;
42use crate::eprocess_throttle::ThrottleConfig;
43use crate::evidence_sink::{EvidenceSinkConfig, EvidenceSinkDestination};
44use crate::voi_sampling::VoiConfig;
45use ftui_render::budget::{DegradationLevel, EProcessConfig, PidGains};
46
47#[cfg(feature = "policy-config")]
48const STANDALONE_POLICY_TOML: &str = "ftui-policy.toml";
49#[cfg(feature = "policy-config")]
50const STANDALONE_POLICY_JSON: &str = "ftui-policy.json";
51#[cfg(feature = "policy-config")]
52const CARGO_MANIFEST_NAME: &str = "Cargo.toml";
53
54// ---------------------------------------------------------------------------
55// Top-level PolicyConfig
56// ---------------------------------------------------------------------------
57
58/// Top-level policy configuration for the FrankenTUI decision stack.
59///
60/// Groups every tunable parameter into a single struct that can be
61/// loaded from TOML or JSON. All fields default to the values currently
62/// hardcoded in the individual config structs.
63#[derive(Debug, Clone, Default)]
64#[cfg_attr(feature = "policy-config", derive(Serialize, Deserialize))]
65#[cfg_attr(feature = "policy-config", serde(default))]
66pub struct PolicyConfig {
67    /// Conformal predictor parameters.
68    pub conformal: ConformalPolicyConfig,
69
70    /// Conformal frame guard parameters.
71    pub frame_guard: FrameGuardPolicyConfig,
72
73    /// Degradation cascade parameters.
74    pub cascade: CascadePolicyConfig,
75
76    /// PID controller gains for budget control.
77    pub pid: PidPolicyConfig,
78
79    /// E-process sequential test parameters (budget controller).
80    pub eprocess_budget: EProcessBudgetPolicyConfig,
81
82    /// BOCPD changepoint detection parameters.
83    pub bocpd: BocpdPolicyConfig,
84
85    /// E-process throttle parameters (recomputation gating).
86    pub eprocess_throttle: EProcessThrottlePolicyConfig,
87
88    /// Value-of-information sampling parameters.
89    pub voi: VoiPolicyConfig,
90
91    /// Evidence logging parameters.
92    pub evidence: EvidencePolicyConfig,
93}
94
95impl PolicyConfig {
96    /// Load from a TOML string.
97    #[cfg(feature = "policy-config")]
98    pub fn from_toml_str(s: &str) -> Result<Self, PolicyConfigError> {
99        let policy: Self = toml::from_str(s).map_err(PolicyConfigError::Toml)?;
100        policy.validate_or_err()
101    }
102
103    /// Load from a TOML file on disk.
104    #[cfg(feature = "policy-config")]
105    pub fn from_toml_file(path: impl AsRef<Path>) -> Result<Self, PolicyConfigError> {
106        let content = std::fs::read_to_string(path.as_ref()).map_err(PolicyConfigError::Io)?;
107        Self::from_toml_str(&content)
108    }
109
110    /// Load from a JSON string.
111    #[cfg(feature = "policy-config")]
112    pub fn from_json_str(s: &str) -> Result<Self, PolicyConfigError> {
113        let policy: Self = serde_json::from_str(s).map_err(PolicyConfigError::Json)?;
114        policy.validate_or_err()
115    }
116
117    /// Load from a JSON file on disk.
118    #[cfg(feature = "policy-config")]
119    pub fn from_json_file(path: impl AsRef<Path>) -> Result<Self, PolicyConfigError> {
120        let content = std::fs::read_to_string(path.as_ref()).map_err(PolicyConfigError::Io)?;
121        Self::from_json_str(&content)
122    }
123
124    /// Load from a Cargo manifest that embeds policy config under
125    /// `[package.metadata.ftui]`.
126    #[cfg(feature = "policy-config")]
127    pub fn from_cargo_toml_str(s: &str) -> Result<Self, PolicyConfigError> {
128        let manifest: CargoManifestPolicyConfig =
129            toml::from_str(s).map_err(PolicyConfigError::Toml)?;
130        let policy = manifest
131            .package
132            .and_then(|package| package.metadata)
133            .and_then(|metadata| metadata.ftui)
134            .ok_or(PolicyConfigError::MissingMetadataSection(
135                "[package.metadata.ftui]",
136            ))?;
137        policy.validate_or_err()
138    }
139
140    /// Load from a Cargo manifest file that embeds policy config under
141    /// `[package.metadata.ftui]`.
142    #[cfg(feature = "policy-config")]
143    pub fn from_cargo_toml_file(path: impl AsRef<Path>) -> Result<Self, PolicyConfigError> {
144        let content = std::fs::read_to_string(path.as_ref()).map_err(PolicyConfigError::Io)?;
145        Self::from_cargo_toml_str(&content)
146    }
147
148    /// Discover policy config from a project directory.
149    ///
150    /// Precedence is:
151    /// 1. `ftui-policy.toml`
152    /// 2. `ftui-policy.json`
153    /// 3. `Cargo.toml` `[package.metadata.ftui]`
154    #[cfg(feature = "policy-config")]
155    pub fn discover_in_dir(dir: impl AsRef<Path>) -> Result<Self, PolicyConfigError> {
156        let dir = dir.as_ref();
157
158        let standalone_toml = dir.join(STANDALONE_POLICY_TOML);
159        if standalone_toml.is_file() {
160            return Self::from_toml_file(standalone_toml);
161        }
162
163        let standalone_json = dir.join(STANDALONE_POLICY_JSON);
164        if standalone_json.is_file() {
165            return Self::from_json_file(standalone_json);
166        }
167
168        let cargo_manifest = dir.join(CARGO_MANIFEST_NAME);
169        if cargo_manifest.is_file() {
170            return Self::from_cargo_toml_file(cargo_manifest);
171        }
172
173        Err(PolicyConfigError::Io(std::io::Error::new(
174            std::io::ErrorKind::NotFound,
175            format!(
176                "no policy config found in {} (expected {}, {}, or {})",
177                dir.display(),
178                STANDALONE_POLICY_TOML,
179                STANDALONE_POLICY_JSON,
180                CARGO_MANIFEST_NAME
181            ),
182        )))
183    }
184
185    #[cfg(feature = "policy-config")]
186    fn validate_or_err(self) -> Result<Self, PolicyConfigError> {
187        let errors = self.validate();
188        if errors.is_empty() {
189            Ok(self)
190        } else {
191            Err(PolicyConfigError::Validation(errors))
192        }
193    }
194
195    /// Validate all parameters are within acceptable ranges.
196    ///
197    /// Returns a list of validation errors. An empty list means the config
198    /// is valid.
199    #[must_use]
200    pub fn validate(&self) -> Vec<String> {
201        let mut errors = Vec::new();
202
203        let conformal_alpha_finite =
204            validate_finite_f64(&mut errors, "conformal.alpha", self.conformal.alpha);
205        let _ = validate_finite_f64(&mut errors, "conformal.q_default", self.conformal.q_default);
206        let frame_guard_fallback_budget_finite = validate_finite_f64(
207            &mut errors,
208            "frame_guard.fallback_budget_us",
209            self.frame_guard.fallback_budget_us,
210        );
211        let pid_kp_finite = validate_finite_f64(&mut errors, "pid.kp", self.pid.kp);
212        let _ = validate_finite_f64(&mut errors, "pid.ki", self.pid.ki);
213        let _ = validate_finite_f64(&mut errors, "pid.kd", self.pid.kd);
214        let pid_integral_max_finite =
215            validate_finite_f64(&mut errors, "pid.integral_max", self.pid.integral_max);
216        let _ = validate_finite_f64(
217            &mut errors,
218            "eprocess_budget.lambda",
219            self.eprocess_budget.lambda,
220        );
221        let eprocess_budget_alpha_finite = validate_finite_f64(
222            &mut errors,
223            "eprocess_budget.alpha",
224            self.eprocess_budget.alpha,
225        );
226        let _ = validate_finite_f64(
227            &mut errors,
228            "eprocess_budget.beta",
229            self.eprocess_budget.beta,
230        );
231        let _ = validate_finite_f64(
232            &mut errors,
233            "eprocess_budget.sigma_ema_decay",
234            self.eprocess_budget.sigma_ema_decay,
235        );
236        let _ = validate_finite_f64(
237            &mut errors,
238            "eprocess_budget.sigma_floor_ms",
239            self.eprocess_budget.sigma_floor_ms,
240        );
241        let _ = validate_finite_f64(&mut errors, "bocpd.mu_steady_ms", self.bocpd.mu_steady_ms);
242        let _ = validate_finite_f64(&mut errors, "bocpd.mu_burst_ms", self.bocpd.mu_burst_ms);
243        let bocpd_hazard_lambda_finite =
244            validate_finite_f64(&mut errors, "bocpd.hazard_lambda", self.bocpd.hazard_lambda);
245        let _ = validate_finite_f64(
246            &mut errors,
247            "bocpd.steady_threshold",
248            self.bocpd.steady_threshold,
249        );
250        let _ = validate_finite_f64(
251            &mut errors,
252            "bocpd.burst_threshold",
253            self.bocpd.burst_threshold,
254        );
255        let _ = validate_finite_f64(&mut errors, "bocpd.burst_prior", self.bocpd.burst_prior);
256        let _ = validate_finite_f64(
257            &mut errors,
258            "bocpd.min_observation_ms",
259            self.bocpd.min_observation_ms,
260        );
261        let _ = validate_finite_f64(
262            &mut errors,
263            "bocpd.max_observation_ms",
264            self.bocpd.max_observation_ms,
265        );
266        let eprocess_throttle_alpha_finite = validate_finite_f64(
267            &mut errors,
268            "eprocess_throttle.alpha",
269            self.eprocess_throttle.alpha,
270        );
271        let _ = validate_finite_f64(
272            &mut errors,
273            "eprocess_throttle.mu_0",
274            self.eprocess_throttle.mu_0,
275        );
276        let _ = validate_finite_f64(
277            &mut errors,
278            "eprocess_throttle.initial_lambda",
279            self.eprocess_throttle.initial_lambda,
280        );
281        let _ = validate_finite_f64(
282            &mut errors,
283            "eprocess_throttle.grapa_eta",
284            self.eprocess_throttle.grapa_eta,
285        );
286        let voi_alpha_finite = validate_finite_f64(&mut errors, "voi.alpha", self.voi.alpha);
287        let _ = validate_finite_f64(&mut errors, "voi.prior_alpha", self.voi.prior_alpha);
288        let _ = validate_finite_f64(&mut errors, "voi.prior_beta", self.voi.prior_beta);
289        let _ = validate_finite_f64(&mut errors, "voi.mu_0", self.voi.mu_0);
290        let _ = validate_finite_f64(&mut errors, "voi.lambda", self.voi.lambda);
291        let _ = validate_finite_f64(&mut errors, "voi.value_scale", self.voi.value_scale);
292        let _ = validate_finite_f64(&mut errors, "voi.boundary_weight", self.voi.boundary_weight);
293        let voi_sample_cost_finite =
294            validate_finite_f64(&mut errors, "voi.sample_cost", self.voi.sample_cost);
295
296        // Conformal alpha must be in (0, 1)
297        if conformal_alpha_finite && (self.conformal.alpha <= 0.0 || self.conformal.alpha >= 1.0) {
298            errors.push(format!(
299                "conformal.alpha must be in (0, 1), got {}",
300                self.conformal.alpha
301            ));
302        }
303
304        if self.conformal.min_samples == 0 {
305            errors.push("conformal.min_samples must be > 0".into());
306        }
307
308        if self.cascade.min_trigger_level > self.cascade.max_degradation {
309            errors.push(format!(
310                "cascade.min_trigger_level ({:?}) cannot be strictly greater than cascade.max_degradation ({:?})",
311                self.cascade.min_trigger_level, self.cascade.max_degradation
312            ));
313        }
314
315        if self.conformal.window_size == 0 {
316            errors.push("conformal.window_size must be > 0".into());
317        }
318
319        // Frame guard budget must be positive
320        if frame_guard_fallback_budget_finite && self.frame_guard.fallback_budget_us <= 0.0 {
321            errors.push(format!(
322                "frame_guard.fallback_budget_us must be > 0, got {}",
323                self.frame_guard.fallback_budget_us
324            ));
325        }
326
327        // PID gains: kp must be non-negative
328        if pid_kp_finite && self.pid.kp < 0.0 {
329            errors.push(format!("pid.kp must be >= 0, got {}", self.pid.kp));
330        }
331        if pid_integral_max_finite && self.pid.integral_max <= 0.0 {
332            errors.push(format!(
333                "pid.integral_max must be > 0, got {}",
334                self.pid.integral_max
335            ));
336        }
337
338        // E-process alpha in (0, 1)
339        if eprocess_budget_alpha_finite
340            && (self.eprocess_budget.alpha <= 0.0 || self.eprocess_budget.alpha >= 1.0)
341        {
342            errors.push(format!(
343                "eprocess_budget.alpha must be in (0, 1), got {}",
344                self.eprocess_budget.alpha
345            ));
346        }
347
348        // BOCPD hazard lambda must be positive
349        if bocpd_hazard_lambda_finite && self.bocpd.hazard_lambda <= 0.0 {
350            errors.push(format!(
351                "bocpd.hazard_lambda must be > 0, got {}",
352                self.bocpd.hazard_lambda
353            ));
354        }
355        if self.bocpd.max_run_length == 0 {
356            errors.push("bocpd.max_run_length must be > 0".into());
357        }
358
359        // E-process throttle alpha in (0, 1)
360        if eprocess_throttle_alpha_finite
361            && (self.eprocess_throttle.alpha <= 0.0 || self.eprocess_throttle.alpha >= 1.0)
362        {
363            errors.push(format!(
364                "eprocess_throttle.alpha must be in (0, 1), got {}",
365                self.eprocess_throttle.alpha
366            ));
367        }
368
369        // VOI alpha in (0, 1)
370        if voi_alpha_finite && (self.voi.alpha <= 0.0 || self.voi.alpha >= 1.0) {
371            errors.push(format!(
372                "voi.alpha must be in (0, 1), got {}",
373                self.voi.alpha
374            ));
375        }
376
377        if voi_sample_cost_finite && self.voi.sample_cost < 0.0 {
378            errors.push(format!(
379                "voi.sample_cost must be >= 0, got {}",
380                self.voi.sample_cost
381            ));
382        }
383
384        // Evidence ledger capacity must be positive
385        if self.evidence.ledger_capacity == 0 {
386            errors.push("evidence.ledger_capacity must be > 0".into());
387        }
388
389        errors
390    }
391
392    /// Build a [`ConformalConfig`] from this policy.
393    #[must_use]
394    pub fn to_conformal_config(&self) -> ConformalConfig {
395        ConformalConfig {
396            alpha: self.conformal.alpha,
397            min_samples: self.conformal.min_samples,
398            window_size: self.conformal.window_size,
399            q_default: self.conformal.q_default,
400        }
401    }
402
403    /// Build a [`ConformalFrameGuardConfig`] from this policy.
404    #[must_use]
405    pub fn to_frame_guard_config(&self) -> ConformalFrameGuardConfig {
406        ConformalFrameGuardConfig {
407            conformal: self.to_conformal_config(),
408            fallback_budget_us: self.frame_guard.fallback_budget_us,
409            time_series_window: self.frame_guard.time_series_window,
410            nonconformity_window: self.frame_guard.nonconformity_window,
411        }
412    }
413
414    /// Build a [`CascadeConfig`] from this policy.
415    #[must_use]
416    pub fn to_cascade_config(&self) -> CascadeConfig {
417        CascadeConfig {
418            guard: self.to_frame_guard_config(),
419            recovery_threshold: self.cascade.recovery_threshold,
420            max_degradation: self.cascade.max_degradation,
421            min_trigger_level: self.cascade.min_trigger_level,
422            degradation_floor: self.cascade.degradation_floor,
423        }
424    }
425
426    /// Build [`PidGains`] from this policy.
427    #[must_use]
428    pub fn to_pid_gains(&self) -> PidGains {
429        PidGains {
430            kp: self.pid.kp,
431            ki: self.pid.ki,
432            kd: self.pid.kd,
433            integral_max: self.pid.integral_max,
434        }
435    }
436
437    /// Build an [`EProcessConfig`] (budget controller) from this policy.
438    #[must_use]
439    pub fn to_eprocess_budget_config(&self) -> EProcessConfig {
440        EProcessConfig {
441            lambda: self.eprocess_budget.lambda,
442            alpha: self.eprocess_budget.alpha,
443            beta: self.eprocess_budget.beta,
444            sigma_ema_decay: self.eprocess_budget.sigma_ema_decay,
445            sigma_floor_ms: self.eprocess_budget.sigma_floor_ms,
446            warmup_frames: self.eprocess_budget.warmup_frames,
447        }
448    }
449
450    /// Build a [`BocpdConfig`] from this policy.
451    #[must_use]
452    pub fn to_bocpd_config(&self) -> BocpdConfig {
453        BocpdConfig {
454            mu_steady_ms: self.bocpd.mu_steady_ms,
455            mu_burst_ms: self.bocpd.mu_burst_ms,
456            hazard_lambda: self.bocpd.hazard_lambda,
457            max_run_length: self.bocpd.max_run_length,
458            steady_threshold: self.bocpd.steady_threshold,
459            burst_threshold: self.bocpd.burst_threshold,
460            burst_prior: self.bocpd.burst_prior,
461            min_observation_ms: self.bocpd.min_observation_ms,
462            max_observation_ms: self.bocpd.max_observation_ms,
463            enable_logging: self.bocpd.enable_logging,
464        }
465    }
466
467    /// Build a [`ThrottleConfig`] from this policy.
468    #[must_use]
469    pub fn to_throttle_config(&self) -> ThrottleConfig {
470        ThrottleConfig {
471            alpha: self.eprocess_throttle.alpha,
472            mu_0: self.eprocess_throttle.mu_0,
473            initial_lambda: self.eprocess_throttle.initial_lambda,
474            grapa_eta: self.eprocess_throttle.grapa_eta,
475            hard_deadline_ms: self.eprocess_throttle.hard_deadline_ms,
476            min_observations_between: self.eprocess_throttle.min_observations_between,
477            rate_window_size: self.eprocess_throttle.rate_window_size,
478            enable_logging: self.eprocess_throttle.enable_logging,
479        }
480    }
481
482    /// Build a [`VoiConfig`] from this policy.
483    #[must_use]
484    pub fn to_voi_config(&self) -> VoiConfig {
485        VoiConfig {
486            alpha: self.voi.alpha,
487            prior_alpha: self.voi.prior_alpha,
488            prior_beta: self.voi.prior_beta,
489            mu_0: self.voi.mu_0,
490            lambda: self.voi.lambda,
491            value_scale: self.voi.value_scale,
492            boundary_weight: self.voi.boundary_weight,
493            sample_cost: self.voi.sample_cost,
494            min_interval_ms: self.voi.min_interval_ms,
495            max_interval_ms: self.voi.max_interval_ms,
496            min_interval_events: self.voi.min_interval_events,
497            max_interval_events: self.voi.max_interval_events,
498            enable_logging: self.voi.enable_logging,
499            max_log_entries: self.voi.max_log_entries,
500        }
501    }
502
503    /// Build an [`EvidenceSinkConfig`] from this policy.
504    #[must_use]
505    pub fn to_evidence_sink_config(&self) -> EvidenceSinkConfig {
506        EvidenceSinkConfig {
507            enabled: self.evidence.sink_enabled,
508            destination: if let Some(ref path) = self.evidence.sink_file {
509                EvidenceSinkDestination::File(path.into())
510            } else {
511                EvidenceSinkDestination::Stdout
512            },
513            flush_on_write: self.evidence.flush_on_write,
514            max_bytes: crate::evidence_sink::DEFAULT_MAX_EVIDENCE_BYTES,
515        }
516    }
517
518    /// Format as a JSONL line for structured logging.
519    #[must_use]
520    pub fn to_jsonl(&self) -> String {
521        format!(
522            r#"{{"schema":"policy-config-v1","conformal_alpha":{},"conformal_min_samples":{},"cascade_recovery_threshold":{},"pid_kp":{},"bocpd_hazard_lambda":{},"voi_alpha":{},"evidence_ledger_capacity":{}}}"#,
523            self.conformal.alpha,
524            self.conformal.min_samples,
525            self.cascade.recovery_threshold,
526            self.pid.kp,
527            self.bocpd.hazard_lambda,
528            self.voi.alpha,
529            self.evidence.ledger_capacity,
530        )
531    }
532}
533
534fn validate_finite_f64(errors: &mut Vec<String>, field: &str, value: f64) -> bool {
535    if value.is_finite() {
536        true
537    } else {
538        errors.push(format!("{field} must be finite, got {value}"));
539        false
540    }
541}
542
543// ---------------------------------------------------------------------------
544// Sub-configs (flat, serde-friendly)
545// ---------------------------------------------------------------------------
546
547/// Conformal predictor policy parameters.
548#[derive(Debug, Clone)]
549#[cfg_attr(feature = "policy-config", derive(Serialize, Deserialize))]
550#[cfg_attr(feature = "policy-config", serde(default))]
551pub struct ConformalPolicyConfig {
552    /// Significance level for conformal prediction. Default: 0.05 (95% coverage).
553    pub alpha: f64,
554    /// Minimum calibration samples before using conformal intervals. Default: 20.
555    pub min_samples: usize,
556    /// Sliding window size for calibration data. Default: 256.
557    pub window_size: usize,
558    /// Default quantile fallback (µs) when bucket has no data. Default: 10 000.
559    pub q_default: f64,
560}
561
562impl Default for ConformalPolicyConfig {
563    fn default() -> Self {
564        Self {
565            alpha: 0.05,
566            min_samples: 20,
567            window_size: 256,
568            q_default: 10_000.0,
569        }
570    }
571}
572
573/// Conformal frame guard policy parameters.
574#[derive(Debug, Clone)]
575#[cfg_attr(feature = "policy-config", derive(Serialize, Deserialize))]
576#[cfg_attr(feature = "policy-config", serde(default))]
577pub struct FrameGuardPolicyConfig {
578    /// Fixed fallback budget threshold (µs) during warmup. Default: 16 000.
579    pub fallback_budget_us: f64,
580    /// Rolling window size for frame time tracking. Default: 512.
581    pub time_series_window: usize,
582    /// Rolling window size for nonconformity scores. Default: 256.
583    pub nonconformity_window: usize,
584}
585
586impl Default for FrameGuardPolicyConfig {
587    fn default() -> Self {
588        Self {
589            fallback_budget_us: 16_000.0,
590            time_series_window: 512,
591            nonconformity_window: 256,
592        }
593    }
594}
595
596/// Degradation cascade policy parameters.
597#[derive(Debug, Clone)]
598#[cfg_attr(feature = "policy-config", derive(Serialize, Deserialize))]
599#[cfg_attr(feature = "policy-config", serde(default))]
600pub struct CascadePolicyConfig {
601    /// Consecutive within-budget frames before upgrading one level. Default: 10.
602    pub recovery_threshold: u32,
603    /// Maximum degradation level allowed. Default: SkipFrame.
604    #[cfg_attr(
605        feature = "policy-config",
606        serde(
607            serialize_with = "serialize_degradation_level",
608            deserialize_with = "deserialize_degradation_level"
609        )
610    )]
611    pub max_degradation: DegradationLevel,
612    /// Minimum degradation level when first triggered. Default: SimpleBorders.
613    #[cfg_attr(
614        feature = "policy-config",
615        serde(
616            serialize_with = "serialize_degradation_level",
617            deserialize_with = "deserialize_degradation_level"
618        )
619    )]
620    pub min_trigger_level: DegradationLevel,
621    /// Minimum quality floor: the cascade will never degrade past this level.
622    ///
623    /// Default: `SimpleBorders` — preserves readable text content, preventing
624    /// escalation to `EssentialOnly`, `Skeleton`, or `SkipFrame` after
625    /// transient focus/resize spikes.
626    #[cfg_attr(
627        feature = "policy-config",
628        serde(
629            serialize_with = "serialize_degradation_level",
630            deserialize_with = "deserialize_degradation_level"
631        )
632    )]
633    pub degradation_floor: DegradationLevel,
634}
635
636impl Default for CascadePolicyConfig {
637    fn default() -> Self {
638        Self {
639            recovery_threshold: 10,
640            max_degradation: DegradationLevel::SkipFrame,
641            min_trigger_level: DegradationLevel::SimpleBorders,
642            degradation_floor: DegradationLevel::SimpleBorders,
643        }
644    }
645}
646
647/// PID controller gains policy parameters.
648#[derive(Debug, Clone)]
649#[cfg_attr(feature = "policy-config", derive(Serialize, Deserialize))]
650#[cfg_attr(feature = "policy-config", serde(default))]
651pub struct PidPolicyConfig {
652    /// Proportional gain. Default: 0.5.
653    pub kp: f64,
654    /// Integral gain. Default: 0.05.
655    pub ki: f64,
656    /// Derivative gain. Default: 0.2.
657    pub kd: f64,
658    /// Maximum integral accumulator. Default: 5.0.
659    pub integral_max: f64,
660}
661
662impl Default for PidPolicyConfig {
663    fn default() -> Self {
664        Self {
665            kp: 0.5,
666            ki: 0.05,
667            kd: 0.2,
668            integral_max: 5.0,
669        }
670    }
671}
672
673/// E-process budget controller policy parameters.
674#[derive(Debug, Clone)]
675#[cfg_attr(feature = "policy-config", derive(Serialize, Deserialize))]
676#[cfg_attr(feature = "policy-config", serde(default))]
677pub struct EProcessBudgetPolicyConfig {
678    /// Likelihood ratio scale. Default: 0.5.
679    pub lambda: f64,
680    /// Type-I error rate. Default: 0.05.
681    pub alpha: f64,
682    /// Wealth decay parameter. Default: 0.5.
683    pub beta: f64,
684    /// EMA decay for sigma estimation. Default: 0.9.
685    pub sigma_ema_decay: f64,
686    /// Floor for sigma estimation (ms). Default: 1.0.
687    pub sigma_floor_ms: f64,
688    /// Warmup frames before e-process is active. Default: 10.
689    pub warmup_frames: u32,
690}
691
692impl Default for EProcessBudgetPolicyConfig {
693    fn default() -> Self {
694        Self {
695            lambda: 0.5,
696            alpha: 0.05,
697            beta: 0.5,
698            sigma_ema_decay: 0.9,
699            sigma_floor_ms: 1.0,
700            warmup_frames: 10,
701        }
702    }
703}
704
705/// BOCPD changepoint detection policy parameters.
706#[derive(Debug, Clone)]
707#[cfg_attr(feature = "policy-config", derive(Serialize, Deserialize))]
708#[cfg_attr(feature = "policy-config", serde(default))]
709pub struct BocpdPolicyConfig {
710    /// Expected inter-event time during steady regime (ms). Default: 200.
711    pub mu_steady_ms: f64,
712    /// Expected inter-event time during burst regime (ms). Default: 20.
713    pub mu_burst_ms: f64,
714    /// Hazard rate (1/expected run length). Default: 50.
715    pub hazard_lambda: f64,
716    /// Maximum run length tracked. Default: 100.
717    pub max_run_length: usize,
718    /// Posterior threshold for steady regime. Default: 0.3.
719    pub steady_threshold: f64,
720    /// Posterior threshold for burst regime. Default: 0.7.
721    pub burst_threshold: f64,
722    /// Prior probability of burst regime. Default: 0.2.
723    pub burst_prior: f64,
724    /// Minimum observation value (ms). Default: 1.0.
725    pub min_observation_ms: f64,
726    /// Maximum observation value (ms). Default: 10 000.
727    pub max_observation_ms: f64,
728    /// Enable debug logging. Default: false.
729    pub enable_logging: bool,
730}
731
732impl Default for BocpdPolicyConfig {
733    fn default() -> Self {
734        Self {
735            mu_steady_ms: 200.0,
736            mu_burst_ms: 20.0,
737            hazard_lambda: 50.0,
738            max_run_length: 100,
739            steady_threshold: 0.3,
740            burst_threshold: 0.7,
741            burst_prior: 0.2,
742            min_observation_ms: 1.0,
743            max_observation_ms: 10_000.0,
744            enable_logging: false,
745        }
746    }
747}
748
749/// E-process throttle (recomputation gating) policy parameters.
750#[derive(Debug, Clone)]
751#[cfg_attr(feature = "policy-config", derive(Serialize, Deserialize))]
752#[cfg_attr(feature = "policy-config", serde(default))]
753pub struct EProcessThrottlePolicyConfig {
754    /// Type-I error rate. Default: 0.05.
755    pub alpha: f64,
756    /// Null hypothesis rate. Default: 0.1.
757    pub mu_0: f64,
758    /// Initial likelihood ratio scale. Default: 0.5.
759    pub initial_lambda: f64,
760    /// GraPa learning rate. Default: 0.1.
761    pub grapa_eta: f64,
762    /// Hard deadline between recomputations (ms). Default: 500.
763    pub hard_deadline_ms: u64,
764    /// Minimum observations between recomputations. Default: 8.
765    pub min_observations_between: u64,
766    /// Sliding window size for rate estimation. Default: 64.
767    pub rate_window_size: usize,
768    /// Enable debug logging. Default: false.
769    pub enable_logging: bool,
770}
771
772impl Default for EProcessThrottlePolicyConfig {
773    fn default() -> Self {
774        Self {
775            alpha: 0.05,
776            mu_0: 0.1,
777            initial_lambda: 0.5,
778            grapa_eta: 0.1,
779            hard_deadline_ms: 500,
780            min_observations_between: 8,
781            rate_window_size: 64,
782            enable_logging: false,
783        }
784    }
785}
786
787/// VOI (value-of-information) sampling policy parameters.
788#[derive(Debug, Clone)]
789#[cfg_attr(feature = "policy-config", derive(Serialize, Deserialize))]
790#[cfg_attr(feature = "policy-config", serde(default))]
791pub struct VoiPolicyConfig {
792    /// Significance level. Default: 0.05.
793    pub alpha: f64,
794    /// Beta prior alpha. Default: 1.0.
795    pub prior_alpha: f64,
796    /// Beta prior beta. Default: 1.0.
797    pub prior_beta: f64,
798    /// Null hypothesis mean. Default: 0.05.
799    pub mu_0: f64,
800    /// Likelihood ratio scale. Default: 0.5.
801    pub lambda: f64,
802    /// Value scaling factor. Default: 1.0.
803    pub value_scale: f64,
804    /// Weight for decision boundary proximity. Default: 1.0.
805    pub boundary_weight: f64,
806    /// Cost per sample. Default: 0.01.
807    pub sample_cost: f64,
808    /// Minimum interval between samples (ms). Default: 0.
809    pub min_interval_ms: u64,
810    /// Maximum interval between samples (ms). Default: 250.
811    pub max_interval_ms: u64,
812    /// Minimum interval between samples (events). Default: 0.
813    pub min_interval_events: u64,
814    /// Maximum interval between samples (events). Default: 20.
815    pub max_interval_events: u64,
816    /// Enable VOI debug logging. Default: false.
817    pub enable_logging: bool,
818    /// Maximum VOI log entries. Default: 2048.
819    pub max_log_entries: usize,
820}
821
822impl Default for VoiPolicyConfig {
823    fn default() -> Self {
824        Self {
825            alpha: 0.05,
826            prior_alpha: 1.0,
827            prior_beta: 1.0,
828            mu_0: 0.05,
829            lambda: 0.5,
830            value_scale: 1.0,
831            boundary_weight: 1.0,
832            sample_cost: 0.01,
833            min_interval_ms: 0,
834            max_interval_ms: 250,
835            min_interval_events: 0,
836            max_interval_events: 20,
837            enable_logging: false,
838            max_log_entries: 2048,
839        }
840    }
841}
842
843/// Evidence logging policy parameters.
844#[derive(Debug, Clone)]
845#[cfg_attr(feature = "policy-config", derive(Serialize, Deserialize))]
846#[cfg_attr(feature = "policy-config", serde(default))]
847pub struct EvidencePolicyConfig {
848    /// Capacity of the unified evidence ledger (ring buffer). Default: 1024.
849    pub ledger_capacity: usize,
850    /// Whether the evidence sink is enabled. Default: false.
851    pub sink_enabled: bool,
852    /// File path for evidence output; None → stdout. Default: None.
853    pub sink_file: Option<String>,
854    /// Flush after every write. Default: true.
855    pub flush_on_write: bool,
856}
857
858impl Default for EvidencePolicyConfig {
859    fn default() -> Self {
860        Self {
861            ledger_capacity: 1024,
862            sink_enabled: false,
863            sink_file: None,
864            flush_on_write: true,
865        }
866    }
867}
868
869// ---------------------------------------------------------------------------
870// Error type
871// ---------------------------------------------------------------------------
872
873/// Errors that can occur when loading a policy configuration.
874#[derive(Debug)]
875pub enum PolicyConfigError {
876    /// I/O error reading a file.
877    Io(std::io::Error),
878    /// Expected `[package.metadata.ftui]` section was not present in Cargo.toml.
879    MissingMetadataSection(&'static str),
880    /// TOML parse error.
881    #[cfg(feature = "policy-config")]
882    Toml(toml::de::Error),
883    /// JSON parse error.
884    #[cfg(feature = "policy-config")]
885    Json(serde_json::Error),
886    /// Validation errors.
887    Validation(Vec<String>),
888}
889
890impl std::fmt::Display for PolicyConfigError {
891    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
892        match self {
893            Self::Io(e) => write!(f, "I/O error: {e}"),
894            Self::MissingMetadataSection(path) => {
895                write!(f, "missing metadata section: {path}")
896            }
897            #[cfg(feature = "policy-config")]
898            Self::Toml(e) => write!(f, "TOML parse error: {e}"),
899            #[cfg(feature = "policy-config")]
900            Self::Json(e) => write!(f, "JSON parse error: {e}"),
901            Self::Validation(errors) => {
902                write!(f, "validation errors: {}", errors.join("; "))
903            }
904        }
905    }
906}
907
908impl std::error::Error for PolicyConfigError {
909    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
910        match self {
911            Self::Io(e) => Some(e),
912            Self::MissingMetadataSection(_) => None,
913            #[cfg(feature = "policy-config")]
914            Self::Toml(e) => Some(e),
915            #[cfg(feature = "policy-config")]
916            Self::Json(e) => Some(e),
917            Self::Validation(_) => None,
918        }
919    }
920}
921
922// ---------------------------------------------------------------------------
923// Serde helpers for DegradationLevel
924// ---------------------------------------------------------------------------
925
926#[cfg(feature = "policy-config")]
927fn serialize_degradation_level<S>(
928    level: &DegradationLevel,
929    serializer: S,
930) -> Result<S::Ok, S::Error>
931where
932    S: serde::Serializer,
933{
934    let s = match level {
935        DegradationLevel::Full => "full",
936        DegradationLevel::SimpleBorders => "simple_borders",
937        DegradationLevel::NoStyling => "no_styling",
938        DegradationLevel::EssentialOnly => "essential_only",
939        DegradationLevel::Skeleton => "skeleton",
940        DegradationLevel::SkipFrame => "skip_frame",
941    };
942    serializer.serialize_str(s)
943}
944
945#[cfg(feature = "policy-config")]
946fn deserialize_degradation_level<'de, D>(deserializer: D) -> Result<DegradationLevel, D::Error>
947where
948    D: serde::Deserializer<'de>,
949{
950    let s = String::deserialize(deserializer)?;
951    match s.as_str() {
952        "full" | "Full" => Ok(DegradationLevel::Full),
953        "simple_borders" | "SimpleBorders" => Ok(DegradationLevel::SimpleBorders),
954        "no_styling" | "NoStyling" => Ok(DegradationLevel::NoStyling),
955        "essential_only" | "EssentialOnly" => Ok(DegradationLevel::EssentialOnly),
956        "skeleton" | "Skeleton" => Ok(DegradationLevel::Skeleton),
957        "skip_frame" | "SkipFrame" => Ok(DegradationLevel::SkipFrame),
958        other => Err(serde::de::Error::custom(format!(
959            "unknown degradation level: {other}"
960        ))),
961    }
962}
963
964#[cfg(feature = "policy-config")]
965#[derive(Debug, Deserialize)]
966struct CargoManifestPolicyConfig {
967    package: Option<CargoPackagePolicyConfig>,
968}
969
970#[cfg(feature = "policy-config")]
971#[derive(Debug, Deserialize)]
972struct CargoPackagePolicyConfig {
973    metadata: Option<CargoMetadataPolicyConfig>,
974}
975
976#[cfg(feature = "policy-config")]
977#[derive(Debug, Deserialize)]
978struct CargoMetadataPolicyConfig {
979    ftui: Option<PolicyConfig>,
980}
981
982// ---------------------------------------------------------------------------
983// Tests
984// ---------------------------------------------------------------------------
985
986#[cfg(test)]
987mod tests {
988    use super::*;
989
990    #[cfg(feature = "policy-config")]
991    use tempfile::tempdir;
992
993    #[test]
994    fn default_matches_component_defaults() {
995        let policy = PolicyConfig::default();
996
997        // Conformal
998        let conformal = policy.to_conformal_config();
999        let expected = ConformalConfig::default();
1000        assert_eq!(conformal.alpha, expected.alpha);
1001        assert_eq!(conformal.min_samples, expected.min_samples);
1002        assert_eq!(conformal.window_size, expected.window_size);
1003        assert_eq!(conformal.q_default, expected.q_default);
1004
1005        // Frame guard
1006        let fg = policy.to_frame_guard_config();
1007        let expected_fg = ConformalFrameGuardConfig::default();
1008        assert_eq!(fg.fallback_budget_us, expected_fg.fallback_budget_us);
1009        assert_eq!(fg.time_series_window, expected_fg.time_series_window);
1010        assert_eq!(fg.nonconformity_window, expected_fg.nonconformity_window);
1011
1012        // Cascade
1013        let cascade = policy.to_cascade_config();
1014        let expected_cc = CascadeConfig::default();
1015        assert_eq!(cascade.recovery_threshold, expected_cc.recovery_threshold);
1016        assert_eq!(cascade.max_degradation, expected_cc.max_degradation);
1017        assert_eq!(cascade.min_trigger_level, expected_cc.min_trigger_level);
1018        assert_eq!(cascade.degradation_floor, expected_cc.degradation_floor);
1019
1020        // PID
1021        let pid = policy.to_pid_gains();
1022        let expected_pid = PidGains::default();
1023        assert_eq!(pid.kp, expected_pid.kp);
1024        assert_eq!(pid.ki, expected_pid.ki);
1025        assert_eq!(pid.kd, expected_pid.kd);
1026        assert_eq!(pid.integral_max, expected_pid.integral_max);
1027
1028        // E-process budget
1029        let ep = policy.to_eprocess_budget_config();
1030        let expected_ep = EProcessConfig::default();
1031        assert_eq!(ep.lambda, expected_ep.lambda);
1032        assert_eq!(ep.alpha, expected_ep.alpha);
1033        assert_eq!(ep.warmup_frames, expected_ep.warmup_frames);
1034
1035        // BOCPD
1036        let bocpd = policy.to_bocpd_config();
1037        let expected_bocpd = BocpdConfig::default();
1038        assert_eq!(bocpd.mu_steady_ms, expected_bocpd.mu_steady_ms);
1039        assert_eq!(bocpd.mu_burst_ms, expected_bocpd.mu_burst_ms);
1040        assert_eq!(bocpd.hazard_lambda, expected_bocpd.hazard_lambda);
1041        assert_eq!(bocpd.max_run_length, expected_bocpd.max_run_length);
1042
1043        // Throttle
1044        let throttle = policy.to_throttle_config();
1045        let expected_throttle = ThrottleConfig::default();
1046        assert_eq!(throttle.alpha, expected_throttle.alpha);
1047        assert_eq!(throttle.mu_0, expected_throttle.mu_0);
1048        assert_eq!(
1049            throttle.hard_deadline_ms,
1050            expected_throttle.hard_deadline_ms
1051        );
1052
1053        // VOI
1054        let voi = policy.to_voi_config();
1055        let expected_voi = VoiConfig::default();
1056        assert_eq!(voi.alpha, expected_voi.alpha);
1057        assert_eq!(voi.sample_cost, expected_voi.sample_cost);
1058        assert_eq!(voi.max_interval_ms, expected_voi.max_interval_ms);
1059    }
1060
1061    #[test]
1062    fn default_validates_clean() {
1063        let errors = PolicyConfig::default().validate();
1064        assert!(errors.is_empty(), "default should validate: {errors:?}");
1065    }
1066
1067    #[test]
1068    fn validate_catches_bad_alpha() {
1069        let mut policy = PolicyConfig::default();
1070        policy.conformal.alpha = 0.0;
1071        let errors = policy.validate();
1072        assert!(errors.iter().any(|e| e.contains("conformal.alpha")));
1073    }
1074
1075    #[test]
1076    fn validate_catches_invalid_cascade_levels() {
1077        let mut policy = PolicyConfig::default();
1078        policy.cascade.min_trigger_level = ftui_render::budget::DegradationLevel::SkipFrame;
1079        policy.cascade.max_degradation = ftui_render::budget::DegradationLevel::SimpleBorders;
1080        let errors = policy.validate();
1081        assert!(
1082            errors
1083                .iter()
1084                .any(|e| e.contains("cascade.min_trigger_level"))
1085        );
1086    }
1087
1088    #[test]
1089    fn validate_catches_negative_pid() {
1090        let mut policy = PolicyConfig::default();
1091        policy.pid.kp = -1.0;
1092        let errors = policy.validate();
1093        assert!(errors.iter().any(|e| e.contains("pid.kp")));
1094    }
1095
1096    #[test]
1097    fn validate_catches_zero_min_samples() {
1098        let mut policy = PolicyConfig::default();
1099        policy.conformal.min_samples = 0;
1100        let errors = policy.validate();
1101        assert!(errors.iter().any(|e| e.contains("min_samples")));
1102    }
1103
1104    #[test]
1105    fn validate_catches_zero_ledger_capacity() {
1106        let mut policy = PolicyConfig::default();
1107        policy.evidence.ledger_capacity = 0;
1108        let errors = policy.validate();
1109        assert!(errors.iter().any(|e| e.contains("ledger_capacity")));
1110    }
1111
1112    #[test]
1113    fn validate_catches_bad_eprocess_alpha() {
1114        let mut policy = PolicyConfig::default();
1115        policy.eprocess_budget.alpha = 1.5;
1116        let errors = policy.validate();
1117        assert!(errors.iter().any(|e| e.contains("eprocess_budget.alpha")));
1118    }
1119
1120    #[test]
1121    fn validate_catches_bad_voi_cost() {
1122        let mut policy = PolicyConfig::default();
1123        policy.voi.sample_cost = -0.5;
1124        let errors = policy.validate();
1125        assert!(errors.iter().any(|e| e.contains("voi.sample_cost")));
1126    }
1127
1128    #[test]
1129    fn validate_catches_bad_bocpd_hazard() {
1130        let mut policy = PolicyConfig::default();
1131        policy.bocpd.hazard_lambda = -1.0;
1132        let errors = policy.validate();
1133        assert!(errors.iter().any(|e| e.contains("bocpd.hazard_lambda")));
1134    }
1135
1136    #[test]
1137    fn validate_catches_bad_throttle_alpha() {
1138        let mut policy = PolicyConfig::default();
1139        policy.eprocess_throttle.alpha = 0.0;
1140        let errors = policy.validate();
1141        assert!(errors.iter().any(|e| e.contains("eprocess_throttle.alpha")));
1142    }
1143
1144    #[test]
1145    fn to_jsonl_produces_valid_json() {
1146        let jsonl = PolicyConfig::default().to_jsonl();
1147        assert!(jsonl.starts_with('{'));
1148        assert!(jsonl.ends_with('}'));
1149        assert!(jsonl.contains("policy-config-v1"));
1150    }
1151
1152    #[test]
1153    fn evidence_sink_config_stdout_default() {
1154        let policy = PolicyConfig::default();
1155        let sink = policy.to_evidence_sink_config();
1156        assert!(!sink.enabled);
1157        assert!(sink.flush_on_write);
1158        assert!(matches!(sink.destination, EvidenceSinkDestination::Stdout));
1159    }
1160
1161    #[test]
1162    fn evidence_sink_config_file_path() {
1163        let mut policy = PolicyConfig::default();
1164        policy.evidence.sink_file = Some("/tmp/evidence.jsonl".into());
1165        let sink = policy.to_evidence_sink_config();
1166        assert!(matches!(sink.destination, EvidenceSinkDestination::File(_)));
1167    }
1168
1169    #[test]
1170    fn partial_override_preserves_defaults() {
1171        // Simulate what TOML partial loading does: only override a few fields
1172        let mut policy = PolicyConfig::default();
1173        policy.conformal.alpha = 0.01;
1174        policy.cascade.recovery_threshold = 20;
1175
1176        // Everything else should still be default
1177        assert_eq!(policy.conformal.min_samples, 20);
1178        assert_eq!(policy.conformal.window_size, 256);
1179        assert_eq!(policy.pid.kp, 0.5);
1180        assert_eq!(policy.bocpd.hazard_lambda, 50.0);
1181
1182        // Overrides should be preserved
1183        assert_eq!(policy.conformal.alpha, 0.01);
1184        assert_eq!(policy.cascade.recovery_threshold, 20);
1185    }
1186
1187    #[test]
1188    fn multiple_validation_errors_collected() {
1189        let mut policy = PolicyConfig::default();
1190        policy.conformal.alpha = 0.0;
1191        policy.pid.kp = -1.0;
1192        policy.evidence.ledger_capacity = 0;
1193        let errors = policy.validate();
1194        assert!(
1195            errors.len() >= 3,
1196            "should catch multiple errors: {errors:?}"
1197        );
1198    }
1199
1200    #[cfg(feature = "policy-config")]
1201    #[test]
1202    fn from_toml_str_rejects_invalid_loaded_values() {
1203        let err = PolicyConfig::from_toml_str(
1204            r#"
1205            [conformal]
1206            alpha = 0.0
1207            "#,
1208        )
1209        .expect_err("invalid TOML policy should fail validation");
1210
1211        assert!(matches!(err, PolicyConfigError::Validation(_)));
1212    }
1213
1214    #[cfg(feature = "policy-config")]
1215    #[test]
1216    fn from_json_str_rejects_invalid_loaded_values() {
1217        let err = PolicyConfig::from_json_str(r#"{"conformal":{"alpha":1.2}}"#)
1218            .expect_err("invalid JSON policy should fail validation");
1219
1220        assert!(matches!(err, PolicyConfigError::Validation(_)));
1221    }
1222
1223    #[test]
1224    fn validate_rejects_non_finite_values() {
1225        let mut policy = PolicyConfig::default();
1226        policy.conformal.q_default = f64::NAN;
1227        policy.frame_guard.fallback_budget_us = f64::INFINITY;
1228        policy.pid.kp = f64::NEG_INFINITY;
1229        policy.voi.sample_cost = f64::NEG_INFINITY;
1230
1231        let errors = policy.validate();
1232        assert!(
1233            errors
1234                .iter()
1235                .any(|error| error.contains("conformal.q_default must be finite")),
1236            "missing q_default finite error: {errors:?}"
1237        );
1238        assert!(
1239            errors
1240                .iter()
1241                .any(|error| error.contains("frame_guard.fallback_budget_us must be finite")),
1242            "missing frame_guard finite error: {errors:?}"
1243        );
1244        assert_eq!(
1245            errors
1246                .iter()
1247                .filter(|error| error.contains("pid.kp"))
1248                .count(),
1249            1,
1250            "pid.kp should emit a single finite-value error: {errors:?}"
1251        );
1252        assert!(
1253            errors
1254                .iter()
1255                .any(|error| error.contains("voi.sample_cost must be finite")),
1256            "missing sample_cost finite error: {errors:?}"
1257        );
1258    }
1259
1260    #[cfg(feature = "policy-config")]
1261    #[test]
1262    fn from_toml_str_rejects_non_finite_loaded_values() {
1263        let err = PolicyConfig::from_toml_str(
1264            r#"
1265            [conformal]
1266            alpha = nan
1267            q_default = inf
1268
1269            [frame_guard]
1270            fallback_budget_us = inf
1271            "#,
1272        )
1273        .expect_err("non-finite TOML policy should fail validation");
1274
1275        match err {
1276            PolicyConfigError::Validation(errors) => {
1277                assert!(
1278                    errors
1279                        .iter()
1280                        .any(|error| error.contains("conformal.alpha must be finite")),
1281                    "missing conformal.alpha finite error: {errors:?}"
1282                );
1283                assert!(
1284                    errors
1285                        .iter()
1286                        .any(|error| error.contains("conformal.q_default must be finite")),
1287                    "missing conformal.q_default finite error: {errors:?}"
1288                );
1289                assert!(
1290                    errors.iter().any(
1291                        |error| error.contains("frame_guard.fallback_budget_us must be finite")
1292                    ),
1293                    "missing frame_guard.fallback_budget_us finite error: {errors:?}"
1294                );
1295            }
1296            other => panic!("expected validation error, got {other:?}"),
1297        }
1298    }
1299
1300    #[cfg(feature = "policy-config")]
1301    #[test]
1302    fn from_cargo_toml_str_extracts_embedded_policy() {
1303        let policy = PolicyConfig::from_cargo_toml_str(
1304            r#"
1305            [package]
1306            name = "demo"
1307            version = "0.1.0"
1308
1309            [package.metadata.ftui.conformal]
1310            alpha = 0.01
1311
1312            [package.metadata.ftui.cascade]
1313            recovery_threshold = 24
1314            "#,
1315        )
1316        .expect("embedded Cargo metadata should load");
1317
1318        assert!((policy.conformal.alpha - 0.01).abs() < f64::EPSILON);
1319        assert_eq!(policy.cascade.recovery_threshold, 24);
1320        assert_eq!(policy.pid.kp, PidPolicyConfig::default().kp);
1321    }
1322
1323    #[cfg(feature = "policy-config")]
1324    #[test]
1325    fn from_cargo_toml_str_requires_ftui_metadata_section() {
1326        let err = PolicyConfig::from_cargo_toml_str(
1327            r#"
1328            [package]
1329            name = "demo"
1330            version = "0.1.0"
1331            "#,
1332        )
1333        .expect_err("missing metadata section should fail");
1334
1335        assert!(matches!(
1336            err,
1337            PolicyConfigError::MissingMetadataSection("[package.metadata.ftui]")
1338        ));
1339    }
1340
1341    #[cfg(feature = "policy-config")]
1342    #[test]
1343    fn discover_in_dir_prefers_standalone_toml() {
1344        let dir = tempdir().expect("tempdir");
1345        std::fs::write(
1346            dir.path().join(STANDALONE_POLICY_TOML),
1347            r#"
1348            [conformal]
1349            alpha = 0.03
1350            "#,
1351        )
1352        .expect("write standalone policy");
1353        std::fs::write(
1354            dir.path().join(CARGO_MANIFEST_NAME),
1355            r#"
1356            [package]
1357            name = "demo"
1358            version = "0.1.0"
1359
1360            [package.metadata.ftui.conformal]
1361            alpha = 0.01
1362            "#,
1363        )
1364        .expect("write Cargo manifest");
1365
1366        let policy = PolicyConfig::discover_in_dir(dir.path()).expect("discover TOML policy");
1367        assert!((policy.conformal.alpha - 0.03).abs() < f64::EPSILON);
1368    }
1369
1370    #[cfg(feature = "policy-config")]
1371    #[test]
1372    fn discover_in_dir_prefers_json_over_cargo_manifest() {
1373        let dir = tempdir().expect("tempdir");
1374        std::fs::write(
1375            dir.path().join(STANDALONE_POLICY_JSON),
1376            r#"{"cascade":{"recovery_threshold":17}}"#,
1377        )
1378        .expect("write standalone json");
1379        std::fs::write(
1380            dir.path().join(CARGO_MANIFEST_NAME),
1381            r#"
1382            [package]
1383            name = "demo"
1384            version = "0.1.0"
1385
1386            [package.metadata.ftui.cascade]
1387            recovery_threshold = 33
1388            "#,
1389        )
1390        .expect("write Cargo manifest");
1391
1392        let policy = PolicyConfig::discover_in_dir(dir.path()).expect("discover JSON policy");
1393        assert_eq!(policy.cascade.recovery_threshold, 17);
1394    }
1395
1396    #[cfg(feature = "policy-config")]
1397    #[test]
1398    fn discover_in_dir_falls_back_to_cargo_manifest() {
1399        let dir = tempdir().expect("tempdir");
1400        std::fs::write(
1401            dir.path().join(CARGO_MANIFEST_NAME),
1402            r#"
1403            [package]
1404            name = "demo"
1405            version = "0.1.0"
1406
1407            [package.metadata.ftui.cascade]
1408            recovery_threshold = 33
1409            "#,
1410        )
1411        .expect("write Cargo manifest");
1412
1413        let policy =
1414            PolicyConfig::discover_in_dir(dir.path()).expect("discover Cargo metadata policy");
1415        assert_eq!(policy.cascade.recovery_threshold, 33);
1416    }
1417}