stygian_charon/pow_profile/
scorer.rs1use serde::{Deserialize, Serialize};
49
50use crate::pow_profile::profile::PowCapabilityProfile;
51
52pub const MIN_OBSERVATIONS_FOR_SCORING: u32 = 3;
63
64pub const SPARSE_FALLBACK_SCORE: f64 = 0.5;
73
74pub const DEFAULT_LATENCY_BUDGET_MS: u64 = 5_000;
81
82pub const DEFAULT_RETRY_BUDGET: f64 = 3.0;
87
88#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
97pub struct ProfileWeights {
98 pub success: f64,
100 pub latency: f64,
102 pub retry: f64,
104 pub failure: f64,
106}
107
108impl Default for ProfileWeights {
109 fn default() -> Self {
110 Self {
111 success: 0.40,
112 latency: 0.20,
113 retry: 0.10,
114 failure: 0.30,
115 }
116 }
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
128#[serde(rename_all = "snake_case")]
129pub enum PowCapabilityBand {
130 Strong,
132 Degraded,
135 Weak,
138 Unknown,
141}
142
143impl PowCapabilityBand {
144 #[must_use]
146 pub const fn label(self) -> &'static str {
147 match self {
148 Self::Strong => "strong",
149 Self::Degraded => "degraded",
150 Self::Weak => "weak",
151 Self::Unknown => "unknown",
152 }
153 }
154}
155
156#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
165pub struct PowCapabilityScorer {
166 weights: ProfileWeights,
167 min_observations: u32,
168 latency_budget_ms: u64,
169 retry_budget: f64,
170}
171
172impl Default for PowCapabilityScorer {
173 fn default() -> Self {
174 Self {
175 weights: ProfileWeights::default(),
176 min_observations: MIN_OBSERVATIONS_FOR_SCORING,
177 latency_budget_ms: DEFAULT_LATENCY_BUDGET_MS,
178 retry_budget: DEFAULT_RETRY_BUDGET,
179 }
180 }
181}
182
183impl PowCapabilityScorer {
184 #[must_use]
186 pub fn new() -> Self {
187 Self::default()
188 }
189
190 #[must_use]
192 pub const fn with_weights(mut self, weights: ProfileWeights) -> Self {
193 self.weights = weights;
194 self
195 }
196
197 #[must_use]
203 pub const fn with_min_observations(mut self, min_observations: u32) -> Self {
204 self.min_observations = min_observations;
205 self
206 }
207
208 #[must_use]
213 pub const fn with_latency_budget_ms(mut self, latency_budget_ms: u64) -> Self {
214 if latency_budget_ms == 0 {
215 self.latency_budget_ms = DEFAULT_LATENCY_BUDGET_MS;
216 } else {
217 self.latency_budget_ms = latency_budget_ms;
218 }
219 self
220 }
221
222 #[must_use]
226 pub fn with_retry_budget(mut self, retry_budget: f64) -> Self {
227 if retry_budget <= 0.0 {
228 self.retry_budget = DEFAULT_RETRY_BUDGET;
229 } else {
230 self.retry_budget = retry_budget;
231 }
232 self
233 }
234
235 #[must_use]
237 pub const fn weights(&self) -> ProfileWeights {
238 self.weights
239 }
240
241 #[must_use]
243 pub const fn min_observations(&self) -> u32 {
244 self.min_observations
245 }
246
247 #[must_use]
249 pub const fn latency_budget_ms(&self) -> u64 {
250 self.latency_budget_ms
251 }
252
253 #[must_use]
255 pub const fn retry_budget(&self) -> f64 {
256 self.retry_budget
257 }
258
259 #[must_use]
270 pub fn score(&self, profile: &PowCapabilityProfile) -> f64 {
271 if profile.total_attempts() < self.min_observations {
272 return SPARSE_FALLBACK_SCORE;
273 }
274
275 let success_rate = profile.success_rate();
276 let latency_score = self.latency_score(profile);
277 let retry_score = self.retry_score(profile);
278 let failure_score = 1.0 - profile.failure_severity();
279
280 let weight_sum =
281 self.weights.success + self.weights.latency + self.weights.retry + self.weights.failure;
282 if weight_sum <= 0.0 {
283 return SPARSE_FALLBACK_SCORE;
284 }
285
286 let raw = self.weights.failure.mul_add(
287 failure_score,
288 self.weights.retry.mul_add(
289 retry_score,
290 self.weights
291 .latency
292 .mul_add(latency_score, self.weights.success * success_rate),
293 ),
294 );
295 let normalised = raw / weight_sum;
296 clamp_unit(normalised)
297 }
298
299 #[must_use]
308 pub fn band(&self, profile: &PowCapabilityProfile) -> PowCapabilityBand {
309 if profile.total_attempts() < self.min_observations {
310 return PowCapabilityBand::Unknown;
311 }
312 let value = self.score(profile);
313 band_for_score(value)
314 }
315
316 fn latency_score(&self, profile: &PowCapabilityProfile) -> f64 {
317 profile.solve_latency_ms_p95.map_or(1.0, |p95| {
318 #[allow(clippy::cast_precision_loss)]
323 let budget = self.latency_budget_ms as f64;
324 #[allow(clippy::cast_precision_loss)]
325 let ratio = ((p95 as f64) / budget).clamp(0.0, 1.0);
326 1.0 - ratio
327 })
328 }
329
330 fn retry_score(&self, profile: &PowCapabilityProfile) -> f64 {
331 let avg = profile.average_retries();
332 let ratio = (avg / self.retry_budget).clamp(0.0, 1.0);
333 1.0 - ratio
334 }
335}
336
337#[must_use]
342pub fn band_for_score(score: f64) -> PowCapabilityBand {
343 if score >= 0.75 {
344 PowCapabilityBand::Strong
345 } else if score >= 0.40 {
346 PowCapabilityBand::Degraded
347 } else {
348 PowCapabilityBand::Weak
349 }
350}
351
352const fn clamp_unit(value: f64) -> f64 {
353 if value.is_nan() {
354 0.0
355 } else {
356 value.clamp(0.0, 1.0)
357 }
358}
359
360#[cfg(test)]
361#[allow(
362 clippy::unwrap_used,
363 clippy::expect_used,
364 clippy::panic,
365 clippy::indexing_slicing
366)]
367mod tests {
368 use super::*;
369 use crate::pow_profile::profile::{PowCapabilityProfile, PowCapabilitySample};
370 use crate::types::TargetClass;
371 use crate::vendor_classifier::VendorId;
372
373 fn approx_eq(a: f64, b: f64) -> bool {
374 (a - b).abs() < 1e-9
375 }
376
377 fn empty_profile() -> PowCapabilityProfile {
378 PowCapabilityProfile::new(
379 "example.com",
380 TargetClass::ContentSite,
381 VendorId::Cloudflare,
382 )
383 }
384
385 #[test]
386 fn empty_profile_returns_sparse_fallback() {
387 let scorer = PowCapabilityScorer::new();
388 let profile = empty_profile();
389 assert!(approx_eq(scorer.score(&profile), SPARSE_FALLBACK_SCORE));
390 assert_eq!(scorer.band(&profile), PowCapabilityBand::Unknown);
391 }
392
393 #[test]
394 fn sparse_profile_returns_sparse_fallback() {
395 let mut profile = empty_profile();
398 profile.merge(&PowCapabilitySample::solved(1_000, 0));
399 profile.merge(&PowCapabilitySample::solved(1_500, 0));
400 assert_eq!(profile.total_attempts(), 2);
401
402 let scorer = PowCapabilityScorer::new();
403 assert!(approx_eq(scorer.score(&profile), SPARSE_FALLBACK_SCORE));
404 assert_eq!(scorer.band(&profile), PowCapabilityBand::Unknown);
405 }
406
407 #[test]
408 fn good_telemetry_scores_strong() {
409 let mut profile = empty_profile();
412 for _ in 0..9 {
413 profile.merge(&PowCapabilitySample::solved(800, 0));
414 }
415 profile.merge(&PowCapabilitySample::failed(
416 1_000,
417 1,
418 crate::pow_profile::profile::PowFailureMode::TokenInvalid,
419 ));
420 assert_eq!(profile.total_attempts(), 10);
421
422 let scorer = PowCapabilityScorer::new();
423 let score = scorer.score(&profile);
424 assert!(
425 score > 0.75,
426 "good telemetry should score Strong, got {score}"
427 );
428 assert_eq!(scorer.band(&profile), PowCapabilityBand::Strong);
429 }
430
431 #[test]
432 fn poor_telemetry_scores_weak() {
433 let mut profile = empty_profile();
436 profile.merge(&PowCapabilitySample::solved(4_000, 2));
437 profile.merge(&PowCapabilitySample::solved(4_500, 3));
438 for _ in 0..4 {
439 profile.merge(&PowCapabilitySample::failed(
440 5_000,
441 3,
442 crate::pow_profile::profile::PowFailureMode::Captcha,
443 ));
444 }
445 for _ in 0..4 {
446 profile.merge(&PowCapabilitySample::failed(
447 5_000,
448 3,
449 crate::pow_profile::profile::PowFailureMode::Blocked,
450 ));
451 }
452 assert_eq!(profile.total_attempts(), 10);
453
454 let scorer = PowCapabilityScorer::new();
455 let score = scorer.score(&profile);
456 assert!(
457 score < 0.40,
458 "poor telemetry should score Weak, got {score}"
459 );
460 assert_eq!(scorer.band(&profile), PowCapabilityBand::Weak);
461 }
462
463 #[test]
464 fn deterministic_for_same_input() {
465 let mut a = empty_profile();
466 let mut b = empty_profile();
467 for _ in 0..5 {
468 a.merge(&PowCapabilitySample::solved(1_000, 0));
469 b.merge(&PowCapabilitySample::solved(1_000, 0));
470 }
471 a.merge(&PowCapabilitySample::failed(
472 2_000,
473 1,
474 crate::pow_profile::profile::PowFailureMode::Timeout,
475 ));
476 b.merge(&PowCapabilitySample::failed(
477 2_000,
478 1,
479 crate::pow_profile::profile::PowFailureMode::Timeout,
480 ));
481
482 let scorer = PowCapabilityScorer::new();
483 assert!(approx_eq(scorer.score(&a), scorer.score(&b)));
484 assert_eq!(scorer.band(&a), scorer.band(&b));
485 }
486
487 #[test]
488 fn weight_sum_normalisation_handles_non_unit_weights() {
489 let mut profile = empty_profile();
492 for _ in 0..3 {
493 profile.merge(&PowCapabilitySample::solved(1_000, 0));
494 }
495 let scorer = PowCapabilityScorer::new().with_weights(ProfileWeights {
496 success: 2.0,
497 latency: 1.0,
498 retry: 0.5,
499 failure: 1.0,
500 });
501 let score = scorer.score(&profile);
502 assert!((0.0..=1.0).contains(&score), "score out of range: {score}");
503 }
504
505 #[test]
506 fn zero_weight_sum_falls_back_to_sparse_default() {
507 let mut profile = empty_profile();
508 for _ in 0..3 {
509 profile.merge(&PowCapabilitySample::solved(1_000, 0));
510 }
511 let scorer = PowCapabilityScorer::new().with_weights(ProfileWeights {
512 success: 0.0,
513 latency: 0.0,
514 retry: 0.0,
515 failure: 0.0,
516 });
517 assert!(approx_eq(scorer.score(&profile), SPARSE_FALLBACK_SCORE));
518 }
519
520 #[test]
521 fn min_observations_override_is_respected() {
522 let mut profile = empty_profile();
523 profile.merge(&PowCapabilitySample::solved(1_000, 0));
524 assert!(approx_eq(
526 PowCapabilityScorer::new().score(&profile),
527 SPARSE_FALLBACK_SCORE
528 ));
529 let scorer = PowCapabilityScorer::new().with_min_observations(1);
531 let score = scorer.score(&profile);
532 assert!((0.0..=1.0).contains(&score));
533 }
534
535 #[test]
536 fn zero_latency_budget_falls_back_to_default() {
537 let scorer = PowCapabilityScorer::new().with_latency_budget_ms(0);
538 assert_eq!(scorer.latency_budget_ms(), DEFAULT_LATENCY_BUDGET_MS);
539 }
540
541 #[test]
542 fn zero_retry_budget_falls_back_to_default() {
543 let scorer = PowCapabilityScorer::new().with_retry_budget(0.0);
544 assert!((scorer.retry_budget() - DEFAULT_RETRY_BUDGET).abs() < 1e-9);
545 }
546
547 #[test]
548 fn band_thresholds_are_stable() {
549 assert_eq!(band_for_score(0.75), PowCapabilityBand::Strong);
550 assert_eq!(band_for_score(1.0), PowCapabilityBand::Strong);
551 assert_eq!(band_for_score(0.40), PowCapabilityBand::Degraded);
552 assert_eq!(band_for_score(0.74), PowCapabilityBand::Degraded);
553 assert_eq!(band_for_score(0.39), PowCapabilityBand::Weak);
554 assert_eq!(band_for_score(0.0), PowCapabilityBand::Weak);
555 }
556
557 #[test]
558 fn band_labels_are_stable() {
559 assert_eq!(PowCapabilityBand::Strong.label(), "strong");
560 assert_eq!(PowCapabilityBand::Degraded.label(), "degraded");
561 assert_eq!(PowCapabilityBand::Weak.label(), "weak");
562 assert_eq!(PowCapabilityBand::Unknown.label(), "unknown");
563 }
564
565 #[test]
566 fn nan_score_clamped_to_zero() {
567 let mut profile = empty_profile();
568 for _ in 0..3 {
569 profile.merge(&PowCapabilitySample::solved(1_000, 0));
570 }
571 let scorer = PowCapabilityScorer::new().with_weights(ProfileWeights {
575 success: 0.0,
576 latency: 0.0,
577 retry: 0.0,
578 failure: 0.0,
579 });
580 let score = scorer.score(&profile);
581 assert!(!score.is_nan());
582 assert!(approx_eq(score, SPARSE_FALLBACK_SCORE));
583 }
584}