Skip to main content

ssg/core/
bench_corpus.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Deterministic synthetic corpora for benchmarking.
5//!
6//! Every benchmark that needed pages used to generate them inline, so the
7//! numbers were only comparable within a single bench file: `bench_scalability`
8//! and `incremental_1000_pages` wrote different front matter and different body
9//! lengths, then reported timings as though they measured the same work. A
10//! published figure has to be reproducible by whoever reads it, which means one
11//! generator, one shape, and no hidden inputs.
12//!
13//! # Determinism
14//!
15//! Content is derived from a seed and the page index through a small
16//! [xorshift] generator rather than the `rand` crate: the corpus must be
17//! byte-identical across machines, architectures and toolchain versions, and
18//! `rand`'s output is explicitly not stable across releases. The same seed
19//! therefore yields the same corpus in a year's time, which is the property
20//! that lets a benchmark number be checked rather than believed.
21//!
22//! Body length and tag selection vary per page — a corpus of identical pages
23//! measures the cache, not the compiler — but vary *predictably*.
24//!
25//! [xorshift]: https://en.wikipedia.org/wiki/Xorshift
26//!
27//! # Examples
28//!
29//! ```rust
30//! use ssg::bench_corpus::{generate_corpus, CorpusSpec};
31//! let dir = tempfile::tempdir().unwrap();
32//! let spec = CorpusSpec::new(64);
33//! let written = generate_corpus(dir.path(), &spec).unwrap();
34//! assert_eq!(written, 64);
35//! ```
36
37use std::fs;
38use std::io;
39use std::path::Path;
40
41/// Words drawn on to build page bodies.
42///
43/// A fixed vocabulary keeps the compressed size of the corpus stable, which
44/// matters because the page-weight gate measures compressed bytes.
45const LEXICON: &[&str] = &[
46    "compiler",
47    "pipeline",
48    "markdown",
49    "template",
50    "static",
51    "render",
52    "accessible",
53    "contrast",
54    "locale",
55    "sitemap",
56    "canonical",
57    "manifest",
58    "fingerprint",
59    "integrity",
60    "streaming",
61    "incremental",
62    "corpus",
63    "benchmark",
64    "throughput",
65    "latency",
66    "deterministic",
67    "artefact",
68];
69
70/// Tags assigned to pages, so taxonomy generation has real work to do.
71const TAGS: &[&str] = &[
72    "architecture",
73    "performance",
74    "accessibility",
75    "security",
76    "tooling",
77];
78
79/// Shape of a synthetic corpus.
80///
81/// # Examples
82///
83/// ```rust
84/// use ssg::bench_corpus::CorpusSpec;
85/// // The published sizes: 1K, 10K, 100K.
86/// let spec = CorpusSpec::new(1_000);
87/// assert_eq!(spec.pages, 1_000);
88/// assert_eq!(spec.seed, CorpusSpec::DEFAULT_SEED);
89/// ```
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub struct CorpusSpec {
92    /// Number of Markdown pages to write.
93    pub pages: usize,
94    /// Seed for the content generator. Fixed by default so two runs of the
95    /// same size produce byte-identical input.
96    pub seed: u64,
97    /// Approximate words per page body.
98    pub words_per_page: usize,
99}
100
101impl CorpusSpec {
102    /// The seed used for published figures. Changing it invalidates
103    /// comparison with every previously published number, so it is a
104    /// constant rather than a parameter with a default.
105    pub const DEFAULT_SEED: u64 = 0x5353_4720_4265_6e63; // "SSG Benc"
106
107    /// A corpus of `pages` pages at the published seed and body length.
108    #[must_use]
109    pub const fn new(pages: usize) -> Self {
110        Self {
111            pages,
112            seed: Self::DEFAULT_SEED,
113            words_per_page: 220,
114        }
115    }
116
117    /// Overrides the seed, for tests that need two distinguishable corpora.
118    #[must_use]
119    pub const fn with_seed(mut self, seed: u64) -> Self {
120        self.seed = seed;
121        self
122    }
123}
124
125/// A tiny xorshift64* PRNG.
126///
127/// Chosen over `rand` because the corpus must be reproducible across releases;
128/// `rand` makes no such guarantee, and a benchmark whose input silently changes
129/// with a dependency bump reports drift as a regression.
130struct Rng(u64);
131
132impl Rng {
133    const fn new(seed: u64) -> Self {
134        // A zero state is a fixed point for xorshift, so it is never allowed.
135        Self(if seed == 0 {
136            0x9E37_79B9_7F4A_7C15
137        } else {
138            seed
139        })
140    }
141
142    const fn next(&mut self) -> u64 {
143        let mut x = self.0;
144        x ^= x >> 12;
145        x ^= x << 25;
146        x ^= x >> 27;
147        self.0 = x;
148        x.wrapping_mul(0x2545_F491_4F6C_DD1D)
149    }
150
151    const fn pick<'a, T>(&mut self, items: &'a [T]) -> &'a T {
152        let idx = (self.next() % items.len() as u64) as usize;
153        &items[idx]
154    }
155}
156
157/// Writes `spec.pages` Markdown files into `dir`, returning the count written.
158///
159/// The directory is created if absent. Existing files are overwritten, so a
160/// re-run refreshes the corpus in place rather than accumulating.
161///
162/// # Errors
163///
164/// Returns any I/O error from creating the directory or writing a page.
165///
166/// # Examples
167///
168/// ```rust
169/// use ssg::bench_corpus::{generate_corpus, CorpusSpec};
170/// let dir = tempfile::tempdir().unwrap();
171/// generate_corpus(dir.path(), &CorpusSpec::new(4)).unwrap();
172/// assert!(dir.path().join("page-0000.md").is_file());
173/// ```
174pub fn generate_corpus(dir: &Path, spec: &CorpusSpec) -> io::Result<usize> {
175    fs::create_dir_all(dir)?;
176
177    for i in 0..spec.pages {
178        // Seeding per page rather than streaming one sequence means page N is
179        // identical whether the corpus holds 1K pages or 100K — so the 1K and
180        // 10K runs share a prefix and are genuinely comparable.
181        let mut rng =
182            Rng::new(spec.seed ^ (i as u64).wrapping_mul(0x9E37_79B9));
183
184        let words: Vec<&str> = (0..spec.words_per_page)
185            .map(|_| *rng.pick(LEXICON))
186            .collect();
187        let tag_a = rng.pick(TAGS);
188        let tag_b = rng.pick(TAGS);
189
190        // Two paragraphs and a heading, so the Markdown parser and the HTML
191        // rewriter both see structure rather than one long text run.
192        let half = words.len() / 2;
193        let body = format!(
194            "## Section {i}\n\n{}\n\n### Detail\n\n{}\n",
195            words[..half].join(" "),
196            words[half..].join(" "),
197        );
198
199        let page = format!(
200            "---\n\
201             title: \"Benchmark page {i}\"\n\
202             description: \"Synthetic page {i} from the SSG benchmark corpus.\"\n\
203             date: \"2026-01-15T09:00:00+00:00\"\n\
204             language: \"en-GB\"\n\
205             layout: \"page\"\n\
206             permalink: \"https://example.com/page-{i}\"\n\
207             author: \"[email protected]\"\n\
208             tags: \"{tag_a}, {tag_b}\"\n\
209             charset: \"utf-8\"\n\
210             viewport: \"width=device-width, initial-scale=1, shrink-to-fit=no\"\n\
211             url: \"https://example.com/page-{i}\"\n\
212             id: \"https://example.com\"\n\
213             name: \"SSG Benchmark\"\n\
214             short_name: \"ssg-bench\"\n\
215             subtitle: \"Synthetic page {i}\"\n\
216             image: \"https://example.com/og-{i}.png\"\n\
217             logo: \"https://example.com/logo.svg\"\n\
218             logo_alt: \"SSG Benchmark logo\"\n\
219             logo_width: \"100\"\n\
220             logo_height: \"33\"\n\
221             theme-color: \"26, 58, 138\"\n\
222             cdn: \"https://cloudcdn.pro\"\n\
223             copyright: \"Copyright © 2026. All rights reserved.\"\n\
224             hreflang: \"en\"\n\
225             item_pub_date: \"2026-01-15T09:00:00+00:00\"\n\
226             last_build_date: \"2026-01-15T09:00:00+00:00\"\n\
227             primary: \"\"\n\
228             opengraph: \"\"\n\
229             twitter: \"\"\n\
230             apple: \"\"\n\
231             microsoft: \"\"\n\
232             ---\n\n{body}"
233        );
234
235        // Zero-padded so lexical order matches numeric order; a directory
236        // listing that jumps 1, 10, 100 makes a partial run hard to read.
237        fs::write(dir.join(format!("page-{i:04}.md")), page)?;
238    }
239
240    Ok(spec.pages)
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    #[test]
248    fn corpus_is_byte_identical_across_runs() {
249        // The whole point: a published number is only checkable if the input
250        // can be regenerated exactly.
251        let a = tempfile::tempdir().unwrap();
252        let b = tempfile::tempdir().unwrap();
253        let spec = CorpusSpec::new(16);
254
255        let _written = generate_corpus(a.path(), &spec).unwrap();
256        let _written = generate_corpus(b.path(), &spec).unwrap();
257
258        for i in 0..16 {
259            let name = format!("page-{i:04}.md");
260            assert_eq!(
261                fs::read(a.path().join(&name)).unwrap(),
262                fs::read(b.path().join(&name)).unwrap(),
263                "{name} differs between runs"
264            );
265        }
266    }
267
268    #[test]
269    fn page_content_does_not_depend_on_corpus_size() {
270        // A 1K run and a 10K run must share their first 1K pages, or the two
271        // published figures measure different inputs.
272        let small = tempfile::tempdir().unwrap();
273        let large = tempfile::tempdir().unwrap();
274        let _written =
275            generate_corpus(small.path(), &CorpusSpec::new(8)).unwrap();
276        let _written =
277            generate_corpus(large.path(), &CorpusSpec::new(64)).unwrap();
278
279        for i in 0..8 {
280            let name = format!("page-{i:04}.md");
281            assert_eq!(
282                fs::read(small.path().join(&name)).unwrap(),
283                fs::read(large.path().join(&name)).unwrap(),
284                "{name} differs between corpus sizes"
285            );
286        }
287    }
288
289    #[test]
290    fn different_seeds_produce_different_corpora() {
291        let a = tempfile::tempdir().unwrap();
292        let b = tempfile::tempdir().unwrap();
293        let _written = generate_corpus(a.path(), &CorpusSpec::new(8)).unwrap();
294        let _written =
295            generate_corpus(b.path(), &CorpusSpec::new(8).with_seed(1))
296                .unwrap();
297
298        let name = "page-0000.md";
299        assert_ne!(
300            fs::read(a.path().join(name)).unwrap(),
301            fs::read(b.path().join(name)).unwrap()
302        );
303    }
304
305    #[test]
306    fn pages_carry_frontmatter_and_structure() {
307        let dir = tempfile::tempdir().unwrap();
308        let _written =
309            generate_corpus(dir.path(), &CorpusSpec::new(1)).unwrap();
310        let page = fs::read_to_string(dir.path().join("page-0000.md")).unwrap();
311
312        assert!(page.starts_with("---\n"), "missing front matter");
313        assert!(page.contains("permalink: \"https://example.com/page-0\""));
314        assert!(page.contains("tags: \""), "taxonomy needs tags");
315        assert!(page.contains("## Section 0"), "missing heading");
316        assert!(page.contains("### Detail"), "missing subheading");
317    }
318
319    #[test]
320    fn zero_seed_does_not_collapse_the_generator() {
321        // Xorshift has a fixed point at zero; an unguarded seed of 0 would
322        // emit the same word for every position.
323        let dir = tempfile::tempdir().unwrap();
324        let _written =
325            generate_corpus(dir.path(), &CorpusSpec::new(1).with_seed(0))
326                .unwrap();
327        let page = fs::read_to_string(dir.path().join("page-0000.md")).unwrap();
328
329        let body: Vec<&str> = page
330            .split("---\n")
331            .nth(2)
332            .unwrap_or_default()
333            .split_whitespace()
334            .collect();
335        let distinct: std::collections::BTreeSet<_> = body.iter().collect();
336        assert!(
337            distinct.len() > 5,
338            "seed 0 collapsed the generator: {} distinct words",
339            distinct.len()
340        );
341    }
342}