1#![forbid(unsafe_code)]
39#![deny(missing_docs)]
40
41use std::fmt;
42
43#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum SerpError {
46 NoImpressions,
48 ClicksExceedImpressions {
50 clicks: u64,
52 impressions: u64,
54 },
55 InvalidPosition,
57 EmptyInput,
59 PositionNotInCurve(u32),
61}
62
63impl fmt::Display for SerpError {
64 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65 match self {
66 SerpError::NoImpressions => write!(f, "no impressions, rate is undefined"),
67 SerpError::ClicksExceedImpressions {
68 clicks,
69 impressions,
70 } => write!(f, "clicks ({clicks}) exceed impressions ({impressions})"),
71 SerpError::InvalidPosition => write!(f, "position must be a finite number >= 1.0"),
72 SerpError::EmptyInput => write!(f, "input is empty"),
73 SerpError::PositionNotInCurve(p) => {
74 write!(f, "curve has no observation for position {p}")
75 }
76 }
77 }
78}
79
80impl std::error::Error for SerpError {}
81
82#[derive(Debug, Clone, Copy, PartialEq)]
84pub struct PositionRow {
85 pub position: f64,
87 pub impressions: u64,
89}
90
91pub fn ctr(clicks: u64, impressions: u64) -> Result<f64, SerpError> {
103 if impressions == 0 {
104 return Err(SerpError::NoImpressions);
105 }
106 if clicks > impressions {
107 return Err(SerpError::ClicksExceedImpressions {
108 clicks,
109 impressions,
110 });
111 }
112 Ok(clicks as f64 / impressions as f64)
113}
114
115pub fn ctr_percent(clicks: u64, impressions: u64, decimals: u32) -> Result<f64, SerpError> {
121 let pct = ctr(clicks, impressions)? * 100.0;
122 let factor = 10_f64.powi(decimals as i32);
123 Ok((pct * factor).round() / factor)
124}
125
126pub fn weighted_average_position(rows: &[PositionRow]) -> Result<f64, SerpError> {
138 if rows.is_empty() {
139 return Err(SerpError::EmptyInput);
140 }
141 let mut total_impressions: u64 = 0;
142 let mut weighted_sum: f64 = 0.0;
143 for row in rows {
144 if !row.position.is_finite() || row.position < 1.0 {
145 return Err(SerpError::InvalidPosition);
146 }
147 total_impressions = total_impressions.saturating_add(row.impressions);
148 weighted_sum += row.position * row.impressions as f64;
149 }
150 if total_impressions == 0 {
151 return Err(SerpError::NoImpressions);
152 }
153 Ok(weighted_sum / total_impressions as f64)
154}
155
156pub fn page_of_position(position: f64, per_page: u32) -> u32 {
165 let per_page = per_page.max(1) as f64;
166 if !position.is_finite() || position < 1.0 {
167 return 1;
168 }
169 (((position - 1.0) / per_page).floor() as u32) + 1
170}
171
172pub fn position_gain(before: f64, after: f64) -> f64 {
182 before - after
183}
184
185#[derive(Debug, Clone, Default, PartialEq, Eq)]
191pub struct CtrCurve {
192 buckets: Vec<(u32, u64, u64)>, }
194
195impl CtrCurve {
196 pub fn new() -> Self {
198 CtrCurve {
199 buckets: Vec::new(),
200 }
201 }
202
203 pub fn observe(
208 &mut self,
209 position: f64,
210 clicks: u64,
211 impressions: u64,
212 ) -> Result<(), SerpError> {
213 if !position.is_finite() || position < 1.0 {
214 return Err(SerpError::InvalidPosition);
215 }
216 if clicks > impressions {
217 return Err(SerpError::ClicksExceedImpressions {
218 clicks,
219 impressions,
220 });
221 }
222 let bucket = position.floor() as u32;
223 match self.buckets.binary_search_by_key(&bucket, |b| b.0) {
224 Ok(i) => {
225 self.buckets[i].1 = self.buckets[i].1.saturating_add(clicks);
226 self.buckets[i].2 = self.buckets[i].2.saturating_add(impressions);
227 }
228 Err(i) => self.buckets.insert(i, (bucket, clicks, impressions)),
229 }
230 Ok(())
231 }
232
233 pub fn from_observations<I>(observations: I) -> Result<Self, SerpError>
235 where
236 I: IntoIterator<Item = (f64, u64, u64)>,
237 {
238 let mut curve = CtrCurve::new();
239 for (position, clicks, impressions) in observations {
240 curve.observe(position, clicks, impressions)?;
241 }
242 Ok(curve)
243 }
244
245 pub fn len(&self) -> usize {
247 self.buckets.len()
248 }
249
250 pub fn is_empty(&self) -> bool {
252 self.buckets.is_empty()
253 }
254
255 pub fn impressions_at(&self, position: u32) -> u64 {
257 self.buckets
258 .binary_search_by_key(&position, |b| b.0)
259 .map(|i| self.buckets[i].2)
260 .unwrap_or(0)
261 }
262
263 pub fn ctr_at(&self, position: u32) -> Result<f64, SerpError> {
272 let i = self
273 .buckets
274 .binary_search_by_key(&position, |b| b.0)
275 .map_err(|_| SerpError::PositionNotInCurve(position))?;
276 let (_, clicks, impressions) = self.buckets[i];
277 ctr(clicks, impressions)
278 }
279
280 pub fn points(&self) -> Vec<(u32, f64)> {
284 self.buckets
285 .iter()
286 .filter(|(_, _, impressions)| *impressions > 0)
287 .map(|(position, clicks, impressions)| {
288 (*position, *clicks as f64 / *impressions as f64)
289 })
290 .collect()
291 }
292
293 pub fn project_clicks(&self, position: u32, impressions: u64) -> Result<u64, SerpError> {
303 let rate = self.ctr_at(position)?;
304 Ok((impressions as f64 * rate).round() as u64)
305 }
306
307 pub fn projected_click_delta(
315 &self,
316 from: u32,
317 to: u32,
318 impressions: u64,
319 ) -> Result<i64, SerpError> {
320 let before = self.project_clicks(from, impressions)? as i64;
321 let after = self.project_clicks(to, impressions)? as i64;
322 Ok(after - before)
323 }
324}
325
326#[derive(Debug, Clone, Copy, PartialEq)]
328pub struct Totals {
329 pub clicks: u64,
331 pub impressions: u64,
333 pub ctr: f64,
335}
336
337pub fn pooled_totals(rows: &[(u64, u64)]) -> Result<Totals, SerpError> {
347 if rows.is_empty() {
348 return Err(SerpError::EmptyInput);
349 }
350 let mut clicks = 0u64;
351 let mut impressions = 0u64;
352 for (c, i) in rows {
353 if c > i {
354 return Err(SerpError::ClicksExceedImpressions {
355 clicks: *c,
356 impressions: *i,
357 });
358 }
359 clicks = clicks.saturating_add(*c);
360 impressions = impressions.saturating_add(*i);
361 }
362 let ctr = ctr(clicks, impressions)?;
363 Ok(Totals {
364 clicks,
365 impressions,
366 ctr,
367 })
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373
374 fn approx(a: f64, b: f64) {
375 assert!((a - b).abs() < 1e-9, "{a} != {b}");
376 }
377
378 #[test]
379 fn ctr_is_a_plain_ratio() {
380 approx(ctr(50, 200).unwrap(), 0.25);
381 approx(ctr(0, 200).unwrap(), 0.0);
382 }
383
384 #[test]
385 fn ctr_rejects_impossible_rows() {
386 assert_eq!(ctr(1, 0), Err(SerpError::NoImpressions));
387 assert_eq!(
388 ctr(5, 4),
389 Err(SerpError::ClicksExceedImpressions {
390 clicks: 5,
391 impressions: 4
392 })
393 );
394 }
395
396 #[test]
397 fn ctr_percent_rounds_to_requested_places() {
398 approx(ctr_percent(7, 1_000, 2).unwrap(), 0.7);
399 approx(ctr_percent(1, 3, 3).unwrap(), 33.333);
400 approx(ctr_percent(1, 3, 0).unwrap(), 33.0);
401 }
402
403 #[test]
404 fn weighted_position_respects_impression_volume() {
405 let rows = [
406 PositionRow {
407 position: 2.0,
408 impressions: 9_000,
409 },
410 PositionRow {
411 position: 90.0,
412 impressions: 1_000,
413 },
414 ];
415 approx(weighted_average_position(&rows).unwrap(), 10.8);
417 }
418
419 #[test]
420 fn weighted_position_rejects_bad_input() {
421 assert_eq!(weighted_average_position(&[]), Err(SerpError::EmptyInput));
422 let bad = [PositionRow {
423 position: 0.5,
424 impressions: 10,
425 }];
426 assert_eq!(
427 weighted_average_position(&bad),
428 Err(SerpError::InvalidPosition)
429 );
430 let zero = [PositionRow {
431 position: 4.0,
432 impressions: 0,
433 }];
434 assert_eq!(
435 weighted_average_position(&zero),
436 Err(SerpError::NoImpressions)
437 );
438 }
439
440 #[test]
441 fn pages_are_one_indexed() {
442 assert_eq!(page_of_position(1.0, 10), 1);
443 assert_eq!(page_of_position(10.9, 10), 1);
444 assert_eq!(page_of_position(11.0, 10), 2);
445 assert_eq!(page_of_position(21.0, 10), 3);
446 assert_eq!(page_of_position(3.0, 0), 3);
447 }
448
449 #[test]
450 fn position_gain_is_positive_when_climbing() {
451 approx(position_gain(12.5, 3.5), 9.0);
452 approx(position_gain(3.5, 12.5), -9.0);
453 }
454
455 #[test]
456 fn curve_pools_observations_into_buckets() {
457 let mut curve = CtrCurve::new();
458 curve.observe(1.2, 30, 100).unwrap();
459 curve.observe(1.9, 20, 100).unwrap();
460 curve.observe(8.0, 1, 100).unwrap();
461 assert_eq!(curve.len(), 2);
462 approx(curve.ctr_at(1).unwrap(), 0.25);
463 approx(curve.ctr_at(8).unwrap(), 0.01);
464 assert_eq!(curve.impressions_at(1), 200);
465 assert_eq!(curve.impressions_at(4), 0);
466 }
467
468 #[test]
469 fn curve_refuses_to_invent_unobserved_positions() {
470 let curve = CtrCurve::from_observations([(1.0, 10, 100)]).unwrap();
471 assert_eq!(curve.ctr_at(5), Err(SerpError::PositionNotInCurve(5)));
472 assert!(curve.project_clicks(5, 1_000).is_err());
473 }
474
475 #[test]
476 fn curve_rejects_invalid_observations() {
477 let mut curve = CtrCurve::new();
478 assert_eq!(curve.observe(0.9, 1, 10), Err(SerpError::InvalidPosition));
479 assert_eq!(curve.observe(f64::NAN, 1, 10), Err(SerpError::InvalidPosition));
480 assert!(curve.observe(2.0, 11, 10).is_err());
481 assert!(curve.is_empty());
482 }
483
484 #[test]
485 fn projection_restates_the_observed_rate() {
486 let curve =
487 CtrCurve::from_observations([(3.0, 60, 1_000), (9.0, 10, 1_000)]).unwrap();
488 assert_eq!(curve.project_clicks(3, 5_000).unwrap(), 300);
489 assert_eq!(curve.project_clicks(9, 5_000).unwrap(), 50);
490 assert_eq!(curve.projected_click_delta(9, 3, 5_000).unwrap(), 250);
491 assert_eq!(curve.projected_click_delta(3, 9, 5_000).unwrap(), -250);
492 }
493
494 #[test]
495 fn curve_points_are_sorted_and_skip_empty_buckets() {
496 let mut curve = CtrCurve::new();
497 curve.observe(9.0, 1, 10).unwrap();
498 curve.observe(2.0, 5, 10).unwrap();
499 curve.observe(5.0, 0, 0).unwrap();
500 let points = curve.points();
501 assert_eq!(points.len(), 2);
502 assert_eq!(points[0].0, 2);
503 assert_eq!(points[1].0, 9);
504 }
505
506 #[test]
507 fn pooled_totals_differ_from_a_mean_of_rates() {
508 let rows = [(1u64, 10u64), (100, 10_000)];
509 let totals = pooled_totals(&rows).unwrap();
510 assert_eq!(totals.clicks, 101);
511 assert_eq!(totals.impressions, 10_010);
512 approx(totals.ctr, 101.0 / 10_010.0);
513 assert!(totals.ctr < 0.055);
515 }
516
517 #[test]
518 fn pooled_totals_rejects_bad_input() {
519 assert_eq!(pooled_totals(&[]), Err(SerpError::EmptyInput));
520 assert!(pooled_totals(&[(5, 1)]).is_err());
521 assert_eq!(pooled_totals(&[(0, 0)]), Err(SerpError::NoImpressions));
522 }
523
524 #[test]
525 fn errors_display_readably() {
526 assert_eq!(
527 SerpError::PositionNotInCurve(7).to_string(),
528 "curve has no observation for position 7"
529 );
530 }
531}