stygian_charon/challenge_feedback/
policy.rs1use crate::challenge_feedback::ChallengeMemory;
2use crate::types::{RequirementsProfile, RuntimePolicy, TargetClass};
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]
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#[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#[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 assert!(approx_eq(adjusted.risk_score, 1.0));
408
409 let low = RuntimePolicy {
410 risk_score: 0.05,
411 ..base_policy()
412 };
413 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 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}