@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.
- package/CHANGELOG.md +56 -221
- package/dist/types/charts/chart-plan.d.ts +595 -13
- package/dist/types/charts/chart-svg.d.ts +38 -9
- package/dist/types/charts/table-data.d.ts +70 -2
- package/dist/types/chat/table-chart.d.ts +3 -2
- package/dist/types/chat/user-message.d.ts +12 -1
- package/dist/types/chrome/shared.d.ts +9 -0
- package/dist/types/chrome/transcript-container.d.ts +1 -0
- package/dist/types/components/editor.d.ts +6 -0
- package/dist/types/components/form.d.ts +1 -0
- package/dist/types/components/markdown.d.ts +7 -0
- package/dist/types/components/tree-view.d.ts +7 -0
- package/dist/types/kitty-graphics.d.ts +2 -2
- package/dist/types/loop-watchdog.d.ts +8 -7
- package/dist/types/multiplexers/cmux.d.ts +16 -0
- package/dist/types/multiplexers/herdr.d.ts +26 -0
- package/dist/types/multiplexers/notify.d.ts +13 -0
- package/dist/types/multiplexers/orca.d.ts +12 -0
- package/dist/types/multiplexers/screen.d.ts +9 -0
- package/dist/types/multiplexers/tmux.d.ts +29 -0
- package/dist/types/multiplexers/types.d.ts +100 -0
- package/dist/types/multiplexers/wmux.d.ts +12 -0
- package/dist/types/multiplexers/zellij.d.ts +12 -0
- package/dist/types/overlays/agent-hub-projection.d.ts +4 -3
- package/dist/types/overlays/agent-hub-types.d.ts +9 -0
- package/dist/types/overlays/annotation-overlay.d.ts +2 -0
- package/dist/types/overlays/model-browser.d.ts +9 -1
- package/dist/types/overlays/settings-defs.d.ts +1 -0
- package/dist/types/overlays/usage-display.d.ts +3 -1
- package/dist/types/setup/scenes/types.d.ts +1 -0
- package/dist/types/status-line/component.d.ts +2 -0
- package/dist/types/status-line/host.d.ts +4 -1
- package/dist/types/status-line/types.d.ts +11 -2
- package/dist/types/terminal-capabilities.d.ts +24 -32
- package/dist/types/terminal-multiplexer.d.ts +127 -14
- package/dist/types/terminal.d.ts +12 -4
- package/dist/types/theme/schema.d.ts +11 -2
- package/dist/types/theme/theme-class.d.ts +7 -0
- package/dist/types/tmux.d.ts +1 -4
- package/dist/types/tools/eval.d.ts +21 -2
- package/dist/types/tools/output-meta.d.ts +11 -1
- package/dist/types/tools/streaming-output.d.ts +9 -0
- package/package.json +9 -9
- package/src/charts/chart-plan.ts +4802 -88
- package/src/charts/chart-svg.ts +4315 -351
- package/src/charts/table-data.ts +1076 -34
- package/src/chat/assistant-message.ts +27 -9
- package/src/chat/chat-transcript-builder.ts +1 -3
- package/src/chat/svg-figure.ts +45 -8
- package/src/chat/table-chart.ts +7 -11
- package/src/chat/user-message.ts +40 -2
- package/src/chrome/shared.ts +53 -0
- package/src/chrome/transcript-container.ts +32 -9
- package/src/components/editor.ts +38 -5
- package/src/components/form.ts +8 -0
- package/src/components/markdown.ts +45 -2
- package/src/components/settings-list.ts +2 -0
- package/src/components/tree-view.ts +38 -8
- package/src/kitty-graphics.ts +4 -4
- package/src/loop-watchdog.ts +18 -13
- package/src/multiplexers/cmux.ts +29 -0
- package/src/multiplexers/herdr.ts +64 -0
- package/src/multiplexers/notify.ts +24 -0
- package/src/multiplexers/orca.ts +16 -0
- package/src/multiplexers/screen.ts +15 -0
- package/src/multiplexers/tmux.ts +40 -0
- package/src/multiplexers/types.ts +110 -0
- package/src/multiplexers/wmux.ts +16 -0
- package/src/multiplexers/zellij.ts +24 -0
- package/src/overlays/agent-hub-projection.ts +51 -14
- package/src/overlays/agent-hub-types.ts +6 -0
- package/src/overlays/annotation-overlay.ts +2 -1
- package/src/overlays/model-browser.ts +43 -7
- package/src/overlays/model-hub.ts +12 -19
- package/src/overlays/settings-defs.ts +4 -3
- package/src/overlays/settings-selector.ts +10 -1
- package/src/overlays/tree-selector.ts +21 -16
- package/src/overlays/usage-display.ts +10 -4
- package/src/setup/scenes/model.ts +2 -1
- package/src/setup/scenes/types.ts +1 -0
- package/src/status-line/component.ts +65 -11
- package/src/status-line/host.ts +4 -1
- package/src/status-line/segments.ts +21 -16
- package/src/status-line/types.ts +12 -2
- package/src/terminal-capabilities.ts +65 -181
- package/src/terminal-multiplexer.ts +112 -33
- package/src/terminal.ts +73 -32
- package/src/theme/schema-validation.ts +1 -0
- package/src/theme/schema.ts +14 -1
- package/src/theme/session-color.ts +1 -7
- package/src/theme/theme-class.ts +26 -1
- package/src/theme/theme-schema.json +5 -1
- package/src/theme/theme.ts +7 -4
- package/src/tmux.ts +4 -11
- package/src/tools/eval.ts +56 -16
- package/src/tools/output-meta.ts +23 -3
- package/src/tools/streaming-output.ts +29 -2
- package/src/tools/todo.ts +4 -1
- package/src/tui.ts +33 -18
- package/src/windows-input-mode.ts +8 -0
package/src/charts/table-data.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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})
|
|
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
|
-
|
|
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
|
-
/**
|
|
136
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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(
|
|
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
|
|
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 =
|
|
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] ? (
|
|
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
|
|
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
|
-
|
|
318
|
-
|
|
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
|
-
|
|
324
|
-
const
|
|
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
|
|
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
|
|
332
|
-
|
|
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
|
-
|
|
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 =
|
|
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 (
|
|
372
|
-
else if (numberShare >= 0.8 &&
|
|
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";
|