parse.rs 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. //! Parsers for benchmark report files: the tool-eval-bench `--json-file`
  2. //! output (authoritative score) and the llama-benchy perf tables found in
  3. //! the `.md`/`.log` reports (generation speed).
  4. use serde::{Deserialize, Serialize};
  5. /// One llama-benchy perf cell: decode speed at a context depth and
  6. /// concurrency.
  7. #[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Debug)]
  8. pub(crate) struct PerfCell {
  9. pub(crate) d: u32, // context depth (0, 4096, 8192)
  10. pub(crate) c: u32, // concurrency (1, 2, …)
  11. pub(crate) tg: f64, // decode tokens/s
  12. }
  13. /// Parse the tool-eval-bench `--json-file` output (authoritative for
  14. /// `sm121 bench` runs): `scores.final_score` (0–100), `scores.total_points`
  15. /// / `max_points` (hardmode). Returns `(pct, points, ratio)`; `points` is
  16. /// suppressed when max is 100 (already normalized, same rule as the text
  17. /// parsers).
  18. pub(crate) fn parse_bench_json(content: &str) -> Option<(f64, Option<String>, Option<f64>)> {
  19. let v: serde_json::Value = serde_json::from_str(content).ok()?;
  20. let s = v.get("scores")?;
  21. let pct = s.get("final_score").and_then(|x| x.as_f64())?;
  22. let tp = s.get("total_points").and_then(|x| x.as_u64());
  23. let mp = s.get("max_points").and_then(|x| x.as_u64());
  24. let (points, ratio) = match (tp, mp) {
  25. (Some(p), Some(m)) if m > 0 && m != 100 => {
  26. (Some(format!("{p}/{m}")), Some(p as f64 / m as f64))
  27. }
  28. _ => (None, None),
  29. };
  30. Some((pct, points, ratio))
  31. }
  32. /// Did this bench run to the end? Decided from the JSON's own metadata,
  33. /// never from a fixed pool size: the tool stamps `status` (absent on
  34. /// legacy files) and `total_scenarios` beside `scenario_results`, so
  35. /// coverage is self-describing for every harness generation (88×2=176 in
  36. /// the dev-era, 92×2=184 since v2.7.0, whatever comes next). A run
  37. /// interrupted early either says so in `status` or leaves the results
  38. /// map shorter than the scenario count.
  39. pub(crate) fn bench_complete(content: &str) -> bool {
  40. let Ok(v) = serde_json::from_str::<serde_json::Value>(content) else {
  41. return false;
  42. };
  43. let scores = match v.get("scores") {
  44. Some(s) if s.is_object() => s,
  45. _ => return false,
  46. };
  47. let covered = match (
  48. v.get("total_scenarios").and_then(|x| x.as_u64()),
  49. scores
  50. .get("scenario_results")
  51. .and_then(|x| x.as_object())
  52. .map(|m| m.len() as u64),
  53. ) {
  54. (Some(total), Some(ran)) => ran >= total,
  55. _ => true, // no coverage metadata to contradict
  56. };
  57. match v.get("status").and_then(|x| x.as_str()) {
  58. Some("completed") => covered,
  59. Some(_) => false,
  60. // Legacy files carry no status — a printed final score was
  61. // written only by a finished run.
  62. None => scores.get("final_score").and_then(|x| x.as_f64()).is_some() && covered,
  63. }
  64. }
  65. /// First non-empty perf box among the given contents (md first, then log —
  66. /// older reports carry the box in the .md, `sm121 bench` runs in the .log).
  67. pub(crate) fn perf_from(md: Option<&str>, log: Option<&str>) -> Vec<PerfCell> {
  68. for c in [md, log] {
  69. let v = c.map(parse_perf_box).unwrap_or_default();
  70. if !v.is_empty() {
  71. return v;
  72. }
  73. }
  74. Vec::new()
  75. }
  76. fn split_tokens(line: &str) -> impl Iterator<Item = &str> + '_ {
  77. line.split(|ch: char| ch.is_whitespace() || ch == '|' || ch == '│')
  78. }
  79. /// Parse llama-benchy perf table rows into `(depth, concurrency, tg t/s)`
  80. /// cells. Two known shapes:
  81. /// box: `│ pp2048 tg128 @ d0 │ c1 │ 1,500 │ 27.4 │ 1,398 │ 5,959 │ 2048+128`
  82. /// md: `| pp2048 tg128 @ d0 c2 | 1,728 | 36.0 | 2,097 | 8,802 | 2048+128`
  83. /// The `c` token (concurrency column or label suffix) is optional — absent
  84. /// means c1. Header/box-chrome lines have no `@ d<depth>` marker and are
  85. /// skipped.
  86. pub(crate) fn parse_perf_box(content: &str) -> Vec<PerfCell> {
  87. let mut out = Vec::new();
  88. for line in content.lines() {
  89. // Perf rows are table rows — box/md chrome with ≥3 column
  90. // separators. llama-benchy progress/spinner frames embed the same
  91. // `pp2048 tg128 @ d0` labels with no columns (they parsed as
  92. // d=0/c=1/tg=0.0 and shadowed the real cells in lookups).
  93. if line.matches(['|', '│']).count() < 3 {
  94. continue;
  95. }
  96. let Some(at) = line.find("@ d") else {
  97. continue;
  98. };
  99. let rest = &line[at + 3..];
  100. let dd = rest
  101. .find(|ch: char| !ch.is_ascii_digit())
  102. .unwrap_or(rest.len());
  103. let d: u32 = match rest[..dd].parse() {
  104. Ok(x) => x,
  105. Err(_) => continue,
  106. };
  107. let c: u32 = split_tokens(line)
  108. .find_map(|tok| tok.strip_prefix("c").and_then(|n| n.parse::<u32>().ok()))
  109. .unwrap_or(1);
  110. // numbers from the label onward: pp t/s, tg t/s, TTFT, total, 2048, 128
  111. // (llama-benchy marks caveat/cold cells with a trailing `*`, e.g. `675*`)
  112. let nums: Vec<f64> = split_tokens(&line[at..])
  113. .filter_map(|tok| {
  114. tok.trim_end_matches('*')
  115. .replace(',', "")
  116. .parse::<f64>()
  117. .ok()
  118. })
  119. .collect();
  120. if nums.len() >= 4 && nums[1] > 0.0 {
  121. out.push(PerfCell { d, c, tg: nums[1] });
  122. }
  123. }
  124. out
  125. }
  126. /// Quantization tokens, longest first so `nvfp4kv` wins over `nvfp4`, etc.
  127. pub(crate) const QUANTS: &[&str] = &[
  128. "nvfp4kv", "nvfp4", "mxfp8", "mxfp4", "w4a16", "dcp4", "exl3", "int8", "int4", "nf4", "fp8",
  129. "awq", "gptq", "bf16", "fp16",
  130. ];
  131. pub(crate) fn detect_quant(name: &str, model: &str) -> String {
  132. let hay = format!("{name} {model}").to_lowercase();
  133. // ponytail: token-scan heuristic over dir+model names; per-recipe `quant`
  134. // field if recipes ever grow one.
  135. for q in QUANTS {
  136. if hay.contains(q) {
  137. return q.to_uppercase();
  138. }
  139. }
  140. "—".to_string()
  141. }
  142. /// Parse the score headers used by campaign reports and raw bench logs:
  143. /// `## Score: 159/176 (90.4)`, `- **Final Score**: **81** / 100`, and the
  144. /// box-drawn log summary `│ Score: 88 / 100 │`. Lines must start with
  145. /// a score prefix (after stripping markdown bullets / box-drawing chars),
  146. /// so prose and embedded tool transcripts can't produce false positives.
  147. /// Returns `(points, max, normalized_pct)`.
  148. pub(crate) fn parse_score(content: &str) -> Option<(u32, u32, Option<f64>)> {
  149. for line in content.lines() {
  150. let l = line.trim_start_matches(|c: char| {
  151. matches!(c, '#' | '-' | '•' | '|' | '│' | '┃') || c.is_whitespace()
  152. });
  153. let is_score = l.starts_with("**Final Score**") || l.starts_with("Score:");
  154. if is_score && let Some((p, m)) = slash_pair(l) {
  155. let pct = paren_pct(l)
  156. // `Final Score` / log box are already out of 100
  157. .or_else(|| (m == 100).then_some(p as f64))
  158. .or_else(|| Some(p as f64 / m as f64 * 100.0));
  159. return Some((p, m, pct));
  160. }
  161. }
  162. None
  163. }
  164. /// First parenthesized number, e.g. `(90.4)` in `## Score: 159/176 (90.4)`.
  165. pub(crate) fn paren_pct(s: &str) -> Option<f64> {
  166. let open = s.find('(')?;
  167. let close = open + s[open..].find(')')?;
  168. s[open + 1..close].trim().parse().ok()
  169. }
  170. /// First `YYYY-MM-DDTHH:MM` (bench `**Date**` line) or `YYYY-MM-DDTHH-MM`
  171. /// (Run ID / full-report path) timestamp; returns `HH:MM`.
  172. pub(crate) fn extract_time(content: &str) -> Option<String> {
  173. for line in content.lines() {
  174. let b = line.as_bytes();
  175. for i in 0..b.len().saturating_sub(15) {
  176. let d = |j: usize| b[i + j].is_ascii_digit();
  177. if d(0)
  178. && d(1)
  179. && d(2)
  180. && d(3)
  181. && b[i + 4] == b'-'
  182. && d(5)
  183. && d(6)
  184. && b[i + 7] == b'-'
  185. && d(8)
  186. && d(9)
  187. && b[i + 10] == b'T'
  188. && d(11)
  189. && d(12)
  190. && (b[i + 13] == b':' || b[i + 13] == b'-')
  191. && d(14)
  192. && d(15)
  193. {
  194. let mut t = line[i + 11..i + 16].to_string();
  195. if t.as_bytes()[2] == b'-' {
  196. t.replace_range(2..3, ":");
  197. }
  198. return Some(t);
  199. }
  200. }
  201. }
  202. None
  203. }
  204. /// First `N…/M` pair in `s`, tolerating `*` and spaces around both sides
  205. /// (bold markdown). Plain digit scan — no regex dependency.
  206. pub(crate) fn slash_pair(s: &str) -> Option<(u32, u32)> {
  207. let b = s.as_bytes();
  208. let mut i = 0;
  209. while i < b.len() {
  210. if !b[i].is_ascii_digit() {
  211. i += 1;
  212. continue;
  213. }
  214. let start = i;
  215. while i < b.len() && b[i].is_ascii_digit() {
  216. i += 1;
  217. }
  218. let mut j = i;
  219. while j < b.len() && matches!(b[j], b' ' | b'*') {
  220. j += 1;
  221. }
  222. if j < b.len() && b[j] == b'/' {
  223. let mut k = j + 1;
  224. while k < b.len() && matches!(b[k], b' ' | b'*') {
  225. k += 1;
  226. }
  227. let dstart = k;
  228. while k < b.len() && b[k].is_ascii_digit() {
  229. k += 1;
  230. }
  231. if let (Ok(n), Ok(m)) = (s[start..i].parse(), s[dstart..k].parse()) {
  232. return Some((n, m));
  233. }
  234. }
  235. }
  236. None
  237. }