@oh-my-pi/pi-tui 18.8.8 → 18.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/CHANGELOG.md +56 -221
  2. package/dist/types/charts/chart-plan.d.ts +595 -13
  3. package/dist/types/charts/chart-svg.d.ts +38 -9
  4. package/dist/types/charts/table-data.d.ts +70 -2
  5. package/dist/types/chat/table-chart.d.ts +3 -2
  6. package/dist/types/chat/user-message.d.ts +12 -1
  7. package/dist/types/chrome/shared.d.ts +9 -0
  8. package/dist/types/chrome/transcript-container.d.ts +1 -0
  9. package/dist/types/components/editor.d.ts +6 -0
  10. package/dist/types/components/form.d.ts +1 -0
  11. package/dist/types/components/markdown.d.ts +7 -0
  12. package/dist/types/components/tree-view.d.ts +7 -0
  13. package/dist/types/kitty-graphics.d.ts +2 -2
  14. package/dist/types/loop-watchdog.d.ts +8 -7
  15. package/dist/types/multiplexers/cmux.d.ts +16 -0
  16. package/dist/types/multiplexers/herdr.d.ts +26 -0
  17. package/dist/types/multiplexers/notify.d.ts +13 -0
  18. package/dist/types/multiplexers/orca.d.ts +12 -0
  19. package/dist/types/multiplexers/screen.d.ts +9 -0
  20. package/dist/types/multiplexers/tmux.d.ts +29 -0
  21. package/dist/types/multiplexers/types.d.ts +100 -0
  22. package/dist/types/multiplexers/wmux.d.ts +12 -0
  23. package/dist/types/multiplexers/zellij.d.ts +12 -0
  24. package/dist/types/overlays/agent-hub-projection.d.ts +4 -3
  25. package/dist/types/overlays/agent-hub-types.d.ts +9 -0
  26. package/dist/types/overlays/annotation-overlay.d.ts +2 -0
  27. package/dist/types/overlays/model-browser.d.ts +9 -1
  28. package/dist/types/overlays/settings-defs.d.ts +1 -0
  29. package/dist/types/overlays/usage-display.d.ts +3 -1
  30. package/dist/types/setup/scenes/types.d.ts +1 -0
  31. package/dist/types/status-line/component.d.ts +2 -0
  32. package/dist/types/status-line/host.d.ts +4 -1
  33. package/dist/types/status-line/types.d.ts +11 -2
  34. package/dist/types/terminal-capabilities.d.ts +24 -32
  35. package/dist/types/terminal-multiplexer.d.ts +127 -14
  36. package/dist/types/terminal.d.ts +12 -4
  37. package/dist/types/theme/schema.d.ts +11 -2
  38. package/dist/types/theme/theme-class.d.ts +7 -0
  39. package/dist/types/tmux.d.ts +1 -4
  40. package/dist/types/tools/eval.d.ts +21 -2
  41. package/dist/types/tools/output-meta.d.ts +11 -1
  42. package/dist/types/tools/streaming-output.d.ts +9 -0
  43. package/package.json +9 -9
  44. package/src/charts/chart-plan.ts +4802 -88
  45. package/src/charts/chart-svg.ts +4315 -351
  46. package/src/charts/table-data.ts +1076 -34
  47. package/src/chat/assistant-message.ts +27 -9
  48. package/src/chat/chat-transcript-builder.ts +1 -3
  49. package/src/chat/svg-figure.ts +45 -8
  50. package/src/chat/table-chart.ts +7 -11
  51. package/src/chat/user-message.ts +40 -2
  52. package/src/chrome/shared.ts +53 -0
  53. package/src/chrome/transcript-container.ts +32 -9
  54. package/src/components/editor.ts +38 -5
  55. package/src/components/form.ts +8 -0
  56. package/src/components/markdown.ts +45 -2
  57. package/src/components/settings-list.ts +2 -0
  58. package/src/components/tree-view.ts +38 -8
  59. package/src/kitty-graphics.ts +4 -4
  60. package/src/loop-watchdog.ts +18 -13
  61. package/src/multiplexers/cmux.ts +29 -0
  62. package/src/multiplexers/herdr.ts +64 -0
  63. package/src/multiplexers/notify.ts +24 -0
  64. package/src/multiplexers/orca.ts +16 -0
  65. package/src/multiplexers/screen.ts +15 -0
  66. package/src/multiplexers/tmux.ts +40 -0
  67. package/src/multiplexers/types.ts +110 -0
  68. package/src/multiplexers/wmux.ts +16 -0
  69. package/src/multiplexers/zellij.ts +24 -0
  70. package/src/overlays/agent-hub-projection.ts +51 -14
  71. package/src/overlays/agent-hub-types.ts +6 -0
  72. package/src/overlays/annotation-overlay.ts +2 -1
  73. package/src/overlays/model-browser.ts +43 -7
  74. package/src/overlays/model-hub.ts +12 -19
  75. package/src/overlays/settings-defs.ts +4 -3
  76. package/src/overlays/settings-selector.ts +10 -1
  77. package/src/overlays/tree-selector.ts +21 -16
  78. package/src/overlays/usage-display.ts +10 -4
  79. package/src/setup/scenes/model.ts +2 -1
  80. package/src/setup/scenes/types.ts +1 -0
  81. package/src/status-line/component.ts +65 -11
  82. package/src/status-line/host.ts +4 -1
  83. package/src/status-line/segments.ts +21 -16
  84. package/src/status-line/types.ts +12 -2
  85. package/src/terminal-capabilities.ts +65 -181
  86. package/src/terminal-multiplexer.ts +112 -33
  87. package/src/terminal.ts +73 -32
  88. package/src/theme/schema-validation.ts +1 -0
  89. package/src/theme/schema.ts +14 -1
  90. package/src/theme/session-color.ts +1 -7
  91. package/src/theme/theme-class.ts +26 -1
  92. package/src/theme/theme-schema.json +5 -1
  93. package/src/theme/theme.ts +7 -4
  94. package/src/tmux.ts +4 -11
  95. package/src/tools/eval.ts +56 -16
  96. package/src/tools/output-meta.ts +23 -3
  97. package/src/tools/streaming-output.ts +29 -2
  98. package/src/tools/todo.ts +4 -1
  99. package/src/tui.ts +33 -18
  100. package/src/windows-input-mode.ts +8 -0
@@ -45,8 +45,23 @@ export interface NumberCell {
45
45
  readonly toFigure?: string;
46
46
  /** `a–b`: `b`, in the same base units. */
47
47
  readonly upper?: number;
48
- /** `a/b`: `b`. */
48
+ /**
49
+ * `a/b`: `b`. {@link analyzeTable} reads a score (`12/12`, `154/160`, a mean
50
+ * like `0.6/4`) as the percent of `b` reached, unless its column also holds a
51
+ * plain fraction; a pair (`85 / 147` under `Edit / read calls`) keeps `a`.
52
+ */
49
53
  readonly denominator?: number;
54
+ /**
55
+ * `a ± e` (`3.67s ± 0.69`, `~404ms ± 60`, `$0.72 (±0.16)`): `e`, in the same base units — the
56
+ * spread the table states around the value; {@link figure} then writes both (`3.67s ± 0.69`).
57
+ */
58
+ readonly error?: number;
59
+ /**
60
+ * A slash list of measurements in one unit (`79 / 124 µs`, `1,864 ms /
61
+ * 2,678 ms`, `**675ms** / 680ms`): every listed value, the first included,
62
+ * in base units, 2–4 of them. `value` is the first.
63
+ */
64
+ readonly values?: readonly number[];
50
65
  }
51
66
 
52
67
  /** A cell that is not a quantity. */
@@ -77,18 +92,51 @@ export interface TableColumn {
77
92
  readonly unit: string;
78
93
  /** Numeric cells disagree on dimension (a metric-per-row table). */
79
94
  readonly mixed: boolean;
95
+ /** Numeric cells are mostly scores out of a total (`12/12`), read as percent of it. */
96
+ readonly scores: boolean;
97
+ /**
98
+ * Per row, the status its cell leads with ({@link cellStatus}), when most
99
+ * of the column's cells lead with one: a pass/fail or progress column.
100
+ */
101
+ readonly statuses?: readonly (Status | undefined)[];
80
102
  /** Every numeric data cell holds the same value: an echoed setting, not a measure. */
81
103
  readonly constant: boolean;
82
104
  /** Share of numeric cells written with an explicit sign. */
83
105
  readonly signedShare: number;
84
106
  /** Share of numeric cells written as `a → b`. */
85
107
  readonly arrowShare: number;
108
+ /**
109
+ * Index of the measure column this one restates, read from the values: a
110
+ * proportional copy (`pass` 33/38 beside `pass%` 87%, `Samples` beside `% of
111
+ * Total`, `Leverage vs $200` beside the cost) or a running total of it
112
+ * (`Offset` summing `Verts`). Such a column is never a measure of its own.
113
+ */
114
+ readonly restates?: number;
115
+ }
116
+
117
+ /**
118
+ * Measure columns that add up, read from the values: leaf `parts` summing to
119
+ * `whole` in every row (`Cached + Uncached input = Input`, `Input + Output =
120
+ * Total` → parts Cached, Uncached input, Output of Total), or — when not
121
+ * `exact` — parts falling within a `Total` column, the rest unlisted (`New +
122
+ * Edited ≤ Prose total`).
123
+ */
124
+ export interface Composition {
125
+ /** Column the parts make up; absent when only a judge says the parts form a whole. */
126
+ readonly whole: number | undefined;
127
+ /** Leaf part columns, in table order. */
128
+ readonly parts: readonly number[];
129
+ /** The parts sum to the whole within the written precision in every row. */
130
+ readonly exact: boolean;
86
131
  }
87
132
 
88
133
  /** A table read for charting. */
