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//! One caution on reading [`ctr`] at low volume: a page with zero clicks on a few dozen
39//! impressions has not measured its CTR yet. How click coverage rises with impressions,
40//! and where zero clicks stops being noise, is measured across 34 properties at
41//! <https://toolsthatrank.com/zero-click-pages/>.
42//!
43//! The rows these functions read come from a Search Console export, and the export itself is
44//! capped: the API returns at most 25,000 rows per request and stops at a daily row limit per
45//! search type, so a large property's long tail can be missing before any arithmetic starts. A
46//! calculator for how many requests and how much time a full export takes under those limits is
47//! at <https://toolsthatrank.com/search-console-export-limits/>.
48
49#![forbid(unsafe_code)]
50#![deny(missing_docs)]
51
52use std::fmt;
53
54/// Errors returned when an input cannot support the requested arithmetic.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum SerpError {
57    /// A rate was requested from zero impressions; the ratio is undefined.
58    NoImpressions,
59    /// Clicks exceeded impressions, which no search-console export can legitimately report.
60    ClicksExceedImpressions {
61        /// The clicks that were supplied.
62        clicks: u64,
63        /// The impressions that were supplied.
64        impressions: u64,
65    },
66    /// A position value was not a finite number `>= 1.0`.
67    InvalidPosition,
68    /// The input slice was empty, so there is nothing to aggregate.
69    EmptyInput,
70    /// The curve holds no observation covering the requested position.
71    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/// One row of a search-performance export: an average position and the impressions behind it.
94#[derive(Debug, Clone, Copy, PartialEq)]
95pub struct PositionRow {
96    /// Average position for the row. Search Console reports `1.0` as the top result.
97    pub position: f64,
98    /// Impressions recorded at that position.
99    pub impressions: u64,
100}
101
102/// Click-through rate as a fraction in `0.0..=1.0`.
103///
104/// # Errors
105///
106/// Returns [`SerpError::NoImpressions`] for zero impressions and
107/// [`SerpError::ClicksExceedImpressions`] when the export is internally inconsistent.
108///
109/// ```
110/// assert_eq!(serp_ctr::ctr(0, 500).unwrap(), 0.0);
111/// assert!(serp_ctr::ctr(1, 0).is_err());
112/// ```
113pub 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
126/// Click-through rate expressed in percent, rounded to `decimals` places.
127///
128/// ```
129/// assert_eq!(serp_ctr::ctr_percent(7, 1_000, 2).unwrap(), 0.7);
130/// ```
131pub 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
137/// Impression-weighted average position across many rows.
138///
139/// This is the only correct way to combine per-query or per-page average positions:
140/// a plain mean of the position column lets a row with three impressions outvote a row
141/// with thirty thousand.
142///
143/// # Errors
144///
145/// Returns [`SerpError::EmptyInput`] for an empty slice, [`SerpError::InvalidPosition`]
146/// for a non-finite or sub-1.0 position, and [`SerpError::NoImpressions`] when every row
147/// has zero impressions.
148pub 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
167/// The SERP page a position falls on, given `per_page` results per page.
168///
169/// Page numbering starts at 1. A `per_page` of 0 is treated as 1.
170///
171/// ```
172/// assert_eq!(serp_ctr::page_of_position(10.0, 10), 1);
173/// assert_eq!(serp_ctr::page_of_position(11.0, 10), 2);
174/// ```
175pub 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
183/// Change in position between two periods, positive when the position improved.
184///
185/// Positions count downwards (1 is best), so an improvement from 9.0 to 4.0 is
186/// reported as `+5.0` rather than `-5.0`.
187///
188/// ```
189/// assert_eq!(serp_ctr::position_gain(9.0, 4.0), 5.0);
190/// assert_eq!(serp_ctr::position_gain(4.0, 9.0), -5.0);
191/// ```
192pub fn position_gain(before: f64, after: f64) -> f64 {
193    before - after
194}
195
196/// A CTR-by-position curve built from observed data.
197///
198/// Positions are stored as whole-number buckets (position `3.4` falls in bucket `3`).
199/// Each bucket accumulates clicks and impressions, so the curve's CTR for a bucket is
200/// the real pooled rate of the data you fed it, never an assumed constant.
201#[derive(Debug, Clone, Default, PartialEq, Eq)]
202pub struct CtrCurve {
203    buckets: Vec<(u32, u64, u64)>, // (position, clicks, impressions), sorted by position
204}
205
206impl CtrCurve {
207    /// An empty curve.
208    pub fn new() -> Self {
209        CtrCurve {
210            buckets: Vec::new(),
211        }
212    }
213
214    /// Fold one observation into the curve.
215    ///
216    /// Non-finite or sub-1.0 positions are rejected with [`SerpError::InvalidPosition`],
217    /// and inconsistent rows with [`SerpError::ClicksExceedImpressions`].
218    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    /// Build a curve from an iterator of `(position, clicks, impressions)` observations.
245    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    /// Number of populated position buckets.
257    pub fn len(&self) -> usize {
258        self.buckets.len()
259    }
260
261    /// Whether the curve holds no observations.
262    pub fn is_empty(&self) -> bool {
263        self.buckets.is_empty()
264    }
265
266    /// Total impressions observed for a bucket, or 0 if the bucket is absent.
267    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    /// Pooled CTR observed at a whole-number position.
275    ///
276    /// # Errors
277    ///
278    /// [`SerpError::PositionNotInCurve`] when nothing was ever observed at that position,
279    /// and [`SerpError::NoImpressions`] when the bucket exists but holds zero impressions.
280    /// The curve never interpolates or extrapolates: an unobserved position is an error,
281    /// not a guess.
282    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    /// Every populated bucket as `(position, ctr)`, ascending by position.
292    ///
293    /// Buckets holding zero impressions are skipped, because they have no rate.
294    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    /// Clicks you would expect from `impressions` at `position`, using this curve.
305    ///
306    /// The result is `impressions * ctr_at(position)`, rounded to the nearest whole click.
307    /// It is a restatement of your own observed rate at a volume you supply — it is not a
308    /// forecast, and it says nothing about whether that position is reachable.
309    ///
310    /// # Errors
311    ///
312    /// Propagates the errors of [`CtrCurve::ctr_at`].
313    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    /// Difference in projected clicks between two positions at the same impression volume.
319    ///
320    /// Positive when `to` earns more than `from`.
321    ///
322    /// # Errors
323    ///
324    /// Propagates the errors of [`CtrCurve::ctr_at`] for either position.
325    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/// Aggregate totals for a set of rows: clicks, impressions and the pooled CTR.
338#[derive(Debug, Clone, Copy, PartialEq)]
339pub struct Totals {
340    /// Summed clicks.
341    pub clicks: u64,
342    /// Summed impressions.
343    pub impressions: u64,
344    /// Pooled CTR, i.e. `clicks / impressions`, not a mean of per-row CTRs.
345    pub ctr: f64,
346}
347
348/// Pool `(clicks, impressions)` rows into a single [`Totals`].
349///
350/// Pooling is not the same as averaging the CTR column, and the difference is the
351/// classic Simpson's-paradox trap in SEO reporting.
352///
353/// # Errors
354///
355/// [`SerpError::EmptyInput`] for an empty slice, [`SerpError::NoImpressions`] when the
356/// totals contain no impressions.
357pub 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        // Plain mean would be 46.0; the weighted answer is 10.8.
427        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        // The mean of the two row CTRs would be 0.055, five times the pooled rate.
525        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}