Skip to main content

cortex/
telemetry.rs

1// Copyright 2015-2025 Deyan Ginev. See the LICENSE
2// file at the top-level directory of this distribution.
3//
4// Licensed under the MIT license <LICENSE-MIT or http://opensource.org/licenses/MIT>.
5// This file may not be copied, modified, or distributed
6// except according to those terms.
7
8//! Retroactive telemetry aggregation over a completed `(corpus, service)` run.
9//!
10//! The latexml-oxide `cortex_worker` writes a per-job `telemetry.json` into every result archive
11//! (wall/RSS, per-phase microseconds, message + asset counts). This module reads those records back
12//! off disk — one random-access `by_name("telemetry.json")` seek per result ZIP, mirroring
13//! [`crate::helpers`]'s `read_cortex_log` — and rolls them up into a [`TelemetrySummary`]:
14//! nearest-rank wall/RSS percentiles, a per-phase P99 breakdown, an outcome mix, and the slowest /
15//! highest-RSS witness papers.
16//!
17//! It is **read-only and off the dispatch hot path** — the frontend telemetry dashboard
18//! ([`crate::frontend::telemetry`]) drives it lazily on a cache miss. Every field of a record is
19//! `#[serde(default)]`, because the worker's failure path emits only `paper_id`/`category`/
20//! `exit_code`; a missing numeric or array field is simply zero / empty, never a parse error.
21
22use std::collections::HashMap;
23use std::path::Path;
24use std::thread::available_parallelism;
25
26use serde::{Deserialize, Serialize};
27
28use crate::helpers::result_archive_path;
29
30/// The 17 conversion phases the worker times, in emission order — the index into a
31/// [`TelemetryRecord::phase_us`] entry and the label of a per-phase P99 row. Kept in lock-step with
32/// the latexml-oxide worker's telemetry writer; a shorter/absent `phase_us` (the failure path)
33/// simply contributes nothing to the tail phases.
34pub const PHASES: [&str; 17] = [
35  "bootstrap",
36  "digest",
37  "build",
38  "rewrite",
39  "math_parse",
40  "post_xml_parse",
41  "post_scan",
42  "bibliography",
43  "crossref",
44  "graphics",
45  "math_images",
46  "mathml_pres",
47  "mathml_cont",
48  "split",
49  "xslt",
50  "html5_fixups",
51  "serialize",
52];
53
54/// One worker `telemetry.json` record. Every field is `#[serde(default)]`: the worker's failure
55/// path emits only `paper_id`/`category`/`exit_code`, so a missing numeric field decodes to `0` and
56/// a missing `phase_us` to an empty vector rather than failing the whole parse.
57#[derive(Debug, Default, Deserialize)]
58pub struct TelemetryRecord {
59  /// The document/paper id (e.g. `0801.1234`).
60  #[serde(default)]
61  pub paper_id: String,
62  /// The git sha of the worker binary that produced this record.
63  #[serde(default)]
64  pub git_sha: String,
65  /// The host that ran the conversion.
66  #[serde(default)]
67  pub host: String,
68  /// The worker's own outcome category: `ok` | `conversion_error` | `conversion_fatal`.
69  #[serde(default)]
70  pub category: String,
71  /// The process exit code (`>= 3` fatal, `2` error, lower OK/warnings).
72  #[serde(default)]
73  pub exit_code: i64,
74  /// Total conversion wall time, in microseconds.
75  #[serde(default)]
76  pub wall_us: u64,
77  /// Peak resident set size, in KiB.
78  #[serde(default)]
79  pub max_rss_kb: u64,
80  /// Per-phase wall time in microseconds, aligned to [`PHASES`] (17 entries on the success path,
81  /// possibly shorter/empty on the failure path).
82  #[serde(default)]
83  pub phase_us: Vec<u64>,
84  /// Warning-severity message count.
85  #[serde(default)]
86  pub warnings: u64,
87  /// Error-severity message count.
88  #[serde(default)]
89  pub errors: u64,
90  /// Fatal-severity message count.
91  #[serde(default)]
92  pub fatal_errors: u64,
93  /// Number of parsed formulae.
94  #[serde(default)]
95  pub formulae: u64,
96  /// Number of math-parse invocations (≈ one per formula parsed).
97  #[serde(default)]
98  pub math_parse_attempts: u64,
99  /// Total candidate parses produced across all invocations — `>= attempts` under
100  /// grammar ambiguity; `count / attempts` is the over-parse (ambiguity) multiplier.
101  #[serde(default)]
102  pub math_parse_count: u64,
103  /// Number of graphics assets emitted.
104  #[serde(default)]
105  pub graphics_assets: u64,
106  /// Serialized output size, in bytes.
107  #[serde(default)]
108  pub output_bytes: u64,
109}
110
111/// Reads and decodes the `telemetry.json` member of a result `.zip`. Mirrors
112/// [`crate::helpers`]'s `read_cortex_log`: the pure-Rust `zip` crate's random-access `by_name`
113/// seeks straight to `telemetry.json` via the central directory, never decompressing the
114/// (potentially large) converted output. Returns the decoded record, or an `Err` describing why it
115/// couldn't (a non-zip / corrupt archive, a missing `telemetry.json`, or invalid JSON) — the caller
116/// counts that as a skipped sample rather than failing the whole aggregation.
117pub fn read_telemetry_json(result: &Path) -> Result<TelemetryRecord, String> {
118  let file = std::fs::File::open(result).map_err(|e| format!("cannot open result archive: {e}"))?;
119  let mut archive = zip::ZipArchive::new(file).map_err(|e| format!("not a readable zip: {e}"))?;
120  let mut entry = archive
121    .by_name("telemetry.json")
122    .map_err(|e| format!("no telemetry.json entry: {e}"))?;
123  let mut raw = Vec::new();
124  {
125    use std::io::Read;
126    entry
127      .read_to_end(&mut raw)
128      .map_err(|e| format!("reading telemetry.json failed: {e}"))?;
129  }
130  serde_json::from_slice(&raw).map_err(|e| format!("malformed telemetry.json: {e}"))
131}
132
133/// Nearest-rank percentiles of a sample (plus its max), in whatever unit the input carries.
134#[derive(Debug, Default, Clone, Serialize)]
135pub struct Percentiles {
136  /// 50th percentile (median).
137  pub p50: u64,
138  /// 90th percentile.
139  pub p90: u64,
140  /// 99th percentile.
141  pub p99: u64,
142  /// Sample maximum.
143  pub max: u64,
144}
145
146/// Nearest-rank percentiles of `values` (the copy is sorted; the input may be unsorted). The rank
147/// for percentile `p` is `ceil(p/100 * n)` clamped to `[1, n]`, taken at index `rank - 1`. An empty
148/// sample yields all-zeros.
149pub fn percentiles(values: &[u64]) -> Percentiles {
150  let n = values.len();
151  if n == 0 {
152    return Percentiles::default();
153  }
154  let mut sorted = values.to_vec();
155  sorted.sort_unstable();
156  // nearest-rank: rank = ceil(p/100 * n), clamped to [1, n]; value at index rank-1.
157  let at = |p: usize| -> u64 {
158    let rank = (p * n).div_ceil(100).clamp(1, n);
159    sorted[rank - 1]
160  };
161  Percentiles {
162    p50: at(50),
163    p90: at(90),
164    p99: at(99),
165    max: sorted[n - 1],
166  }
167}
168
169/// Wall-time tail concentration: how top-heavy the run is, and how close the slowest papers get to
170/// the conversion timeout. `topN_pct_wall_share` is the fraction (%) of *all* wall time held by the
171/// slowest N% of papers; the `over_*s` counts are papers at/above that wall threshold.
172#[derive(Debug, Default, Clone, Serialize)]
173pub struct TailStats {
174  /// % of all wall time held by the slowest 1% of papers.
175  pub top1pct_wall_share: f64,
176  /// % of all wall time held by the slowest 5% of papers.
177  pub top5pct_wall_share: f64,
178  /// Papers with wall time ≥ 30s.
179  pub over_30s: u64,
180  /// Papers with wall time ≥ 60s.
181  pub over_60s: u64,
182  /// Papers with wall time ≥ 120s.
183  pub over_120s: u64,
184  /// Papers with wall time ≥ 180s (the conversion timeout).
185  pub over_180s: u64,
186}
187
188/// Peak-RSS bucket counts — how many papers cross each memory line (the 4 GiB alloc wall being the
189/// release concern).
190#[derive(Debug, Default, Clone, Serialize)]
191pub struct RssBuckets {
192  /// Papers with peak RSS ≥ 2 GiB.
193  pub over_2gib: u64,
194  /// Papers with peak RSS ≥ 3 GiB.
195  pub over_3gib: u64,
196  /// Papers with peak RSS ≥ 4 GiB (the alloc wall).
197  pub over_4gib: u64,
198}
199
200/// Math-parsing rollup. `parses_per_formula` = `parse_count / attempts` — the corpus-wide grammar
201/// ambiguity (over-parse) multiplier that semantics-pruning then collapses.
202#[derive(Debug, Default, Clone, Serialize)]
203pub struct MathStats {
204  /// Total formulae parsed across the run.
205  pub formulae: u64,
206  /// Total math-parse invocations (≈ one per formula).
207  pub parse_invocations: u64,
208  /// Total candidate parses produced (≥ invocations under ambiguity).
209  pub parse_count: u64,
210  /// `parse_count / invocations` — the over-parse (ambiguity) multiplier.
211  pub parses_per_formula: f64,
212}
213
214/// Wall-time profile of one outcome bucket — used to show that `fatal` papers are bimodal (most
215/// fail fast; a slow-runaway subset burns ~timeout before dying).
216#[derive(Debug, Default, Clone, Serialize)]
217pub struct OutcomeWall {
218  /// Number of papers in this outcome bucket.
219  pub n: usize,
220  /// Median wall time (ms).
221  pub median_ms: u64,
222  /// Mean wall time (ms).
223  pub mean_ms: u64,
224  /// P99 wall time (ms).
225  pub p99_ms: u64,
226}
227
228/// The rolled-up telemetry of a completed `(corpus, service)` run — the shared read model for both
229/// the telemetry dashboard screen and its agent JSON twin.
230#[derive(Debug, Clone, Serialize)]
231pub struct TelemetrySummary {
232  /// Corpus name.
233  pub corpus: String,
234  /// Service name.
235  pub service: String,
236  /// Number of result archives that yielded a telemetry record.
237  pub sample_count: usize,
238  /// Number of tasks whose result archive was missing / unreadable / lacked telemetry.json.
239  pub skipped: usize,
240  /// Outcome mix, in canonical order (`no_problem`, `warning`, `error`, `fatal`).
241  pub outcome_counts: Vec<(String, u64)>,
242  /// Wall-time percentiles, in milliseconds.
243  pub wall_ms: Percentiles,
244  /// Peak-RSS percentiles, in MiB.
245  pub rss_mib: Percentiles,
246  /// Per-phase P99 wall time in milliseconds, one entry per [`PHASES`] label (17 entries).
247  pub phase_p99_ms: Vec<(String, u64)>,
248  /// Per-phase share (%) of *total* wall time — the run's time budget, one entry per [`PHASES`]
249  /// label, ordered by descending share. "Where the wall goes."
250  pub phase_wall_pct: Vec<(String, f64)>,
251  /// Wall-time tail concentration + near-timeout counts.
252  pub tail: TailStats,
253  /// Peak-RSS bucket counts.
254  pub rss_buckets: RssBuckets,
255  /// Math parsing rollup (over-parse multiplier).
256  pub math: MathStats,
257  /// Dominant phase among the slowest 50 papers (which phase drives the tail), highest count
258  /// first.
259  pub slow_tail_dominant: Vec<(String, u64)>,
260  /// Wall profile of the `fatal` bucket.
261  pub fatal_profile: OutcomeWall,
262  /// Wall profile of the `no_problem` bucket (baseline to contrast fatals against).
263  pub no_problem_profile: OutcomeWall,
264  /// The slowest paper by wall time — `(paper_id, wall_ms)` — or `None` for an empty sample.
265  pub slowest: Option<(String, u64)>,
266  /// The highest peak-RSS paper — `(paper_id, rss_mib)` — or `None` for an empty sample.
267  pub highest_rss: Option<(String, u64)>,
268  /// Total formulae parsed across the run.
269  pub total_formulae: u64,
270  /// Total graphics assets emitted across the run.
271  pub total_graphics_assets: u64,
272  /// Total serialized output bytes across the run.
273  pub total_output_bytes: u64,
274  /// Unix timestamp (seconds) at which this summary was computed.
275  pub generated_unix: i64,
276}
277
278/// Rolls a slice of telemetry records into a [`TelemetrySummary`]. Pure and DB-free (the reading is
279/// [`aggregate`]'s job): outcome bucketing, unit conversions (µs→ms, KiB→MiB), nearest-rank
280/// percentiles, per-phase P99, witnesses, and totals. `skipped` is left `0` — [`aggregate`] fills
281/// it from the read pass.
282pub fn summarize(corpus: &str, service: &str, records: &[TelemetryRecord]) -> TelemetrySummary {
283  // Outcome bucket — a record lands in exactly one, most-severe-first (a fatal record with
284  // warnings is a fatal, not a warning). Shared by the mix count and the per-bucket wall profiles.
285  let bucket = |r: &TelemetryRecord| -> &'static str {
286    if r.fatal_errors > 0 || r.category.contains("fatal") || r.exit_code >= 3 {
287      "fatal"
288    } else if r.errors > 0 || r.category == "conversion_error" || r.exit_code == 2 {
289      "error"
290    } else if r.warnings > 0 {
291      "warning"
292    } else {
293      "no_problem"
294    }
295  };
296  let (mut no_problem, mut warning, mut error, mut fatal) = (0u64, 0u64, 0u64, 0u64);
297  for record in records {
298    match bucket(record) {
299      "fatal" => fatal += 1,
300      "error" => error += 1,
301      "warning" => warning += 1,
302      _ => no_problem += 1,
303    }
304  }
305  let outcome_counts = vec![
306    ("no_problem".to_string(), no_problem),
307    ("warning".to_string(), warning),
308    ("error".to_string(), error),
309    ("fatal".to_string(), fatal),
310  ];
311
312  // Wall / RSS percentiles, converted to the display units up front so the percentile ranks over
313  // the same integers the witnesses report.
314  let wall_ms_values: Vec<u64> = records.iter().map(|record| record.wall_us / 1000).collect();
315  let rss_mib_values: Vec<u64> = records
316    .iter()
317    .map(|record| record.max_rss_kb / 1024)
318    .collect();
319  let wall_ms = percentiles(&wall_ms_values);
320  let rss_mib = percentiles(&rss_mib_values);
321
322  // Per-phase P99 (ms). A record with a short/empty `phase_us` simply doesn't contribute to the
323  // phases it lacks (filter_map on the missing index), so the failure path never skews a phase.
324  let phase_p99_ms = PHASES
325    .iter()
326    .enumerate()
327    .map(|(index, &name)| {
328      let phase_ms: Vec<u64> = records
329        .iter()
330        .filter_map(|record| record.phase_us.get(index).map(|us| us / 1000))
331        .collect();
332      (name.to_string(), percentiles(&phase_ms).p99)
333    })
334    .collect();
335
336  // Witnesses: the extreme papers, reported in the same display units as their percentile tables.
337  let slowest = records
338    .iter()
339    .max_by_key(|record| record.wall_us)
340    .map(|record| (record.paper_id.clone(), record.wall_us / 1000));
341  let highest_rss = records
342    .iter()
343    .max_by_key(|record| record.max_rss_kb)
344    .map(|record| (record.paper_id.clone(), record.max_rss_kb / 1024));
345
346  // Totals — saturating, so a pathological corpus can never overflow-panic the aggregation.
347  let total_formulae = records
348    .iter()
349    .fold(0u64, |acc, record| acc.saturating_add(record.formulae));
350  let total_graphics_assets = records.iter().fold(0u64, |acc, record| {
351    acc.saturating_add(record.graphics_assets)
352  });
353  let total_output_bytes = records
354    .iter()
355    .fold(0u64, |acc, record| acc.saturating_add(record.output_bytes));
356
357  // --- Phase budget: each phase's share (%) of total wall, ordered by descending share. ---
358  let total_wall: u64 = records
359    .iter()
360    .map(|r| r.wall_us)
361    .fold(0, u64::saturating_add);
362  let mut phase_tot = [0u128; 17];
363  for r in records {
364    for (i, &us) in r.phase_us.iter().take(17).enumerate() {
365      phase_tot[i] += us as u128;
366    }
367  }
368  let mut phase_wall_pct: Vec<(String, f64)> = PHASES
369    .iter()
370    .enumerate()
371    .map(|(i, &name)| {
372      let pct = if total_wall > 0 {
373        100.0 * phase_tot[i] as f64 / total_wall as f64
374      } else {
375        0.0
376      };
377      (name.to_string(), pct)
378    })
379    .collect();
380  phase_wall_pct.sort_by(|a, b| b.1.total_cmp(&a.1));
381
382  // --- Wall tail concentration + near-timeout counts. ---
383  let mut walls_us: Vec<u64> = records.iter().map(|r| r.wall_us).collect();
384  walls_us.sort_unstable();
385  let tot_wall_f = total_wall as f64;
386  let top_share = |frac: f64| -> f64 {
387    let k = ((walls_us.len() as f64) * frac).floor() as usize;
388    if k == 0 || tot_wall_f == 0.0 {
389      return 0.0;
390    }
391    let s: u128 = walls_us[walls_us.len() - k..]
392      .iter()
393      .map(|&w| w as u128)
394      .sum();
395    100.0 * s as f64 / tot_wall_f
396  };
397  let count_over = |secs: u64| walls_us.iter().filter(|&&w| w >= secs * 1_000_000).count() as u64;
398  let tail = TailStats {
399    top1pct_wall_share: top_share(0.01),
400    top5pct_wall_share: top_share(0.05),
401    over_30s: count_over(30),
402    over_60s: count_over(60),
403    over_120s: count_over(120),
404    over_180s: count_over(180),
405  };
406
407  // --- Peak-RSS buckets. ---
408  let rss_over = |gib: u64| {
409    records
410      .iter()
411      .filter(|r| r.max_rss_kb >= gib * 1024 * 1024)
412      .count() as u64
413  };
414  let rss_buckets = RssBuckets {
415    over_2gib: rss_over(2),
416    over_3gib: rss_over(3),
417    over_4gib: rss_over(4),
418  };
419
420  // --- Math over-parse multiplier (parses produced per parse invocation). ---
421  let m_inv = records
422    .iter()
423    .fold(0u64, |a, r| a.saturating_add(r.math_parse_attempts));
424  let m_cnt = records
425    .iter()
426    .fold(0u64, |a, r| a.saturating_add(r.math_parse_count));
427  let math = MathStats {
428    formulae: total_formulae,
429    parse_invocations: m_inv,
430    parse_count: m_cnt,
431    parses_per_formula: if m_inv > 0 {
432      m_cnt as f64 / m_inv as f64
433    } else {
434      0.0
435    },
436  };
437
438  // --- Slow-tail driver: dominant phase among the slowest 50 papers. ---
439  let mut by_wall: Vec<&TelemetryRecord> = records.iter().collect();
440  by_wall.sort_by_key(|r| std::cmp::Reverse(r.wall_us));
441  let mut dom: HashMap<&'static str, u64> = HashMap::new();
442  for r in by_wall.iter().take(50) {
443    if let Some((i, _)) = r
444      .phase_us
445      .iter()
446      .take(17)
447      .enumerate()
448      .max_by_key(|&(_, us)| *us)
449    {
450      *dom.entry(PHASES[i]).or_insert(0) += 1;
451    }
452  }
453  let mut slow_tail_dominant: Vec<(String, u64)> =
454    dom.into_iter().map(|(k, v)| (k.to_string(), v)).collect();
455  slow_tail_dominant.sort_by_key(|d| std::cmp::Reverse(d.1));
456
457  // --- Wall profile per outcome bucket (fatal bimodality: fast-fail vs slow-runaway). ---
458  let profile = |name: &str| -> OutcomeWall {
459    let mut ms: Vec<u64> = records
460      .iter()
461      .filter(|r| bucket(r) == name)
462      .map(|r| r.wall_us / 1000)
463      .collect();
464    if ms.is_empty() {
465      return OutcomeWall::default();
466    }
467    ms.sort_unstable();
468    let sum: u128 = ms.iter().map(|&x| x as u128).sum();
469    let p = percentiles(&ms);
470    OutcomeWall {
471      n: ms.len(),
472      median_ms: p.p50,
473      mean_ms: (sum / ms.len() as u128) as u64,
474      p99_ms: p.p99,
475    }
476  };
477  let fatal_profile = profile("fatal");
478  let no_problem_profile = profile("no_problem");
479
480  let generated_unix = std::time::SystemTime::now()
481    .duration_since(std::time::UNIX_EPOCH)
482    .map(|elapsed| elapsed.as_secs() as i64)
483    .unwrap_or(0);
484
485  TelemetrySummary {
486    corpus: corpus.to_string(),
487    service: service.to_string(),
488    sample_count: records.len(),
489    skipped: 0,
490    outcome_counts,
491    wall_ms,
492    rss_mib,
493    phase_p99_ms,
494    phase_wall_pct,
495    tail,
496    rss_buckets,
497    math,
498    slow_tail_dominant,
499    fatal_profile,
500    no_problem_profile,
501    slowest,
502    highest_rss,
503    total_formulae,
504    total_graphics_assets,
505    total_output_bytes,
506    generated_unix,
507  }
508}
509
510/// Reads one slice of task `entry` paths' result archives into telemetry records, returning the
511/// records and the count skipped (missing / unreadable / no telemetry.json). The per-thread body of
512/// [`aggregate`].
513fn read_chunk(
514  chunk: &[String],
515  service_name: &str,
516  sandbox_id: Option<i32>,
517) -> (Vec<TelemetryRecord>, usize) {
518  let mut records = Vec::new();
519  let mut skipped = 0usize;
520  for entry in chunk {
521    match result_archive_path(entry, service_name, sandbox_id) {
522      Some(path) => match read_telemetry_json(&path) {
523        Ok(mut record) => {
524          // The worker stamps `paper_id` with the numeric cortex task id, but the dashboard's
525          // witness links target `/document/<corpus>/<service>/<name>`, which resolves by the
526          // document's short name (e.g. `2605.11315`). Re-key on the entry-derived name so the
527          // witness links resolve and read as paper ids, not opaque task ids.
528          record.paper_id = crate::helpers::entry_document_name(entry);
529          records.push(record);
530        },
531        Err(_) => skipped += 1,
532      },
533      None => skipped += 1,
534    }
535  }
536  (records, skipped)
537}
538
539/// Aggregates the telemetry of a completed run: reads each task `entry`'s result-archive
540/// `telemetry.json` **in parallel** (a bounded `std::thread::scope` pool, `N =
541/// available_parallelism` capped at 16, the entries chunked evenly across threads), skipping any
542/// archive that is missing / unreadable / lacks a telemetry record, then [`summarize`]s.
543/// `sandbox_id` is threaded through [`result_archive_path`] so a **sandbox** corpus reads its own
544/// name-scoped archives, not the parent's.
545pub fn aggregate(
546  corpus_name: &str,
547  service_name: &str,
548  sandbox_id: Option<i32>,
549  entries: Vec<String>,
550) -> TelemetrySummary {
551  let total = entries.len();
552  if total == 0 {
553    return summarize(corpus_name, service_name, &[]);
554  }
555  // Bound the pool: never more threads than cores (cap 16), nor than there are entries.
556  let workers = available_parallelism()
557    .map(|n| n.get())
558    .unwrap_or(1)
559    .clamp(1, 16)
560    .min(total);
561  let chunk_size = total.div_ceil(workers);
562
563  let mut records: Vec<TelemetryRecord> = Vec::new();
564  let mut skipped = 0usize;
565  std::thread::scope(|scope| {
566    // Pair each handle with its chunk length so a panicked worker counts its whole chunk as
567    // skipped rather than silently vanishing (fail-safe toward flagging, per DESIGN_PRINCIPLES).
568    let handles: Vec<_> = entries
569      .chunks(chunk_size)
570      .map(|chunk| {
571        (
572          chunk.len(),
573          scope.spawn(move || read_chunk(chunk, service_name, sandbox_id)),
574        )
575      })
576      .collect();
577    for (chunk_len, handle) in handles {
578      match handle.join() {
579        Ok((chunk_records, chunk_skipped)) => {
580          records.extend(chunk_records);
581          skipped += chunk_skipped;
582        },
583        Err(_) => skipped += chunk_len,
584      }
585    }
586  });
587
588  let mut summary = summarize(corpus_name, service_name, &records);
589  summary.skipped = skipped;
590  summary
591}
592
593#[cfg(test)]
594mod tests {
595  use super::{PHASES, TelemetryRecord, percentiles, summarize};
596
597  /// A record with everything zeroed and a full 17-entry `phase_us`, for terse test construction.
598  fn record(paper_id: &str) -> TelemetryRecord {
599    TelemetryRecord {
600      paper_id: paper_id.to_string(),
601      phase_us: vec![0; PHASES.len()],
602      ..Default::default()
603    }
604  }
605
606  #[test]
607  fn nearest_rank_percentiles_on_a_known_vector() {
608    // Ten evenly-spaced values (deliberately unsorted on input — `percentiles` sorts a copy).
609    let values = [50, 20, 100, 40, 10, 70, 30, 90, 60, 80];
610    let p = percentiles(&values);
611    // rank = ceil(p/100 * 10): p50→5→idx4→50, p90→9→idx8→90, p99→ceil(9.9)=10→idx9→100.
612    assert_eq!(p.p50, 50);
613    assert_eq!(p.p90, 90);
614    assert_eq!(p.p99, 100);
615    assert_eq!(p.max, 100);
616    // Empty sample → all zeros, no panic.
617    let empty = percentiles(&[]);
618    assert_eq!((empty.p50, empty.p90, empty.p99, empty.max), (0, 0, 0, 0));
619  }
620
621  #[test]
622  fn summarize_buckets_percentiles_and_phase_length() {
623    // One record per outcome, with distinct wall times so the percentiles are predictable.
624    let mut clean = record("clean"); // no problems
625    clean.wall_us = 1_000_000; // 1000 ms
626    clean.max_rss_kb = 1_048_576; // 1024 MiB
627    let mut warned = record("warned");
628    warned.warnings = 3;
629    warned.wall_us = 2_000_000; // 2000 ms
630    let mut errored = record("errored");
631    errored.errors = 1;
632    errored.category = "conversion_error".to_string();
633    errored.exit_code = 2;
634    errored.wall_us = 3_000_000; // 3000 ms
635    let mut fataled = record("fataled");
636    fataled.fatal_errors = 1;
637    fataled.category = "conversion_fatal".to_string();
638    fataled.exit_code = 3;
639    fataled.wall_us = 4_000_000; // 4000 ms — the slowest
640
641    let records = [clean, warned, errored, fataled];
642    let summary = summarize("c", "s", &records);
643
644    // Outcome buckets, in canonical order, one each.
645    assert_eq!(
646      summary.outcome_counts,
647      vec![
648        ("no_problem".to_string(), 1),
649        ("warning".to_string(), 1),
650        ("error".to_string(), 1),
651        ("fatal".to_string(), 1),
652      ]
653    );
654    // Wall percentiles over [1000,2000,3000,4000] ms: p50→idx1→2000, p90/p99→idx3→4000, max→4000.
655    assert_eq!(summary.wall_ms.p50, 2000);
656    assert_eq!(summary.wall_ms.p90, 4000);
657    assert_eq!(summary.wall_ms.p99, 4000);
658    assert_eq!(summary.wall_ms.max, 4000);
659    // Always one P99 row per phase.
660    assert_eq!(summary.phase_p99_ms.len(), 17);
661    assert_eq!(summary.phase_p99_ms[0].0, "bootstrap");
662    // The slowest witness is the 4000 ms fatal paper.
663    assert_eq!(summary.slowest, Some(("fataled".to_string(), 4000)));
664    assert_eq!(summary.sample_count, 4);
665    assert_eq!(summary.skipped, 0);
666  }
667
668  #[test]
669  fn summarize_budget_tail_math_and_profiles() {
670    let mut records = Vec::new();
671    // 8 fast no_problem papers: 1s, digest 0.7s + math 0.2s, 10 formulae / 13 parses each.
672    for i in 0..8 {
673      let mut r = record(&format!("ok{i}"));
674      r.wall_us = 1_000_000;
675      r.phase_us[1] = 700_000; // digest
676      r.phase_us[4] = 200_000; // math_parse
677      r.formulae = 10;
678      r.math_parse_attempts = 10;
679      r.math_parse_count = 13;
680      records.push(r);
681    }
682    // 1 slow fatal: 40s, all in digest, 0 formulae (the runaway signature).
683    let mut slow_fatal = record("slowfatal");
684    slow_fatal.wall_us = 40_000_000;
685    slow_fatal.phase_us[1] = 39_000_000;
686    slow_fatal.fatal_errors = 1;
687    records.push(slow_fatal);
688    // 1 slow warning: 65s, math-dominated (over the 60s line).
689    let mut slow_warn = record("slowwarn");
690    slow_warn.wall_us = 65_000_000;
691    slow_warn.phase_us[4] = 60_000_000;
692    slow_warn.warnings = 1;
693    records.push(slow_warn);
694
695    let s = summarize("c", "s", &records);
696
697    // Phase budget: 17 entries, sorted descending, math_parse (61.6s) tops digest (44.6s).
698    assert_eq!(s.phase_wall_pct.len(), 17);
699    assert_eq!(s.phase_wall_pct[0].0, "math_parse");
700    assert!(s.phase_wall_pct.windows(2).all(|w| w[0].1 >= w[1].1));
701    let budget_sum: f64 = s.phase_wall_pct.iter().map(|(_, p)| p).sum();
702    assert!(
703      (90.0..=100.0).contains(&budget_sum),
704      "instrumented phases ≈ total wall"
705    );
706
707    // Tail: the 40s + 65s papers are ≥30s; only the 65s is ≥60s; none hit the timeout.
708    assert_eq!(s.tail.over_30s, 2);
709    assert_eq!(s.tail.over_60s, 1);
710    assert_eq!(s.tail.over_180s, 0);
711
712    // Math over-parse: 8×13 parses / 8×10 invocations = 1.3.
713    assert_eq!(s.math.formulae, 80);
714    assert!((s.math.parses_per_formula - 1.3).abs() < 1e-9);
715
716    // Outcome profiles: one fatal (40s), eight clean (1s each).
717    assert_eq!(s.fatal_profile.n, 1);
718    assert_eq!(s.fatal_profile.median_ms, 40_000);
719    assert_eq!(s.no_problem_profile.n, 8);
720    assert_eq!(s.no_problem_profile.median_ms, 1_000);
721
722    // Slow-tail driver present (digest dominates the two slow papers... math dominates the warn).
723    assert!(!s.slow_tail_dominant.is_empty());
724  }
725
726  #[test]
727  fn minimal_failure_record_parses_and_summarizes() {
728    // The worker's failure path emits only these three fields — every numeric/array field is
729    // `#[serde(default)]`, so this must parse with an empty `phase_us`.
730    let json = r#"{"paper_id":"x","category":"conversion_fatal","exit_code":3}"#;
731    let record: TelemetryRecord = serde_json::from_str(json).expect("minimal record parses");
732    assert_eq!(record.paper_id, "x");
733    assert_eq!(record.exit_code, 3);
734    assert!(
735      record.phase_us.is_empty(),
736      "absent phase_us decodes to empty"
737    );
738
739    // summarize must handle the empty phase arrays without panicking, still yielding 17 phase rows
740    // (all zero) and classifying the record as fatal.
741    let summary = summarize("c", "s", std::slice::from_ref(&record));
742    assert_eq!(summary.phase_p99_ms.len(), 17);
743    assert!(summary.phase_p99_ms.iter().all(|(_, p99)| *p99 == 0));
744    assert_eq!(summary.outcome_counts[3], ("fatal".to_string(), 1));
745  }
746}