1#![forbid(unsafe_code)]
2
3#[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#[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 pub conformal: ConformalPolicyConfig,
69
70 pub frame_guard: FrameGuardPolicyConfig,
72
73 pub cascade: CascadePolicyConfig,
75
76 pub pid: PidPolicyConfig,
78
79 pub eprocess_budget: EProcessBudgetPolicyConfig,
81
82 pub bocpd: BocpdPolicyConfig,
84
85 pub eprocess_throttle: EProcessThrottlePolicyConfig,
87
88 pub voi: VoiPolicyConfig,
90
91 pub evidence: EvidencePolicyConfig,
93}
94
95impl PolicyConfig {
96 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 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 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 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 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 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 if self.evidence.ledger_capacity == 0 {
386 errors.push("evidence.ledger_capacity must be > 0".into());
387 }
388
389 errors
390 }
391
392 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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#[derive(Debug, Clone)]
549#[cfg_attr(feature = "policy-config", derive(Serialize, Deserialize))]
550#[cfg_attr(feature = "policy-config", serde(default))]
551pub struct ConformalPolicyConfig {
552 pub alpha: f64,
554 pub min_samples: usize,
556 pub window_size: usize,
558 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#[derive(Debug, Clone)]
575#[cfg_attr(feature = "policy-config", derive(Serialize, Deserialize))]
576#[cfg_attr(feature = "policy-config", serde(default))]
577pub struct FrameGuardPolicyConfig {
578 pub fallback_budget_us: f64,
580 pub time_series_window: usize,
582 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#[derive(Debug, Clone)]
598#[cfg_attr(feature = "policy-config", derive(Serialize, Deserialize))]
599#[cfg_attr(feature = "policy-config", serde(default))]
600pub struct CascadePolicyConfig {
601 pub recovery_threshold: u32,
603 #[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 #[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 #[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#[derive(Debug, Clone)]
649#[cfg_attr(feature = "policy-config", derive(Serialize, Deserialize))]
650#[cfg_attr(feature = "policy-config", serde(default))]
651pub struct PidPolicyConfig {
652 pub kp: f64,
654 pub ki: f64,
656 pub kd: f64,
658 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#[derive(Debug, Clone)]
675#[cfg_attr(feature = "policy-config", derive(Serialize, Deserialize))]
676#[cfg_attr(feature = "policy-config", serde(default))]
677pub struct EProcessBudgetPolicyConfig {
678 pub lambda: f64,
680 pub alpha: f64,
682 pub beta: f64,
684 pub sigma_ema_decay: f64,
686 pub sigma_floor_ms: f64,
688 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#[derive(Debug, Clone)]
707#[cfg_attr(feature = "policy-config", derive(Serialize, Deserialize))]
708#[cfg_attr(feature = "policy-config", serde(default))]
709pub struct BocpdPolicyConfig {
710 pub mu_steady_ms: f64,
712 pub mu_burst_ms: f64,
714 pub hazard_lambda: f64,
716 pub max_run_length: usize,
718 pub steady_threshold: f64,
720 pub burst_threshold: f64,
722 pub burst_prior: f64,
724 pub min_observation_ms: f64,
726 pub max_observation_ms: f64,
728 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#[derive(Debug, Clone)]
751#[cfg_attr(feature = "policy-config", derive(Serialize, Deserialize))]
752#[cfg_attr(feature = "policy-config", serde(default))]
753pub struct EProcessThrottlePolicyConfig {
754 pub alpha: f64,
756 pub mu_0: f64,
758 pub initial_lambda: f64,
760 pub grapa_eta: f64,
762 pub hard_deadline_ms: u64,
764 pub min_observations_between: u64,
766 pub rate_window_size: usize,
768 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#[derive(Debug, Clone)]
789#[cfg_attr(feature = "policy-config", derive(Serialize, Deserialize))]
790#[cfg_attr(feature = "policy-config", serde(default))]
791pub struct VoiPolicyConfig {
792 pub alpha: f64,
794 pub prior_alpha: f64,
796 pub prior_beta: f64,
798 pub mu_0: f64,
800 pub lambda: f64,
802 pub value_scale: f64,
804 pub boundary_weight: f64,
806 pub sample_cost: f64,
808 pub min_interval_ms: u64,
810 pub max_interval_ms: u64,
812 pub min_interval_events: u64,
814 pub max_interval_events: u64,
816 pub enable_logging: bool,
818 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#[derive(Debug, Clone)]
845#[cfg_attr(feature = "policy-config", derive(Serialize, Deserialize))]
846#[cfg_attr(feature = "policy-config", serde(default))]
847pub struct EvidencePolicyConfig {
848 pub ledger_capacity: usize,
850 pub sink_enabled: bool,
852 pub sink_file: Option<String>,
854 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#[derive(Debug)]
875pub enum PolicyConfigError {
876 Io(std::io::Error),
878 MissingMetadataSection(&'static str),
880 #[cfg(feature = "policy-config")]
882 Toml(toml::de::Error),
883 #[cfg(feature = "policy-config")]
885 Json(serde_json::Error),
886 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#[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#[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 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 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 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 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 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 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 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 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 let mut policy = PolicyConfig::default();
1173 policy.conformal.alpha = 0.01;
1174 policy.cascade.recovery_threshold = 20;
1175
1176 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 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}