1#![forbid(unsafe_code)]
50#![deny(missing_docs)]
51
52use std::fmt;
53
54#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum SerpError {
57 NoImpressions,
59 ClicksExceedImpressions {
61 clicks: u64,
63 impressions: u64,
65 },
66 InvalidPosition,
68 EmptyInput,
70 PositionNotInCurve(u32),
72}
73
74impl fmt::Display for SerpError {
75 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76 match self {
77 SerpError::NoImpressions => write!(f, "no impressions, rate is undefined"),
78 SerpError::ClicksExceedImpressions {
79 clicks,
80 impressions,
81 } => write!(f, "clicks ({clicks}) exceed impressions ({impressions})"),
82 SerpError::InvalidPosition => write!(f, "position must be a finite number >= 1.0"),
83 SerpError::EmptyInput => write!(f, "input is empty"),
84 SerpError::PositionNotInCurve(p) => {
85 write!(f, "curve has no observation for position {p}")
86 }
87 }
88 }
89}
90
91impl std::error::Error for SerpError {}
92
93#[derive(Debug, Clone, Copy, PartialEq)]
95pub struct PositionRow {
96 pub position: f64,
98 pub impressions: u64,
100}
101
102pub fn ctr(clicks: u64, impressions: u64) -> Result<f64, SerpError> {
114 if impressions == 0 {
115 return Err(SerpError::NoImpressions);
116 }
117 if clicks > impressions {
118 return Err(SerpError::ClicksExceedImpressions {
119 clicks,
120 impressions,
121 });
122 }
123 Ok(clicks as f64 / impressions as f64)
124}
125
126pub fn ctr_percent(clicks: u64, impressions: u64, decimals: u32) -> Result<f64, SerpError> {
132 let pct = ctr(clicks, impressions)? * 100.0;
133 let factor = 10_f64.powi(decimals as i32);
134 Ok((pct * factor).round() / factor)
135}
136
137pub fn weighted_average_position(rows: &[PositionRow]) -> Result<f64, SerpError> {
149 if rows.is_empty() {
150 return Err(SerpError::EmptyInput);
151 }
152 let mut total_impressions: u64 = 0;
153 let mut weighted_sum: f64 = 0.0;
154 for row in rows {
155 if !row.position.is_finite() || row.position < 1.0 {
156 return Err(SerpError::InvalidPosition);
157 }
158 total_impressions = total_impressions.saturating_add(row.impressions);
159 weighted_sum += row.position * row.impressions as f64;
160 }
161 if total_impressions == 0 {
162 return Err(SerpError::NoImpressions);
163 }
164 Ok(weighted_sum / total_impressions as f64)
165}
166
167pub fn page_of_position(position: f64, per_page: u32) -> u32 {
176 let per_page = per_page.max(1) as f64;
177 if !position.is_finite() || position < 1.0 {
178 return 1;
179 }
180 (((position - 1.0) / per_page).floor() as u32) + 1
181}
182
183pub fn position_gain(before: f64, after: f64) -> f64 {
193 before - after
194}
195
196#[derive(Debug, Clone, Default, PartialEq, Eq)]
202pub struct CtrCurve {
203 buckets: Vec<(u32, u64, u64)>, }
205
206impl CtrCurve {
207 pub fn new() -> Self {
209 CtrCurve {
210 buckets: Vec::new(),
211 }
212 }
213
214 pub fn observe(
219 &mut self,
220 position: f64,
221 clicks: u64,
222 impressions: u64,
223 ) -> Result<(), SerpError> {
224 if !position.is_finite() || position < 1.0 {
225 return Err(SerpError::InvalidPosition);
226 }
227 if clicks > impressions {
228 return Err(SerpError::ClicksExceedImpressions {
229 clicks,
230 impressions,
231 });
232 }
233 let bucket = position.floor() as u32;
234 match self.buckets.binary_search_by_key(&bucket, |b| b.0) {
235 Ok(i) => {
236 self.buckets[i].1 = self.buckets[i].1.saturating_add(clicks);
237 self.buckets[i].2 = self.buckets[i].2.saturating_add(impressions);
238 }
239 Err(i) => self.buckets.insert(i, (bucket, clicks, impressions)),
240 }
241 Ok(())
242 }
243
244 pub fn from_observations<I>(observations: I) -> Result<Self, SerpError>
246 where
247 I: IntoIterator<Item = (f64, u64, u64)>,
248 {
249 let mut curve = CtrCurve::new();
250 for (position, clicks, impressions) in observations {
251 curve.observe(position, clicks, impressions)?;
252 }
253 Ok(curve)
254 }
255
256 pub fn len(&self) -> usize {
258 self.buckets.len()
259 }
260
261 pub fn is_empty(&self) -> bool {
263 self.buckets.is_empty()
264 }
265
266 pub fn impressions_at(&self, position: u32) -> u64 {
268 self.buckets
269 .binary_search_by_key(&position, |b| b.0)
270 .map(|i| self.buckets[i].2)
271 .unwrap_or(0)
272 }
273
274 pub fn ctr_at(&self, position: u32) -> Result<f64, SerpError> {
283 let i = self
284 .buckets
285 .binary_search_by_key(&position, |b| b.0)
286 .map_err(|_| SerpError::PositionNotInCurve(position))?;
287 let (_, clicks, impressions) = self.buckets[i];
288 ctr(clicks, impressions)
289 }
290
291 pub fn points(&self) -> Vec<(u32, f64)> {
295 self.buckets
296 .iter()
297 .filter(|(_, _, impressions)| *impressions > 0)
298 .map(|(position, clicks, impressions)| {
299 (*position, *clicks as f64 / *impressions as f64)
300 })
301 .collect()
302 }
303
304 pub fn project_clicks(&self, position: u32, impressions: u64) -> Result<u64, SerpError> {
314 let rate = self.ctr_at(position)?;
315 Ok((impressions as f64 * rate).round() as u64)
316 }
317
318 pub fn projected_click_delta(
326 &self,
327 from: u32,
328 to: u32,
329 impressions: u64,
330 ) -> Result<i64, SerpError> {
331 let before = self.project_clicks(from, impressions)? as i64;
332 let after = self.project_clicks(to, impressions)? as i64;
333 Ok(after - before)
334 }
335}
336
337#[derive(Debug, Clone, Copy, PartialEq)]
339pub struct Totals {
340 pub clicks: u64,
342 pub impressions: u64,
344 pub ctr: f64,
346}
347
348pub fn pooled_totals(rows: &[(u64, u64)]) -> Result<Totals, SerpError> {
358 if rows.is_empty() {
359 return Err(SerpError::EmptyInput);
360 }
361 let mut clicks = 0u64;
362 let mut impressions = 0u64;
363 for (c, i) in rows {
364 if c > i {
365 return Err(SerpError::ClicksExceedImpressions {
366 clicks: *c,
367 impressions: *i,
368 });
369 }
370 clicks = clicks.saturating_add(*c);
371 impressions = impressions.saturating_add(*i);
372 }
373 let ctr = ctr(clicks, impressions)?;
374 Ok(Totals {
375 clicks,
376 impressions,
377 ctr,
378 })
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384
385 fn approx(a: f64, b: f64) {
386 assert!((a - b).abs() < 1e-9, "{a} != {b}");
387 }
388
389 #[test]
390 fn ctr_is_a_plain_ratio() {
391 approx(ctr(50, 200).unwrap(), 0.25);
392 approx(ctr(0, 200).unwrap(), 0.0);
393 }
394
395 #[test]
396 fn ctr_rejects_impossible_rows() {
397 assert_eq!(ctr(1, 0), Err(SerpError::NoImpressions));
398 assert_eq!(
399 ctr(5, 4),
400 Err(SerpError::ClicksExceedImpressions {
401 clicks: 5,
402 impressions: 4
403 })
404 );
405 }
406
407 #[test]
408 fn ctr_percent_rounds_to_requested_places() {
409 approx(ctr_percent(7, 1_000, 2).unwrap(), 0.7);
410 approx(ctr_percent(1, 3, 3).unwrap(), 33.333);
411 approx(ctr_percent(1, 3, 0).unwrap(), 33.0);
412 }
413
414 #[test]
415 fn weighted_position_respects_impression_volume() {
416 let rows = [
417 PositionRow {
418 position: 2.0,
419 impressions: 9_000,
420 },
421 PositionRow {
422 position: 90.0,
423 impressions: 1_000,
424 },
425 ];
426 approx(weighted_average_position(&rows).unwrap(), 10.8);
428 }
429
430 #[test]
431 fn weighted_position_rejects_bad_input() {
432 assert_eq!(weighted_average_position(&[]), Err(SerpError::EmptyInput));
433 let bad = [PositionRow {
434 position: 0.5,
435 impressions: 10,
436 }];
437 assert_eq!(
438 weighted_average_position(&bad),
439 Err(SerpError::InvalidPosition)
440 );
441 let zero = [PositionRow {
442 position: 4.0,
443 impressions: 0,
444 }];
445 assert_eq!(
446 weighted_average_position(&zero),
447 Err(SerpError::NoImpressions)
448 );
449 }
450
451 #[test]
452 fn pages_are_one_indexed() {
453 assert_eq!(page_of_position(1.0, 10), 1);
454 assert_eq!(page_of_position(10.9, 10), 1);
455 assert_eq!(page_of_position(11.0, 10), 2);
456 assert_eq!(page_of_position(21.0, 10), 3);
457 assert_eq!(page_of_position(3.0, 0), 3);
458 }
459
460 #[test]
461 fn position_gain_is_positive_when_climbing() {
462 approx(position_gain(12.5, 3.5), 9.0);
463 approx(position_gain(3.5, 12.5), -9.0);
464 }
465
466 #[test]
467 fn curve_pools_observations_into_buckets() {
468 let mut curve = CtrCurve::new();
469 curve.observe(1.2, 30, 100).unwrap();
470 curve.observe(1.9, 20, 100).unwrap();
471 curve.observe(8.0, 1, 100).unwrap();
472 assert_eq!(curve.len(), 2);
473 approx(curve.ctr_at(1).unwrap(), 0.25);
474 approx(curve.ctr_at(8).unwrap(), 0.01);
475 assert_eq!(curve.impressions_at(1), 200);
476 assert_eq!(curve.impressions_at(4), 0);
477 }
478
479 #[test]
480 fn curve_refuses_to_invent_unobserved_positions() {
481 let curve = CtrCurve::from_observations([(1.0, 10, 100)]).unwrap();
482 assert_eq!(curve.ctr_at(5), Err(SerpError::PositionNotInCurve(5)));
483 assert!(curve.project_clicks(5, 1_000).is_err());
484 }
485
486 #[test]
487 fn curve_rejects_invalid_observations() {
488 let mut curve = CtrCurve::new();
489 assert_eq!(curve.observe(0.9, 1, 10), Err(SerpError::InvalidPosition));
490 assert_eq!(curve.observe(f64::NAN, 1, 10), Err(SerpError::InvalidPosition));
491 assert!(curve.observe(2.0, 11, 10).is_err());
492 assert!(curve.is_empty());
493 }
494
495 #[test]
496 fn projection_restates_the_observed_rate() {
497 let curve =
498 CtrCurve::from_observations([(3.0, 60, 1_000), (9.0, 10, 1_000)]).unwrap();
499 assert_eq!(curve.project_clicks(3, 5_000).unwrap(), 300);
500 assert_eq!(curve.project_clicks(9, 5_000).unwrap(), 50);
501 assert_eq!(curve.projected_click_delta(9, 3, 5_000).unwrap(), 250);
502 assert_eq!(curve.projected_click_delta(3, 9, 5_000).unwrap(), -250);
503 }
504
505 #[test]
506 fn curve_points_are_sorted_and_skip_empty_buckets() {
507 let mut curve = CtrCurve::new();
508 curve.observe(9.0, 1, 10).unwrap();
509 curve.observe(2.0, 5, 10).unwrap();
510 curve.observe(5.0, 0, 0).unwrap();
511 let points = curve.points();
512 assert_eq!(points.len(), 2);
513 assert_eq!(points[0].0, 2);
514 assert_eq!(points[1].0, 9);
515 }
516
517 #[test]
518 fn pooled_totals_differ_from_a_mean_of_rates() {
519 let rows = [(1u64, 10u64), (100, 10_000)];
520 let totals = pooled_totals(&rows).unwrap();
521 assert_eq!(totals.clicks, 101);
522 assert_eq!(totals.impressions, 10_010);
523 approx(totals.ctr, 101.0 / 10_010.0);
524 assert!(totals.ctr < 0.055);
526 }
527
528 #[test]
529 fn pooled_totals_rejects_bad_input() {
530 assert_eq!(pooled_totals(&[]), Err(SerpError::EmptyInput));
531 assert!(pooled_totals(&[(5, 1)]).is_err());
532 assert_eq!(pooled_totals(&[(0, 0)]), Err(SerpError::NoImpressions));
533 }
534
535 #[test]
536 fn errors_display_readably() {
537 assert_eq!(
538 SerpError::PositionNotInCurve(7).to_string(),
539 "curve has no observation for position 7"
540 );
541 }
542}