Skip to main content

jugar_probar/perf_gate/
samples.rs

1//! §4.4.5 — raw per-request sample retention.
2//!
3//! "Raw per-request samples are retained on every cell — a summary-only receipt
4//! cannot be resampled and is rejected (I-4)." Gzipped JSONL inside the receipt
5//! directory, with its `sha256` and byte size recorded so the receipt names what
6//! it points at.
7//!
8//! The `receipt_size_budget_bytes` assertion §4.4.5 asks for is **not** given a
9//! literal here. The spec says "measure one full receipt, commit its size as
10//! `receipt_size_budget_bytes` … No literal until measured `[U]`", and no
11//! conformant band has been run yet. [`SamplesFile::exceeds_budget`] takes the
12//! budget as an argument so the check can be armed the day the number is
13//! measured, without a plausible-looking placeholder being committed today.
14
15use std::fs::File;
16use std::io::{BufWriter, Write};
17use std::path::{Path, PathBuf};
18
19use flate2::write::GzEncoder;
20use flate2::Compression;
21use serde::{Deserialize, Serialize};
22use sha2::{Digest, Sha256};
23
24use super::metrics::RequestSample;
25
26/// Where the raw samples went, and what they hash to.
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(deny_unknown_fields)]
29pub struct SamplesFile {
30    /// Path relative to the receipt directory.
31    pub path: PathBuf,
32    /// `sha256` of the gzipped bytes on disk.
33    pub sha256: String,
34    /// Size of the gzipped bytes.
35    pub bytes: u64,
36    /// Rows written — one JSON object per request, one request per line.
37    pub rows: usize,
38}
39
40impl SamplesFile {
41    /// §4.4.5 budget check. The budget is an argument, not a constant, because
42    /// the spec forbids inventing the literal before a full receipt is measured.
43    #[must_use]
44    pub fn exceeds_budget(&self, budget_bytes: u64) -> bool {
45        self.bytes > budget_bytes
46    }
47}
48
49/// Write `samples` as gzipped JSONL to `path`, returning its digest and size.
50///
51/// One JSON object per line, in issue order. JSONL rather than a JSON array so
52/// a truncated file still yields every complete row: a receipt that cannot be
53/// partially read is a receipt that gets discarded whole.
54///
55/// # Errors
56/// On any I/O or serialisation failure. A retention failure is returned, never
57/// swallowed — a receipt whose samples silently failed to write is exactly the
58/// summary-only receipt §4.4.5 rejects.
59pub fn write_samples_gz(path: &Path, samples: &[RequestSample]) -> std::io::Result<SamplesFile> {
60    if let Some(parent) = path.parent() {
61        std::fs::create_dir_all(parent)?;
62    }
63    {
64        let file = File::create(path)?;
65        let mut gz = GzEncoder::new(BufWriter::new(file), Compression::default());
66        for s in samples {
67            let line = serde_json::to_string(s)
68                .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
69            gz.write_all(line.as_bytes())?;
70            gz.write_all(b"\n")?;
71        }
72        gz.finish()?.flush()?;
73    }
74
75    let bytes = std::fs::read(path)?;
76    let mut hasher = Sha256::new();
77    hasher.update(&bytes);
78    Ok(SamplesFile {
79        path: path
80            .file_name()
81            .map_or_else(|| path.to_path_buf(), PathBuf::from),
82        sha256: format!("{:x}", hasher.finalize()),
83        bytes: bytes.len() as u64,
84        rows: samples.len(),
85    })
86}
87
88/// Read back gzipped JSONL samples. The receipt is only re-derivable if this
89/// round-trips, so it ships alongside the writer rather than being left to the
90/// consumer to reimplement.
91///
92/// # Errors
93/// On any I/O or parse failure.
94pub fn read_samples_gz(path: &Path) -> std::io::Result<Vec<RequestSample>> {
95    use std::io::BufRead;
96    let file = File::open(path)?;
97    let gz = flate2::read::GzDecoder::new(file);
98    let reader = std::io::BufReader::new(gz);
99    let mut out = Vec::new();
100    for line in reader.lines() {
101        let line = line?;
102        if line.trim().is_empty() {
103            continue;
104        }
105        out.push(
106            serde_json::from_str(&line)
107                .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?,
108        );
109    }
110    Ok(out)
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use crate::perf_gate::bootstrap::bootstrap_agg_tok_s_ci;
117    use crate::perf_gate::protocol::Outcome;
118
119    fn deck(n: usize) -> Vec<RequestSample> {
120        (0..n)
121            .map(|i| RequestSample {
122                index: i,
123                worker: i % 4,
124                start_s: i as f64 * 0.25,
125                end_s: i as f64 * 0.25 + 1.0 + f64::from((i % 3) as u32) * 0.1,
126                token_times_s: vec![i as f64 * 0.25 + 0.05, i as f64 * 0.25 + 0.9],
127                generated_tokens: 128,
128                prompt_tokens: 512,
129                outcome: Outcome::Completed,
130                in_flight_at_start: 4,
131                drained: false,
132            })
133            .collect()
134    }
135
136    fn tmpdir(name: &str) -> PathBuf {
137        let d = std::env::temp_dir().join(format!("perf024-{name}-{}", std::process::id()));
138        let _ = std::fs::remove_dir_all(&d);
139        d
140    }
141
142    #[test]
143    fn samples_round_trip_through_gzip() {
144        let dir = tmpdir("roundtrip");
145        let path = dir.join("samples.jsonl.gz");
146        let want = deck(25);
147        let meta = write_samples_gz(&path, &want).expect("write");
148        assert_eq!(meta.rows, 25);
149        assert!(meta.bytes > 0);
150        assert_eq!(meta.sha256.len(), 64);
151        assert_eq!(meta.path, PathBuf::from("samples.jsonl.gz"));
152
153        let got = read_samples_gz(&path).expect("read");
154        assert_eq!(got, want, "retained samples must survive the round trip");
155        let _ = std::fs::remove_dir_all(&dir);
156    }
157
158    /// The reason retention is mandatory: the CI must be re-derivable from the
159    /// file alone, by someone who never saw the run.
160    #[test]
161    fn the_ci_is_reproducible_from_the_retained_file_alone() {
162        let dir = tmpdir("rederive");
163        let path = dir.join("samples.jsonl.gz");
164        let original = deck(40);
165        write_samples_gz(&path, &original).expect("write");
166
167        let from_disk = read_samples_gz(&path).expect("read");
168        let a = bootstrap_agg_tok_s_ci(&original, 0.95).expect("n >= 2");
169        let b = bootstrap_agg_tok_s_ci(&from_disk, 0.95).expect("n >= 2");
170        assert_eq!(a, b, "a receipt is only evidence if its CI re-derives");
171        let _ = std::fs::remove_dir_all(&dir);
172    }
173
174    /// It really is gzip, not a JSONL file with a misleading name.
175    #[test]
176    fn the_file_is_actually_gzip() {
177        let dir = tmpdir("magic");
178        let path = dir.join("samples.jsonl.gz");
179        write_samples_gz(&path, &deck(3)).expect("write");
180        let bytes = std::fs::read(&path).expect("read raw");
181        assert_eq!(&bytes[..2], &[0x1f, 0x8b], "gzip magic bytes absent");
182        let _ = std::fs::remove_dir_all(&dir);
183    }
184
185    #[test]
186    fn digest_matches_the_bytes_on_disk() {
187        let dir = tmpdir("digest");
188        let path = dir.join("samples.jsonl.gz");
189        let meta = write_samples_gz(&path, &deck(5)).expect("write");
190        let bytes = std::fs::read(&path).expect("read raw");
191        let mut h = Sha256::new();
192        h.update(&bytes);
193        assert_eq!(meta.sha256, format!("{:x}", h.finalize()));
194        let _ = std::fs::remove_dir_all(&dir);
195    }
196
197    /// One line per request: a partially-truncated file still yields whole rows.
198    #[test]
199    fn one_json_object_per_line() {
200        let dir = tmpdir("lines");
201        let path = dir.join("samples.jsonl.gz");
202        write_samples_gz(&path, &deck(7)).expect("write");
203        let file = File::open(&path).expect("open");
204        let mut text = String::new();
205        std::io::Read::read_to_string(&mut flate2::read::GzDecoder::new(file), &mut text)
206            .expect("decode");
207        assert_eq!(text.lines().count(), 7);
208        for line in text.lines() {
209            let _: RequestSample = serde_json::from_str(line).expect("each line stands alone");
210        }
211        let _ = std::fs::remove_dir_all(&dir);
212    }
213
214    /// Every `Deserialize` type in `perf_gate/` refuses a key it does not know.
215    ///
216    /// `SamplesFile` did not, so a retention record could carry an extra field
217    /// — `rows_dropped`, a second digest, anything — and the reader would
218    /// silently ignore it. §4.4.5 makes the samples file the thing a receipt is
219    /// re-derivable FROM; a reader that drops what it does not understand
220    /// cannot say whether it read the whole record.
221    #[test]
222    fn a_samples_record_with_an_unknown_key_is_refused() {
223        let honest = r#"{"path":"samples.c1.r1.jsonl.gz","sha256":"ab","bytes":10,"rows":3}"#;
224        let parsed: SamplesFile = serde_json::from_str(honest).expect("the known shape parses");
225        assert_eq!(parsed.rows, 3);
226
227        let extra = r#"{"path":"s.gz","sha256":"ab","bytes":10,"rows":3,"rows_dropped":7}"#;
228        let err = serde_json::from_str::<SamplesFile>(extra)
229            .expect_err("an unknown key must be refused, not dropped");
230        assert!(err.to_string().contains("rows_dropped"), "{err}");
231    }
232
233    #[test]
234    fn budget_check_takes_the_budget_as_an_argument() {
235        let dir = tmpdir("budget");
236        let path = dir.join("samples.jsonl.gz");
237        let meta = write_samples_gz(&path, &deck(10)).expect("write");
238        assert!(meta.exceeds_budget(0));
239        assert!(!meta.exceeds_budget(u64::MAX));
240        let _ = std::fs::remove_dir_all(&dir);
241    }
242}