Skip to main content

stygian_charon/pow_profile/
policy.rs

1//! Policy mapping from `PoW` capability score to runtime-policy
2//! adjustments (T93).
3//!
4//! The mapper consumes a [`PowCapabilityScore`]
5//! (a unit-interval score plus a coarse band) and a
6//! [`RuntimePolicy`][crate::types::RuntimePolicy] and returns
7//! a **new** policy with deterministic escalation / pacing
8//! adjustments. Adjustments are bounded by
9//! [`MAX_POW_RISK_DELTA`] — the same safety-clamp pattern
10//! the T83 [`MAX_RISK_DELTA`][crate::challenge_feedback::MAX_RISK_DELTA]
11//! uses — so a single `PoW` profile can never shift the
12//! risk score by more than the documented ceiling.
13//!
14//! ## Band → adjustment table
15//!
16//! | Band       | Execution mode        | Session mode     | Pacing                                    | Retries | Risk delta |
17//! |------------|----------------------|------------------|-------------------------------------------|---------|------------|
18//! | `Strong`   | unchanged            | unchanged        | rate floor at 80% of current              | current | `0.0`      |
19//! | `Degraded` | unchanged            | unchanged        | unchanged                                 | current | `0.0`      |
20//! | `Weak`     | escalate to Browser  | escalate to Sticky | rate floor at 1.0 rps, backoff ≥ 1000 ms | +1      | `+0.10`    |
21//! | `Unknown`  | unchanged            | unchanged        | unchanged                                 | current | `0.0`      |
22//!
23//! The `Strong` band reduces `rate_limit_rps` (but never
24//! below `1.0` rps) because a profile that consistently
25//! solves fast is safe to drive at a higher rate. The
26//! `Weak` band escalates execution mode (when not already
27//! browser+sticky), tightens pacing, and adds a
28//! `+MAX_POW_RISK_DELTA` risk-score lift.
29//!
30//! ## Why a clamp?
31//!
32//! A feedback loop that can shift the risk score
33//! arbitrarily would amplify noise. The clamp mirrors the
34//! T83 pattern: a single `PoW` profile can never move the
35//! risk score by more than [`MAX_POW_RISK_DELTA`], and the
36//! final risk score is re-clamped to `[0.0, 1.0]` after
37//! the adjustment.
38
39use 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
46/// Documented **upper bound** for the risk-score lift the
47/// `PoW` policy mapper can apply to a single
48/// [`RuntimePolicy`][crate::types::RuntimePolicy].
49///
50/// The default is **0.10** (half of the T83
51/// [`MAX_RISK_DELTA`][crate::challenge_feedback::MAX_RISK_DELTA]).
52/// The `PoW` profile is a *secondary* signal — the primary
53/// risk driver is the T83 challenge memory and the T91
54/// token lifecycle. Operators may **lower** the clamp via
55/// [`PowPolicyThresholds::with_max_risk_delta`] but cannot
56/// raise it above this documented safety bound.
57pub const MAX_POW_RISK_DELTA: f64 = 0.10;
58
59/// Configurable thresholds for the `PoW` policy mapper.
60///
61/// The defaults match the band boundaries the
62/// [`PowCapabilityScorer`][crate::pow_profile::PowCapabilityScorer]
63/// uses (`strong` ≥ `0.75`, `degraded` ≥ `0.40`). The struct
64/// is `Copy` so it can live in a static configuration
65/// struct.
66#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
67pub struct PowPolicyThresholds {
68    /// Lower edge of the `Strong` band (inclusive).
69    pub strong_floor: f64,
70    /// Lower edge of the `Degraded` band (inclusive).
71    pub degraded_floor: f64,
72    /// Maximum risk-score lift the mapper may apply
73    /// (clamped to `[0.0, MAX_POW_RISK_DELTA]`).
74    pub max_risk_delta: f64,
75    /// Pacing floor (rps) for the `Strong` band — the
76    /// adjusted policy never drops below
77    /// `strong_rate_floor_rps` even if the input policy had
78    /// a higher rate. Defaults to `1.0` so a previously
79    /// high-rate policy does not get silently slowed to
80    /// nothing by the mapper.
81    pub strong_rate_floor_rps: f64,
82    /// Pacing ceiling (rps) for the `Weak` band — the
83    /// adjusted policy never exceeds
84    /// `weak_rate_ceiling_rps` even if the input policy had
85    /// a higher rate.
86    pub weak_rate_ceiling_rps: f64,
87    /// Backoff floor (ms) for the `Weak` band.
88    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    /// Replace the `max_risk_delta` clamp. Clamped to
106    /// `[0.0, MAX_POW_RISK_DELTA]` so callers cannot
107    /// widen the documented safety bound.
108    #[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    /// Replace the `strong_rate_floor_rps` floor. Non-finite
122    /// or non-positive values fall back to the documented
123    /// default so the mapper cannot silently disable
124    /// pacing.
125    #[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    /// Replace the `weak_rate_ceiling_rps` ceiling.
134    /// Non-finite or non-positive values fall back to the
135    /// documented default.
136    #[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    /// Replace the `weak_backoff_floor_ms` floor. Zero
145    /// values fall back to the documented default.
146    #[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/// A `PoW` capability score plus the band label that the
158/// scorer derived from it.
159///
160/// This is the **unit** the policy mapper consumes — the
161/// mapper does not need to know which scorer produced the
162/// score. Helpers like
163/// [`score_from_profile`][crate::pow_profile::score_from_profile]
164/// build a [`PowCapabilityScore`] from a profile + scorer
165/// pair so callers can chain the two.
166#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
167pub struct PowCapabilityScore {
168    /// Unit-interval score in `[0.0, 1.0]`.
169    pub value: f64,
170    /// Coarse band derived from `value`.
171    pub band: PowCapabilityBand,
172}
173
174impl PowCapabilityScore {
175    /// Build a score + band directly (no profile required).
176    /// The band is recomputed from the score so the two
177    /// fields cannot drift out of sync. A value equal to
178    /// [`SPARSE_FALLBACK_SCORE`] (the documented "no
179    /// signal" default) always maps to
180    /// [`PowCapabilityBand::Unknown`].
181    #[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    /// `true` if the score is the sparse-telemetry default
200    /// ([`SPARSE_FALLBACK_SCORE`]) or the band is
201    /// [`PowCapabilityBand::Unknown`].
202    #[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/// Map a [`PowCapabilityScore`] to a deterministic
220/// [`RuntimePolicy`][crate::types::RuntimePolicy] adjustment.
221///
222/// The mapper is **non-mutating** — it clones the input
223/// policy and returns a new one with the documented
224/// adjustments applied. The risk score is re-clamped to
225/// `[0.0, 1.0]` after the lift so the final value is
226/// always in the unit interval.
227///
228/// `Strong` and `Degraded` bands produce **non-escalating**
229/// adjustments (`Strong` reduces the rate-limit floor,
230/// `Degraded` is a no-op besides the config hint). `Weak`
231/// is the only band that escalates the policy.
232#[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    // The "Strong" adjustment: keep the operator's pacing
251    // unless it is below the documented floor. We never
252    // raise the rate above the policy's existing value —
253    // the runner already picked a sensible rate for the
254    // target; we only ensure it does not silently drop
255    // below the floor.
256    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    // Also tag the band label for downstream tools that
303    // want a stable enum string rather than the raw score.
304    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        // stygian-proxy must not be added twice.
432        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        // Even an extreme input risk + the max lift must
490        // not exceed 1.0.
491        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}