stygian_charon/pow_profile/
policy.rs1use std::collections::BTreeMap;
40
41use serde::{Deserialize, Serialize};
42
43use crate::pow_profile::scorer::{PowCapabilityBand, SPARSE_FALLBACK_SCORE};
44use crate::types::{ExecutionMode, RuntimePolicy, SessionMode};
45
46pub const MAX_POW_RISK_DELTA: f64 = 0.10;
58
59#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
67pub struct PowPolicyThresholds {
68 pub strong_floor: f64,
70 pub degraded_floor: f64,
72 pub max_risk_delta: f64,
75 pub strong_rate_floor_rps: f64,
82 pub weak_rate_ceiling_rps: f64,
87 pub weak_backoff_floor_ms: u64,
89}
90
91impl Default for PowPolicyThresholds {
92 fn default() -> Self {
93 Self {
94 strong_floor: 0.75,
95 degraded_floor: 0.40,
96 max_risk_delta: MAX_POW_RISK_DELTA,
97 strong_rate_floor_rps: 1.0,
98 weak_rate_ceiling_rps: 1.0,
99 weak_backoff_floor_ms: 1_000,
100 }
101 }
102}
103
104impl PowPolicyThresholds {
105 #[must_use]
109 pub const fn with_max_risk_delta(mut self, max_risk_delta: f64) -> Self {
110 let clamped = if max_risk_delta < 0.0 {
111 0.0
112 } else if max_risk_delta > MAX_POW_RISK_DELTA {
113 MAX_POW_RISK_DELTA
114 } else {
115 max_risk_delta
116 };
117 self.max_risk_delta = clamped;
118 self
119 }
120
121 #[must_use]
126 pub fn with_strong_rate_floor_rps(mut self, floor: f64) -> Self {
127 if floor.is_finite() && floor > 0.0 {
128 self.strong_rate_floor_rps = floor;
129 }
130 self
131 }
132
133 #[must_use]
137 pub fn with_weak_rate_ceiling_rps(mut self, ceiling: f64) -> Self {
138 if ceiling.is_finite() && ceiling > 0.0 {
139 self.weak_rate_ceiling_rps = ceiling;
140 }
141 self
142 }
143
144 #[must_use]
147 pub const fn with_weak_backoff_floor_ms(mut self, floor: u64) -> Self {
148 if floor == 0 {
149 self.weak_backoff_floor_ms = 1_000;
150 } else {
151 self.weak_backoff_floor_ms = floor;
152 }
153 self
154 }
155}
156
157#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
167pub struct PowCapabilityScore {
168 pub value: f64,
170 pub band: PowCapabilityBand,
172}
173
174impl PowCapabilityScore {
175 #[must_use]
182 pub fn new(value: f64) -> Self {
183 let clamped = if value.is_nan() {
184 SPARSE_FALLBACK_SCORE
185 } else {
186 value.clamp(0.0, 1.0)
187 };
188 let band = if (clamped - SPARSE_FALLBACK_SCORE).abs() < 1e-9 {
189 PowCapabilityBand::Unknown
190 } else {
191 band_for_score(clamped)
192 };
193 Self {
194 value: clamped,
195 band,
196 }
197 }
198
199 #[must_use]
203 pub fn is_unknown(&self) -> bool {
204 matches!(self.band, PowCapabilityBand::Unknown)
205 || (self.value - SPARSE_FALLBACK_SCORE).abs() < 1e-9
206 }
207}
208
209fn band_for_score(score: f64) -> PowCapabilityBand {
210 if score >= 0.75 {
211 PowCapabilityBand::Strong
212 } else if score >= 0.40 {
213 PowCapabilityBand::Degraded
214 } else {
215 PowCapabilityBand::Weak
216 }
217}
218
219#[must_use]
233pub fn adjust_runtime_policy_for_pow(
234 policy: &RuntimePolicy,
235 score: &PowCapabilityScore,
236 thresholds: &PowPolicyThresholds,
237) -> RuntimePolicy {
238 let mut adjusted = policy.clone();
239 match score.band {
240 PowCapabilityBand::Strong => apply_strong(&mut adjusted, thresholds),
241 PowCapabilityBand::Degraded => apply_degraded(&mut adjusted),
242 PowCapabilityBand::Weak => apply_weak(&mut adjusted, thresholds),
243 PowCapabilityBand::Unknown => apply_unknown(&mut adjusted),
244 }
245 adjusted.risk_score = (adjusted.risk_score).clamp(0.0, 1.0);
246 adjusted
247}
248
249fn apply_strong(policy: &mut RuntimePolicy, thresholds: &PowPolicyThresholds) {
250 if policy.rate_limit_rps < thresholds.strong_rate_floor_rps {
257 policy.rate_limit_rps = thresholds.strong_rate_floor_rps;
258 }
259 insert_capability_hint(&mut policy.config_hints, "strong", "strong");
260}
261
262fn apply_degraded(policy: &mut RuntimePolicy) {
263 insert_capability_hint(&mut policy.config_hints, "degraded", "degraded");
264}
265
266fn apply_weak(policy: &mut RuntimePolicy, thresholds: &PowPolicyThresholds) {
267 if policy.execution_mode != ExecutionMode::Browser {
268 policy.execution_mode = ExecutionMode::Browser;
269 }
270 if policy.session_mode != SessionMode::Sticky {
271 policy.session_mode = SessionMode::Sticky;
272 }
273 if policy.rate_limit_rps > thresholds.weak_rate_ceiling_rps {
274 policy.rate_limit_rps = thresholds.weak_rate_ceiling_rps;
275 }
276 policy.backoff_base_ms = policy.backoff_base_ms.max(thresholds.weak_backoff_floor_ms);
277 policy.max_retries = policy.max_retries.saturating_add(1);
278 if policy.sticky_session_ttl_secs.is_none() {
279 policy.sticky_session_ttl_secs = Some(600);
280 }
281 if !policy
282 .required_stygian_features
283 .iter()
284 .any(|f| f == "stygian-proxy")
285 {
286 policy
287 .required_stygian_features
288 .push("stygian-proxy".to_string());
289 }
290 insert_capability_hint(&mut policy.config_hints, "weak", "weak");
291 insert_pow_escalation_hint(&mut policy.config_hints, "weak");
292 let lift = thresholds.max_risk_delta;
293 policy.risk_score = (policy.risk_score + lift).clamp(0.0, 1.0);
294}
295
296fn apply_unknown(policy: &mut RuntimePolicy) {
297 insert_capability_hint(&mut policy.config_hints, "unknown", "unknown");
298}
299
300fn insert_capability_hint(hints: &mut BTreeMap<String, String>, label: &str, value: &str) {
301 hints.insert("pow.capability".to_string(), value.to_string());
302 hints.insert("pow.capability_band".to_string(), label.to_string());
305}
306
307fn insert_pow_escalation_hint(hints: &mut BTreeMap<String, String>, level: &str) {
308 hints.insert("pow.escalation".to_string(), level.to_string());
309}
310
311#[cfg(test)]
312#[allow(
313 clippy::unwrap_used,
314 clippy::expect_used,
315 clippy::panic,
316 clippy::indexing_slicing
317)]
318mod tests {
319 use super::*;
320
321 fn approx_eq(a: f64, b: f64) -> bool {
322 (a - b).abs() < 1e-9
323 }
324
325 fn base_policy() -> RuntimePolicy {
326 RuntimePolicy {
327 execution_mode: ExecutionMode::Http,
328 session_mode: SessionMode::Stateless,
329 telemetry_level: crate::types::TelemetryLevel::Standard,
330 rate_limit_rps: 3.0,
331 max_retries: 2,
332 backoff_base_ms: 250,
333 enable_warmup: false,
334 enforce_webrtc_proxy_only: false,
335 sticky_session_ttl_secs: None,
336 required_stygian_features: Vec::new(),
337 config_hints: BTreeMap::new(),
338 risk_score: 0.30,
339 }
340 }
341
342 #[test]
343 fn strong_band_keeps_default_and_floors_rate() {
344 let score = PowCapabilityScore::new(0.90);
345 let thresholds = PowPolicyThresholds::default();
346 let policy = RuntimePolicy {
347 rate_limit_rps: 0.5,
348 ..base_policy()
349 };
350 let adjusted = adjust_runtime_policy_for_pow(&policy, &score, &thresholds);
351 assert_eq!(adjusted.execution_mode, ExecutionMode::Http);
352 assert!(adjusted.rate_limit_rps >= 1.0);
353 assert!(approx_eq(adjusted.risk_score, policy.risk_score));
354 assert_eq!(
355 adjusted.config_hints.get("pow.capability"),
356 Some(&"strong".to_string())
357 );
358 }
359
360 #[test]
361 fn degraded_band_is_a_no_op() {
362 let score = PowCapabilityScore::new(0.55);
363 let thresholds = PowPolicyThresholds::default();
364 let policy = base_policy();
365 let adjusted = adjust_runtime_policy_for_pow(&policy, &score, &thresholds);
366 assert_eq!(adjusted.execution_mode, policy.execution_mode);
367 assert_eq!(adjusted.session_mode, policy.session_mode);
368 assert!(approx_eq(adjusted.rate_limit_rps, policy.rate_limit_rps));
369 assert!(approx_eq(adjusted.risk_score, policy.risk_score));
370 assert_eq!(
371 adjusted.config_hints.get("pow.capability"),
372 Some(&"degraded".to_string())
373 );
374 }
375
376 #[test]
377 fn weak_band_escalates_to_browser_sticky() {
378 let score = PowCapabilityScore::new(0.20);
379 let thresholds = PowPolicyThresholds::default();
380 let policy = base_policy();
381 let adjusted = adjust_runtime_policy_for_pow(&policy, &score, &thresholds);
382 assert_eq!(adjusted.execution_mode, ExecutionMode::Browser);
383 assert_eq!(adjusted.session_mode, SessionMode::Sticky);
384 assert!(adjusted.rate_limit_rps <= thresholds.weak_rate_ceiling_rps);
385 assert!(adjusted.backoff_base_ms >= thresholds.weak_backoff_floor_ms);
386 assert!(adjusted.max_retries > policy.max_retries);
387 assert!(adjusted.sticky_session_ttl_secs.is_some());
388 assert!(
389 adjusted
390 .required_stygian_features
391 .contains(&"stygian-proxy".to_string())
392 );
393 assert!(approx_eq(
394 adjusted.risk_score,
395 (policy.risk_score + MAX_POW_RISK_DELTA).clamp(0.0, 1.0)
396 ));
397 assert_eq!(
398 adjusted.config_hints.get("pow.escalation"),
399 Some(&"weak".to_string())
400 );
401 }
402
403 #[test]
404 fn unknown_band_is_a_no_op_with_hint() {
405 let score = PowCapabilityScore::new(SPARSE_FALLBACK_SCORE);
406 let thresholds = PowPolicyThresholds::default();
407 let policy = base_policy();
408 let adjusted = adjust_runtime_policy_for_pow(&policy, &score, &thresholds);
409 assert_eq!(adjusted.execution_mode, policy.execution_mode);
410 assert_eq!(adjusted.session_mode, policy.session_mode);
411 assert!(approx_eq(adjusted.risk_score, policy.risk_score));
412 assert_eq!(
413 adjusted.config_hints.get("pow.capability"),
414 Some(&"unknown".to_string())
415 );
416 }
417
418 #[test]
419 fn weak_band_respects_already_browser_sticky_policy() {
420 let score = PowCapabilityScore::new(0.10);
421 let thresholds = PowPolicyThresholds::default();
422 let policy = RuntimePolicy {
423 execution_mode: ExecutionMode::Browser,
424 session_mode: SessionMode::Sticky,
425 max_retries: 5,
426 ..base_policy()
427 };
428 let adjusted = adjust_runtime_policy_for_pow(&policy, &score, &thresholds);
429 assert_eq!(adjusted.execution_mode, ExecutionMode::Browser);
430 assert_eq!(adjusted.session_mode, SessionMode::Sticky);
431 let proxy_count = adjusted
433 .required_stygian_features
434 .iter()
435 .filter(|f| f.as_str() == "stygian-proxy")
436 .count();
437 assert_eq!(proxy_count, 1);
438 }
439
440 #[test]
441 fn max_risk_delta_cannot_exceed_documented_max() {
442 let thresholds = PowPolicyThresholds::default().with_max_risk_delta(0.95);
443 assert!(thresholds.max_risk_delta <= MAX_POW_RISK_DELTA);
444
445 let narrowed = PowPolicyThresholds::default().with_max_risk_delta(0.02);
446 assert!(approx_eq(narrowed.max_risk_delta, 0.02));
447 }
448
449 #[test]
450 fn strong_rate_floor_ignores_non_finite_or_non_positive() {
451 let thresholds = PowPolicyThresholds::default().with_strong_rate_floor_rps(0.0);
452 assert!(thresholds.strong_rate_floor_rps > 0.0);
453 let thresholds = PowPolicyThresholds::default().with_strong_rate_floor_rps(f64::NAN);
454 assert!(thresholds.strong_rate_floor_rps.is_finite());
455 }
456
457 #[test]
458 fn weak_backoff_floor_ignores_zero() {
459 let thresholds = PowPolicyThresholds::default().with_weak_backoff_floor_ms(0);
460 assert_eq!(thresholds.weak_backoff_floor_ms, 1_000);
461 }
462
463 #[test]
464 fn unknown_score_constructor_returns_unknown_band() {
465 let score = PowCapabilityScore::new(SPARSE_FALLBACK_SCORE);
466 assert!(score.is_unknown());
467 assert_eq!(score.band, PowCapabilityBand::Unknown);
468 }
469
470 #[test]
471 fn strong_score_constructor_returns_strong_band() {
472 let score = PowCapabilityScore::new(0.95);
473 assert!(!score.is_unknown());
474 assert_eq!(score.band, PowCapabilityBand::Strong);
475 }
476
477 #[test]
478 fn score_constructor_clamps_and_clamps_nan() {
479 let s = PowCapabilityScore::new(f64::NAN);
480 assert!(approx_eq(s.value, SPARSE_FALLBACK_SCORE));
481 let s = PowCapabilityScore::new(2.0);
482 assert!(approx_eq(s.value, 1.0));
483 let s = PowCapabilityScore::new(-0.5);
484 assert!(approx_eq(s.value, 0.0));
485 }
486
487 #[test]
488 fn risk_score_is_clamped_to_unit_interval_after_lift() {
489 let score = PowCapabilityScore::new(0.10);
492 let thresholds = PowPolicyThresholds::default();
493 let policy = RuntimePolicy {
494 risk_score: 0.99,
495 ..base_policy()
496 };
497 let adjusted = adjust_runtime_policy_for_pow(&policy, &score, &thresholds);
498 assert!(adjusted.risk_score <= 1.0);
499 }
500}