Skip to main content

serp_ctr/
lib.rs

1//! Search-console arithmetic for SERP performance data.
2//!
3//! This crate does the small pile of arithmetic that sits between a Search Console
4//! export and a decision: click-through rate, impression-weighted average position,
5//! position movement between two periods, and a projection of clicks at a different
6//! position using a CTR-by-position curve.
7//!
8//! # What this crate deliberately does not ship
9//!
10//! There is **no built-in CTR curve**. Published "average CTR by position" tables are
11//! third-party estimates that differ by study, by query intent and by SERP layout, and
12//! baking one in would turn somebody else's sample into this crate's constant. So
13//! [`CtrCurve`] is something you construct from your *own* measured data — for example
14//! the impressions and clicks your own Search Console reports at each position. A
15//! projection is then a statement about your own history, not about the internet.
16//!
17//! Every function here is pure integer or `f64` arithmetic. No I/O, no dependencies.
18//!
19//! # Example
20//!
21//! ```
22//! use serp_ctr::{ctr, weighted_average_position, PositionRow};
23//!
24//! let ctr = ctr(7, 1_000).unwrap();
25//! assert!((ctr - 0.007).abs() < 1e-12);
26//!
27//! let rows = vec![
28//!     PositionRow { position: 3.0, impressions: 900 },
29//!     PositionRow { position: 40.0, impressions: 100 },
30//! ];
31//! let avg = weighted_average_position(&rows).unwrap();
32//! assert!((avg - 6.7).abs() < 1e-12);
33//! ```
34//!
35//! Built for the pipeline behind <https://toolsthatrank.com/>, which verifies a figure
36//! against its source before it ships one.
37
38#![forbid(unsafe_code)]
39#![deny(missing_docs)]
40
41use std::fmt;
42
43/// Errors returned when an input cannot support the requested arithmetic.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum SerpError {
46    /// A rate was requested from zero impressions; the ratio is undefined.
47    NoImpressions,
48    /// Clicks exceeded impressions, which no search-console export can legitimately report.
49    ClicksExceedImpressions {
50        /// The clicks that were supplied.
51        clicks: u64,
52        /// The impressions that were supplied.
53        impressions: u64,
54    },
55    /// A position value was not a finite number `>= 1.0`.
56    InvalidPosition,
57    /// The input slice was empty, so there is nothing to aggregate.
58    EmptyInput,
59    /// The curve holds no observation covering the requested position.
60    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/// One row of a search-performance export: an average position and the impressions behind it.
83#[derive(Debug, Clone, Copy, PartialEq)]
84pub struct PositionRow {
85    /// Average position for the row. Search Console reports `1.0` as the top result.
86    pub position: f64,
87    /// Impressions recorded at that position.
88    pub impressions: u64,
89}
90
91/// Click-through rate as a fraction in `0.0..=1.0`.
92///
93/// # Errors
94///
95/// Returns [`SerpError::NoImpressions`] for zero impressions and
96/// [`SerpError::ClicksExceedImpressions`] when the export is internally inconsistent.
97///
98/// ```
99/// assert_eq!(serp_ctr::ctr(0, 500).unwrap(), 0.0);
100/// assert!(serp_ctr::ctr(1, 0).is_err());
101/// ```
102pub 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
115/// Click-through rate expressed in percent, rounded to `decimals` places.
116///
117/// ```
118/// assert_eq!(serp_ctr::ctr_percent(7, 1_000, 2).unwrap(), 0.7);
119/// ```
120pub 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
126/// Impression-weighted average position across many rows.
127///
128/// This is the only correct way to combine per-query or per-page average positions:
129/// a plain mean of the position column lets a row with three impressions outvote a row
130/// with thirty thousand.
131///
132/// # Errors
133///
134/// Returns [`SerpError::EmptyInput`] for an empty slice, [`SerpError::InvalidPosition`]
135/// for a non-finite or sub-1.0 position, and [`SerpError::NoImpressions`] when every row
136/// has zero impressions.
137pub 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
156/// The SERP page a position falls on, given `per_page` results per page.
157///
158/// Page numbering starts at 1. A `per_page` of 0 is treated as 1.
159///
160/// ```
161/// assert_eq!(serp_ctr::page_of_position(10.0, 10), 1);
162/// assert_eq!(serp_ctr::page_of_position(11.0, 10), 2);
163/// ```
164pub 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
172/// Change in position between two periods, positive when the position improved.
173///
174/// Positions count downwards (1 is best), so an improvement from 9.0 to 4.0 is
175/// reported as `+5.0` rather than `-5.0`.
176///
177/// ```
178/// assert_eq!(serp_ctr::position_gain(9.0, 4.0), 5.0);
179/// assert_eq!(serp_ctr::position_gain(4.0, 9.0), -5.0);
180/// ```
181pub fn position_gain(before: f64, after: f64) -> f64 {
182    before - after
183}
184
185/// A CTR-by-position curve built from observed data.
186///
187/// Positions are stored as whole-number buckets (position `3.4` falls in bucket `3`).
188/// Each bucket accumulates clicks and impressions, so the curve's CTR for a bucket is
189/// the real pooled rate of the data you fed it, never an assumed constant.
190#[derive(Debug, Clone, Default, PartialEq, Eq)]
191pub struct CtrCurve {
192    buckets: Vec<(u32, u64, u64)>, // (position, clicks, impressions), sorted by position
193}
194
195impl CtrCurve {
196    /// An empty curve.
197    pub fn new() -> Self {
198        CtrCurve {
199            buckets: Vec::new(),
200        }
201    }
202
203    /// Fold one observation into the curve.
204    ///
205    /// Non-finite or sub-1.0 positions are rejected with [`SerpError::InvalidPosition`],
206    /// and inconsistent rows with [`SerpError::ClicksExceedImpressions`].
207    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    /// Build a curve from an iterator of `(position, clicks, impressions)` observations.
234    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    /// Number of populated position buckets.
246    pub fn len(&self) -> usize {
247        self.buckets.len()
248    }
249
250    /// Whether the curve holds no observations.
251    pub fn is_empty(&self) -> bool {
252        self.buckets.is_empty()
253    }
254
255    /// Total impressions observed for a bucket, or 0 if the bucket is absent.
256    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    /// Pooled CTR observed at a whole-number position.
264    ///
265    /// # Errors
266    ///
267    /// [`SerpError::PositionNotInCurve`] when nothing was ever observed at that position,
268    /// and [`SerpError::NoImpressions`] when the bucket exists but holds zero impressions.
269    /// The curve never interpolates or extrapolates: an unobserved position is an error,
270    /// not a guess.
271    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    /// Every populated bucket as `(position, ctr)`, ascending by position.
281    ///
282    /// Buckets holding zero impressions are skipped, because they have no rate.
283    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    /// Clicks you would expect from `impressions` at `position`, using this curve.
294    ///
295    /// The result is `impressions * ctr_at(position)`, rounded to the nearest whole click.
296    /// It is a restatement of your own observed rate at a volume you supply — it is not a
297    /// forecast, and it says nothing about whether that position is reachable.
298    ///
299    /// # Errors
300    ///
301    /// Propagates the errors of [`CtrCurve::ctr_at`].
302    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    /// Difference in projected clicks between two positions at the same impression volume.
308    ///
309    /// Positive when `to` earns more than `from`.
310    ///
311    /// # Errors
312    ///
313    /// Propagates the errors of [`CtrCurve::ctr_at`] for either position.
314    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/// Aggregate totals for a set of rows: clicks, impressions and the pooled CTR.
327#[derive(Debug, Clone, Copy, PartialEq)]
328pub struct Totals {
329    /// Summed clicks.
330    pub clicks: u64,
331    /// Summed impressions.
332    pub impressions: u64,
333    /// Pooled CTR, i.e. `clicks / impressions`, not a mean of per-row CTRs.
334    pub ctr: f64,
335}
336
337/// Pool `(clicks, impressions)` rows into a single [`Totals`].
338///
339/// Pooling is not the same as averaging the CTR column, and the difference is the
340/// classic Simpson's-paradox trap in SEO reporting.
341///
342/// # Errors
343///
344/// [`SerpError::EmptyInput`] for an empty slice, [`SerpError::NoImpressions`] when the
345/// totals contain no impressions.
346pub 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        // Plain mean would be 46.0; the weighted answer is 10.8.
416        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        // The mean of the two row CTRs would be 0.055, five times the pooled rate.
514        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}