Skip to main content

stygian_charon/challenge_feedback/
policy.rs

1use crate::challenge_feedback::{ChallengeMemory, EngineKey};
2use crate::types::{RequirementsProfile, RuntimePolicy};
3
4/// Documented **upper bound** for any single per-key risk-score
5/// adjustment the challenge memory can apply.
6///
7/// The default is **0.20** (twenty percent of the risk-score
8/// range). This conservative ceiling is the key safety property of
9/// the feedback loop: a single transient outcome can never move the
10/// policy into a fundamentally different strategy band. Callers may
11/// **lower** the clamp via
12/// [`ChallengeFeedbackPolicy::with_max_delta`] but the value is
13/// hard-capped at `MAX_RISK_DELTA` to prevent runaway escalation.
14pub const MAX_RISK_DELTA: f64 = 0.20;
15
16/// Configurable knobs for the challenge-aware policy feedback loop.
17///
18/// All fields are bounded by the documented safety constants —
19/// [`with_max_delta`][Self::with_max_delta] clamps the supplied
20/// value to `[-MAX_RISK_DELTA, +MAX_RISK_DELTA]`.
21///
22/// # Example
23///
24/// ```
25/// use stygian_charon::challenge_feedback::{ChallengeFeedbackPolicy, MAX_RISK_DELTA};
26/// use std::time::Duration;
27///
28/// let policy = ChallengeFeedbackPolicy::default();
29/// assert!(policy.max_delta().abs() <= MAX_RISK_DELTA);
30/// assert_eq!(policy.ttl(), Duration::from_mins(10));
31/// ```
32#[derive(Debug, Clone, Copy, PartialEq)]
33pub struct ChallengeFeedbackPolicy {
34    max_delta: f64,
35    ttl: std::time::Duration,
36}
37
38impl ChallengeFeedbackPolicy {
39    /// Build a feedback policy with a custom clamp and TTL. The
40    /// supplied `max_delta` is clamped to `[-MAX_RISK_DELTA,
41    /// +MAX_RISK_DELTA]` so callers cannot widen the documented
42    /// safety bound.
43    #[must_use]
44    pub fn new(max_delta: f64, ttl: std::time::Duration) -> Self {
45        Self {
46            max_delta: max_delta.clamp(-MAX_RISK_DELTA, MAX_RISK_DELTA),
47            ttl,
48        }
49    }
50
51    /// Replace the per-key clamp. Clamped to `[-MAX_RISK_DELTA,
52    /// +MAX_RISK_DELTA]`.
53    #[must_use]
54    pub fn with_max_delta(mut self, max_delta: f64) -> Self {
55        self.max_delta = max_delta.clamp(-MAX_RISK_DELTA, MAX_RISK_DELTA);
56        self
57    }
58
59    /// Replace the memory TTL. Non-positive values fall back to a
60    /// one-minute default so the loop cannot accidentally live
61    /// forever.
62    #[must_use]
63    pub const fn with_ttl(mut self, ttl: std::time::Duration) -> Self {
64        self.ttl = if ttl.is_zero() {
65            std::time::Duration::from_mins(1)
66        } else {
67            ttl
68        };
69        self
70    }
71
72    /// Configured per-key clamp.
73    #[must_use]
74    pub const fn max_delta(&self) -> f64 {
75        self.max_delta
76    }
77
78    /// Configured memory TTL.
79    #[must_use]
80    pub const fn ttl(&self) -> std::time::Duration {
81        self.ttl
82    }
83}
84
85impl Default for ChallengeFeedbackPolicy {
86    fn default() -> Self {
87        Self {
88            max_delta: MAX_RISK_DELTA,
89            ttl: super::memory::DEFAULT_CHALLENGE_TTL,
90        }
91    }
92}
93
94/// Compute the risk-score adjustment a [`ChallengeMemory`] would
95/// apply for an [`EngineKey`], using the
96/// [`ChallengeFeedbackPolicy::default`] clamp.
97///
98/// Returns `0.0` when the memory has no entry for the key (the
99/// common case on first contact with a new target).
100///
101/// # Example
102///
103/// ```
104/// use stygian_charon::challenge_feedback::{
105///     memory_adjustment_for, ChallengeMemory, ChallengeOutcome, EngineKey,
106/// };
107/// use stygian_charon::types::TargetClass;
108/// use stygian_charon::vendor_classifier::VendorId;
109///
110/// let memory = ChallengeMemory::with_defaults();
111/// let key = EngineKey {
112///     engine: VendorId::Cloudflare,
113///     version: None,
114///     target_class: TargetClass::ContentSite,
115///     tls_profile: None,
116/// };
117/// memory.record(&key, None, ChallengeOutcome::Captcha);
118/// let delta = memory_adjustment_for(&memory, &key);
119/// assert!(delta > 0.0);
120/// ```
121#[must_use]
122pub fn memory_adjustment_for(memory: &ChallengeMemory, key: &EngineKey) -> f64 {
123    memory.lookup(key).map_or(0.0, |entry| {
124        clamp_to_policy(
125            &ChallengeFeedbackPolicy::default(),
126            entry.last_outcome.risk_delta_for(key.target_class),
127        )
128    })
129}
130
131/// Build a [`RuntimePolicy`] from an investigation report and
132/// requirements profile, then apply a bounded challenge-memory
133/// adjustment via [`adjust_runtime_policy`].
134///
135/// Convenience wrapper for the common "rebuild policy from scratch"
136/// workflow.
137///
138/// # Example
139///
140/// ```
141/// use stygian_charon::challenge_feedback::{
142///     build_runtime_policy_with_memory, ChallengeMemory, ChallengeOutcome, EngineKey,
143/// };
144/// use stygian_charon::build_runtime_policy;
145/// use stygian_charon::types::{
146///     AdapterStrategy, AntiBotProvider, Detection, IntegrationRecommendation,
147///     InvestigationReport, RequirementsProfile, TargetClass,
148/// };
149/// use stygian_charon::vendor_classifier::VendorId;
150/// use std::collections::BTreeMap;
151///
152/// let memory = ChallengeMemory::with_defaults();
153/// let key = EngineKey {
154///     engine: VendorId::Cloudflare,
155///     version: None,
156///     target_class: TargetClass::ContentSite,
157///     tls_profile: None,
158/// };
159/// memory.record(&key, None, ChallengeOutcome::Captcha);
160/// let report = InvestigationReport {
161///     page_title: Some("example.com".to_string()),
162///     total_requests: 100,
163///     blocked_requests: 0,
164///     status_histogram: BTreeMap::new(),
165///     resource_type_histogram: BTreeMap::new(),
166///     provider_histogram: BTreeMap::new(),
167///     marker_histogram: BTreeMap::new(),
168///     top_markers: Vec::new(),
169///     hosts: Vec::new(),
170///     suspicious_requests: Vec::new(),
171///     aggregate: Detection {
172///         provider: AntiBotProvider::Unknown,
173///         confidence: 0.0,
174///         markers: Vec::new(),
175///     },
176///     target_class: Some(TargetClass::ContentSite),
177/// };
178/// let requirements = RequirementsProfile {
179///     provider: AntiBotProvider::Unknown,
180///     confidence: 0.0,
181///     requirements: Vec::new(),
182///     recommendation: IntegrationRecommendation {
183///         strategy: AdapterStrategy::DirectHttp,
184///         rationale: "test".to_string(),
185///         required_stygian_features: Vec::new(),
186///         config_hints: BTreeMap::new(),
187///     },
188/// };
189/// let policy = build_runtime_policy(&report, &requirements);
190/// let with_memory = build_runtime_policy_with_memory(&report, &requirements, &memory, &key);
191/// assert!(with_memory.risk_score >= policy.risk_score);
192/// ```
193#[must_use]
194pub fn build_runtime_policy_with_memory(
195    report: &crate::types::InvestigationReport,
196    requirements: &RequirementsProfile,
197    memory: &ChallengeMemory,
198    key: &EngineKey,
199) -> RuntimePolicy {
200    let policy = crate::policy::build_runtime_policy(report, requirements);
201    adjust_runtime_policy(&policy, memory, key)
202}
203
204/// Apply a bounded challenge-memory adjustment to an existing
205/// [`RuntimePolicy`].
206///
207/// The adjustment is looked up under the supplied [`EngineKey`]
208/// and added to `policy.risk_score`; the result is re-clamped to
209/// `[0.0, 1.0]`. The adjustment itself is **per-key clamped** to
210/// [`ChallengeFeedbackPolicy::max_delta`][ChallengeFeedbackPolicy::max_delta]
211/// (default `MAX_RISK_DELTA = 0.20`) before being added, so a single
212/// entry can never shift the risk score by more than the documented
213/// ceiling.
214///
215/// # Example
216///
217/// ```
218/// use stygian_charon::challenge_feedback::{
219///     adjust_runtime_policy, ChallengeMemory, ChallengeOutcome, EngineKey, MAX_RISK_DELTA,
220/// };
221/// use stygian_charon::types::{
222///     ExecutionMode, RuntimePolicy, SessionMode, TargetClass, TelemetryLevel,
223/// };
224/// use stygian_charon::vendor_classifier::VendorId;
225/// use std::collections::BTreeMap;
226///
227/// let memory = ChallengeMemory::with_defaults();
228/// let key = EngineKey {
229///     engine: VendorId::Cloudflare,
230///     version: None,
231///     target_class: TargetClass::ContentSite,
232///     tls_profile: None,
233/// };
234/// memory.record(&key, None, ChallengeOutcome::Captcha);
235///
236/// let base = RuntimePolicy {
237///     execution_mode: ExecutionMode::Http,
238///     session_mode: SessionMode::Stateless,
239///     telemetry_level: TelemetryLevel::Standard,
240///     rate_limit_rps: 3.0,
241///     max_retries: 2,
242///     backoff_base_ms: 250,
243///     enable_warmup: false,
244///     enforce_webrtc_proxy_only: false,
245///     sticky_session_ttl_secs: None,
246///     required_stygian_features: Vec::new(),
247///     config_hints: BTreeMap::new(),
248///     risk_score: 0.30,
249/// };
250/// let adjusted = adjust_runtime_policy(&base, &memory, &key);
251/// assert!(adjusted.risk_score >= base.risk_score);
252/// assert!(adjusted.risk_score <= base.risk_score + MAX_RISK_DELTA);
253/// ```
254#[must_use]
255pub fn adjust_runtime_policy(
256    policy: &RuntimePolicy,
257    memory: &ChallengeMemory,
258    key: &EngineKey,
259) -> RuntimePolicy {
260    let adjustment = memory_adjustment_for(memory, key);
261    let mut adjusted = policy.clone();
262    adjusted.risk_score = (policy.risk_score + adjustment).clamp(0.0, 1.0);
263    adjusted
264}
265
266fn clamp_to_policy(policy: &ChallengeFeedbackPolicy, raw_delta: f64) -> f64 {
267    let bound = policy.max_delta().abs();
268    if bound <= 0.0 {
269        0.0
270    } else if raw_delta > bound {
271        bound
272    } else if raw_delta < -bound {
273        -bound
274    } else {
275        raw_delta
276    }
277}
278
279#[cfg(test)]
280#[allow(
281    clippy::unwrap_used,
282    clippy::expect_used,
283    clippy::panic,
284    clippy::indexing_slicing
285)]
286mod tests {
287    use super::*;
288    use crate::challenge_feedback::ChallengeOutcome;
289    use crate::challenge_feedback::EngineKey;
290    use crate::types::{
291        AdapterStrategy, AntiBotProvider, Detection, ExecutionMode, IntegrationRecommendation,
292        InvestigationReport, RuntimePolicy, SessionMode, TargetClass, TelemetryLevel,
293    };
294    use crate::vendor_classifier::VendorId;
295    use std::collections::BTreeMap;
296    use std::num::NonZeroUsize;
297    use std::time::Duration;
298
299    fn approx_eq(a: f64, b: f64) -> bool {
300        (a - b).abs() < 1e-9
301    }
302
303    fn cf_content() -> EngineKey {
304        EngineKey {
305            engine: VendorId::Cloudflare,
306            version: None,
307            target_class: TargetClass::ContentSite,
308            tls_profile: None,
309        }
310    }
311
312    fn cf_api() -> EngineKey {
313        EngineKey {
314            engine: VendorId::Cloudflare,
315            version: None,
316            target_class: TargetClass::Api,
317            tls_profile: None,
318        }
319    }
320
321    fn cf_high_security() -> EngineKey {
322        EngineKey {
323            engine: VendorId::Cloudflare,
324            version: None,
325            target_class: TargetClass::HighSecurity,
326            tls_profile: None,
327        }
328    }
329
330    fn cf_unknown() -> EngineKey {
331        EngineKey {
332            engine: VendorId::Cloudflare,
333            version: None,
334            target_class: TargetClass::Unknown,
335            tls_profile: None,
336        }
337    }
338
339    fn base_policy() -> RuntimePolicy {
340        RuntimePolicy {
341            execution_mode: ExecutionMode::Http,
342            session_mode: SessionMode::Stateless,
343            telemetry_level: TelemetryLevel::Standard,
344            rate_limit_rps: 3.0,
345            max_retries: 2,
346            backoff_base_ms: 250,
347            enable_warmup: false,
348            enforce_webrtc_proxy_only: false,
349            sticky_session_ttl_secs: None,
350            required_stygian_features: Vec::new(),
351            config_hints: BTreeMap::new(),
352            risk_score: 0.30,
353        }
354    }
355
356    fn empty_report(target_class: TargetClass) -> InvestigationReport {
357        InvestigationReport {
358            page_title: Some("example.com".to_string()),
359            total_requests: 10,
360            blocked_requests: 0,
361            status_histogram: BTreeMap::new(),
362            resource_type_histogram: BTreeMap::new(),
363            provider_histogram: BTreeMap::new(),
364            marker_histogram: BTreeMap::new(),
365            top_markers: Vec::new(),
366            hosts: Vec::new(),
367            suspicious_requests: Vec::new(),
368            aggregate: Detection {
369                provider: AntiBotProvider::Unknown,
370                confidence: 0.0,
371                markers: Vec::new(),
372            },
373            target_class: Some(target_class),
374        }
375    }
376
377    fn empty_requirements() -> RequirementsProfile {
378        RequirementsProfile {
379            provider: AntiBotProvider::Unknown,
380            confidence: 0.0,
381            requirements: Vec::new(),
382            recommendation: IntegrationRecommendation {
383                strategy: AdapterStrategy::DirectHttp,
384                rationale: "test".to_string(),
385                required_stygian_features: Vec::new(),
386                config_hints: BTreeMap::new(),
387            },
388        }
389    }
390
391    #[test]
392    fn policy_with_no_memory_returns_base() {
393        let memory = ChallengeMemory::with_defaults();
394        let policy = base_policy();
395        let adjusted = adjust_runtime_policy(&policy, &memory, &cf_content());
396        assert!(approx_eq(adjusted.risk_score, policy.risk_score));
397    }
398
399    #[test]
400    fn positive_outcome_lifts_risk_score_within_clamp() {
401        let memory = ChallengeMemory::new(NonZeroUsize::new(4).unwrap(), Duration::from_mins(1));
402        memory.record(&cf_content(), None, ChallengeOutcome::HardChallenge);
403
404        let policy = base_policy();
405        let adjusted = adjust_runtime_policy(&policy, &memory, &cf_content());
406
407        let expected_delta = ChallengeOutcome::HardChallenge.risk_delta();
408        assert!(adjusted.risk_score >= policy.risk_score);
409        assert!(approx_eq(
410            adjusted.risk_score,
411            (policy.risk_score + expected_delta).clamp(0.0, 1.0)
412        ));
413        assert!(adjusted.risk_score <= policy.risk_score + MAX_RISK_DELTA);
414    }
415
416    #[test]
417    fn negative_outcome_lowers_risk_score_within_clamp() {
418        let memory = ChallengeMemory::new(NonZeroUsize::new(4).unwrap(), Duration::from_mins(1));
419        memory.record(&cf_content(), None, ChallengeOutcome::Pass);
420
421        let policy = base_policy();
422        let adjusted = adjust_runtime_policy(&policy, &memory, &cf_content());
423
424        assert!(adjusted.risk_score <= policy.risk_score);
425        assert!(adjusted.risk_score >= (policy.risk_score - MAX_RISK_DELTA).max(0.0));
426    }
427
428    #[test]
429    fn risk_score_clamps_to_unit_interval_under_extreme_inputs() {
430        let memory = ChallengeMemory::with_defaults();
431        memory.record(&cf_content(), None, ChallengeOutcome::Captcha);
432
433        let high = RuntimePolicy {
434            risk_score: 0.95,
435            ..base_policy()
436        };
437        let adjusted = adjust_runtime_policy(&high, &memory, &cf_content());
438        assert!(adjusted.risk_score <= 1.0);
439        // Single Captcha adds 0.20, so 0.95 + 0.20 = 1.15 clamps to 1.0
440        assert!(approx_eq(adjusted.risk_score, 1.0));
441
442        let low = RuntimePolicy {
443            risk_score: 0.05,
444            ..base_policy()
445        };
446        // No memory entry — the low baseline is unchanged.
447        let no_memory = ChallengeMemory::with_defaults();
448        let low_adjusted = adjust_runtime_policy(&low, &no_memory, &cf_unknown());
449        assert!(approx_eq(low_adjusted.risk_score, low.risk_score));
450    }
451
452    #[test]
453    fn risk_score_adjustment_is_bounded_by_max_risk_delta() {
454        // Even an outcome that is the largest possible (Blocked/Captcha = 0.20)
455        // must never push the adjustment beyond MAX_RISK_DELTA.
456        let memory = ChallengeMemory::with_defaults();
457        memory.record(&cf_content(), None, ChallengeOutcome::Blocked);
458
459        let policy = RuntimePolicy {
460            risk_score: 0.0,
461            ..base_policy()
462        };
463        let adjusted = adjust_runtime_policy(&policy, &memory, &cf_content());
464
465        let lift = adjusted.risk_score - policy.risk_score;
466        assert!(lift >= 0.0);
467        assert!(lift <= MAX_RISK_DELTA + 1e-9);
468        assert!(approx_eq(lift, ChallengeOutcome::Blocked.risk_delta()));
469    }
470
471    #[test]
472    fn feedback_policy_max_delta_cannot_exceed_documented_max() {
473        let widened = ChallengeFeedbackPolicy::default().with_max_delta(0.95);
474        assert!(widened.max_delta() <= MAX_RISK_DELTA);
475
476        let narrowed = ChallengeFeedbackPolicy::default().with_max_delta(0.05);
477        assert!(approx_eq(narrowed.max_delta(), 0.05));
478    }
479
480    #[test]
481    fn feedback_policy_zero_ttl_falls_back_to_one_minute() {
482        let policy = ChallengeFeedbackPolicy::default().with_ttl(Duration::from_millis(0));
483        assert_eq!(policy.ttl(), Duration::from_mins(1));
484    }
485
486    #[test]
487    fn build_runtime_policy_with_memory_includes_adjustment() {
488        let memory = ChallengeMemory::with_defaults();
489        memory.record(&cf_content(), None, ChallengeOutcome::Captcha);
490
491        let report = empty_report(TargetClass::ContentSite);
492        let requirements = empty_requirements();
493        let base = crate::policy::build_runtime_policy(&report, &requirements);
494        let adjusted =
495            build_runtime_policy_with_memory(&report, &requirements, &memory, &cf_content());
496
497        assert!(adjusted.risk_score >= base.risk_score);
498    }
499
500    #[test]
501    fn memory_adjustment_for_returns_zero_when_absent() {
502        let memory = ChallengeMemory::with_defaults();
503        let missing = EngineKey {
504            engine: VendorId::DataDome,
505            version: None,
506            target_class: TargetClass::ContentSite,
507            tls_profile: None,
508        };
509        assert!(approx_eq(memory_adjustment_for(&memory, &missing), 0.0));
510    }
511
512    /// T110 guard test (policy layer): `adjust_runtime_policy`
513    /// looks up by engine + `target_class` + `tls_profile`, so a
514    /// patch recorded under one `target_class` cannot leak into
515    /// an adjustment for a different `target_class`.
516    #[test]
517    fn adjust_runtime_policy_is_target_class_scoped() {
518        let memory = ChallengeMemory::with_defaults();
519        memory.record(&cf_content(), None, ChallengeOutcome::Captcha);
520
521        let policy = base_policy();
522        let content_adjusted = adjust_runtime_policy(&policy, &memory, &cf_content());
523        let api_adjusted = adjust_runtime_policy(&policy, &memory, &cf_api());
524        let high_adjusted = adjust_runtime_policy(&policy, &memory, &cf_high_security());
525
526        assert!(content_adjusted.risk_score > policy.risk_score);
527        assert!(approx_eq(api_adjusted.risk_score, policy.risk_score));
528        assert!(approx_eq(high_adjusted.risk_score, policy.risk_score));
529    }
530}