89
134
  export interface TableAnalysis {
90
135
  readonly columns: readonly TableColumn[];
91
- /** Category labels: the first text or temporal column, else a sequence or index column. */
136
+ /**
137
+ * Category labels: the first text or temporal column not made only of codes
138
+ * (`#2233`, `00`), else the first text column, else a sequence or index column.
139
+ */
92
140
  readonly label: TableColumn | undefined;
93
141
  /** Plottable measure columns, constant ones excluded. */
94
142
  readonly measures: readonly TableColumn[];
@@ -96,22 +144,45 @@ export interface TableAnalysis {
96
144
  readonly rows: readonly number[];
97
145
  /** Total/summary rows (`Total`, `Average`, …), kept out of the scale. */
98
146
  readonly totals: readonly number[];
147
+ /** Measure columns adding up to a whole, when the values show it. */
148
+ readonly composition?: Composition;
99
149
  }
100
150
 
101
151
  const NUMBER = String.raw`(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?(?:[eE][+-]?\d+)?|\.\d+`;
102
152
  const CELL = new RegExp(
103
- String.raw`^(?<approx>~|≈|<=|>=|<|>|≤|≥)?\s*(?<sign>[+\-−±])?\s*(?<cur>[$€£¥])?\s*(?<num>${NUMBER})\s*(?<unit>%|[A-Za-zµμ×]+(?:\/[A-Za-z]+)?|\/s)?(?<rest>.*)$`,
153
+ String.raw`^(?<approx>~|≈|<=|>=|<|>|≤|≥)?\s*(?<sign>[+\-−±])?\s*(?<cur>[$€£¥])?\s*(?<num>${NUMBER})(?<plus>\+(?=\s*[A-Za-zµμ%]))?\s*(?<unit>%|[A-Za-zµμ×]+(?:\/[A-Za-z]+)?|\/s)?(?<rest>.*)$`,
104
154
  );
105
155
  const FRACTION = new RegExp(String.raw`^(${NUMBER})\s*\/\s*(${NUMBER})(?![\d/])(.*)$`);
106
156
  const RANGE_TAIL = new RegExp(String.raw`^[-–—]\s*[~≈]?\s*[$€£¥]?\s*(${NUMBER})\s*(%|[A-Za-zµμ×]+)?`);
107
157
  const ARROW_TAIL = new RegExp(String.raw`^(?:→|->|⇒)\s*[~≈]?\s*([+\-−])?\s*[$€£¥]?\s*(${NUMBER})\s*(%|[A-Za-zµμ×]+)?`);
158
+ /** The stated spread after a value: `± 0.69`, `±.074`, `(±0.16)`, `± 2.9 t/s`. */
159
+ const ERROR_TAIL = new RegExp(String.raw`^\(?\s*±\s*(${NUMBER})\s*(%|[A-Za-zµμ]+(?:\/[A-Za-z]+)?)?\s*\)?$`);
108
160
  const COMPOUND_DURATION =
109
161
  /^(?:(\d+(?:\.\d+)?)\s*d)?\s*(?:(\d+(?:\.\d+)?)\s*h)?\s*(?:(\d+(?:\.\d+)?)\s*m(?:in)?)?\s*(?:(\d+(?:\.\d+)?)\s*s)?$/;
110
162
  const DATE =
111
163
  /^\d{4}-\d{2}(?:-\d{2})?(?:[ T]\d{1,2}:\d{2}(?::\d{2})?)?\b|^\d{1,2}\/\d{1,2}\/\d{2,4}$|^(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\.?\s+\d{1,2}\b/i;
112
164
  const TIME = /^\d{1,2}:\d{2}(?::\d{2})?(?:\.\d+)?\s*(?:am|pm)?$/i;
113
165
  const VERSION = /^v?\d+\.\d+\.\d+|^v\d/i;
114
- const IDENTIFIER = /^(?:#\d+|[0-9a-f]{7,40}$|PR\s*#?\d+|L\d+\b)/i;
166
+ /** `#12`, a bare hex hash, a hash leading its subject (`42e18af0 (Responses API)`), `PR #3`, `L42`. */
167
+ const IDENTIFIER =
168
+ /^(?:#\d+|[0-9a-f]{7,40}$|(?=[0-9a-f]{7,40}\s)(?=\d*[a-f])(?=[a-f]*\d)[0-9a-f]{7,40}\s|PR\s*#?\d+|L\d+\b)/i;
169
+ /** One number of a slash list or a sum, as written: estimate mark, sign, currency, number, unit or noun. */
170
+ const ITEM = new RegExp(
171
+ String.raw`(?<approx>[~≈])?\s*(?<sign>[+\-−])?\s*(?<cur>[$€£¥])?\s*(?<num>${NUMBER})\s*(?<unit>%|[A-Za-zµμ×]+(?:\/[A-Za-z]+)?)?`,
172
+ "y",
173
+ );
174
+ /** Between the items of a slash list (`79 / 124 µs`, `9/47/85`). */
175
+ const LIST_SEPARATOR = /\s*\/\s*/y;
176
+ /** Between the terms of a sum (`27.8 GB + 4.1 GB`, `4 hooks + 9 overlays`). */
177
+ const SUM_SEPARATOR = /\s*\+\s*/y;
178
+ /** The total a sum is written to equal (`… = 14 files`). */
179
+ const SUM_TOTAL = /\s*=\s*/y;
180
+ /** A cell that is one Markdown link, bold or not, and nothing else. */
181
+ const LINKED = /^\s*(?:\*\*|__)?\[[^\]]*\]\([^)]*\)(?:\*\*|__)?\s*$/;
182
+ /** Most values one slash list holds; longer lists are prose. */
183
+ const MAX_LISTED_VALUES = 4;
184
+ /** Spread (max/min) within which a bare slash list repeats one measurement rather than listing different things. */
185
+ const ALIKE_SPREAD = 3;
115
186
  /** Cell texts that stand for "no value". */
116
187
  const MISSING: Readonly<Record<string, true>> = {
117
188
  "": true,
@@ -132,8 +203,96 @@ const MISSING: Readonly<Record<string, true>> = {
132
203
  x: true,
133
204
  "✗": true,
134
205
  };
135
- /** Status marks that decorate a value without changing it (`**4** ✅`, `⚠️ 12`). */
136
- const MARKS = /[✅❌⚠🟢🔴🟡✓✗✔★⭐]|\uFE0E|\uFE0F/gu;
206
+ /**
207
+ * What a status cell (`ok`, `FAIL`, `pending`, ✅) says about its row: `good`
208
+ * passed or landed, `bad` failed or broke, `warn` partly or flaky, `pending`
209
+ * not settled yet.
210
+ */
211
+ export type Status = "good" | "bad" | "warn" | "pending";
212
+ /** Leading status words, matched whole and case-insensitively. */
213
+ const STATUS_WORDS: Readonly<Record<string, Status>> = {
214
+ ok: "good",
215
+ pass: "good",
216
+ passed: "good",
217
+ passes: "good",
218
+ passing: "good",
219
+ success: "good",
220
+ succeeded: "good",
221
+ done: "good",
222
+ complete: "good",
223
+ completed: "good",
224
+ landed: "good",
225
+ merged: "good",
226
+ fixed: "good",
227
+ works: "good",
228
+ working: "good",
229
+ green: "good",
230
+ fail: "bad",
231
+ failed: "bad",
232
+ fails: "bad",
233
+ failing: "bad",
234
+ failure: "bad",
235
+ error: "bad",
236
+ errored: "bad",
237
+ broken: "bad",
238
+ timeout: "bad",
239
+ "timed out": "bad",
240
+ crash: "bad",
241
+ crashed: "bad",
242
+ oom: "bad",
243
+ panic: "bad",
244
+ red: "bad",
245
+ blocked: "bad",
246
+ truncated: "bad",
247
+ partial: "warn",
248
+ flaky: "warn",
249
+ degraded: "warn",
250
+ pending: "pending",
251
+ running: "pending",
252
+ waiting: "pending",
253
+ queued: "pending",
254
+ skipped: "pending",
255
+ wip: "pending",
256
+ "in progress": "pending",
257
+ };
258
+ /** Leading status marks. */
259
+ const STATUS_MARKS: readonly (readonly [RegExp, Status])[] = [
260
+ [/^[✅✓✔🟢]/u, "good"],
261
+ [/^[❌✗✘🔴]/u, "bad"],
262
+ [/^[⚠🟡]/u, "warn"],
263
+ [/^[⏳🕐]/u, "pending"],
264
+ ];
265
+ const STATUS_WORD = new RegExp(
266
+ String.raw`^(?:${Object.keys(STATUS_WORDS)
267
+ .sort((a, b) => b.length - a.length)
268
+ .join("|")})(?![\w-])`,
269
+ "i",
270
+ );
271
+
272
+ /**
273
+ * The status a cell's text leads with (`FAIL`, `✓ via mmdc`, `**pending**`,
274
+ * `1 ok`), or `undefined` for any other text. A count before the word
275
+ * (`3 ok`) still reads as the word's status.
276
+ */
277
+ export function cellStatus(text: string): Status | undefined {
278
+ const trimmed = plainCell(text).text.trim();
279
+ for (const [mark, status] of STATUS_MARKS) if (mark.test(trimmed)) return status;
280
+ const word = STATUS_WORD.exec(trimmed.replace(/^\d+\s+/, ""));
281
+ return word ? STATUS_WORDS[word[0].toLowerCase()] : undefined;
282
+ }
283
+
284
+ /** `~Sort of (bad coords)` in a column of ✓ and ✗: a tilde before words hedges the verdict, read as `warn`. */
285
+ const HEDGE = /^~\s*\p{L}/u;
286
+
287
+ /** Status and trend marks that decorate a value without changing it (`**4** ✅`, `⚠️ 12`, `6 ↓`, `32 ▲`). */
288
+ const MARKS = /[✅❌⚠🟢🔴🟡✓✗✔★⭐↑↓⬆⬇▲▼]|\uFE0E|\uFE0F/gu;
289
+ /** Footnote marks after a value (`165*`, `12 ms†`): the value stands as written. */
290
+ const FOOTNOTE = /^[*†‡]+$/;
291
+ /** A whole number written with a leading zero (`03`, `004,006`): a code or a list of codes, never a quantity. */
292
+ const ZERO_PADDED = /^0\d/;
293
+ /** HTML tags a Markdown cell may carry; other `<…>` (`<cwd>`, `<T>`) is literal text. */
294
+ const HTML_TAG =
295
+ /<\/?(?:a|abbr|b|br|code|del|details|div|em|i|img|ins|kbd|mark|p|s|small|span|strong|sub|summary|sup|u)\b[^>]*>/gi;
137
296
 
138
297
  /** Unit → dimension and factor to base units. Matched case-insensitively unless listed in {@link CASED_UNITS}. */
139
298
  const UNITS: Readonly<Record<string, readonly [Dimension, number]>> = {
@@ -172,17 +331,27 @@ const UNITS: Readonly<Record<string, readonly [Dimension, number]>> = {
172
331
  k: ["count", 1e3],
173
332
  bn: ["count", 1e9],
174
333
  };
175
- /** Units whose case decides their meaning: `B` bytes vs `b` billions, `M` millions vs `m` minutes. */
334
+ /**
335
+ * Units whose case decides their meaning: `B` bytes vs `b` billions, `M`
336
+ * millions vs `m` minutes; `G` and `T` are giga and tera of a count until
337
+ * {@link resolveMagnitudes} reads them as sizes.
338
+ */
176
339
  const CASED_UNITS: Readonly<Record<string, readonly [Dimension, number]>> = {
177
340
  B: ["bytes", 1],
178
341
  b: ["count", 1e9],
179
342
  M: ["count", 1e6],
180
343
  m: ["duration", 60],
344
+ G: ["count", 1e9],
345
+ T: ["count", 1e12],
181
346
  };
182
347
 
183
- /** Header names of row numbers and identifiers: never plotted, never a category axis when text exists. */
348
+ /**
349
+ * Header names of row numbers and identifiers: never plotted, never a category
350
+ * axis when text exists. A header ending in an identifier noun (`New version`,
351
+ * `Issue ID`) counts too; either only for plain numbers, so `PR | 20 ms` stays a measure.
352
+ */
184
353
  const INDEX_HEADER =
185
- /^(?:#|no\.?|n°|id|ids|rank|row|idx|index|line|lines?\s*#|ln|pr|pr\s*#|issue|commit|sha|hash|port|pid|code|exit\s*code|status|version|ver|ref|offset|priority|prio)$/i;
354
+ /^(?:#|no\.?|n°|id|ids|rank|row|idx|index|line|lines?\s*#|ln|pr|pr\s*#|issue|commit|sha|hash|port|pid|code|exit\s*code|status|version|ver|ref|offset|priority|prio)$|\s(?:id|ids|version|ver|sha|hash|pid|port)$/i;
186
355
  /** Header names of ordered steps: with monotonic values, a valid x axis for a line chart. */
187
356
  const SEQUENCE_HEADER =
188
357
  /^(?:step|phase|stage|round|tier|level|wave|pass|attempt|iter|iteration|epoch|run|batch|day|week|month|year|quarter|n|workers?|threads?|concurrency|jobs|depth|k)$/i;
@@ -192,6 +361,14 @@ const SEQUENCE_HEADER =
192
361
  */
193
362
  const TOTAL_ROW =
194
363
  /^(?:(?:total|totals|sum|overall|grand total|subtotal|σ)\b|(?:average|avg|mean|median|all|combined|net)\s*(?:\(.*\))?:?$)/i;
364
+ /**
365
+ * Summary labels that say nothing else (`Total`, `**Grand total**`, `Median:`), which sum or average the
366
+ * rows above them wherever they stand. Longer names (`Total spend`, `Totals by agent`) and words that also
367
+ * name options (`all`, `net`, `combined`) summarize only at the table's start or end.
368
+ */
369
+ const BARE_TOTAL = /^(?:total|totals|sum|grand total|sub-?total|σ|average|avg|mean|median)\s*(?:\(.*\))?:?$/i;
370
+ /** Relative slack within which a total row's figure matches the sum of the rows above it. */
371
+ const APPROX_TOTAL = 0.005;
195
372
 
196
373
  /**
197
374
  * Strip inline Markdown from a cell: links keep their text; bold, italics,
@@ -199,16 +376,22 @@ const TOTAL_ROW =
199
376
  */
200
377
  export function plainCell(markdown: string): { text: string; emphasis: boolean } {
201
378
  let text = markdown.replace(/\\\|/g, "|");
202
- const emphasis = /(\*\*|__)\S/.test(text);
379
+ // A closed pair: `__advisor*.jsonl` holds underscores, not bold.
380
+ const emphasis = /(\*\*|__)(?=\S).*?\S\1/.test(text);
381
+ // Code spans are literal: `sessions/<cwd>/*.jsonl` keeps its `<cwd>` and `*`.
382
+ const spans: string[] = [];
203
383
  text = text
384
+ .replace(/`([^`]*)`/g, (_, code: string) => `\u0000${spans.push(code) - 1}\u0000`)
385
+ // A backslash escape (`\$12.50`, `\*`) is its character, never markup.
386
+ .replace(/\\([!-/:-@[-`{-~])/g, (_, escaped: string) => `\u0000${spans.push(escaped) - 1}\u0000`)
204
387
  .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
205
388
  .replace(/(\*\*|__)(.+?)\1/g, "$2")
206
389
  .replace(/(?<![\w*])\*(?!\s)(.+?)(?<!\s)\*(?![\w*])/g, "$1")
207
390
  .replace(/(?<!\w)_(?!\s)(.+?)(?<!\s)_(?!\w)/g, "$1")
208
391
  .replace(/~~(.+?)~~/g, "$1")
209
- .replace(/`([^`]*)`/g, "$1")
210
392
  .replace(/<br\s*\/?>/gi, " ")
211
- .replace(/<[^>]+>/g, "")
393
+ .replace(HTML_TAG, "")
394
+ .replace(/\u0000(\d+)\u0000/g, (_, at: string) => spans[Number(at)]!)
212
395
  .replace(/\s+/g, " ")
213
396
  .trim();
214
397
  return { text, emphasis };
@@ -220,13 +403,19 @@ export function parseCell(markdown: string): Cell {
220
403
  const text = plain.text.replace(MARKS, "").replace(/\s+/g, " ").trim();
221
404
  const emphasis = plain.emphasis;
222
405
  const other = (kind: OtherCell["kind"]): OtherCell => ({ kind, text: plain.text, emphasis });
223
- if (MISSING[text.toLowerCase()]) return other("missing");
406
+ if (Object.hasOwn(MISSING, text.toLowerCase())) return other("missing");
224
407
  if (DATE.test(text)) return other("date");
225
408
  if (TIME.test(text)) return other("time");
226
409
  if (VERSION.test(text)) return other("version");
227
- if (IDENTIFIER.test(text)) return other("id");
410
+ if (IDENTIFIER.test(text) || ZERO_PADDED.test(text)) return other("id");
411
+ // A number that is a link's whole text (`[312530](…/models/312530)`) names what it points at.
412
+ if (LINKED.test(markdown) && /^\d/.test(text)) return other("id");
228
413
 
229
414
  const base = { approx: false, signed: false, emphasis, text: plain.text };
415
+ const listed = listCell(text, base);
416
+ if (listed) return listed;
417
+ const summed = sumCell(text, base);
418
+ if (summed) return summed;
230
419
  const fraction = FRACTION.exec(text);
231
420
  if (fraction) {
232
421
  const rest = fraction[3]!;
@@ -257,14 +446,14 @@ export function parseCell(markdown: string): Cell {
257
446
  let dim: Dimension = groups.cur ? "currency" : "count";
258
447
  let factor = 1;
259
448
  if (unit) {
260
- const known = CASED_UNITS[unit] ?? UNITS[unit.toLowerCase()];
449
+ const known = knownUnit(unit);
261
450
  if (known) {
262
451
  [dim, factor] = known;
263
452
  } else if (unit.includes("/")) {
264
453
  dim = "rate";
265
454
  } else {
266
- // A word after the number is a noun (`16 pass`), not a unit.
267
- rest = `${unit} ${rest}`.trim();
455
+ // A word after the number is a noun (`16 pass`, `8+ structs`), not a unit.
456
+ rest = `${groups.plus ?? ""}${unit} ${rest}`.trim();
268
457
  unit = "";
269
458
  }
270
459
  }
@@ -274,11 +463,16 @@ export function parseCell(markdown: string): Cell {
274
463
  if (groups.cur) unit = groups.cur;
275
464
  // `24–72 h`: the unit after the range's end applies to its start too.
276
465
  const range = RANGE_TAIL.exec(rest);
277
- const rangeUnit = range?.[2] ? (CASED_UNITS[range[2]] ?? UNITS[range[2].toLowerCase()]) : undefined;
466
+ const rangeUnit = range?.[2] ? knownUnit(range[2]) : undefined;
278
467
  if (!unit && rangeUnit) {
279
468
  [dim, factor] = rangeUnit;
280
469
  unit = range![2]!;
281
470
  }
471
+ // A magnitude suffix on money (`$6.0k`, `$1.2M`, `$4.4k–6.1k`) scales it; the amount stays currency.
472
+ if (groups.cur && dim === "count") {
473
+ dim = "currency";
474
+ unit = groups.cur;
475
+ }
282
476
  const negative = groups.sign === "-" || groups.sign === "−";
283
477
  const value = toNumber(groups.num!) * factor * (negative ? -1 : 1);
284
478
  const cell = {
@@ -288,11 +482,27 @@ export function parseCell(markdown: string): Cell {
288
482
  dim,
289
483
  unit,
290
484
  figure,
291
- // A trailing `+` (`~$250+`) marks a lower bound.
292
- approx: groups.approx !== undefined || rest === "+",
485
+ // A trailing `+` (`~$250+`, `4.9+ GiB`) marks a lower bound.
486
+ approx: groups.approx !== undefined || (groups.plus !== undefined && unit !== "") || rest === "+",
293
487
  signed: groups.sign !== undefined,
294
488
  };
295
- if (!rest || rest === "+") return { ...cell, fit: "pure" };
489
+ if (!rest || rest === "+" || FOOTNOTE.test(rest)) return { ...cell, fit: "pure" };
490
+ const spread = ERROR_TAIL.exec(rest);
491
+ if (spread) {
492
+ // `482.8 ± 1.2 t/s`: a unit after the spread applies to the value too.
493
+ const written = spread[2] ?? "";
494
+ const tail: readonly [Dimension, number] | undefined =
495
+ knownUnit(written) ?? (written.includes("/") ? ["rate", 1] : undefined);
496
+ const own = !unit && tail ? { dim: tail[0], unit: written, value: value * tail[1] } : undefined;
497
+ const error = toNumber(spread[1]!) * (tail && own ? tail[1] : factor);
498
+ return {
499
+ ...cell,
500
+ ...own,
501
+ fit: "note",
502
+ error,
503
+ figure: `${figure} ± ${spread[1]}${spread[2] ? ` ${spread[2]}` : ""}`,
504
+ };
505
+ }
296
506
  if (range) {
297
507
  const upper = toNumber(range[1]!) * tailFactor(range[2], factor);
298
508
  return { ...cell, fit: "range", upper, figure: `${figure}${range[0].trim()}` };
@@ -311,35 +521,700 @@ function toNumber(written: string): number {
311
521
  return Number(written.replaceAll(",", ""));
312
522
  }
313
523
 
524
+ /** A number of a list or sum: its value as written, its unit (known or a noun) and how it was marked. */
525
+ interface Item {
526
+ readonly value: number;
527
+ readonly unit: string;
528
+ readonly approx: boolean;
529
+ readonly signed: boolean;
530
+ readonly currency: string;
531
+ }
532
+
533
+ /**
534
+ * The items of `text` from its start joined by `separator` (two at least, `max`
535
+ * at most), and where they end; `undefined` when fewer than two lead it.
536
+ */
537
+ function readItems(
538
+ text: string,
539
+ separator: RegExp,
540
+ max: number,
541
+ ): { items: Item[]; separators: string[]; end: number } | undefined {
542
+ const items: Item[] = [];
543
+ const separators: string[] = [];
544
+ let end = 0;
545
+ let at = 0;
546
+ while (items.length < max) {
547
+ ITEM.lastIndex = at;
548
+ const match = ITEM.exec(text);
549
+ if (!match?.groups) break;
550
+ const { approx, sign, cur, num } = match.groups;
551
+ // A word that is no unit (`214 / 72 cumulative`, `4 hooks`) ends the item at its number.
552
+ const unit = match.groups.unit ?? "";
553
+ const noun = unit !== "" && !knownUnit(unit) && !unit.includes("/");
554
+ items.push({
555
+ value: toNumber(num!) * (sign === "-" || sign === "−" ? -1 : 1),
556
+ unit: noun ? "" : unit,
557
+ approx: approx !== undefined,
558
+ signed: sign !== undefined,
559
+ currency: cur ?? "",
560
+ });
561
+ end = at = noun ? at + match[0].indexOf(num!) + num!.length : ITEM.lastIndex;
562
+ if (noun) break;
563
+ separator.lastIndex = at;
564
+ if (!separator.test(text)) break;
565
+ separators.push(text.slice(at, separator.lastIndex));
566
+ at = separator.lastIndex;
567
+ }
568
+ return items.length >= 2 ? { items, separators: separators.slice(0, items.length - 1), end } : undefined;
569
+ }
570
+
571
+ /**
572
+ * Dimension and per-item factor of `items` read as one quantity: a unit written
573
+ * after some items carries to the bare ones before it (`79 / 124 µs`); two
574
+ * dimensions make no one quantity ({@link readItems} ends an item at a noun).
575
+ * Bare items are counts; a unit per something (`tok/s`) is a rate.
576
+ */
577
+ function itemUnits(items: readonly Item[]): { dim: Dimension; factors: number[]; unit: string } | undefined {
578
+ let dim: Dimension | undefined;
579
+ const written: (number | undefined)[] = [];
580
+ const carried: (number | undefined)[] = [];
581
+ for (const item of items) {
582
+ const known = item.unit ? knownUnit(item.unit) : undefined;
583
+ // Money keeps its symbol; only a magnitude (`$1.2k`) may follow it.
584
+ if (item.currency && known && known[0] !== "count") return undefined;
585
+ const itemDim: Dimension | undefined = item.currency
586
+ ? "currency"
587
+ : item.unit
588
+ ? (known?.[0] ?? "rate")
589
+ : undefined;
590
+ if (itemDim && dim && itemDim !== dim) return undefined;
591
+ dim ??= itemDim;
592
+ // An item's own unit or currency scales it alone (`$627 + $1.48k`); only a physical unit carries back to
593
+ // bare items before it (`3.2 + 9.8 s`), never a magnitude (`5 + 4k`) or a currency sign.
594
+ written.push(item.unit || item.currency ? (known?.[1] ?? 1) : undefined);
595
+ carried.push(item.unit && !item.currency && known && known[0] !== "count" ? known[1] : undefined);
596
+ }
597
+ const factors: number[] = [];
598
+ for (let at = items.length - 1; at >= 0; at--)
599
+ factors[at] =
600
+ written[at] ?? (items[at + 1]?.unit || items[at + 1]?.currency ? carried[at + 1] : factors[at + 1]) ?? 1;
601
+ const lead = items.find(item => item.currency) ?? items.find(item => item.unit);
602
+ return { dim: dim ?? "count", factors, unit: lead?.currency || lead?.unit || "" };
603
+ }
604
+
605
+ /** Text after a list or a sum that only annotates it: nothing, a parenthetical, or one short word (`(45 total)`, `pass`). */
606
+ const SHORT_NOTE = /^$|^\(.*\)$|^[A-Za-z][\w-]{0,11}$/;
607
+ /** What a list or sum cell shares with any cell: its text and emphasis. */
608
+ type CellBase = Pick<NumberCell, "emphasis" | "text">;
609
+
610
+ /**
611
+ * A slash list of measurements as its first value with every value kept
612
+ * ({@link NumberCell.values}): two to {@link MAX_LISTED_VALUES} values of one
613
+ * unit written on each (`1,864 ms / 2,678 ms`) or after the last only (`79 /
614
+ * 124 µs`), slashes all spaced or all tight; or a spaced pair of bare values
615
+ * that is no score (`25.0 / 27.8`, `12.6 / 9.4`). Other bare lists (`5/8`,
616
+ * `27 / 30`, `19 / 1 / 0`) are fractions, scores or tallies, read elsewhere;
617
+ * `2/1/20k/3k` and `7/12 / 29%` are no one list.
618
+ */
619
+ function listCell(text: string, base: CellBase): NumberCell | undefined {
620
+ const read = readItems(text, LIST_SEPARATOR, MAX_LISTED_VALUES + 1);
621
+ if (!read || read.items.length > MAX_LISTED_VALUES) return undefined;
622
+ const { items, separators, end } = read;
623
+ const rest = text.slice(end).trim();
624
+ // `1/3 clean, else …`, `12/` and `3/6/9 more` read as prose or fractions; a list ends at its last value.
625
+ if (/^[\d/]/.test(rest)) return undefined;
626
+ if (new Set(separators.map(separator => separator === "/")).size > 1) return undefined;
627
+ // A unit after the last value carries back to bare ones; a currency sign leads only its own (`1.000 / $3.31`
628
+ // is a score and a price), and a magnitude only its own (`2/1/20k` is turns, edits and tokens).
629
+ const marked = items.map(item => Boolean(item.unit || item.currency));
630
+ const last = items.at(-1)!;
631
+ const carried = marked.slice(0, -1).every(mark => !mark) && !last.currency && knownUnit(last.unit)?.[0] !== "count";
632
+ // A spaced pair of bare numbers that is no score (`25.0 / 27.8`, `12.6 / 9.4`: the second is no whole, or
633
+ // the first exceeds it) lists two values of the column's quantity; `27 / 30` stays a score.
634
+ const [first, second] = items;
635
+ const whole = /^\d[\d,]*$/.test(text.slice(text.indexOf("/") + 1, end).trim());
636
+ const score = whole && first!.value >= 0 && first!.value <= second!.value;
637
+ const pair = items.length === 2 && !marked.some(Boolean) && separators[0] !== "/" && !score;
638
+ if (!pair && (!marked.at(-1) || !(marked.every(Boolean) || carried))) return undefined;
639
+ const units = itemUnits(items);
640
+ if (!units) return undefined;
641
+ const values = items.map((item, at) => item.value * units.factors[at]!);
642
+ return {
643
+ ...base,
644
+ kind: "number",
645
+ value: values[0]!,
646
+ dim: units.dim,
647
+ unit: units.unit,
648
+ fit: SHORT_NOTE.test(rest) ? "note" : "prose",
649
+ figure: text.slice(0, end).trim(),
650
+ approx: items.some(item => item.approx),
651
+ signed: items[0]!.signed,
652
+ values,
653
+ };
654
+ }
655
+
656
+ /**
657
+ * A sum of one quantity as its total: `27.8 GB + 4.1 GB` (the terms' sum, when
658
+ * they share a dimension and name no other thing) or `4 hooks + 9 overlays + 1
659
+ * helper = 14 files` (the total written after `=`).
660
+ */
661
+ function sumCell(text: string, base: CellBase): NumberCell | undefined {
662
+ const read = readItems(text, SUM_SEPARATOR, Number.POSITIVE_INFINITY);
663
+ if (!read || read.items[0]!.signed) return undefined;
664
+ let { end } = read;
665
+ SUM_TOTAL.lastIndex = end;
666
+ let total: Item | undefined;
667
+ if (SUM_TOTAL.test(text)) {
668
+ ITEM.lastIndex = SUM_TOTAL.lastIndex;
669
+ const match = ITEM.exec(text);
670
+ if (match?.groups) {
671
+ const { approx, cur, num, unit } = match.groups;
672
+ total = {
673
+ value: toNumber(num!),
674
+ unit: cur ?? unit ?? "",
675
+ approx: approx !== undefined,
676
+ signed: false,
677
+ currency: cur ?? "",
678
+ };
679
+ end = ITEM.lastIndex;
680
+ }
681
+ }
682
+ const rest = text.slice(end).trim();
683
+ if (/^[\d+=]/.test(rest)) return undefined;
684
+ const terms = total ? [total] : read.items;
685
+ const units = itemUnits(terms);
686
+ if (!units) return undefined;
687
+ const value = terms.reduce((sum, item, at) => sum + item.value * units.factors[at]!, 0);
688
+ return {
689
+ ...base,
690
+ kind: "number",
691
+ value,
692
+ dim: units.dim,
693
+ unit: units.unit,
694
+ fit: SHORT_NOTE.test(rest) ? "note" : "prose",
695
+ figure: text.slice(0, end).trim(),
696
+ approx: terms.some(item => item.approx) || read.items.some(item => item.approx),
697
+ signed: false,
698
+ };
699
+ }
700
+
314
701
  /** Factor of a unit written after the second number of a range or transition; the first number's when absent. */
315
702
  function tailFactor(unit: string | undefined, fallback: number): number {
316
703
  if (!unit) return fallback;
317
- const known = CASED_UNITS[unit] ?? UNITS[unit.toLowerCase()];
318
- return known ? known[1] : fallback;
704
+ return knownUnit(unit)?.[1] ?? fallback;
705
+ }
706
+
707
+ /** Dimension and base factor of `unit`, cased spellings first; own keys only, so `constructor` is no unit. */
708
+ function knownUnit(unit: string): readonly [Dimension, number] | undefined {
709
+ if (Object.hasOwn(CASED_UNITS, unit)) return CASED_UNITS[unit];
710
+ const lower = unit.toLowerCase();
711
+ return Object.hasOwn(UNITS, lower) ? UNITS[lower] : undefined;
319
712
  }
320
713
 
321
714
  /** Read a table's columns and rows for charting. Cells are the raw Markdown of each cell. */
322
715
  export function analyzeTable(header: readonly string[], rows: readonly (readonly string[])[]): TableAnalysis {
323
- const parsed = rows.map(row => header.map((_, index) => parseCell(row[index] ?? "")));
324
- const draft = header.map((cell, index) => readColumn(index, plainCell(cell).text, parsed));
716
+ let headers = header.map(cell => plainCell(cell).text);
717
+ const stacked = unstack(headers, rows);
718
+ if (stacked) {
719
+ headers = headers.slice(0, stacked.width);
720
+ rows = stacked.rows;
721
+ }
722
+ rows = unlist(rows);
723
+ const raw = rows.map(row => headers.map((_, index) => parseCell(row[index] ?? "")));
724
+ // `Edit / read calls | 85 / 147`: a spaced slash in the row's name (text before its numbers) pairs two
725
+ // values; one in a note after them (`copies whole message / hallucinates bullets`) pairs nothing.
726
+ const paired = raw.map(cells =>
727
+ cells
728
+ .slice(0, Math.max(0, cells.findIndex(isNumber)))
729
+ .some(cell => cell.kind === "text" && PAIR_SLASH.test(cell.text)),
730
+ );
731
+ // Scores or plain fractions, never both in one column: the two read on different axes and one kind drops out.
732
+ const scoring = headers.map(
733
+ (text, index) =>
734
+ !PAIR_SLASH.test(text) && raw.every((cells, row) => paired[row] || fractionKind(cells[index]) !== "fraction"),
735
+ );
736
+ // A column listing bare pairs (`214 / 104`) reads every spaced pair in it as one (`22 / 49` too, which alone
737
+ // would read as a score).
738
+ const pairing = headers.map((_, index) =>
739
+ raw.some(cells => {
740
+ const cell = cells[index];
741
+ return isNumber(cell) && cell.values !== undefined && cell.unit === "";
742
+ }),
743
+ );
744
+ const parsed = raw.map((cells, row) =>
745
+ cells.map((cell, index): Cell =>
746
+ // A list the header does not name item by item (`Layers/heads/d_model`, unlike `in / out / cache`)
747
+ // holds different things where its values are unlike: none of them is the cell's number.
748
+ !PAIR_SLASH.test(headers[index]!) && unlikeList(cell)
749
+ ? { kind: "text", text: cell.text, emphasis: cell.emphasis }
750
+ : pairing[index]
751
+ ? asPair(cell)
752
+ : scoring[index] && !paired[row]
753
+ ? asScore(cell)
754
+ : cell,
755
+ ),
756
+ );
757
+ const draft = fillDown(headers.map((text, index) => readColumn(index, text, parsed)));
758
+ // Without a text column, a leading column of named numbers (`4096B chunks`, `03`, `12-hover`) names the rows.
759
+ const lead = draft[0];
760
+ const unnamed = !draft.some(column => column.role === "label" || column.role === "temporal");
761
+ if (
762
+ lead?.role === "measure" &&
763
+ unnamed &&
764
+ lead.cells.some(
765
+ cell => cell.kind !== "missing" && (!isNumber(cell) || cell.fit === "note" || cell.fit === "prose"),
766
+ )
767
+ )
768
+ draft[0] = { ...lead, role: "label" };
769
+ else if (unnamed) {
770
+ // Else a column of words around numbers in mixed units (`5m write`, `+ 1 ping`) says how each row is
771
+ // done: it names the rows rather than measuring them.
772
+ const worded = draft.findIndex(
773
+ column =>
774
+ column.role === "measure" &&
775
+ column.mixed &&
776
+ column.cells.filter(cell => isNumber(cell) && cell.fit !== "pure").length * 2 > column.cells.length,
777
+ );
778
+ if (worded >= 0) draft[worded] = { ...draft[worded]!, role: "label" };
779
+ }
325
780
  const labels = draft.filter(column => column.role === "label" || column.role === "temporal");
781
+ // Bare codes and numbers (`#2233`, `00`, `14`) name rows only when no worded column does; `#1 (baseline)` is worded.
782
+ const worded = (cell: Cell) =>
783
+ cell.kind !== "missing" && ((cell.kind !== "id" && !isNumber(cell)) || /\s/.test(cell.text));
326
784
  const label =
327
- labels[0] ?? draft.find(column => column.role === "sequence") ?? draft.find(column => column.role === "index");
785
+ labels.find(column => column.cells.some(worded)) ??
786
+ labels[0] ??
787
+ draft.find(column => column.role === "sequence") ??
788
+ draft.find(column => column.role === "index");
328
789
  const totals: number[] = [];
329
790
  const data: number[] = [];
791
+ const names = rows.map((_, row) => (label ? label.cells[row]!.text.replace(MARKS, "").trim() : ""));
792
+ const summary = names.map(name => label !== undefined && TOTAL_ROW.test(name));
793
+ // Whether a figure of `row` is the sum of two or more rows right above it (`Total scrapers | 74`).
794
+ const sumsAbove = (row: number) =>
795
+ draft.some(column => {
796
+ const total = column.cells[row];
797
+ if (column.role !== "measure" || !isNumber(total) || total.value <= 0) return false;
798
+ let sum = 0;
799
+ for (let above = row - 1; above >= 0; above--) {
800
+ const cell = column.cells[above];
801
+ if (!isNumber(cell) || cell.dim !== total.dim) return false;
802
+ sum += cell.value;
803
+ if (above < row - 1 && Math.abs(sum - total.value) <= total.value * APPROX_TOTAL) return true;
804
+ }
805
+ return false;
806
+ });
807
+ // A summary closes (or opens) the table, after any other summary rows; one amid the data only shares a
808
+ // name with one (a `net` suite, a `Totals by agent` query) unless its name is the bare summary word or
809
+ // its figure adds up the rows above it.
330
810
  for (let row = 0; row < rows.length; row++) {
331
- const name = label ? label.cells[row]!.text : "";
332
- (label && TOTAL_ROW.test(name.replace(MARKS, "").trim()) ? totals : data).push(row);
811
+ const summarizes =
812
+ summary[row] &&
813
+ (summary.slice(row + 1).every(Boolean) ||
814
+ summary.slice(0, row).every(Boolean) ||
815
+ BARE_TOTAL.test(names[row]!) ||
816
+ sumsAbove(row));
817
+ (summarizes ? totals : data).push(row);
818
+ }
819
+ const finished = keepSweeps(draft.map(column => finishColumn(column, data)));
820
+ const restated = restatedColumns(
821
+ finished.filter(column => column.role === "measure" && !column.constant),
822
+ data,
823
+ );
824
+ const columns = finished.map(column =>
825
+ restated.has(column.index) ? { ...column, restates: restated.get(column.index) } : column,
826
+ );
827
+ const measures = columns.filter(
828
+ column => column.role === "measure" && !column.constant && column.restates === undefined,
829
+ );
830
+ return {
831
+ columns,
832
+ label: label ? columns[label.index] : undefined,
833
+ measures,
834
+ rows: data,
835
+ totals,
836
+ composition: findComposition(measures, data),
837
+ };
838
+ }
839
+
840
+ /** A header with its numbers blanked: `scale 1 (200 tiles)` and `scale 8 (1600 tiles)` are one sweep. */
841
+ const SWEEP_STEP = /\d+(?:\.\d+)?/g;
842
+
843
+ /**
844
+ * `columns` with constant measures that are one step of a sweep kept as
845
+ * measures: `scale 8 (1600 tiles)` at 165 in every row beside `scale 1 (200
846
+ * tiles)` and `scale 4 (800 tiles)` is the sweep's last condition, not an
847
+ * echoed setting — the header, numbers blanked, matches a varying measure's.
848
+ */
849
+ function keepSweeps(columns: readonly TableColumn[]): TableColumn[] {
850
+ const varying = new Set(
851
+ columns
852
+ .filter(column => column.role === "measure" && !column.constant && /\d/.test(column.header))
853
+ .map(column => column.header.replace(SWEEP_STEP, "#")),
854
+ );
855
+ return columns.map(column =>
856
+ column.constant && column.role === "measure" && varying.has(column.header.replace(SWEEP_STEP, "#"))
857
+ ? { ...column, constant: false }
858
+ : column,
859
+ );
860
+ }
861
+
862
+ /** Most columns searched for parts summing to a whole (subsets grow combinatorially). */
863
+ const MAX_COMPOSED = 8;
864
+ /** Most parts one whole splits into directly. */
865
+ const MAX_PARTS = 4;
866
+ /** Share of its total that parts falling within it must make up in one row at least. */
867
+ const MIN_PART_SHARE = 0.25;
868
+ /** A whole that parts may fall within, leaving an unlisted rest. */
869
+ const WHOLE_HEADER = /\b(?:total|all|sum|overall)\b/i;
870
+
871
+ /**
872
+ * Half the last written digit of `cell`, in base units (`6,313k` → 500,
873
+ * `38.5%` → 0.05): how far its value may sit from the exact quantity it
874
+ * rounds. A score (`33/38` read as percent) and a whole count written in full
875
+ * (`46`, `1,980`) are exact.
876
+ */
877
+ export function writtenSlack(cell: NumberCell): number {
878
+ if (cell.denominator !== undefined && cell.dim === "percent") return 0;
879
+ const match = /(\d[\d,]*)(?:\.(\d+))?/.exec(cell.figure);
880
+ if (!match) return 0;
881
+ const written = Number(`${match[1]!.replaceAll(",", "")}${match[2] ? `.${match[2]}` : ""}`);
882
+ const factor = written ? Math.abs(cell.value) / written : 1;
883
+ const decimals = match[2]?.length ?? 0;
884
+ if (cell.dim === "count" && decimals === 0 && factor === 1 && !cell.approx) return 0;
885
+ return 0.5 * 10 ** -decimals * factor;
886
+ }
887
+
888
+ /** The cell of `column` at `row` as a plain number of the column's dimension; ranges and transitions hold no single value. */
889
+ function plainValue(column: TableColumn, row: number): NumberCell | undefined {
890
+ const cell = column.cells[row];
891
+ return isNumber(cell) && cell.dim === column.dim && cell.upper === undefined && cell.to === undefined
892
+ ? cell
893
+ : undefined;
894
+ }
895
+
896
+ /**
897
+ * Columns restating another column's values: `y = k·x` within the written
898
+ * precision over three or more rows where `x` varies, or a running total
899
+ * (`Offset` stepping by `Verts`). Of a proportional pair the first column
900
+ * stays, unless the second is a share summing to 100% beside counts or the
901
+ * table bolded more of its cells (`**87%**` beside `33/38`).
902
+ */
903
+ function restatedColumns(measures: readonly TableColumn[], rows: readonly number[]): Map<number, number> {
904
+ const restated = new Map<number, number>();
905
+ const share = (column: TableColumn) => {
906
+ if (column.dim !== "percent" || column.scores) return false;
907
+ const sum = rows.reduce((total, row) => total + (plainValue(column, row)?.value ?? 0), 0);
908
+ return sum >= 97 && sum <= 103;
909
+ };
910
+ for (const [at, a] of measures.entries()) {
911
+ for (const b of measures.slice(at + 1)) {
912
+ if (restated.has(a.index) || restated.has(b.index) || !proportional(a, b, rows)) continue;
913
+ const bolder = emphasized(b, rows) > emphasized(a, rows);
914
+ if ((share(b) && !share(a)) || bolder) restated.set(a.index, b.index);
915
+ else restated.set(b.index, a.index);
916
+ }
917
+ }
918
+ for (const column of measures) {
919
+ if (restated.has(column.index)) continue;
920
+ const source = measures.find(other => other !== column && runningTotal(column, other, rows));
921
+ if (source) restated.set(column.index, source.index);
922
+ }
923
+ return restated;
924
+ }
925
+
926
+ /** How many of `rows` the table bolded in `column`. */
927
+ function emphasized(column: TableColumn, rows: readonly number[]): number {
928
+ return rows.filter(row => column.cells[row]?.emphasis).length;
929
+ }
930
+
931
+ /** Most relative rounding a row may carry to count toward proving a proportion (`2%` beside `1` proves little). */
932
+ const TIGHT = 0.015;
933
+
934
+ /**
935
+ * `b = k·a` for one `k` in every row holding both (positive), within their
936
+ * written precision, over three or more rows where `a` varies and three
937
+ * written precisely enough to pin `k` down.
938
+ */
939
+ function proportional(a: TableColumn, b: TableColumn, rows: readonly number[]): boolean {
940
+ const pairs = rows.flatMap(row => {
941
+ const x = plainValue(a, row);
942
+ const y = plainValue(b, row);
943
+ return x && y ? [[x, y] as const] : [];
944
+ });
945
+ if (pairs.length < 3 || pairs.some(([x, y]) => x.value <= 0 || y.value <= 0)) return false;
946
+ const xs = pairs.map(([x]) => x.value);
947
+ if (Math.max(...xs) < 1.5 * Math.min(...xs)) return false;
948
+ let low = 0;
949
+ let high = Number.POSITIVE_INFINITY;
950
+ let tight = 0;
951
+ for (const [x, y] of pairs) {
952
+ const [sx, sy] = [writtenSlack(x), writtenSlack(y)];
953
+ if (sx >= x.value) return false;
954
+ low = Math.max(low, (y.value - sy) / (x.value + sx));
955
+ high = Math.min(high, (y.value + sy) / (x.value - sx));
956
+ if (sx / x.value + sy / y.value <= TIGHT) tight++;
957
+ }
958
+ return low <= high * (1 + 1e-9) && tight >= 3;
959
+ }
960
+
961
+ /** `total` steps by `step` from row to row (`Offset` 0, 16, 20 beside `Verts` 16, 4, 8), over three or more steps. */
962
+ function runningTotal(total: TableColumn, step: TableColumn, rows: readonly number[]): boolean {
963
+ if (total.dim !== step.dim || rows.length < 4) return false;
964
+ const at = (column: TableColumn, row: number) => plainValue(column, row)?.value;
965
+ const fits = (offset: 0 | 1) =>
966
+ rows.slice(1).every((row, index) => {
967
+ const now = at(total, row);
968
+ const before = at(total, rows[index]!);
969
+ const by = at(step, offset ? row : rows[index]!);
970
+ return now !== undefined && before !== undefined && by !== undefined && by !== 0 && now - before === by;
971
+ });
972
+ return fits(0) || fits(1);
973
+ }
974
+
975
+ /**
976
+ * The parts `measures` add up to: every relation `whole = a + b (+ …)` holding
977
+ * within the written precision in each row (three rows at least), expanded from
978
+ * the largest whole down to leaves. Without one, the same-unit columns beside
979
+ * a `Total` column that never exceed it are parts with an unlisted rest.
980
+ */
981
+ function findComposition(measures: readonly TableColumn[], rows: readonly number[]): Composition | undefined {
982
+ const pool = measures
983
+ .filter(
984
+ column =>
985
+ !column.mixed &&
986
+ column.arrowShare === 0 &&
987
+ rows.every(row => {
988
+ const cell = column.cells[row];
989
+ return !isNumber(cell) || (cell.value >= 0 && cell.upper === undefined);
990
+ }),
991
+ )
992
+ .slice(0, MAX_COMPOSED);
993
+ const sums = new Map<number, readonly TableColumn[]>();
994
+ for (const whole of pool) {
995
+ const others = pool.filter(column => column !== whole && column.dim === whole.dim);
996
+ for (const subset of subsets(others, 2, MAX_PARTS)) {
997
+ if (sums.has(whole.index)) break;
998
+ if (adds(whole, subset, rows, true)) sums.set(whole.index, subset);
999
+ }
1000
+ }
1001
+ if (sums.size > 0) {
1002
+ // Parts are non-negative and non-zero somewhere, so no column sums into itself: the expansion ends.
1003
+ const leaves = (index: number): number[] => sums.get(index)?.flatMap(part => leaves(part.index)) ?? [index];
1004
+ const nested = new Set([...sums.values()].flatMap(parts => parts.map(part => part.index)));
1005
+ const roots = [...sums.keys()].filter(index => !nested.has(index));
1006
+ const root = roots.sort((a, b) => leaves(b).length - leaves(a).length)[0] ?? [...sums.keys()][0]!;
1007
+ return { whole: root, parts: leaves(root).sort((a, b) => a - b), exact: true };
1008
+ }
1009
+ const whole = pool.find(column => WHOLE_HEADER.test(column.header));
1010
+ if (!whole) return undefined;
1011
+ // Within a total, a signed column (`vs current` +$4.66k) is a change from another row, not a part.
1012
+ const parts = pool.filter(
1013
+ column =>
1014
+ column !== whole && column.dim === whole.dim && !WHOLE_HEADER.test(column.header) && column.signedShare < 0.5,
1015
+ );
1016
+ if (parts.length < 2 || parts.length > MAX_PARTS || !adds(whole, parts, rows, false)) return undefined;
1017
+ // Parts make up a real share of their total somewhere: `27` tests and `6` turns fall within `16.8k` total tokens
1018
+ // only because they count other things.
1019
+ const share = (row: number) => {
1020
+ const total = plainValue(whole, row)?.value ?? 0;
1021
+ const sum = parts.reduce((acc, part) => acc + (plainValue(part, row)?.value ?? 0), 0);
1022
+ return total > 0 ? sum / total : 0;
1023
+ };
1024
+ if (!rows.some(row => share(row) >= MIN_PART_SHARE)) return undefined;
1025
+ return { whole: whole.index, parts: parts.map(column => column.index), exact: false };
1026
+ }
1027
+
1028
+ /** Subsets of `items` from `min` to `max` long, smallest first, each in `items` order. */
1029
+ function subsets<T>(items: readonly T[], min: number, max: number): T[][] {
1030
+ const out: T[][] = [];
1031
+ const grow = (start: number, picked: T[]) => {
1032
+ if (picked.length >= min) out.push(picked);
1033
+ if (picked.length === max) return;
1034
+ for (let at = start; at < items.length; at++) grow(at + 1, [...picked, items[at]!]);
1035
+ };
1036
+ grow(0, []);
1037
+ return out.sort((a, b) => a.length - b.length);
1038
+ }
1039
+
1040
+ /**
1041
+ * `parts` sum to `whole` (`exact`) or stay within it, short of it somewhere,
1042
+ * in every row holding all of them (three at least), each part non-zero in one.
1043
+ */
1044
+ function adds(whole: TableColumn, parts: readonly TableColumn[], rows: readonly number[], exact: boolean): boolean {
1045
+ let held = 0;
1046
+ let short = false;
1047
+ const used = new Set<TableColumn>();
1048
+ for (const row of rows) {
1049
+ const total = plainValue(whole, row);
1050
+ const cells = parts.map(part => plainValue(part, row));
1051
+ if (!total || cells.some(cell => !cell)) continue;
1052
+ held++;
1053
+ const sum = cells.reduce((acc, cell) => acc + cell!.value, 0);
1054
+ const slack =
1055
+ writtenSlack(total) + cells.reduce((acc, cell) => acc + writtenSlack(cell!), 0) + 1e-9 * total.value;
1056
+ if (exact ? Math.abs(sum - total.value) > slack : sum > total.value + slack) return false;
1057
+ if (sum < total.value - slack) short = true;
1058
+ cells.forEach((cell, at) => {
1059
+ if (cell!.value > 0) used.add(parts[at]!);
1060
+ });
333
1061
  }
334
- const columns = draft.map(column => finishColumn(column, data));
335
- const measures = columns.filter(column => column.role === "measure" && !column.constant);
336
- return { columns, label: label ? columns[label.index] : undefined, measures, rows: data, totals };
1062
+ return held >= 3 && used.size === parts.length && (exact || short);
337
1063
  }
338
1064
 
339
1065
  type DraftColumn = Omit<TableColumn, "constant">;
340
1066
 
1067
+ /**
1068
+ * Two or more copies of one table set side by side to save height
1069
+ * (`| | doc | lines | | doc | lines |`): the header repeats in blocks of
1070
+ * `width` columns, read as one table of `width` columns, block after block.
1071
+ * Block rows left empty (an odd row count) are dropped.
1072
+ */
1073
+ function unstack(
1074
+ headers: readonly string[],
1075
+ rows: readonly (readonly string[])[],
1076
+ ): { width: number; rows: string[][] } | undefined {
1077
+ for (let width = 2; width * 2 <= headers.length; width++) {
1078
+ if (headers.length % width !== 0 || !headers.slice(0, width).some(Boolean)) continue;
1079
+ if (!headers.every((name, at) => name === headers[at % width])) continue;
1080
+ const stacked: string[][] = [];
1081
+ for (let start = 0; start < headers.length; start += width)
1082
+ for (const row of rows) {
1083
+ const block = headers.slice(0, width).map((_, at) => row[start + at] ?? "");
1084
+ if (block.some(cell => plainCell(cell).text)) stacked.push(block);
1085
+ }
1086
+ return { width, rows: stacked };
1087
+ }
1088
+ return undefined;
1089
+ }
1090
+
1091
+ /** Fewest items a slash list must hold to split into rows wherever its name cell stands. */
1092
+ const MIN_LISTED = 3;
1093
+ /** Fewest items a slash list splits into when the row's name lists them before the values they pair. */
1094
+ const MIN_LISTED_PAIR = 2;
1095
+ /** Fewest numeric columns listing values in a row it splits: one list of numbers may be a row of unrelated metrics. */
1096
+ const MIN_LISTS = 2;
1097
+ /** A spaced slash between list items. */
1098
+ const LIST_SLASH = /\s+\/\s+/;
1099
+ /** A list item that is one number, as written (`12`, `~1,400`, `3.5 ms`, `40%`). */
1100
+ const LISTED_NUMBER = /^[~≈]?[$€£¥]?\d[\d,]*(?:\.\d+)?\s*(?:%|[A-Za-zµμ]+)?$/;
1101
+
1102
+ /**
1103
+ * Rows folding several rows into one (`response anthropic / chat / responses /
1104
+ * gemini → 3 wires | 12 / 12 / 12 / 9 | 6 / 0 / 0 / 0`), split back into one
1105
+ * row per item: a name cell listing {@link MIN_LISTED} or more items (a pair
1106
+ * only when the name cell comes before every list: `pager 24×80 / 48×120 |
1107
+ * 25.0 / 27.8 | 12.6 / 9.4`), {@link MIN_LISTS} or more numeric cells each
1108
+ * listing that many numbers, and no other number in the row. Each
1109
+ * item keeps the words the name cell writes before its first item and the
1110
+ * aside after its last (` → 3 wires`, ` (ms)`: a tail opening on a symbol, so
1111
+ * `blocks / conditional branches` keeps its last item whole); other text cells repeat.
1112
+ */
1113
+ function unlist(rows: readonly (readonly string[])[]): readonly (readonly string[])[] {
1114
+ return rows.flatMap(row => {
1115
+ const lists = row.map(cell => plainCell(cell).text.split(LIST_SLASH));
1116
+ const size = Math.max(...lists.map(list => list.length));
1117
+ if (size < MIN_LISTED_PAIR) return [row];
1118
+ const numeric = lists.map(list => list.every(item => LISTED_NUMBER.test(item)));
1119
+ const name = lists.findIndex((list, at) => list.length === size && !numeric[at]);
1120
+ const fits = lists.every(
1121
+ (list, at) => at === name || (list.length === size ? numeric[at] : !numeric[at] || !list[0]),
1122
+ );
1123
+ const listed = lists.filter((list, at) => list.length === size && numeric[at]).length;
1124
+ if (name < 0 || !fits || listed < MIN_LISTS) return [row];
1125
+ // A pair splits only where the row's name lists it, before the values it pairs (`pager 24×80 / 48×120 |
1126
+ // 25.0 / 27.8 | 12.6 / 9.4`); a slash in a note after them pairs nothing.
1127
+ if (size < MIN_LISTED && lists.some((list, at) => at < name && list.length === size)) return [row];
1128
+ const items = [...lists[name]!];
1129
+ const first = items[0]!;
1130
+ const last = items[size - 1]!;
1131
+ const before = first.includes(" ") ? first.slice(0, first.lastIndexOf(" ") + 1) : "";
1132
+ const aside = /\s+[^\p{L}\p{N}\s]/u.exec(last);
1133
+ const after = aside ? last.slice(aside.index) : "";
1134
+ items[0] = first.slice(before.length);
1135
+ items[size - 1] = last.slice(0, last.length - after.length);
1136
+ return items.map((item, index) =>
1137
+ row.map((cell, at) =>
1138
+ at === name ? `${before}${item}${after}` : lists[at]!.length === size ? lists[at]![index]! : cell,
1139
+ ),
1140
+ );
1141
+ });
1142
+ }
1143
+
1144
+ /**
1145
+ * Leading text columns written as an outline: a blank cell under a filled one
1146
+ * repeats it (`DB | Table.Column` naming the database once for its tables), so
1147
+ * the continuation rows keep their parent's name. Only columns before the
1148
+ * first measure; a blank written as `—` stays missing.
1149
+ */
1150
+ function fillDown(columns: DraftColumn[]): DraftColumn[] {
1151
+ const firstMeasure = columns.findIndex(column => column.role === "measure");
1152
+ return columns.map(column => {
1153
+ if (column.role !== "label" || (firstMeasure >= 0 && column.index > firstMeasure)) return column;
1154
+ const { cells } = column;
1155
+ const blank = (cell: Cell) => cell.kind === "missing" && cell.text === "";
1156
+ if (cells.length < 3 || blank(cells[0]!) || !cells.some(blank)) return column;
1157
+ const filled: Cell[] = [];
1158
+ for (const cell of cells) filled.push(blank(cell) ? filled.at(-1)! : cell);
1159
+ return { ...column, cells: filled };
1160
+ });
1161
+ }
1162
+
1163
+ /** A spaced slash naming two values (`p50 / p95`, `median / max`) rather than a path or a score. */
1164
+ const PAIR_SLASH = /\S \/ \S/;
1165
+
1166
+ /**
1167
+ * How an `a/b` cell reads: a `score` out of a whole-number `b` with
1168
+ * `0 ≤ a ≤ b` (`12/12`, `154/160`, a mean like `0.6/4`), a `list` like
1169
+ * `327 / 333 / 339`, else a plain `fraction` (`5/3`, `1.2/3.4`);
1170
+ * `undefined` for any other cell.
1171
+ */
1172
+ function fractionKind(cell: Cell | undefined): "score" | "list" | "fraction" | undefined {
1173
+ if (!isNumber(cell) || cell.dim !== "fraction" || !cell.denominator) return undefined;
1174
+ const after = cell.text.indexOf(cell.figure);
1175
+ if (after < 0 || /^\s*\//.test(cell.text.slice(after + cell.figure.length))) return "list";
1176
+ // The figure ends in its denominator as written: `/4` counts a whole, `/3.4` is a second value.
1177
+ const whole = /\/\s*\d[\d,]*$/.test(cell.figure);
1178
+ return whole && cell.value >= 0 && cell.value <= cell.denominator ? "score" : "fraction";
1179
+ }
1180
+
1181
+ /**
1182
+ * A bare slash list of three or more values unlike each other (`12 / 12 / 768`, `19 / 1 / 0`): a list of
1183
+ * different things, where alike values (`327 / 333 / 339`) repeat one measurement.
1184
+ */
1185
+ function unlikeList(cell: Cell): boolean {
1186
+ if (!isNumber(cell) || cell.dim !== "fraction") return false;
1187
+ const items = readItems(cell.text.replace(MARKS, "").trim(), LIST_SEPARATOR, MAX_LISTED_VALUES)?.items;
1188
+ if (!items || items.length < 3 || items.some(item => item.unit || item.currency)) return false;
1189
+ const values = items.map(item => item.value);
1190
+ return values.some(value => value <= 0) || Math.max(...values) > ALIKE_SPREAD * Math.min(...values);
1191
+ }
1192
+
1193
+ /** A spaced bare `a / b` read as a fraction or score (`22 / 49`) as the pair of values it lists; any other cell unchanged. */
1194
+ function asPair(cell: Cell): Cell {
1195
+ if (!isNumber(cell) || cell.denominator === undefined || cell.fit !== "fraction" || !PAIR_SLASH.test(cell.figure))
1196
+ return cell;
1197
+ return {
1198
+ ...cell,
1199
+ dim: "count",
1200
+ unit: "",
1201
+ fit: "note",
1202
+ denominator: undefined,
1203
+ values: [cell.value, cell.denominator],
1204
+ };
1205
+ }
1206
+
1207
+ /** A {@link fractionKind} `score` as the percent of its denominator it reached; any other cell unchanged. */
1208
+ function asScore(cell: Cell): Cell {
1209
+ if (!isNumber(cell) || !cell.denominator || fractionKind(cell) !== "score") return cell;
1210
+ return { ...cell, dim: "percent", value: (100 * cell.value) / cell.denominator };
1211
+ }
1212
+
341
1213
  function readColumn(index: number, header: string, parsed: readonly (readonly Cell[])[]): DraftColumn {
342
- const cells = resolveMinutes(parsed.map(row => row[index]!));
1214
+ const cells = otherNouns(
1215
+ resolveClock(resolveMagnitudes(resolveMinutes(parsed.map(row => row[index]!)), header)),
1216
+ header,
1217
+ );
343
1218
  const present = cells.filter(cell => cell.kind !== "missing");
344
1219
  const numbers = present.filter(isNumber);
345
1220
  const strict = numbers.filter(cell => cell.fit !== "prose");
@@ -358,18 +1233,39 @@ function readColumn(index: number, header: string, parsed: readonly (readonly Ce
358
1233
  for (const group of dims.values()) if (group.length > dominant.length) dominant = group;
359
1234
  const dim = dominant[0]?.dim;
360
1235
  const mixed = numbers.length > 0 && dominant.length / numbers.length < 0.8;
1236
+ const scored = numbers.filter(cell => cell.dim === "percent" && cell.denominator !== undefined).length;
361
1237
  const unit = dim === "currency" || dim === "rate" ? (dominant[0]?.unit ?? "") : "";
362
1238
 
1239
+ // A lone `✗` reads as a missing value yet still says failed.
1240
+ const statuses = cells.map(cell => cellStatus(cell.text));
1241
+ const written = cells.filter(cell => cell.text.trim()).length;
1242
+ const statusShare = written ? statuses.filter(Boolean).length / written : 0;
1243
+
363
1244
  const trimmed = header.trim();
364
1245
  const sequential =
365
1246
  index === 0 &&
366
1247
  numbers.length >= 3 &&
367
1248
  numbers.length === present.length &&
368
1249
  numbers.every((cell, at) => cell.dim === "count" && cell.value === numbers[0]!.value + at);
1250
+ // Row numbers and identifiers are bare counts; a unit (`20 ms` under `PR`) makes the column a measure.
1251
+ const bare = numbers.every(cell => cell.dim === "count" && !cell.unit);
1252
+ // `1 ok`, `87 ok`, `3 fail`: every count tallies an outcome, a verdict per row rather than a quantity to plot.
1253
+ const tallies =
1254
+ numbers.length > 0 && bare && cells.every((cell, at) => !isNumber(cell) || statuses[at] !== undefined);
369
1255
  let role: ColumnRole;
370
1256
  if (temporalShare >= 0.8) role = "temporal";
371
- else if (numberShare >= 0.8 && (INDEX_HEADER.test(trimmed) || sequential)) role = "index";
372
- else if (numberShare >= 0.8 && SEQUENCE_HEADER.test(trimmed) && isMonotonic(numbers.map(cell => cell.value)))
1257
+ else if (tallies) role = "label";
1258
+ else if (numberShare >= 0.8 && (sequential || (bare && INDEX_HEADER.test(trimmed)))) role = "index";
1259
+ // Case numbers `01–11`, `12–17`, …, `64`, `65` beside the rows' names: spans tiling one numbering say where each
1260
+ // row sits, not how much it holds.
1261
+ else if (index > 0 && bare && tilesSpans(present)) role = "index";
1262
+ // A step header over ordered values (`Tier` 1, 1, 2, 3): repeats make it a bucket the rows fall into, still no measure.
1263
+ else if (
1264
+ numberShare >= 0.8 &&
1265
+ bare &&
1266
+ SEQUENCE_HEADER.test(trimmed) &&
1267
+ (isMonotonic(numbers.map(cell => cell.value)) || isOrdered(numbers.map(cell => cell.value)))
1268
+ )
373
1269
  role = "sequence";
374
1270
  else if (numberShare >= 0.8 && strictShare >= 0.6) role = "measure";
375
1271
  // A mostly numeric column whose other cells are short status words (`TIMEOUT`, `MISS`, `OOM`).
@@ -385,6 +1281,11 @@ function readColumn(index: number, header: string, parsed: readonly (readonly Ce
385
1281
  dim,
386
1282
  unit,
387
1283
  mixed,
1284
+ scores: numbers.length > 0 && scored / numbers.length >= 0.8,
1285
+ statuses:
1286
+ statusShare >= 0.6
1287
+ ? statuses.map((status, at) => status ?? (HEDGE.test(cells[at]!.text) ? "warn" : undefined))
1288
+ : undefined,
388
1289
  signedShare: numbers.length ? numbers.filter(cell => cell.signed).length / numbers.length : 0,
389
1290
  arrowShare: numbers.length ? numbers.filter(cell => cell.to !== undefined).length / numbers.length : 0,
390
1291
  };
@@ -406,6 +1307,116 @@ function resolveMinutes(cells: Cell[]): Cell[] {
406
1307
  );
407
1308
  }
408
1309
 
1310
+ /** The word a bare count names after its figure (`4 new (45 total)` → `new`, `371 entries`). */
1311
+ const COUNTED_NOUN = /^\s*([A-Za-z][A-Za-z-]*)/;
1312
+
1313
+ /**
1314
+ * Counts of another thing than most of the column's (`371 entries` among `4
1315
+ * new` tests) read as text: one axis would set them against the rest. Applies
1316
+ * where most counts name one noun and two or more others name one the header
1317
+ * does not (a lone `1200px` among `rem` sizes is a stray unit, not a second quantity).
1318
+ */
1319
+ function otherNouns(cells: Cell[], header: string): Cell[] {
1320
+ const nouns = cells.map(cell => {
1321
+ if (!isNumber(cell) || cell.dim !== "count" || cell.unit || cell.fit === "pure" || cellStatus(cell.text))
1322
+ return undefined;
1323
+ const word = COUNTED_NOUN.exec(cell.text.slice(cell.text.indexOf(cell.figure) + cell.figure.length))?.[1];
1324
+ return word?.toLowerCase().replace(/e?s$/, "");
1325
+ });
1326
+ const tally = new Map<string, number>();
1327
+ for (const noun of nouns) if (noun) tally.set(noun, (tally.get(noun) ?? 0) + 1);
1328
+ const [main, count] = [...tally].sort((a, b) => b[1] - a[1])[0] ?? ["", 0];
1329
+ if (count < 2 || count * 2 <= cells.filter(isNumber).length) return cells;
1330
+ const named = header.toLowerCase();
1331
+ return cells.map((cell, at) => {
1332
+ const noun = nouns[at];
1333
+ return noun && noun !== main && (tally.get(noun) ?? 0) >= 2 && !named.includes(noun)
1334
+ ? { kind: "text", text: cell.text, emphasis: cell.emphasis }
1335
+ : cell;
1336
+ });
1337
+ }
1338
+
1339
+ /** Headers of a size on disk or in memory, whose bare magnitudes (`3.4G`, `960M`) count bytes. */
1340
+ const SIZE_HEADER = /\b(?:sizes?|disk|du|rss|memory|mem|heap|storage|footprint|bytes)\b/i;
1341
+ /** Bare magnitude suffixes of a `du -h` size, as powers of 1024. */
1342
+ const BINARY_POWER: Readonly<Record<string, number>> = { K: 1, k: 1, M: 2, G: 3, T: 4 };
1343
+
1344
+ /**
1345
+ * Bare magnitudes read by what they stand beside: under a size header
1346
+ * ({@link SIZE_HEADER}), `3.4G`, `960M` and `12K` are `du -h` sizes in powers
1347
+ * of 1024; beside millions (`600M` parameters), `1.2B` is billions, not bytes.
1348
+ */
1349
+ function resolveMagnitudes(cells: Cell[], header: string): Cell[] {
1350
+ const numbers = cells.filter(isNumber);
1351
+ const suffixed = numbers.filter(cell => cell.dim === "count" && Object.hasOwn(BINARY_POWER, cell.unit));
1352
+ if (suffixed.length === 0) return cells;
1353
+ const rescale = (value: number | undefined, from: number, to: number) =>
1354
+ value === undefined ? undefined : (value / from) * to;
1355
+ if (SIZE_HEADER.test(header) && numbers.every(cell => cell.dim === "count" || cell.dim === "bytes"))
1356
+ return cells.map(cell => {
1357
+ if (!isNumber(cell) || !suffixed.includes(cell)) return cell;
1358
+ const from = knownUnit(cell.unit)![1];
1359
+ const to = 1024 ** BINARY_POWER[cell.unit]!;
1360
+ return {
1361
+ ...cell,
1362
+ dim: "bytes",
1363
+ value: rescale(cell.value, from, to)!,
1364
+ to: rescale(cell.to, from, to),
1365
+ upper: rescale(cell.upper, from, to),
1366
+ };
1367
+ });
1368
+ const bytes = numbers.filter(cell => cell.dim === "bytes");
1369
+ // Billions read like millions: a few units each (`1.2B` beside `600M`), where bytes would be thousands (`4096B`).
1370
+ if (
1371
+ bytes.length === 0 ||
1372
+ !suffixed.some(cell => cell.unit === "M") ||
1373
+ !bytes.every(cell => cell.unit === "B" && Math.abs(cell.value) < 1000)
1374
+ )
1375
+ return cells;
1376
+ return cells.map(cell =>
1377
+ isNumber(cell) && cell.dim === "bytes"
1378
+ ? {
1379
+ ...cell,
1380
+ dim: "count",
1381
+ value: cell.value * 1e9,
1382
+ to: rescale(cell.to, 1, 1e9),
1383
+ upper: rescale(cell.upper, 1, 1e9),
1384
+ }
1385
+ : cell,
1386
+ );
1387
+ }
1388
+
1389
+ /** An elapsed time written on a clock face: `m:ss` or `h:mm:ss`, optionally with fractions of a second. */
1390
+ const CLOCK = /^(?:(\d{1,2}):)?(\d{1,3}):(\d{2}(?:\.\d+)?)$/;
1391
+
1392
+ /**
1393
+ * `4:14` beside `8s` and `24s` is four minutes fourteen seconds, not a time of
1394
+ * day: in a column holding other durations, clock cells read as durations
1395
+ * (`m:ss`, or `h:mm:ss`).
1396
+ */
1397
+ function resolveClock(cells: Cell[]): Cell[] {
1398
+ if (!cells.some(cell => isNumber(cell) && cell.dim === "duration")) return cells;
1399
+ return cells.map(cell => {
1400
+ if (cell.kind !== "time") return cell;
1401
+ const text = cell.text.replace(MARKS, "").trim();
1402
+ const clock = CLOCK.exec(text);
1403
+ if (!clock) return cell;
1404
+ const value = Number(clock[1] ?? 0) * 3600 + Number(clock[2]) * 60 + Number(clock[3]);
1405
+ return {
1406
+ kind: "number",
1407
+ value,
1408
+ dim: "duration",
1409
+ unit: "s",
1410
+ fit: "pure",
1411
+ approx: false,
1412
+ signed: false,
1413
+ emphasis: cell.emphasis,
1414
+ text: cell.text,
1415
+ figure: text,
1416
+ } satisfies NumberCell;
1417
+ });
1418
+ }
1419
+
409
1420
  function finishColumn(column: DraftColumn, rows: readonly number[]): TableColumn {
410
1421
  const values = new Set<number>();
411
1422
  let count = 0;
@@ -419,6 +1430,31 @@ function finishColumn(column: DraftColumn, rows: readonly number[]): TableColumn
419
1430
  return { ...column, constant: count > 0 && values.size <= 1 };
420
1431
  }
421
1432
 
1433
+ /** A span of whole numbers written `a–b` (zero-padded or not: `01–11`), or one number. */
1434
+ const SPAN = /^(\d+)(?:\s*[-–—]\s*(\d+))?$/;
1435
+ /** Fewest spans that tile a numbering; two in a row may meet by chance. */
1436
+ const MIN_TILES = 3;
1437
+
1438
+ /**
1439
+ * Whether `cells` are spans of whole numbers laid end to end in order (`01–11`, `12–17`, `18–21`, `64`):
1440
+ * each starts right after the previous one ends, one at least a range, {@link MIN_TILES} at least.
1441
+ */
1442
+ function tilesSpans(cells: readonly Cell[]): boolean {
1443
+ if (cells.length < MIN_TILES) return false;
1444
+ let end: number | undefined;
1445
+ let ranged = false;
1446
+ for (const cell of cells) {
1447
+ const span = SPAN.exec(cell.text.replace(MARKS, "").trim());
1448
+ if (!span) return false;
1449
+ const first = Number(span[1]);
1450
+ const last = span[2] === undefined ? first : Number(span[2]);
1451
+ if (last < first || (end !== undefined && first !== end + 1)) return false;
1452
+ ranged ||= last > first;
1453
+ end = last;
1454
+ }
1455
+ return ranged;
1456
+ }
1457
+
422
1458
  /** Strictly increasing or strictly decreasing. */
423
1459
  export function isMonotonic(values: readonly number[]): boolean {
424
1460
  if (values.length < 2) return false;
@@ -430,6 +1466,12 @@ export function isMonotonic(values: readonly number[]): boolean {
430
1466
  return true;
431
1467
  }
432
1468
 
1469
+ /** Non-decreasing with at least one repeat: ordered buckets (`1, 1, 2, 2, 3`), not a strictly monotonic axis. */
1470
+ function isOrdered(values: readonly number[]): boolean {
1471
+ if (values.length < 3 || new Set(values).size === values.length) return false;
1472
+ return values.every((value, at) => at === 0 || value >= values[at - 1]!);
1473
+ }
1474
+
433
1475
  /** Whether `cell` reads as a number. */
434
1476
  export function isNumber(cell: Cell | undefined): cell is NumberCell {
435
1477
  return cell?.kind === "number";