Skip to main content

stygian_charon/challenge_feedback/
policy.rs

1use crate::challenge_feedback::ChallengeMemory;
2use crate::types::{RequirementsProfile, RuntimePolicy, TargetClass};
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 a `(domain, target_class)` key, 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,
106/// };
107/// use stygian_charon::types::TargetClass;
108///
109/// let memory = ChallengeMemory::with_defaults();
110/// memory.record("example.com", TargetClass::ContentSite, ChallengeOutcome::Captcha);
111/// let delta = memory_adjustment_for(&memory, "example.com", TargetClass::ContentSite);
112/// assert!(delta > 0.0);
113/// ```
114#[must_use]
115pub fn memory_adjustment_for(
116    memory: &ChallengeMemory,
117    domain: &str,
118    target_class: TargetClass,
119) -> f64 {
120    memory.lookup(domain, target_class).map_or(0.0, |entry| {
121        clamp_to_policy(&ChallengeFeedbackPolicy::default(), entry.risk_delta())
122    })
123}
124
125/// Build a [`RuntimePolicy`] from an investigation report and
126/// requirements profile, then apply a bounded challenge-memory
127/// adjustment to the risk score.
128///
129/// The adjustment path is identical to
130/// [`adjust_runtime_policy`] — this is a convenience wrapper for
131/// the common "rebuild policy from scratch" workflow.
132///
133/// # Example
134///
135/// ```
136/// use stygian_charon::challenge_feedback::{
137///     build_runtime_policy_with_memory, ChallengeMemory, ChallengeOutcome,
138/// };
139/// use stygian_charon::build_runtime_policy;
140/// use stygian_charon::types::{
141///     AdapterStrategy, AntiBotProvider, Detection, IntegrationRecommendation,
142///     InvestigationReport, RequirementsProfile, TargetClass,
143/// };
144/// use std::collections::BTreeMap;
145///
146/// let memory = ChallengeMemory::with_defaults();
147/// memory.record("example.com", TargetClass::ContentSite, ChallengeOutcome::Captcha);
148/// let report = InvestigationReport {
149///     page_title: Some("example.com".to_string()),
150///     total_requests: 100,
151///     blocked_requests: 0,
152///     status_histogram: BTreeMap::new(),
153///     resource_type_histogram: BTreeMap::new(),
154///     provider_histogram: BTreeMap::new(),
155///     marker_histogram: BTreeMap::new(),
156///     top_markers: Vec::new(),
157///     hosts: Vec::new(),
158///     suspicious_requests: Vec::new(),
159///     aggregate: Detection {
160///         provider: AntiBotProvider::Unknown,
161///         confidence: 0.0,
162///         markers: Vec::new(),
163///     },
164///     target_class: Some(TargetClass::ContentSite),
165/// };
166/// let requirements = RequirementsProfile {
167///     provider: AntiBotProvider::Unknown,
168///     confidence: 0.0,
169///     requirements: Vec::new(),
170///     recommendation: IntegrationRecommendation {
171///         strategy: AdapterStrategy::DirectHttp,
172///         rationale: "test".to_string(),
173///         required_stygian_features: Vec::new(),
174///         config_hints: BTreeMap::new(),
175///     },
176/// };
177/// let policy = build_runtime_policy(&report, &requirements);
178/// let with_memory = build_runtime_policy_with_memory(
179///     &report,
180///     &requirements,
181///     &memory,
182///     "example.com",
183///     TargetClass::ContentSite,
184/// );
185/// assert!(with_memory.risk_score >= policy.risk_score);
186/// ```
187#[must_use]
188pub fn build_runtime_policy_with_memory(
189    report: &crate::types::InvestigationReport,
190    requirements: &RequirementsProfile,
191    memory: &ChallengeMemory,
192    domain: &str,
193    target_class: TargetClass,
194) -> RuntimePolicy {
195    let policy = crate::policy::build_runtime_policy(report, requirements);
196    adjust_runtime_policy(&policy, memory, domain, target_class)
197}
198
199/// Apply a bounded challenge-memory adjustment to an existing
200/// [`RuntimePolicy`].
201///
202/// The adjustment is added to `policy.risk_score` and the result is
203/// re-clamped to `[0.0, 1.0]`. The adjustment itself is
204/// **per-key clamped** to
205/// [`ChallengeFeedbackPolicy::max_delta`][ChallengeFeedbackPolicy::max_delta]
206/// (default `MAX_RISK_DELTA = 0.20`) before being added, so a single
207/// entry can never shift the risk score by more than the documented
208/// ceiling.
209///
210/// # Example
211///
212/// ```
213/// use stygian_charon::challenge_feedback::{
214///     adjust_runtime_policy, ChallengeMemory, ChallengeOutcome, MAX_RISK_DELTA,
215/// };
216/// use stygian_charon::types::{
217///     ExecutionMode, RuntimePolicy, SessionMode, TargetClass, TelemetryLevel,
218/// };
219/// use std::collections::BTreeMap;
220///
221/// let memory = ChallengeMemory::with_defaults();
222/// memory.record("example.com", TargetClass::ContentSite, ChallengeOutcome::Captcha);
223///
224/// let base = RuntimePolicy {
225///     execution_mode: ExecutionMode::Http,
226///     session_mode: SessionMode::Stateless,
227///     telemetry_level: TelemetryLevel::Standard,
228///     rate_limit_rps: 3.0,
229///     max_retries: 2,
230///     backoff_base_ms: 250,
231///     enable_warmup: false,
232///     enforce_webrtc_proxy_only: false,
233///     sticky_session_ttl_secs: None,
234///     required_stygian_features: Vec::new(),
235///     config_hints: BTreeMap::new(),
236///     risk_score: 0.30,
237/// };
238/// let adjusted = adjust_runtime_policy(&base, &memory, "example.com", TargetClass::ContentSite);
239/// assert!(adjusted.risk_score >= base.risk_score);
240/// assert!(adjusted.risk_score <= base.risk_score + MAX_RISK_DELTA);
241/// ```
242#[must_use]
243pub fn adjust_runtime_policy(
244    policy: &RuntimePolicy,
245    memory: &ChallengeMemory,
246    domain: &str,
247    target_class: TargetClass,
248) -> RuntimePolicy {
249    let adjustment = memory_adjustment_for(memory, domain, target_class);
250    let mut adjusted = policy.clone();
251    adjusted.risk_score = (policy.risk_score + adjustment).clamp(0.0, 1.0);
252    adjusted
253}
254
255fn clamp_to_policy(policy: &ChallengeFeedbackPolicy, raw_delta: f64) -> f64 {
256    let bound = policy.max_delta().abs();
257    if bound <= 0.0 {
258        0.0
259    } else if raw_delta > bound {
260        bound
261    } else if raw_delta < -bound {
262        -bound
263    } else {
264        raw_delta
265    }
266}
267
268#[cfg(test)]
269#[allow(
270    clippy::unwrap_used,
271    clippy::expect_used,
272    clippy::panic,
273    clippy::indexing_slicing
274)]
275mod tests {
276    use super::*;
277    use crate::challenge_feedback::ChallengeOutcome;
278    use crate::types::{
279        AdapterStrategy, AntiBotProvider, Detection, ExecutionMode, IntegrationRecommendation,
280        InvestigationReport, RuntimePolicy, SessionMode, TelemetryLevel,
281    };
282    use std::collections::BTreeMap;
283    use std::num::NonZeroUsize;
284    use std::time::Duration;
285
286    fn approx_eq(a: f64, b: f64) -> bool {
287        (a - b).abs() < 1e-9
288    }
289
290    fn base_policy() -> RuntimePolicy {
291        RuntimePolicy {
292            execution_mode: ExecutionMode::Http,
293            session_mode: SessionMode::Stateless,
294            telemetry_level: TelemetryLevel::Standard,
295            rate_limit_rps: 3.0,
296            max_retries: 2,
297            backoff_base_ms: 250,
298            enable_warmup: false,
299            enforce_webrtc_proxy_only: false,
300            sticky_session_ttl_secs: None,
301            required_stygian_features: Vec::new(),
302            config_hints: BTreeMap::new(),
303            risk_score: 0.30,
304        }
305    }
306
307    fn empty_report(target_class: TargetClass) -> InvestigationReport {
308        InvestigationReport {
309            page_title: Some("example.com".to_string()),
310            total_requests: 10,
311            blocked_requests: 0,
312            status_histogram: BTreeMap::new(),
313            resource_type_histogram: BTreeMap::new(),
314            provider_histogram: BTreeMap::new(),
315            marker_histogram: BTreeMap::new(),
316            top_markers: Vec::new(),
317            hosts: Vec::new(),
318            suspicious_requests: Vec::new(),
319            aggregate: Detection {
320                provider: AntiBotProvider::Unknown,
321                confidence: 0.0,
322                markers: Vec::new(),
323            },
324            target_class: Some(target_class),
325        }
326    }
327
328    fn empty_requirements() -> RequirementsProfile {
329        RequirementsProfile {
330            provider: AntiBotProvider::Unknown,
331            confidence: 0.0,
332            requirements: Vec::new(),
333            recommendation: IntegrationRecommendation {
334                strategy: AdapterStrategy::DirectHttp,
335                rationale: "test".to_string(),
336                required_stygian_features: Vec::new(),
337                config_hints: BTreeMap::new(),
338            },
339        }
340    }
341
342    #[test]
343    fn policy_with_no_memory_returns_base() {
344        let memory = ChallengeMemory::with_defaults();
345        let policy = base_policy();
346        let adjusted =
347            adjust_runtime_policy(&policy, &memory, "example.com", TargetClass::ContentSite);
348        assert!(approx_eq(adjusted.risk_score, policy.risk_score));
349    }
350
351    #[test]
352    fn positive_outcome_lifts_risk_score_within_clamp() {
353        let memory = ChallengeMemory::new(NonZeroUsize::new(4).unwrap(), Duration::from_mins(1));
354        memory.record(
355            "example.com",
356            TargetClass::ContentSite,
357            ChallengeOutcome::HardChallenge,
358        );
359
360        let policy = base_policy();
361        let adjusted =
362            adjust_runtime_policy(&policy, &memory, "example.com", TargetClass::ContentSite);
363
364        let expected_delta = ChallengeOutcome::HardChallenge.risk_delta();
365        assert!(adjusted.risk_score >= policy.risk_score);
366        assert!(approx_eq(
367            adjusted.risk_score,
368            (policy.risk_score + expected_delta).clamp(0.0, 1.0)
369        ));
370        assert!(adjusted.risk_score <= policy.risk_score + MAX_RISK_DELTA);
371    }
372
373    #[test]
374    fn negative_outcome_lowers_risk_score_within_clamp() {
375        let memory = ChallengeMemory::new(NonZeroUsize::new(4).unwrap(), Duration::from_mins(1));
376        memory.record(
377            "example.com",
378            TargetClass::ContentSite,
379            ChallengeOutcome::Pass,
380        );
381
382        let policy = base_policy();
383        let adjusted =
384            adjust_runtime_policy(&policy, &memory, "example.com", TargetClass::ContentSite);
385
386        assert!(adjusted.risk_score <= policy.risk_score);
387        assert!(adjusted.risk_score >= (policy.risk_score - MAX_RISK_DELTA).max(0.0));
388    }
389
390    #[test]
391    fn risk_score_clamps_to_unit_interval_under_extreme_inputs() {
392        let memory = ChallengeMemory::with_defaults();
393        memory.record(
394            "example.com",
395            TargetClass::ContentSite,
396            ChallengeOutcome::Captcha,
397        );
398
399        let high = RuntimePolicy {
400            risk_score: 0.95,
401            ..base_policy()
402        };
403        let adjusted =
404            adjust_runtime_policy(&high, &memory, "example.com", TargetClass::ContentSite);
405        assert!(adjusted.risk_score <= 1.0);
406        // Single Captcha adds 0.20, so 0.95 + 0.20 = 1.15 clamps to 1.0
407        assert!(approx_eq(adjusted.risk_score, 1.0));
408
409        let low = RuntimePolicy {
410            risk_score: 0.05,
411            ..base_policy()
412        };
413        // No memory entry — the low baseline is unchanged.
414        let no_memory = ChallengeMemory::with_defaults();
415        let low_adjusted =
416            adjust_runtime_policy(&low, &no_memory, "nope.example", TargetClass::ContentSite);
417        assert!(approx_eq(low_adjusted.risk_score, low.risk_score));
418    }
419
420    #[test]
421    fn risk_score_adjustment_is_bounded_by_max_risk_delta() {
422        // Even an outcome that is the largest possible (Blocked/Captcha = 0.20)
423        // must never push the adjustment beyond MAX_RISK_DELTA.
424        let memory = ChallengeMemory::with_defaults();
425        memory.record(
426            "example.com",
427            TargetClass::ContentSite,
428            ChallengeOutcome::Blocked,
429        );
430
431        let policy = RuntimePolicy {
432            risk_score: 0.0,
433            ..base_policy()
434        };
435        let adjusted =
436            adjust_runtime_policy(&policy, &memory, "example.com", TargetClass::ContentSite);
437
438        let lift = adjusted.risk_score - policy.risk_score;
439        assert!(lift >= 0.0);
440        assert!(lift <= MAX_RISK_DELTA + 1e-9);
441        assert!(approx_eq(lift, ChallengeOutcome::Blocked.risk_delta()));
442    }
443
444    #[test]
445    fn feedback_policy_max_delta_cannot_exceed_documented_max() {
446        let widened = ChallengeFeedbackPolicy::default().with_max_delta(0.95);
447        assert!(widened.max_delta() <= MAX_RISK_DELTA);
448
449        let narrowed = ChallengeFeedbackPolicy::default().with_max_delta(0.05);
450        assert!(approx_eq(narrowed.max_delta(), 0.05));
451    }
452
453    #[test]
454    fn feedback_policy_zero_ttl_falls_back_to_one_minute() {
455        let policy = ChallengeFeedbackPolicy::default().with_ttl(Duration::from_millis(0));
456        assert_eq!(policy.ttl(), Duration::from_mins(1));
457    }
458
459    #[test]
460    fn build_runtime_policy_with_memory_includes_adjustment() {
461        let memory = ChallengeMemory::with_defaults();
462        memory.record(
463            "example.com",
464            TargetClass::ContentSite,
465            ChallengeOutcome::Captcha,
466        );
467
468        let report = empty_report(TargetClass::ContentSite);
469        let requirements = empty_requirements();
470        let base = crate::policy::build_runtime_policy(&report, &requirements);
471        let adjusted = build_runtime_policy_with_memory(
472            &report,
473            &requirements,
474            &memory,
475            "example.com",
476            TargetClass::ContentSite,
477        );
478
479        assert!(adjusted.risk_score >= base.risk_score);
480    }
481
482    #[test]
483    fn memory_adjustment_for_returns_zero_when_absent() {
484        let memory = ChallengeMemory::with_defaults();
485        assert!(approx_eq(
486            memory_adjustment_for(&memory, "nope.example", TargetClass::ContentSite),
487            0.0
488        ));
489    }
490}