1use std::collections::BTreeMap;
40use std::time::{SystemTime, UNIX_EPOCH};
41
42use serde::{Deserialize, Serialize};
43
44use crate::types::TargetClass;
45use crate::vendor_classifier::VendorId;
46
47pub const DEFAULT_SAMPLE_WINDOW_SECS: u64 = 3_600;
55
56const ZERO_FALLBACK_UNIX_SECS: u64 = 0;
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
79#[serde(rename_all = "snake_case")]
80pub enum PowFailureMode {
81 TokenInvalid,
83 NonceReplayed,
85 Timeout,
87 Blocked,
89 Captcha,
91 Other,
93}
94
95impl PowFailureMode {
96 #[must_use]
98 pub const fn label(self) -> &'static str {
99 match self {
100 Self::TokenInvalid => "token_invalid",
101 Self::NonceReplayed => "nonce_replayed",
102 Self::Timeout => "timeout",
103 Self::Blocked => "blocked",
104 Self::Captcha => "captcha",
105 Self::Other => "other",
106 }
107 }
108
109 #[must_use]
114 pub const fn severity_weight(self) -> f64 {
115 match self {
116 Self::TokenInvalid => 0.50,
117 Self::NonceReplayed => 0.30,
118 Self::Timeout => 0.70,
119 Self::Blocked => 0.60,
120 Self::Captcha => 0.80,
121 Self::Other => 0.40,
122 }
123 }
124}
125
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
148pub struct PowCapabilitySample {
149 pub solved: bool,
152 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub latency_ms: Option<u64>,
156 pub retries: u32,
159 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub failure_mode: Option<PowFailureMode>,
163}
164
165impl PowCapabilitySample {
166 #[must_use]
168 pub const fn solved(latency_ms: u64, retries: u32) -> Self {
169 Self {
170 solved: true,
171 latency_ms: Some(latency_ms),
172 retries,
173 failure_mode: None,
174 }
175 }
176
177 #[must_use]
179 pub const fn failed(latency_ms: u64, retries: u32, mode: PowFailureMode) -> Self {
180 Self {
181 solved: false,
182 latency_ms: Some(latency_ms),
183 retries,
184 failure_mode: Some(mode),
185 }
186 }
187}
188
189#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
220pub struct PowCapabilityProfile {
221 pub domain: String,
223 pub target_class: TargetClass,
225 pub vendor_family: VendorId,
227 pub solved_count: u32,
229 pub failed_count: u32,
231 pub retry_count: u32,
234 #[serde(default, skip_serializing_if = "Option::is_none")]
237 pub solve_latency_ms_p50: Option<u64>,
238 #[serde(default, skip_serializing_if = "Option::is_none")]
242 pub solve_latency_ms_p95: Option<u64>,
243 #[serde(default)]
245 pub failure_modes: BTreeMap<PowFailureMode, u32>,
246 pub observation_window_secs: u64,
248 pub recorded_at_unix_secs: u64,
250}
251
252impl PowCapabilityProfile {
253 #[must_use]
261 pub fn new(domain: &str, target_class: TargetClass, vendor_family: VendorId) -> Self {
262 Self {
263 domain: domain.to_ascii_lowercase(),
264 target_class,
265 vendor_family,
266 solved_count: 0,
267 failed_count: 0,
268 retry_count: 0,
269 solve_latency_ms_p50: None,
270 solve_latency_ms_p95: None,
271 failure_modes: BTreeMap::new(),
272 observation_window_secs: DEFAULT_SAMPLE_WINDOW_SECS,
273 recorded_at_unix_secs: current_unix_secs(),
274 }
275 }
276
277 #[must_use]
280 pub const fn total_attempts(&self) -> u32 {
281 self.solved_count.saturating_add(self.failed_count)
282 }
283
284 #[must_use]
287 pub fn success_rate(&self) -> f64 {
288 let total = self.total_attempts();
289 if total == 0 {
290 0.0
291 } else {
292 f64::from(self.solved_count) / f64::from(total)
293 }
294 }
295
296 #[must_use]
299 pub fn average_retries(&self) -> f64 {
300 let total = self.total_attempts();
301 if total == 0 {
302 0.0
303 } else {
304 f64::from(self.retry_count) / f64::from(total)
305 }
306 }
307
308 #[must_use]
313 pub fn failure_severity(&self) -> f64 {
314 let total_failures: u32 = self.failure_modes.values().copied().sum();
315 if total_failures == 0 {
316 return 0.0;
317 }
318 let weighted: f64 = self
319 .failure_modes
320 .iter()
321 .map(|(mode, count)| mode.severity_weight() * f64::from(*count))
322 .sum();
323 weighted / f64::from(total_failures)
324 }
325
326 pub fn merge(&mut self, sample: &PowCapabilitySample) {
337 if sample.solved {
338 self.solved_count = self.solved_count.saturating_add(1);
339 } else {
340 self.failed_count = self.failed_count.saturating_add(1);
341 if let Some(mode) = sample.failure_mode {
342 let entry = self.failure_modes.entry(mode).or_insert(0);
343 *entry = entry.saturating_add(1);
344 }
345 }
346 self.retry_count = self.retry_count.saturating_add(sample.retries);
347
348 if let Some(latency) = sample.latency_ms {
349 let (new_p50, new_p95) = update_latency_percentiles(
350 self.solved_count,
351 self.solve_latency_ms_p50,
352 self.solve_latency_ms_p95,
353 latency,
354 );
355 self.solve_latency_ms_p50 = new_p50;
356 self.solve_latency_ms_p95 = new_p95;
357 }
358
359 self.recorded_at_unix_secs = current_unix_secs();
360 }
361
362 pub fn merge_profile(&mut self, other: &Self) {
373 self.solved_count = self.solved_count.saturating_add(other.solved_count);
374 self.failed_count = self.failed_count.saturating_add(other.failed_count);
375 self.retry_count = self.retry_count.saturating_add(other.retry_count);
376 for (mode, count) in &other.failure_modes {
377 let entry = self.failure_modes.entry(*mode).or_insert(0);
378 *entry = entry.saturating_add(*count);
379 }
380 self.observation_window_secs = self
381 .observation_window_secs
382 .max(other.observation_window_secs);
383 self.recorded_at_unix_secs = current_unix_secs();
384 }
385}
386
387fn update_latency_percentiles(
388 new_solved_count: u32,
389 prev_p50: Option<u64>,
390 prev_p95: Option<u64>,
391 new_latency_ms: u64,
392) -> (Option<u64>, Option<u64>) {
393 let p50 = prev_p50.map_or(new_latency_ms, |prev| {
398 (prev / 2).saturating_add(new_latency_ms / 2)
399 });
400 let p95 = match prev_p95 {
401 Some(prev) => {
402 (prev.saturating_mul(95) / 100).saturating_add(new_latency_ms.saturating_mul(5) / 100)
407 }
408 None if new_solved_count >= 5 => new_latency_ms,
409 None => new_latency_ms,
410 };
411 (Some(p50), Some(p95))
412}
413
414fn current_unix_secs() -> u64 {
415 SystemTime::now()
416 .duration_since(UNIX_EPOCH)
417 .map_or(ZERO_FALLBACK_UNIX_SECS, |duration| duration.as_secs())
418}
419
420#[cfg(test)]
421#[allow(
422 clippy::unwrap_used,
423 clippy::expect_used,
424 clippy::panic,
425 clippy::indexing_slicing
426)]
427mod tests {
428 use super::*;
429
430 fn empty_profile() -> PowCapabilityProfile {
431 PowCapabilityProfile::new(
432 "example.com",
433 TargetClass::ContentSite,
434 VendorId::Cloudflare,
435 )
436 }
437
438 #[test]
439 fn new_profile_uses_defaults() {
440 let profile = empty_profile();
441 assert_eq!(profile.domain, "example.com");
442 assert_eq!(profile.target_class, TargetClass::ContentSite);
443 assert_eq!(profile.vendor_family, VendorId::Cloudflare);
444 assert_eq!(profile.solved_count, 0);
445 assert_eq!(profile.failed_count, 0);
446 assert_eq!(profile.retry_count, 0);
447 assert!(profile.solve_latency_ms_p50.is_none());
448 assert!(profile.solve_latency_ms_p95.is_none());
449 assert!(profile.failure_modes.is_empty());
450 assert_eq!(profile.observation_window_secs, DEFAULT_SAMPLE_WINDOW_SECS);
451 }
452
453 #[test]
454 fn new_profile_normalises_domain_to_lower_case() {
455 let profile =
456 PowCapabilityProfile::new("Example.COM", TargetClass::Api, VendorId::Cloudflare);
457 assert_eq!(profile.domain, "example.com");
458 }
459
460 #[test]
461 fn merge_increments_solved_count() {
462 let mut profile = empty_profile();
463 profile.merge(&PowCapabilitySample::solved(1_000, 0));
464 profile.merge(&PowCapabilitySample::solved(1_500, 1));
465 assert_eq!(profile.solved_count, 2);
466 assert_eq!(profile.retry_count, 1);
467 assert!(profile.solve_latency_ms_p50.is_some());
468 assert!(profile.solve_latency_ms_p95.is_some());
469 assert!(profile.failure_modes.is_empty());
470 }
471
472 #[test]
473 fn merge_increments_failed_count_and_failure_histogram() {
474 let mut profile = empty_profile();
475 profile.merge(&PowCapabilitySample::failed(
476 2_000,
477 1,
478 PowFailureMode::Timeout,
479 ));
480 profile.merge(&PowCapabilitySample::failed(
481 2_500,
482 2,
483 PowFailureMode::Timeout,
484 ));
485 profile.merge(&PowCapabilitySample::failed(
486 3_000,
487 1,
488 PowFailureMode::Blocked,
489 ));
490 assert_eq!(profile.failed_count, 3);
491 assert_eq!(profile.retry_count, 4);
492 assert_eq!(
493 profile.failure_modes.get(&PowFailureMode::Timeout),
494 Some(&2)
495 );
496 assert_eq!(
497 profile.failure_modes.get(&PowFailureMode::Blocked),
498 Some(&1)
499 );
500 }
501
502 #[test]
503 fn success_rate_and_average_retries_handle_empty_profile() {
504 let profile = empty_profile();
505 assert!((profile.success_rate() - 0.0).abs() < 1e-9);
506 assert!((profile.average_retries() - 0.0).abs() < 1e-9);
507 assert_eq!(profile.total_attempts(), 0);
508 }
509
510 #[test]
511 fn failure_severity_is_zero_for_clean_profiles() {
512 let mut profile = empty_profile();
513 profile.merge(&PowCapabilitySample::solved(1_000, 0));
514 assert!((profile.failure_severity() - 0.0).abs() < 1e-9);
515 }
516
517 #[test]
518 fn failure_severity_averages_over_histogram() {
519 let mut profile = empty_profile();
520 profile.merge(&PowCapabilitySample::failed(
521 1_000,
522 0,
523 PowFailureMode::Captcha,
524 ));
525 profile.merge(&PowCapabilitySample::failed(
526 1_000,
527 0,
528 PowFailureMode::Timeout,
529 ));
530 let expected = f64::midpoint(
531 PowFailureMode::Captcha.severity_weight(),
532 PowFailureMode::Timeout.severity_weight(),
533 );
534 assert!((profile.failure_severity() - expected).abs() < 1e-9);
535 }
536
537 #[test]
538 fn merge_profile_preserves_key_and_combines_counts() {
539 let mut a = empty_profile();
540 a.merge(&PowCapabilitySample::solved(1_000, 0));
541 a.merge(&PowCapabilitySample::failed(
542 2_000,
543 1,
544 PowFailureMode::Timeout,
545 ));
546
547 let mut b = empty_profile();
548 b.merge(&PowCapabilitySample::solved(1_500, 1));
549 b.merge(&PowCapabilitySample::failed(
550 2_500,
551 0,
552 PowFailureMode::Blocked,
553 ));
554
555 a.merge_profile(&b);
556 assert_eq!(a.domain, "example.com");
557 assert_eq!(a.target_class, TargetClass::ContentSite);
558 assert_eq!(a.vendor_family, VendorId::Cloudflare);
559 assert_eq!(a.solved_count, 2);
560 assert_eq!(a.failed_count, 2);
561 assert_eq!(a.retry_count, 2);
562 assert_eq!(a.failure_modes.get(&PowFailureMode::Timeout), Some(&1));
563 assert_eq!(a.failure_modes.get(&PowFailureMode::Blocked), Some(&1));
564 }
565
566 #[test]
567 fn merge_profile_preserves_larger_window() {
568 let mut a = empty_profile();
569 a.observation_window_secs = 1_800;
570 let mut b = empty_profile();
571 b.observation_window_secs = 7_200;
572 a.merge_profile(&b);
573 assert_eq!(a.observation_window_secs, 7_200);
574 }
575
576 #[test]
577 fn failure_mode_labels_are_stable() {
578 assert_eq!(PowFailureMode::TokenInvalid.label(), "token_invalid");
579 assert_eq!(PowFailureMode::NonceReplayed.label(), "nonce_replayed");
580 assert_eq!(PowFailureMode::Timeout.label(), "timeout");
581 assert_eq!(PowFailureMode::Blocked.label(), "blocked");
582 assert_eq!(PowFailureMode::Captcha.label(), "captcha");
583 assert_eq!(PowFailureMode::Other.label(), "other");
584 }
585
586 #[test]
587 fn failure_mode_severity_weights_are_bounded() {
588 for mode in [
589 PowFailureMode::TokenInvalid,
590 PowFailureMode::NonceReplayed,
591 PowFailureMode::Timeout,
592 PowFailureMode::Blocked,
593 PowFailureMode::Captcha,
594 PowFailureMode::Other,
595 ] {
596 let w = mode.severity_weight();
597 assert!((0.0..=1.0).contains(&w), "weight out of range: {w}");
598 }
599 }
600
601 #[test]
602 fn profile_round_trips_through_json() {
603 let mut profile = empty_profile();
604 profile.merge(&PowCapabilitySample::solved(1_000, 0));
605 profile.merge(&PowCapabilitySample::failed(
606 2_000,
607 1,
608 PowFailureMode::Timeout,
609 ));
610 let json = serde_json::to_string(&profile).expect("serialize");
611 let back: PowCapabilityProfile = serde_json::from_str(&json).expect("deserialize");
612 assert_eq!(back, profile);
613 }
614
615 #[test]
616 fn sample_round_trips_through_json() {
617 let solved = PowCapabilitySample::solved(1_500, 2);
618 let json = serde_json::to_string(&solved).expect("serialize");
619 let back: PowCapabilitySample = serde_json::from_str(&json).expect("deserialize");
620 assert_eq!(back, solved);
621
622 let failed = PowCapabilitySample::failed(2_000, 1, PowFailureMode::Captcha);
623 let json = serde_json::to_string(&failed).expect("serialize");
624 let back: PowCapabilitySample = serde_json::from_str(&json).expect("deserialize");
625 assert_eq!(back, failed);
626 }
627}