stygian_charon/challenge_feedback/
policy.rs1use crate::challenge_feedback::{ChallengeMemory, EngineKey};
2use crate::types::{RequirementsProfile, RuntimePolicy};
3
4pub const MAX_RISK_DELTA: f64 = 0.20;
15
16#[derive(Debug, Clone, Copy, PartialEq)]
33pub struct ChallengeFeedbackPolicy {
34 max_delta: f64,
35 ttl: std::time::Duration,
36}
37
38impl ChallengeFeedbackPolicy {
39 #[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 #[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 #[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 #[must_use]
74 pub const fn max_delta(&self) -> f64 {
75 self.max_delta
76 }
77
78 #[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#[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#[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#[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 assert!(approx_eq(adjusted.risk_score, 1.0));
441
442 let low = RuntimePolicy {
443 risk_score: 0.05,
444 ..base_policy()
445 };
446 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 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 #[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}