@openmixer/plugin-qualify 0.0.0-stage → 0.4.1
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/LICENSE +674 -0
- package/README.md +203 -2
- package/bin/plugin-qualify.mjs +77 -0
- package/dist/clap-loader.d.ts +147 -0
- package/dist/clap-loader.js +252 -0
- package/dist/clap-loader.js.map +1 -0
- package/dist/clap-qualify.d.ts +126 -0
- package/dist/clap-qualify.js +272 -0
- package/dist/clap-qualify.js.map +1 -0
- package/dist/host-profiles.d.ts +42 -0
- package/dist/host-profiles.js +106 -0
- package/dist/host-profiles.js.map +1 -0
- package/dist/hosting-codes.d.ts +62 -0
- package/dist/hosting-codes.js +72 -0
- package/dist/hosting-codes.js.map +1 -0
- package/dist/hosting-suitability.d.ts +362 -0
- package/dist/hosting-suitability.js +669 -0
- package/dist/hosting-suitability.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/loader.d.ts +26 -0
- package/dist/loader.js +9 -0
- package/dist/loader.js.map +1 -0
- package/dist/lv2-loader.d.ts +7 -0
- package/dist/lv2-loader.js +50 -0
- package/dist/lv2-loader.js.map +1 -0
- package/dist/measurement/cli.d.ts +168 -0
- package/dist/measurement/cli.js +353 -0
- package/dist/measurement/cli.js.map +1 -0
- package/dist/measurement/fixtures.d.ts +23 -0
- package/dist/measurement/fixtures.js +129 -0
- package/dist/measurement/fixtures.js.map +1 -0
- package/dist/measurement/format.d.ts +483 -0
- package/dist/measurement/format.js +627 -0
- package/dist/measurement/format.js.map +1 -0
- package/dist/measurement/guards.d.ts +33 -0
- package/dist/measurement/guards.js +61 -0
- package/dist/measurement/guards.js.map +1 -0
- package/dist/measurement/index.d.ts +36 -0
- package/dist/measurement/index.js +39 -0
- package/dist/measurement/index.js.map +1 -0
- package/dist/measurement/merge.d.ts +143 -0
- package/dist/measurement/merge.js +315 -0
- package/dist/measurement/merge.js.map +1 -0
- package/dist/measurement/plan.d.ts +127 -0
- package/dist/measurement/plan.js +141 -0
- package/dist/measurement/plan.js.map +1 -0
- package/dist/measurement/probe-json.d.ts +61 -0
- package/dist/measurement/probe-json.js +253 -0
- package/dist/measurement/probe-json.js.map +1 -0
- package/dist/measurement/provenance.d.ts +91 -0
- package/dist/measurement/provenance.js +173 -0
- package/dist/measurement/provenance.js.map +1 -0
- package/dist/measurement/sweep-jsonl.d.ts +40 -0
- package/dist/measurement/sweep-jsonl.js +154 -0
- package/dist/measurement/sweep-jsonl.js.map +1 -0
- package/dist/measurement/vocabulary.d.ts +125 -0
- package/dist/measurement/vocabulary.js +217 -0
- package/dist/measurement/vocabulary.js.map +1 -0
- package/dist/qualify-cli.d.ts +83 -0
- package/dist/qualify-cli.js +224 -0
- package/dist/qualify-cli.js.map +1 -0
- package/dist/qualify-declarations.d.ts +98 -0
- package/dist/qualify-declarations.js +76 -0
- package/dist/qualify-declarations.js.map +1 -0
- package/dist/qualify-options.d.ts +22 -0
- package/dist/qualify-options.js +36 -0
- package/dist/qualify-options.js.map +1 -0
- package/dist/rating.d.ts +32 -0
- package/dist/rating.js +30 -0
- package/dist/rating.js.map +1 -0
- package/dist/tool-paths.d.ts +12 -0
- package/dist/tool-paths.js +29 -0
- package/dist/tool-paths.js.map +1 -0
- package/fixtures/clap-scan/controls.json +8 -0
- package/fixtures/clap-scan/fake.json +16 -0
- package/fixtures/clap-scan/mixed.json +18 -0
- package/fixtures/zero-path.lv2/manifest.ttl +3 -0
- package/fixtures/zero-path.lv2/zero-path.c +85 -0
- package/fixtures/zero-path.lv2/zero-path.ttl +21 -0
- package/package.json +94 -4
- package/src/clap-loader.ts +393 -0
- package/src/clap-qualify.ts +387 -0
- package/src/host-profiles.ts +122 -0
- package/src/hosting-codes.ts +71 -0
- package/src/hosting-suitability.ts +998 -0
- package/src/index.ts +19 -0
- package/src/loader.ts +30 -0
- package/src/lv2-loader.ts +50 -0
- package/src/measurement/cli.ts +458 -0
- package/src/measurement/fixtures.ts +141 -0
- package/src/measurement/format.ts +1064 -0
- package/src/measurement/guards.ts +64 -0
- package/src/measurement/index.ts +39 -0
- package/src/measurement/merge.ts +434 -0
- package/src/measurement/plan.ts +219 -0
- package/src/measurement/probe-json.ts +271 -0
- package/src/measurement/provenance.ts +220 -0
- package/src/measurement/sweep-jsonl.ts +173 -0
- package/src/measurement/vocabulary.ts +298 -0
- package/src/qualify-cli.ts +277 -0
- package/src/qualify-declarations.ts +117 -0
- package/src/qualify-options.ts +49 -0
- package/src/rating.ts +53 -0
- package/src/tool-paths.ts +32 -0
- package/tools/_declarations.py +21 -0
- package/tools/benchmark.py +2462 -0
- package/tools/gen-python-declarations.d.mts +4 -0
- package/tools/gen-python-declarations.mjs +92 -0
- package/tools/hosting-sweep.mjs +51 -0
- package/tools/lv2-measure.mjs +204 -0
- package/tools/lv2_presets.py +132 -0
- package/tools/scan.py +432 -0
|
@@ -0,0 +1,1064 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
// Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
|
|
3
|
+
/**
|
|
4
|
+
* **The LV2 plugin measurement interchange format** — one measurement run, on one
|
|
5
|
+
* machine, in one document.
|
|
6
|
+
*
|
|
7
|
+
* `lv2:latency` is a declared port and it is often wrong: the 958-plugin sweep behind
|
|
8
|
+
* `data/catalog.json` found 57 declared-vs-measured mismatches. Measured figures exist in
|
|
9
|
+
* several projects and are shared by none of them, because there is no shape to share them
|
|
10
|
+
* in. This is that shape. The schema is the deliverable; the prober is replaceable.
|
|
11
|
+
*
|
|
12
|
+
* ## The three rules the format exists to enforce
|
|
13
|
+
*
|
|
14
|
+
* 1. **A figure carries the conditions it was taken under.** {@link MeasurementRun} is not
|
|
15
|
+
* optional metadata and is not a footer — it is the document's other half. A cost
|
|
16
|
+
* figure without its host, governor, block and stimulus is a decoration; a latency
|
|
17
|
+
* figure without the plugin's version is a trap, because the next release moves the FFT
|
|
18
|
+
* window and the number silently becomes a lie.
|
|
19
|
+
* 2. **Unknown is a value.** A plugin that could not be measured carries a REASON from a
|
|
20
|
+
* controlled vocabulary ({@link ./vocabulary.ts}) and no number at all. Nothing in this
|
|
21
|
+
* format has a default: an absent field means "not determined", never zero and never
|
|
22
|
+
* false. Readers here return `undefined` rather than inventing.
|
|
23
|
+
* 3. **Per rate, always.** Latency does not scale one way and cost does not scale at all
|
|
24
|
+
* predictably — 19 plugins in the reference sweep fit neither scaling law, and 48 cost
|
|
25
|
+
* figures grow with the SQUARE of the rate. A single-rate document is a claim about one
|
|
26
|
+
* rate and the format will not let it pretend otherwise: every figure lives under an
|
|
27
|
+
* explicit rate key.
|
|
28
|
+
*
|
|
29
|
+
* ## Version strategy
|
|
30
|
+
*
|
|
31
|
+
* `formatVersion` is `"<major>.<minor>"`.
|
|
32
|
+
*
|
|
33
|
+
* - **major** changes when a reader written for the previous major would MISREAD a
|
|
34
|
+
* document: a field removed, renamed, re-scaled, or given a new meaning. A reader that
|
|
35
|
+
* meets a higher major must refuse the document ({@link readMeasurementDocument} does),
|
|
36
|
+
* because the alternative is silently consuming figures it has misunderstood.
|
|
37
|
+
* - **minor** changes are additive only: new optional fields, new vocabulary terms. A
|
|
38
|
+
* reader at minor N reads minor N+1 fine, ignoring what it does not know — and is TOLD
|
|
39
|
+
* it is doing so ({@link MeasurementReadResult.forwardMinor}), so a UI can say "this
|
|
40
|
+
* document was written by a newer tool" instead of quietly dropping data.
|
|
41
|
+
*
|
|
42
|
+
* Unknown fields are preserved nowhere and that is deliberate: a reader that round-trips
|
|
43
|
+
* fields it does not understand invites a consumer to depend on them. Re-emit from the
|
|
44
|
+
* original document, never from a parse.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import {
|
|
48
|
+
booleanValue,
|
|
49
|
+
finiteNumber,
|
|
50
|
+
isRecord,
|
|
51
|
+
mapArray,
|
|
52
|
+
nonEmptyString,
|
|
53
|
+
optional,
|
|
54
|
+
positiveNumber,
|
|
55
|
+
} from "./guards.js";
|
|
56
|
+
import {
|
|
57
|
+
type HostIsolation,
|
|
58
|
+
type HostLatencyHandling,
|
|
59
|
+
type MeasurementDimension,
|
|
60
|
+
type VerdictRating,
|
|
61
|
+
HOST_ISOLATIONS,
|
|
62
|
+
HOST_LATENCY_HANDLING,
|
|
63
|
+
MEASUREMENT_DIMENSIONS,
|
|
64
|
+
VERDICT_RATINGS,
|
|
65
|
+
readDimension,
|
|
66
|
+
} from "./vocabulary.js";
|
|
67
|
+
|
|
68
|
+
/** The `format` discriminator every document carries. Never absent, never anything else. */
|
|
69
|
+
export const MEASUREMENT_FORMAT_ID = "lv2-plugin-measurements";
|
|
70
|
+
|
|
71
|
+
/** The major this build writes and is guaranteed to understand. */
|
|
72
|
+
export const MEASUREMENT_FORMAT_MAJOR = 1;
|
|
73
|
+
|
|
74
|
+
/** The minor this build writes. Additive-only within {@link MEASUREMENT_FORMAT_MAJOR}.
|
|
75
|
+
* `1.1` added the three hosting dimensions — {@link StabilityMeasurement},
|
|
76
|
+
* {@link RtSafetyMeasurement}, {@link FeaturesMeasurement} — which a 1.0 reader skips. */
|
|
77
|
+
export const MEASUREMENT_FORMAT_MINOR = 2;
|
|
78
|
+
|
|
79
|
+
/** The version string this build writes, e.g. `"1.0"`. */
|
|
80
|
+
export const MEASUREMENT_FORMAT_VERSION = `${MEASUREMENT_FORMAT_MAJOR}.${MEASUREMENT_FORMAT_MINOR}`;
|
|
81
|
+
|
|
82
|
+
/** A parsed `formatVersion`. */
|
|
83
|
+
export interface FormatVersion {
|
|
84
|
+
readonly major: number;
|
|
85
|
+
readonly minor: number;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Parse a `"<major>.<minor>"` version, or `undefined` when it is not one.
|
|
90
|
+
*
|
|
91
|
+
* Deliberately strict — two non-negative integers and nothing else. A version we cannot
|
|
92
|
+
* parse is a document we cannot claim to understand, and guessing at `"1"` or `"1.0.0"`
|
|
93
|
+
* would be exactly the sloppiness a version field exists to prevent.
|
|
94
|
+
*/
|
|
95
|
+
export function parseFormatVersion(value: unknown): FormatVersion | undefined {
|
|
96
|
+
const text = nonEmptyString(value);
|
|
97
|
+
if (text === undefined) return undefined;
|
|
98
|
+
const match = /^(\d+)\.(\d+)$/.exec(text);
|
|
99
|
+
if (match === null) return undefined;
|
|
100
|
+
const major = Number(match[1]);
|
|
101
|
+
const minor = Number(match[2]);
|
|
102
|
+
if (!Number.isSafeInteger(major) || !Number.isSafeInteger(minor)) return undefined;
|
|
103
|
+
return { major, minor };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// Provenance
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The machine a run happened on.
|
|
112
|
+
*
|
|
113
|
+
* Every field except {@link hostname} is optional and every one of them is absent when it
|
|
114
|
+
* could not be READ — the prober reports what `/proc` and `/sys` told it and nothing else.
|
|
115
|
+
* `realtimePriority` and `memoryLocked` are the exception and are required, because
|
|
116
|
+
* "we did not try" and "we tried and were not allowed" are both `false` and a consumer
|
|
117
|
+
* comparing two runs must not have to guess which.
|
|
118
|
+
*/
|
|
119
|
+
export interface MeasurementHost {
|
|
120
|
+
/** The machine's name. The single field an operator recognises. */
|
|
121
|
+
readonly hostname: string;
|
|
122
|
+
readonly kernel?: string;
|
|
123
|
+
readonly os?: string;
|
|
124
|
+
readonly cpuModel?: string;
|
|
125
|
+
readonly cpuCount?: number;
|
|
126
|
+
/** The core the prober pinned itself to, when it pinned at all. */
|
|
127
|
+
readonly pinnedCpu?: number;
|
|
128
|
+
/** What kind of core that was — `performance` / `efficiency` on a hybrid CPU. */
|
|
129
|
+
readonly pinnedCpuKind?: string;
|
|
130
|
+
readonly pinnedCpuMaxKHz?: number;
|
|
131
|
+
/** The cpufreq governor in force, read at run time. `performance` and `powersave`
|
|
132
|
+
* produce materially different cost figures on the same silicon. */
|
|
133
|
+
readonly governor?: string;
|
|
134
|
+
readonly turboEnabled?: boolean;
|
|
135
|
+
/** Whether the prober ran with realtime scheduling. Required: `false` is a finding. */
|
|
136
|
+
readonly realtimePriority: boolean;
|
|
137
|
+
/** Whether the prober's pages were locked. Required, same reason. */
|
|
138
|
+
readonly memoryLocked: boolean;
|
|
139
|
+
/** Versions of what did the probing (`{ python: "3.14.6", lilv: "0.24.26" }`). */
|
|
140
|
+
readonly toolchain?: Readonly<Record<string, string>>;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** How the latency figures in this document were taken. */
|
|
144
|
+
export interface LatencyMethod {
|
|
145
|
+
/** A {@link ../vocabulary.METHOD_TERMS} code — `impulse-onset` or `declared-port`. */
|
|
146
|
+
readonly method: string;
|
|
147
|
+
readonly blockFrames: number;
|
|
148
|
+
/** What was fed in, in words. An onset measured through an opened gate is not the same
|
|
149
|
+
* measurement as one through a bare impulse, and the difference must be legible. */
|
|
150
|
+
readonly stimulus: string;
|
|
151
|
+
/** Whether latency-bearing controls were swept to find parameter-dependent latency. */
|
|
152
|
+
readonly paramSweep: boolean;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** How the cost figures in this document were taken. */
|
|
156
|
+
export interface CostMethod {
|
|
157
|
+
/** A {@link ../vocabulary.METHOD_TERMS} code — `block-time-percentile`. */
|
|
158
|
+
readonly method: string;
|
|
159
|
+
readonly blockFrames: number;
|
|
160
|
+
/** Blocks timed and then DISCARDED before the steady-state window opened. */
|
|
161
|
+
readonly warmupBlocks: number;
|
|
162
|
+
readonly minTimedBlocks?: number;
|
|
163
|
+
readonly maxTimedBlocks?: number;
|
|
164
|
+
/** Wall-time budget per plugin per rate, after which timing stops early. */
|
|
165
|
+
readonly targetSecondsPerPlugin?: number;
|
|
166
|
+
/** The percentile the headline figure is quoted at. The mean hides the tail that xruns. */
|
|
167
|
+
readonly percentile: number;
|
|
168
|
+
readonly stimulus: string;
|
|
169
|
+
/** How control ports were set — the operating point the figure describes. */
|
|
170
|
+
readonly controls: string;
|
|
171
|
+
/** Lower bound on the harness's own per-block overhead. Plugins near it are too cheap
|
|
172
|
+
* to rank apart, and it is REPORTED rather than subtracted. */
|
|
173
|
+
readonly timingFloorNsPerBlock?: number;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* One measurement run: who measured, on what, how, when, and over which rates.
|
|
178
|
+
*
|
|
179
|
+
* A document is exactly one run, on purpose. Merging figures taken on different machines
|
|
180
|
+
* is a consumer's decision with a consumer's caveats ({@link ./merge.ts}); baking it into
|
|
181
|
+
* the document would make a merged file indistinguishable from a measured one.
|
|
182
|
+
*/
|
|
183
|
+
export interface MeasurementRun {
|
|
184
|
+
/** Stable identifier for this run — referenced by a merged view to name a figure's source. */
|
|
185
|
+
readonly id: string;
|
|
186
|
+
/** ISO 8601, with offset. */
|
|
187
|
+
readonly measuredAt: string;
|
|
188
|
+
/** Which dimensions this run covered. A plugin carrying no figure for a dimension NOT
|
|
189
|
+
* listed here was never asked; one carrying none for a dimension that IS listed has a
|
|
190
|
+
* reason. That distinction is why the field exists. */
|
|
191
|
+
readonly dimensions: readonly MeasurementDimension[];
|
|
192
|
+
/** Every sample rate the run measured at, ascending. */
|
|
193
|
+
readonly rates: readonly number[];
|
|
194
|
+
readonly tool: { readonly name: string; readonly version: string };
|
|
195
|
+
readonly host: MeasurementHost;
|
|
196
|
+
readonly method: { readonly latency?: LatencyMethod; readonly cost?: CostMethod };
|
|
197
|
+
/** Wall-clock duration of the run. The only honest basis for estimating the next one. */
|
|
198
|
+
readonly elapsedSeconds?: number;
|
|
199
|
+
/** Free-text label an operator gave the run ("after installing LSP 1.2.22"). */
|
|
200
|
+
readonly label?: string;
|
|
201
|
+
/**
|
|
202
|
+
* Always `true` on a run carrying cost figures: the numbers RANK plugins on the machine
|
|
203
|
+
* named above and do not predict another. Kept as an explicit field rather than a
|
|
204
|
+
* convention so a consumer cannot claim it never saw the caveat.
|
|
205
|
+
*/
|
|
206
|
+
readonly relativeRankingOnly: boolean;
|
|
207
|
+
/** The caveat in prose, for anyone reading the file directly. */
|
|
208
|
+
readonly note: string;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
// Per-plugin figures
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
/** What the prober saw of the plugin's ports. Cheap, portable, and it decides suitability. */
|
|
216
|
+
export interface PluginTopology {
|
|
217
|
+
readonly audioInputs: number;
|
|
218
|
+
readonly audioOutputs: number;
|
|
219
|
+
readonly midiInputs?: number;
|
|
220
|
+
readonly controlInputs?: number;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** One rate's latency reading. */
|
|
224
|
+
export interface LatencyAtRate {
|
|
225
|
+
readonly frames: number;
|
|
226
|
+
readonly ms: number;
|
|
227
|
+
/** Set when this reading needed an ESCALATED stimulus (a gate that had to be opened).
|
|
228
|
+
* The figure then describes that operating point, not the plugin at rest. */
|
|
229
|
+
readonly stimulus?: string;
|
|
230
|
+
/**
|
|
231
|
+
* Set when the reading was taken but REJECTED as evidence, with a vocabulary code
|
|
232
|
+
* ({@link ../vocabulary.EXCLUSION_TERMS}). The number stays — this is the measurement
|
|
233
|
+
* record — but a consumer must not resolve a rate to it.
|
|
234
|
+
*/
|
|
235
|
+
readonly excludedReason?: string;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** The controls that move a plugin's latency, and the span they move it over. */
|
|
239
|
+
export interface MeasuredParamSweep {
|
|
240
|
+
readonly rate: number;
|
|
241
|
+
readonly defaultFrames: number;
|
|
242
|
+
readonly minFrames: number;
|
|
243
|
+
readonly maxFrames: number;
|
|
244
|
+
/** Per-control span, keyed by control-port symbol. */
|
|
245
|
+
readonly controls: Readonly<Record<string, { readonly minFrames: number; readonly maxFrames: number }>>;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Everything one run learned about one plugin's latency. */
|
|
249
|
+
export interface LatencyMeasurement {
|
|
250
|
+
/** Readings keyed by sample rate in Hz as a decimal string (`"96000"`). May be empty. */
|
|
251
|
+
readonly perRate: Readonly<Record<string, LatencyAtRate>>;
|
|
252
|
+
/** A {@link ../vocabulary.SCALING_TERMS} code, derived from {@link perRate}. */
|
|
253
|
+
readonly scalingClass?: string;
|
|
254
|
+
/** A {@link ../vocabulary.SCALING_NOTE_TERMS} code: why the class is not the naive fit. */
|
|
255
|
+
readonly scalingNote?: string;
|
|
256
|
+
/** Symbol of the plugin's own `lv2:latency` port, when it has one. */
|
|
257
|
+
readonly declaredPortSymbol?: string;
|
|
258
|
+
/** What that port CLAIMED, in frames. Kept next to the measurement, never instead of it. */
|
|
259
|
+
readonly declaredFrames?: number;
|
|
260
|
+
/** Set when declared and measured disagree beyond rounding — a data-quality flag on the
|
|
261
|
+
* PLUGIN, and the single most useful thing this format publishes. */
|
|
262
|
+
readonly declaredMismatch?: boolean;
|
|
263
|
+
readonly paramSweep?: MeasuredParamSweep;
|
|
264
|
+
/** An {@link ../vocabulary.UNMEASURED_TERMS} code. Set exactly when {@link perRate} is
|
|
265
|
+
* empty; a figure and a reason are mutually exclusive. */
|
|
266
|
+
readonly unmeasuredReason?: string;
|
|
267
|
+
/** The prober's own words for that reason, kept verbatim. The CODE is what a merger
|
|
268
|
+
* branches on; this is what a human reads when the code is too coarse to debug with. */
|
|
269
|
+
readonly unmeasuredDetail?: string;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** One rate's cost reading. Per SAMPLE, so it is independent of the block size. */
|
|
273
|
+
export interface CostAtRate {
|
|
274
|
+
readonly nsPerSampleMedian: number;
|
|
275
|
+
/** The percentile named in {@link CostMethod.percentile} — what a bad block costs. */
|
|
276
|
+
readonly nsPerSamplePercentile: number;
|
|
277
|
+
readonly nsPerSampleMin?: number;
|
|
278
|
+
readonly nsPerSampleMax?: number;
|
|
279
|
+
/** Timed blocks the statistics were computed over, warm-up already dropped. */
|
|
280
|
+
readonly blocks: number;
|
|
281
|
+
readonly blockFrames: number;
|
|
282
|
+
/** Median over the DISCARDED warm-up blocks: the evidence that discarding was warranted
|
|
283
|
+
* rather than assumed. */
|
|
284
|
+
readonly warmupNsPerSampleMedian?: number;
|
|
285
|
+
/** Timed, but emitted silence under a stimulus that should have passed — the figure may
|
|
286
|
+
* describe a short-circuit path rather than the DSP. */
|
|
287
|
+
readonly silentOutput?: boolean;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Everything one run learned about one plugin's CPU cost. */
|
|
291
|
+
export interface CostMeasurement {
|
|
292
|
+
/** Readings keyed by sample rate in Hz as a decimal string. May be empty. */
|
|
293
|
+
readonly perRate: Readonly<Record<string, CostAtRate>>;
|
|
294
|
+
/** An {@link ../vocabulary.UNMEASURED_TERMS} code, set exactly when {@link perRate} is empty. */
|
|
295
|
+
readonly unmeasuredReason?: string;
|
|
296
|
+
/** The prober's own words for that reason, kept verbatim. See {@link LatencyMeasurement.unmeasuredDetail}. */
|
|
297
|
+
readonly unmeasuredDetail?: string;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
// The hosting dimensions (1.1): whether a plugin SURVIVES, whether its run() is RT-clean,
|
|
302
|
+
// and what it demands of a host. RAW readings, each one what the prober saw — never a
|
|
303
|
+
// conclusion. Whether a soak held or a count is a measurement is the consumer's rule, and
|
|
304
|
+
// a format that stored the conclusion would make every consumer share one reader's rules.
|
|
305
|
+
// ---------------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
/** Instantiate/destroy cycles, as run. */
|
|
308
|
+
export interface LifecycleReading {
|
|
309
|
+
readonly cycles: number;
|
|
310
|
+
readonly instantiated: number;
|
|
311
|
+
readonly failed: number;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** The process's thread count around the plugin's first instance. */
|
|
315
|
+
export interface ThreadReading {
|
|
316
|
+
readonly before: number;
|
|
317
|
+
readonly afterFirstInstantiate: number;
|
|
318
|
+
readonly afterAllFreed?: number;
|
|
319
|
+
readonly leaked?: number;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** A crash as observed. Attribution — whose fault — is the consumer's, from its own table. */
|
|
323
|
+
export interface CrashReading {
|
|
324
|
+
readonly signal?: string;
|
|
325
|
+
readonly topFrame?: string;
|
|
326
|
+
/** The error class a sanitizer build reported, verbatim (`"heap-buffer-overflow"`). */
|
|
327
|
+
readonly sanitizer?: string;
|
|
328
|
+
/** Basename of the shared object the faulting frame lies in (`"zero-path.so"`). */
|
|
329
|
+
readonly frameObject?: string;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** One rate of a continuous soak: the render as it went, window by window. */
|
|
333
|
+
export interface SoakAtRate {
|
|
334
|
+
/** The process died at this rate. Every other field is then absent. */
|
|
335
|
+
readonly died?: boolean;
|
|
336
|
+
readonly instantiated?: boolean;
|
|
337
|
+
readonly seconds?: number;
|
|
338
|
+
readonly blockFrames?: number;
|
|
339
|
+
readonly windows?: number;
|
|
340
|
+
readonly nonFiniteWindows?: number;
|
|
341
|
+
readonly silentWindows?: number;
|
|
342
|
+
readonly firstRmsDbfs?: number;
|
|
343
|
+
readonly lastRmsDbfs?: number;
|
|
344
|
+
readonly minRmsDbfs?: number;
|
|
345
|
+
readonly rssStartKb?: number;
|
|
346
|
+
readonly rssMaxKb?: number;
|
|
347
|
+
readonly rssGrowthKb?: number;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** A continuous soak, per rate, with whether the controls were swept while it ran. */
|
|
351
|
+
export interface SoakReading {
|
|
352
|
+
readonly sweptParams: boolean;
|
|
353
|
+
readonly perRate: Readonly<Record<string, SoakAtRate>>;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Everything one run learned about one plugin SURVIVING. Readable when it has a lifecycle. */
|
|
357
|
+
export interface StabilityMeasurement {
|
|
358
|
+
readonly lifecycle?: LifecycleReading;
|
|
359
|
+
readonly threads?: ThreadReading;
|
|
360
|
+
readonly crashes?: readonly CrashReading[];
|
|
361
|
+
readonly soak?: SoakReading;
|
|
362
|
+
readonly unmeasuredReason?: string;
|
|
363
|
+
readonly unmeasuredDetail?: string;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* What an interposer counted INSIDE `run()`. `rate` and `blockFrames` are the pair it ran at —
|
|
368
|
+
* a count is a property of the code paths the run took there. Readable when all three counts
|
|
369
|
+
* are present.
|
|
370
|
+
*/
|
|
371
|
+
export interface RtSafetyMeasurement {
|
|
372
|
+
readonly rate?: number;
|
|
373
|
+
readonly blockFrames?: number;
|
|
374
|
+
readonly blocks?: number;
|
|
375
|
+
readonly repeats?: number;
|
|
376
|
+
/** The interposer was actually loaded. A zero from an absent interposer counts nothing. */
|
|
377
|
+
readonly interposer?: boolean;
|
|
378
|
+
/** The controls were swept across their ranges while counting. */
|
|
379
|
+
readonly swept?: boolean;
|
|
380
|
+
/** False when the plugin has no control a sweep could move. */
|
|
381
|
+
readonly sweepApplicable?: boolean;
|
|
382
|
+
readonly allocationsInRun?: number;
|
|
383
|
+
readonly allocationsMin?: number;
|
|
384
|
+
readonly locksInRun?: number;
|
|
385
|
+
readonly syscallsInRun?: number;
|
|
386
|
+
/** Repeats disagreed. */
|
|
387
|
+
readonly variable?: boolean;
|
|
388
|
+
readonly unmeasuredReason?: string;
|
|
389
|
+
readonly unmeasuredDetail?: string;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** What the plugin DEMANDS of a host, read off the instance the run loaded. */
|
|
393
|
+
export interface FeaturesMeasurement {
|
|
394
|
+
readonly required?: readonly string[];
|
|
395
|
+
readonly optional?: readonly string[];
|
|
396
|
+
readonly cvPorts?: number;
|
|
397
|
+
readonly unmeasuredReason?: string;
|
|
398
|
+
readonly unmeasuredDetail?: string;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** One plugin as one run saw it. */
|
|
402
|
+
export interface MeasuredPlugin {
|
|
403
|
+
readonly uri: string;
|
|
404
|
+
readonly name?: string;
|
|
405
|
+
/**
|
|
406
|
+
* The plugin's own version (`lv2:minorVersion.lv2:microVersion`), when it declares one.
|
|
407
|
+
* A latency figure attached to no version is a trap: the next release moves
|
|
408
|
+
* the window and the figure becomes wrong without becoming absent.
|
|
409
|
+
*/
|
|
410
|
+
readonly version?: string;
|
|
411
|
+
/** The LV2 bundle it came from — what an operator greps for when a figure surprises them. */
|
|
412
|
+
readonly bundle?: string;
|
|
413
|
+
/** The plugin's declared LV2 class label, verbatim. */
|
|
414
|
+
readonly lv2Class?: string;
|
|
415
|
+
readonly topology: PluginTopology;
|
|
416
|
+
readonly latency?: LatencyMeasurement;
|
|
417
|
+
readonly cost?: CostMeasurement;
|
|
418
|
+
readonly stability?: StabilityMeasurement;
|
|
419
|
+
readonly rtSafety?: RtSafetyMeasurement;
|
|
420
|
+
readonly features?: FeaturesMeasurement;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** What a host spends on one plugin instance: the P95 core-fraction ceiling, at its rate and quantum. */
|
|
424
|
+
export interface HostBudget {
|
|
425
|
+
readonly coreFractionCeiling: number;
|
|
426
|
+
readonly rate: number;
|
|
427
|
+
readonly quantum: number;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* A HOST PROFILE (`2026-09-25-plugin-qualify.md` §3a; §3b here): the host a verdict was judged
|
|
432
|
+
* against — only what differs between hosts. A document carries every profile its verdicts name,
|
|
433
|
+
* in full, so a verdict against a project's own profile is reproducible from the document alone.
|
|
434
|
+
*/
|
|
435
|
+
export interface HostProfile {
|
|
436
|
+
readonly id: string;
|
|
437
|
+
/** The LV2 feature URIs the host passes to `instantiate`. */
|
|
438
|
+
readonly lv2Features: readonly string[];
|
|
439
|
+
/** The CLAP host extensions it offers; `null` for a host with no CLAP path at all. */
|
|
440
|
+
readonly clapExtensions: readonly string[] | null;
|
|
441
|
+
/** Whether it feeds note/MIDI input — hosts instruments. */
|
|
442
|
+
readonly instruments: boolean;
|
|
443
|
+
readonly cost: HostBudget;
|
|
444
|
+
readonly latency: HostLatencyHandling;
|
|
445
|
+
readonly isolation: HostIsolation;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** One plugin's verdict under one host profile (§3b). `host` is a `hostProfiles[].id`. */
|
|
449
|
+
export interface DocumentVerdict {
|
|
450
|
+
readonly uri: string;
|
|
451
|
+
readonly host: string;
|
|
452
|
+
readonly rating: VerdictRating;
|
|
453
|
+
/** `rating === "suitable"`, and nothing else. */
|
|
454
|
+
readonly qualified: boolean;
|
|
455
|
+
/** The code of the reason that decided it; absent for `suitable`. */
|
|
456
|
+
readonly deciding?: string;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** A complete document: one run, and what it found. */
|
|
460
|
+
export interface MeasurementDocument {
|
|
461
|
+
readonly format: typeof MEASUREMENT_FORMAT_ID;
|
|
462
|
+
readonly formatVersion: string;
|
|
463
|
+
readonly run: MeasurementRun;
|
|
464
|
+
readonly plugins: readonly MeasuredPlugin[];
|
|
465
|
+
/** v1.2: the hosts the run's verdicts were judged against (§3b). */
|
|
466
|
+
readonly hostProfiles?: readonly HostProfile[];
|
|
467
|
+
/** v1.2: one verdict per plugin per host profile — conclusions, beside the raw blocks. */
|
|
468
|
+
readonly verdicts?: readonly DocumentVerdict[];
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
export type HostProfileRead = { ok: true; profile: HostProfile } | { ok: false; problem: string };
|
|
472
|
+
|
|
473
|
+
const isStringList = (v: unknown): v is string[] => Array.isArray(v) && v.every((x) => typeof x === "string" && x !== "");
|
|
474
|
+
const isPositive = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v) && v > 0;
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Read a host profile — a document's, or a project's own file. Every field is required and
|
|
478
|
+
* typed; a missing or mistyped one is refused by name, and a host the reader was not told about
|
|
479
|
+
* is never filled in from a default.
|
|
480
|
+
*/
|
|
481
|
+
export function readHostProfile(value: unknown): HostProfileRead {
|
|
482
|
+
if (!isRecord(value)) return { ok: false, problem: "a host profile is a JSON object" };
|
|
483
|
+
const { id, lv2Features, clapExtensions, instruments, cost, latency, isolation } = value;
|
|
484
|
+
if (typeof id !== "string" || id === "") return { ok: false, problem: "id: a non-empty string" };
|
|
485
|
+
if (!isStringList(lv2Features)) return { ok: false, problem: "lv2Features: a list of feature URIs" };
|
|
486
|
+
if (clapExtensions !== null && !isStringList(clapExtensions)) {
|
|
487
|
+
return { ok: false, problem: "clapExtensions: a list of extension ids, or null for no CLAP" };
|
|
488
|
+
}
|
|
489
|
+
if (typeof instruments !== "boolean") return { ok: false, problem: "instruments: true or false" };
|
|
490
|
+
if (!isRecord(cost) || !isPositive(cost.coreFractionCeiling) || !isPositive(cost.rate) || !isPositive(cost.quantum)) {
|
|
491
|
+
return { ok: false, problem: "cost: { coreFractionCeiling, rate, quantum }, each a positive number" };
|
|
492
|
+
}
|
|
493
|
+
if (!Number.isInteger(cost.rate) || !Number.isInteger(cost.quantum)) {
|
|
494
|
+
return { ok: false, problem: "cost: rate and quantum are integers" };
|
|
495
|
+
}
|
|
496
|
+
if (!(HOST_LATENCY_HANDLING as readonly unknown[]).includes(latency)) {
|
|
497
|
+
return { ok: false, problem: `latency: one of ${HOST_LATENCY_HANDLING.join(", ")}` };
|
|
498
|
+
}
|
|
499
|
+
if (!(HOST_ISOLATIONS as readonly unknown[]).includes(isolation)) {
|
|
500
|
+
return { ok: false, problem: `isolation: one of ${HOST_ISOLATIONS.join(", ")}` };
|
|
501
|
+
}
|
|
502
|
+
return {
|
|
503
|
+
ok: true,
|
|
504
|
+
profile: {
|
|
505
|
+
id,
|
|
506
|
+
lv2Features,
|
|
507
|
+
clapExtensions,
|
|
508
|
+
instruments,
|
|
509
|
+
cost: { coreFractionCeiling: cost.coreFractionCeiling, rate: cost.rate, quantum: cost.quantum },
|
|
510
|
+
latency: latency as HostLatencyHandling,
|
|
511
|
+
isolation: isolation as HostIsolation,
|
|
512
|
+
},
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** A verdict as written, or `undefined`: its host must be declared, `qualified` must be its rating's. */
|
|
517
|
+
function readVerdict(value: unknown, hosts: ReadonlySet<string>): DocumentVerdict | undefined {
|
|
518
|
+
if (!isRecord(value)) return undefined;
|
|
519
|
+
const uri = nonEmptyString(value.uri);
|
|
520
|
+
const host = nonEmptyString(value.host);
|
|
521
|
+
const rating = (VERDICT_RATINGS as readonly unknown[]).includes(value.rating) ? (value.rating as VerdictRating) : undefined;
|
|
522
|
+
if (uri === undefined || host === undefined || !hosts.has(host) || rating === undefined) return undefined;
|
|
523
|
+
if (value.qualified !== (rating === "suitable")) return undefined;
|
|
524
|
+
if (value.deciding !== undefined && nonEmptyString(value.deciding) === undefined) return undefined;
|
|
525
|
+
return { uri, host, rating, qualified: rating === "suitable", ...optional("deciding", nonEmptyString(value.deciding)) };
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// ---------------------------------------------------------------------------
|
|
529
|
+
// Reading
|
|
530
|
+
// ---------------------------------------------------------------------------
|
|
531
|
+
|
|
532
|
+
/** A refusal to read a document, with the reason in a form a UI can render. */
|
|
533
|
+
export interface MeasurementReadFailure {
|
|
534
|
+
readonly ok: false;
|
|
535
|
+
/** Stable code: `not-an-object` / `wrong-format` / `bad-version` / `future-major` /
|
|
536
|
+
* `missing-run` / `missing-plugins`. */
|
|
537
|
+
readonly problem: string;
|
|
538
|
+
/** English detail for logs. UIs render from {@link problem} plus {@link found}. */
|
|
539
|
+
readonly detail: string;
|
|
540
|
+
/** What was actually there, when the problem is about a value. */
|
|
541
|
+
readonly found?: string;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** A document read successfully, plus what the reader had to tolerate to do it. */
|
|
545
|
+
export interface MeasurementReadSuccess {
|
|
546
|
+
readonly ok: true;
|
|
547
|
+
readonly document: MeasurementDocument;
|
|
548
|
+
readonly version: FormatVersion;
|
|
549
|
+
/**
|
|
550
|
+
* Set when the document's minor is AHEAD of this build's: it was written by a newer tool
|
|
551
|
+
* and may carry fields this reader dropped. Everything read is still valid — minors are
|
|
552
|
+
* additive — but a surface should say so rather than imply completeness.
|
|
553
|
+
*/
|
|
554
|
+
readonly forwardMinor?: true;
|
|
555
|
+
/**
|
|
556
|
+
* Plugin entries dropped for being malformed. Never silent: a document that half-parses
|
|
557
|
+
* must say how much of it was lost, or a consumer will read a partial catalogue as a
|
|
558
|
+
* complete one.
|
|
559
|
+
*/
|
|
560
|
+
readonly droppedPlugins: number;
|
|
561
|
+
/**
|
|
562
|
+
* v1.2: verdicts (and host profiles) dropped for being malformed or naming a host the document
|
|
563
|
+
* does not declare — present whenever the document carried either list.
|
|
564
|
+
*/
|
|
565
|
+
readonly droppedVerdicts?: number;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** The outcome of {@link readMeasurementDocument}. */
|
|
569
|
+
export type MeasurementReadResult = MeasurementReadSuccess | MeasurementReadFailure;
|
|
570
|
+
|
|
571
|
+
function readToolchain(value: unknown): Readonly<Record<string, string>> | undefined {
|
|
572
|
+
if (!isRecord(value)) return undefined;
|
|
573
|
+
const out: Record<string, string> = {};
|
|
574
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
575
|
+
const text = nonEmptyString(entry);
|
|
576
|
+
if (text !== undefined) out[key] = text;
|
|
577
|
+
}
|
|
578
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function readHost(value: unknown): MeasurementHost | undefined {
|
|
582
|
+
if (!isRecord(value)) return undefined;
|
|
583
|
+
const hostname = nonEmptyString(value.hostname);
|
|
584
|
+
const realtimePriority = booleanValue(value.realtimePriority);
|
|
585
|
+
const memoryLocked = booleanValue(value.memoryLocked);
|
|
586
|
+
if (hostname === undefined || realtimePriority === undefined || memoryLocked === undefined) {
|
|
587
|
+
return undefined;
|
|
588
|
+
}
|
|
589
|
+
return {
|
|
590
|
+
hostname,
|
|
591
|
+
realtimePriority,
|
|
592
|
+
memoryLocked,
|
|
593
|
+
...optional("kernel", nonEmptyString(value.kernel)),
|
|
594
|
+
...optional("os", nonEmptyString(value.os)),
|
|
595
|
+
...optional("cpuModel", nonEmptyString(value.cpuModel)),
|
|
596
|
+
...optional("cpuCount", finiteNumber(value.cpuCount)),
|
|
597
|
+
...optional("pinnedCpu", finiteNumber(value.pinnedCpu)),
|
|
598
|
+
...optional("pinnedCpuKind", nonEmptyString(value.pinnedCpuKind)),
|
|
599
|
+
...optional("pinnedCpuMaxKHz", finiteNumber(value.pinnedCpuMaxKHz)),
|
|
600
|
+
...optional("governor", nonEmptyString(value.governor)),
|
|
601
|
+
...optional("turboEnabled", booleanValue(value.turboEnabled)),
|
|
602
|
+
...optional("toolchain", readToolchain(value.toolchain)),
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function readLatencyMethod(value: unknown): LatencyMethod | undefined {
|
|
607
|
+
if (!isRecord(value)) return undefined;
|
|
608
|
+
const method = nonEmptyString(value.method);
|
|
609
|
+
const blockFrames = positiveNumber(value.blockFrames);
|
|
610
|
+
const stimulus = nonEmptyString(value.stimulus);
|
|
611
|
+
const paramSweep = booleanValue(value.paramSweep);
|
|
612
|
+
if (method === undefined || blockFrames === undefined || stimulus === undefined || paramSweep === undefined) {
|
|
613
|
+
return undefined;
|
|
614
|
+
}
|
|
615
|
+
return { method, blockFrames, stimulus, paramSweep };
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function readCostMethod(value: unknown): CostMethod | undefined {
|
|
619
|
+
if (!isRecord(value)) return undefined;
|
|
620
|
+
const method = nonEmptyString(value.method);
|
|
621
|
+
const blockFrames = positiveNumber(value.blockFrames);
|
|
622
|
+
const warmupBlocks = finiteNumber(value.warmupBlocks);
|
|
623
|
+
const percentile = finiteNumber(value.percentile);
|
|
624
|
+
const stimulus = nonEmptyString(value.stimulus);
|
|
625
|
+
const controls = nonEmptyString(value.controls);
|
|
626
|
+
if (
|
|
627
|
+
method === undefined ||
|
|
628
|
+
blockFrames === undefined ||
|
|
629
|
+
warmupBlocks === undefined ||
|
|
630
|
+
percentile === undefined ||
|
|
631
|
+
stimulus === undefined ||
|
|
632
|
+
controls === undefined
|
|
633
|
+
) {
|
|
634
|
+
return undefined;
|
|
635
|
+
}
|
|
636
|
+
return {
|
|
637
|
+
method,
|
|
638
|
+
blockFrames,
|
|
639
|
+
warmupBlocks,
|
|
640
|
+
percentile,
|
|
641
|
+
stimulus,
|
|
642
|
+
controls,
|
|
643
|
+
...optional("minTimedBlocks", finiteNumber(value.minTimedBlocks)),
|
|
644
|
+
...optional("maxTimedBlocks", finiteNumber(value.maxTimedBlocks)),
|
|
645
|
+
...optional("targetSecondsPerPlugin", finiteNumber(value.targetSecondsPerPlugin)),
|
|
646
|
+
...optional("timingFloorNsPerBlock", finiteNumber(value.timingFloorNsPerBlock)),
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function readRun(value: unknown): MeasurementRun | undefined {
|
|
651
|
+
if (!isRecord(value)) return undefined;
|
|
652
|
+
const id = nonEmptyString(value.id);
|
|
653
|
+
const measuredAt = nonEmptyString(value.measuredAt);
|
|
654
|
+
const host = readHost(value.host);
|
|
655
|
+
const note = nonEmptyString(value.note);
|
|
656
|
+
const relativeRankingOnly = booleanValue(value.relativeRankingOnly);
|
|
657
|
+
const dimensions = mapArray(value.dimensions, readDimension);
|
|
658
|
+
const rates = mapArray(value.rates, positiveNumber);
|
|
659
|
+
const tool = isRecord(value.tool)
|
|
660
|
+
? { name: nonEmptyString(value.tool.name), version: nonEmptyString(value.tool.version) }
|
|
661
|
+
: undefined;
|
|
662
|
+
if (
|
|
663
|
+
id === undefined ||
|
|
664
|
+
measuredAt === undefined ||
|
|
665
|
+
host === undefined ||
|
|
666
|
+
note === undefined ||
|
|
667
|
+
relativeRankingOnly === undefined ||
|
|
668
|
+
dimensions === undefined ||
|
|
669
|
+
rates === undefined ||
|
|
670
|
+
tool?.name === undefined ||
|
|
671
|
+
tool.version === undefined
|
|
672
|
+
) {
|
|
673
|
+
return undefined;
|
|
674
|
+
}
|
|
675
|
+
const method = isRecord(value.method) ? value.method : {};
|
|
676
|
+
return {
|
|
677
|
+
id,
|
|
678
|
+
measuredAt,
|
|
679
|
+
dimensions,
|
|
680
|
+
rates: [...rates].sort((a, b) => a - b),
|
|
681
|
+
tool: { name: tool.name, version: tool.version },
|
|
682
|
+
host,
|
|
683
|
+
method: {
|
|
684
|
+
...optional("latency", readLatencyMethod(method.latency)),
|
|
685
|
+
...optional("cost", readCostMethod(method.cost)),
|
|
686
|
+
},
|
|
687
|
+
relativeRankingOnly,
|
|
688
|
+
note,
|
|
689
|
+
...optional("elapsedSeconds", finiteNumber(value.elapsedSeconds)),
|
|
690
|
+
...optional("label", nonEmptyString(value.label)),
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function readTopology(value: unknown): PluginTopology | undefined {
|
|
695
|
+
if (!isRecord(value)) return undefined;
|
|
696
|
+
const audioInputs = finiteNumber(value.audioInputs);
|
|
697
|
+
const audioOutputs = finiteNumber(value.audioOutputs);
|
|
698
|
+
if (audioInputs === undefined || audioOutputs === undefined) return undefined;
|
|
699
|
+
return {
|
|
700
|
+
audioInputs,
|
|
701
|
+
audioOutputs,
|
|
702
|
+
...optional("midiInputs", finiteNumber(value.midiInputs)),
|
|
703
|
+
...optional("controlInputs", finiteNumber(value.controlInputs)),
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Read a `{ "48000": {...} }` rate map, keeping only entries whose KEY is a positive
|
|
709
|
+
* integer rate and whose value the item reader accepted. A rate key that is not a number
|
|
710
|
+
* is not a rate, and silently keeping it would let `"default"` masquerade as one.
|
|
711
|
+
*/
|
|
712
|
+
function readRateMap<T>(value: unknown, read: (item: unknown) => T | undefined): Readonly<Record<string, T>> {
|
|
713
|
+
if (!isRecord(value)) return {};
|
|
714
|
+
const out: Record<string, T> = {};
|
|
715
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
716
|
+
const rate = positiveNumber(Number(key));
|
|
717
|
+
if (rate === undefined || !Number.isInteger(rate)) continue;
|
|
718
|
+
const parsed = read(entry);
|
|
719
|
+
if (parsed !== undefined) out[String(rate)] = parsed;
|
|
720
|
+
}
|
|
721
|
+
return out;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function readLatencyAtRate(value: unknown): LatencyAtRate | undefined {
|
|
725
|
+
if (!isRecord(value)) return undefined;
|
|
726
|
+
const frames = finiteNumber(value.frames);
|
|
727
|
+
const ms = finiteNumber(value.ms);
|
|
728
|
+
if (frames === undefined || ms === undefined) return undefined;
|
|
729
|
+
return {
|
|
730
|
+
frames,
|
|
731
|
+
ms,
|
|
732
|
+
...optional("stimulus", nonEmptyString(value.stimulus)),
|
|
733
|
+
...optional("excludedReason", nonEmptyString(value.excludedReason)),
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function readSweepControls(value: unknown): Readonly<Record<string, { minFrames: number; maxFrames: number }>> {
|
|
738
|
+
if (!isRecord(value)) return {};
|
|
739
|
+
const out: Record<string, { minFrames: number; maxFrames: number }> = {};
|
|
740
|
+
for (const [symbol, entry] of Object.entries(value)) {
|
|
741
|
+
if (!isRecord(entry)) continue;
|
|
742
|
+
const minFrames = finiteNumber(entry.minFrames);
|
|
743
|
+
const maxFrames = finiteNumber(entry.maxFrames);
|
|
744
|
+
if (minFrames === undefined || maxFrames === undefined) continue;
|
|
745
|
+
out[symbol] = { minFrames, maxFrames };
|
|
746
|
+
}
|
|
747
|
+
return out;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function readParamSweep(value: unknown): MeasuredParamSweep | undefined {
|
|
751
|
+
if (!isRecord(value)) return undefined;
|
|
752
|
+
const rate = positiveNumber(value.rate);
|
|
753
|
+
const defaultFrames = finiteNumber(value.defaultFrames);
|
|
754
|
+
const minFrames = finiteNumber(value.minFrames);
|
|
755
|
+
const maxFrames = finiteNumber(value.maxFrames);
|
|
756
|
+
if (rate === undefined || defaultFrames === undefined || minFrames === undefined || maxFrames === undefined) {
|
|
757
|
+
return undefined;
|
|
758
|
+
}
|
|
759
|
+
return { rate, defaultFrames, minFrames, maxFrames, controls: readSweepControls(value.controls) };
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function readLatency(value: unknown): LatencyMeasurement | undefined {
|
|
763
|
+
if (!isRecord(value)) return undefined;
|
|
764
|
+
const perRate = readRateMap(value.perRate, readLatencyAtRate);
|
|
765
|
+
return {
|
|
766
|
+
perRate,
|
|
767
|
+
...optional("scalingClass", nonEmptyString(value.scalingClass)),
|
|
768
|
+
...optional("scalingNote", nonEmptyString(value.scalingNote)),
|
|
769
|
+
...optional("declaredPortSymbol", nonEmptyString(value.declaredPortSymbol)),
|
|
770
|
+
...optional("declaredFrames", finiteNumber(value.declaredFrames)),
|
|
771
|
+
...optional("declaredMismatch", booleanValue(value.declaredMismatch)),
|
|
772
|
+
...optional("paramSweep", readParamSweep(value.paramSweep)),
|
|
773
|
+
...optional("unmeasuredReason", nonEmptyString(value.unmeasuredReason)),
|
|
774
|
+
...optional("unmeasuredDetail", nonEmptyString(value.unmeasuredDetail)),
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function readCostAtRate(value: unknown): CostAtRate | undefined {
|
|
779
|
+
if (!isRecord(value)) return undefined;
|
|
780
|
+
const nsPerSampleMedian = finiteNumber(value.nsPerSampleMedian);
|
|
781
|
+
const nsPerSamplePercentile = finiteNumber(value.nsPerSamplePercentile);
|
|
782
|
+
const blocks = finiteNumber(value.blocks);
|
|
783
|
+
const blockFrames = positiveNumber(value.blockFrames);
|
|
784
|
+
if (
|
|
785
|
+
nsPerSampleMedian === undefined ||
|
|
786
|
+
nsPerSamplePercentile === undefined ||
|
|
787
|
+
blocks === undefined ||
|
|
788
|
+
blockFrames === undefined
|
|
789
|
+
) {
|
|
790
|
+
return undefined;
|
|
791
|
+
}
|
|
792
|
+
return {
|
|
793
|
+
nsPerSampleMedian,
|
|
794
|
+
nsPerSamplePercentile,
|
|
795
|
+
blocks,
|
|
796
|
+
blockFrames,
|
|
797
|
+
...optional("nsPerSampleMin", finiteNumber(value.nsPerSampleMin)),
|
|
798
|
+
...optional("nsPerSampleMax", finiteNumber(value.nsPerSampleMax)),
|
|
799
|
+
...optional("warmupNsPerSampleMedian", finiteNumber(value.warmupNsPerSampleMedian)),
|
|
800
|
+
...optional("silentOutput", booleanValue(value.silentOutput)),
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function readCost(value: unknown): CostMeasurement | undefined {
|
|
805
|
+
if (!isRecord(value)) return undefined;
|
|
806
|
+
return {
|
|
807
|
+
perRate: readRateMap(value.perRate, readCostAtRate),
|
|
808
|
+
...optional("unmeasuredReason", nonEmptyString(value.unmeasuredReason)),
|
|
809
|
+
...optional("unmeasuredDetail", nonEmptyString(value.unmeasuredDetail)),
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function reasonOf(value: Record<string, unknown>): { unmeasuredReason?: string; unmeasuredDetail?: string } {
|
|
814
|
+
return {
|
|
815
|
+
...optional("unmeasuredReason", nonEmptyString(value.unmeasuredReason)),
|
|
816
|
+
...optional("unmeasuredDetail", nonEmptyString(value.unmeasuredDetail)),
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/** A lifecycle reading, or `undefined` unless all three counts are there. */
|
|
821
|
+
export function readLifecycle(value: unknown): LifecycleReading | undefined {
|
|
822
|
+
if (!isRecord(value)) return undefined;
|
|
823
|
+
const cycles = finiteNumber(value.cycles);
|
|
824
|
+
const instantiated = finiteNumber(value.instantiated);
|
|
825
|
+
const failed = finiteNumber(value.failed);
|
|
826
|
+
if (cycles === undefined || instantiated === undefined || failed === undefined) return undefined;
|
|
827
|
+
return { cycles, instantiated, failed };
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/** A thread reading, or `undefined` without its before/after pair. */
|
|
831
|
+
export function readThreads(value: unknown): ThreadReading | undefined {
|
|
832
|
+
if (!isRecord(value)) return undefined;
|
|
833
|
+
const before = finiteNumber(value.before);
|
|
834
|
+
const afterFirstInstantiate = finiteNumber(value.afterFirstInstantiate);
|
|
835
|
+
if (before === undefined || afterFirstInstantiate === undefined) return undefined;
|
|
836
|
+
return {
|
|
837
|
+
before,
|
|
838
|
+
afterFirstInstantiate,
|
|
839
|
+
...optional("afterAllFreed", finiteNumber(value.afterAllFreed)),
|
|
840
|
+
...optional("leaked", finiteNumber(value.leaked)),
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function readCrash(value: unknown): CrashReading | undefined {
|
|
845
|
+
if (!isRecord(value)) return undefined;
|
|
846
|
+
return {
|
|
847
|
+
...optional("signal", nonEmptyString(value.signal)),
|
|
848
|
+
...optional("topFrame", nonEmptyString(value.topFrame)),
|
|
849
|
+
...optional("sanitizer", nonEmptyString(value.sanitizer)),
|
|
850
|
+
...optional("frameObject", nonEmptyString(value.frameObject)),
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/** One soak rate. Every field optional: a rate that died carries `died` and nothing else. */
|
|
855
|
+
export function readSoakAtRate(value: unknown): SoakAtRate | undefined {
|
|
856
|
+
if (!isRecord(value)) return undefined;
|
|
857
|
+
return {
|
|
858
|
+
...optional("died", booleanValue(value.died)),
|
|
859
|
+
...optional("instantiated", booleanValue(value.instantiated)),
|
|
860
|
+
...optional("seconds", finiteNumber(value.seconds)),
|
|
861
|
+
...optional("blockFrames", positiveNumber(value.blockFrames)),
|
|
862
|
+
...optional("windows", finiteNumber(value.windows)),
|
|
863
|
+
...optional("nonFiniteWindows", finiteNumber(value.nonFiniteWindows)),
|
|
864
|
+
...optional("silentWindows", finiteNumber(value.silentWindows)),
|
|
865
|
+
...optional("firstRmsDbfs", finiteNumber(value.firstRmsDbfs)),
|
|
866
|
+
...optional("lastRmsDbfs", finiteNumber(value.lastRmsDbfs)),
|
|
867
|
+
...optional("minRmsDbfs", finiteNumber(value.minRmsDbfs)),
|
|
868
|
+
...optional("rssStartKb", finiteNumber(value.rssStartKb)),
|
|
869
|
+
...optional("rssMaxKb", finiteNumber(value.rssMaxKb)),
|
|
870
|
+
...optional("rssGrowthKb", finiteNumber(value.rssGrowthKb)),
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function readSoak(value: unknown): SoakReading | undefined {
|
|
875
|
+
if (!isRecord(value)) return undefined;
|
|
876
|
+
const sweptParams = booleanValue(value.sweptParams);
|
|
877
|
+
if (sweptParams === undefined) return undefined;
|
|
878
|
+
return { sweptParams, perRate: readRateMap(value.perRate, readSoakAtRate) };
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function readStability(value: unknown): StabilityMeasurement | undefined {
|
|
882
|
+
if (!isRecord(value)) return undefined;
|
|
883
|
+
return {
|
|
884
|
+
...optional("lifecycle", readLifecycle(value.lifecycle)),
|
|
885
|
+
...optional("threads", readThreads(value.threads)),
|
|
886
|
+
...optional("crashes", mapArray(value.crashes, readCrash)),
|
|
887
|
+
...optional("soak", readSoak(value.soak)),
|
|
888
|
+
...reasonOf(value),
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function readRtSafety(value: unknown): RtSafetyMeasurement | undefined {
|
|
893
|
+
if (!isRecord(value)) return undefined;
|
|
894
|
+
return {
|
|
895
|
+
...optional("rate", positiveNumber(value.rate)),
|
|
896
|
+
...optional("blockFrames", positiveNumber(value.blockFrames)),
|
|
897
|
+
...optional("blocks", finiteNumber(value.blocks)),
|
|
898
|
+
...optional("repeats", finiteNumber(value.repeats)),
|
|
899
|
+
...optional("interposer", booleanValue(value.interposer)),
|
|
900
|
+
...optional("swept", booleanValue(value.swept)),
|
|
901
|
+
...optional("sweepApplicable", booleanValue(value.sweepApplicable)),
|
|
902
|
+
...optional("allocationsInRun", finiteNumber(value.allocationsInRun)),
|
|
903
|
+
...optional("allocationsMin", finiteNumber(value.allocationsMin)),
|
|
904
|
+
...optional("locksInRun", finiteNumber(value.locksInRun)),
|
|
905
|
+
...optional("syscallsInRun", finiteNumber(value.syscallsInRun)),
|
|
906
|
+
...optional("variable", booleanValue(value.variable)),
|
|
907
|
+
...reasonOf(value),
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function readFeatures(value: unknown): FeaturesMeasurement | undefined {
|
|
912
|
+
if (!isRecord(value)) return undefined;
|
|
913
|
+
return {
|
|
914
|
+
...optional("required", mapArray(value.required, nonEmptyString)),
|
|
915
|
+
...optional("optional", mapArray(value.optional, nonEmptyString)),
|
|
916
|
+
...optional("cvPorts", finiteNumber(value.cvPorts)),
|
|
917
|
+
...reasonOf(value),
|
|
918
|
+
};
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
function readPlugin(value: unknown): MeasuredPlugin | undefined {
|
|
922
|
+
if (!isRecord(value)) return undefined;
|
|
923
|
+
const uri = nonEmptyString(value.uri);
|
|
924
|
+
const topology = readTopology(value.topology);
|
|
925
|
+
if (uri === undefined || topology === undefined) return undefined;
|
|
926
|
+
return {
|
|
927
|
+
uri,
|
|
928
|
+
topology,
|
|
929
|
+
...optional("name", nonEmptyString(value.name)),
|
|
930
|
+
...optional("version", nonEmptyString(value.version)),
|
|
931
|
+
...optional("bundle", nonEmptyString(value.bundle)),
|
|
932
|
+
...optional("lv2Class", nonEmptyString(value.lv2Class)),
|
|
933
|
+
...optional("latency", readLatency(value.latency)),
|
|
934
|
+
...optional("cost", readCost(value.cost)),
|
|
935
|
+
...optional("stability", readStability(value.stability)),
|
|
936
|
+
...optional("rtSafety", readRtSafety(value.rtSafety)),
|
|
937
|
+
...optional("features", readFeatures(value.features)),
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* Read an untrusted value — a parsed JSON file, a wire frame — as a measurement document.
|
|
943
|
+
*
|
|
944
|
+
* Never throws and never casts: every field is validated on the way in and the result is
|
|
945
|
+
* built from the pieces that passed. A document whose major exceeds this build's is
|
|
946
|
+
* REFUSED rather than partially read, because a major bump means a field this reader
|
|
947
|
+
* recognises no longer means what it thinks.
|
|
948
|
+
*/
|
|
949
|
+
export function readMeasurementDocument(value: unknown): MeasurementReadResult {
|
|
950
|
+
if (!isRecord(value)) {
|
|
951
|
+
return { ok: false, problem: "not-an-object", detail: "expected a JSON object" };
|
|
952
|
+
}
|
|
953
|
+
if (value.format !== MEASUREMENT_FORMAT_ID) {
|
|
954
|
+
return {
|
|
955
|
+
ok: false,
|
|
956
|
+
problem: "wrong-format",
|
|
957
|
+
detail: `expected format "${MEASUREMENT_FORMAT_ID}"`,
|
|
958
|
+
...optional("found", nonEmptyString(value.format)),
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
const version = parseFormatVersion(value.formatVersion);
|
|
962
|
+
if (version === undefined) {
|
|
963
|
+
return {
|
|
964
|
+
ok: false,
|
|
965
|
+
problem: "bad-version",
|
|
966
|
+
detail: 'formatVersion must be "<major>.<minor>"',
|
|
967
|
+
...optional("found", nonEmptyString(value.formatVersion)),
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
if (version.major > MEASUREMENT_FORMAT_MAJOR) {
|
|
971
|
+
return {
|
|
972
|
+
ok: false,
|
|
973
|
+
problem: "future-major",
|
|
974
|
+
detail: `this build understands major ${MEASUREMENT_FORMAT_MAJOR}; reading a newer major would misinterpret it`,
|
|
975
|
+
found: `${version.major}.${version.minor}`,
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
const run = readRun(value.run);
|
|
979
|
+
if (run === undefined) {
|
|
980
|
+
return {
|
|
981
|
+
ok: false,
|
|
982
|
+
problem: "missing-run",
|
|
983
|
+
detail: "run provenance is mandatory: a figure without its conditions is not comparable",
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
if (!Array.isArray(value.plugins)) {
|
|
987
|
+
return { ok: false, problem: "missing-plugins", detail: "plugins must be an array" };
|
|
988
|
+
}
|
|
989
|
+
const plugins = value.plugins.flatMap((entry) => {
|
|
990
|
+
const plugin = readPlugin(entry);
|
|
991
|
+
return plugin === undefined ? [] : [plugin];
|
|
992
|
+
});
|
|
993
|
+
const judged = readJudged(value);
|
|
994
|
+
const document: MeasurementDocument = {
|
|
995
|
+
format: MEASUREMENT_FORMAT_ID,
|
|
996
|
+
formatVersion: `${version.major}.${version.minor}`,
|
|
997
|
+
run,
|
|
998
|
+
plugins,
|
|
999
|
+
...(judged === undefined ? {} : { hostProfiles: judged.hostProfiles, verdicts: judged.verdicts }),
|
|
1000
|
+
};
|
|
1001
|
+
return {
|
|
1002
|
+
ok: true,
|
|
1003
|
+
document,
|
|
1004
|
+
version,
|
|
1005
|
+
droppedPlugins: value.plugins.length - plugins.length,
|
|
1006
|
+
...(judged === undefined ? {} : { droppedVerdicts: judged.dropped }),
|
|
1007
|
+
...(version.major === MEASUREMENT_FORMAT_MAJOR && version.minor > MEASUREMENT_FORMAT_MINOR
|
|
1008
|
+
? { forwardMinor: true }
|
|
1009
|
+
: {}),
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/** The v1.2 pair, when the document carries either list; a malformed entry is dropped and counted. */
|
|
1014
|
+
function readJudged(
|
|
1015
|
+
value: Record<string, unknown>,
|
|
1016
|
+
): { hostProfiles: HostProfile[]; verdicts: DocumentVerdict[]; dropped: number } | undefined {
|
|
1017
|
+
if (value.hostProfiles === undefined && value.verdicts === undefined) return undefined;
|
|
1018
|
+
const rawProfiles = Array.isArray(value.hostProfiles) ? value.hostProfiles : [];
|
|
1019
|
+
const rawVerdicts = Array.isArray(value.verdicts) ? value.verdicts : [];
|
|
1020
|
+
const hostProfiles = rawProfiles.flatMap((p) => {
|
|
1021
|
+
const r = readHostProfile(p);
|
|
1022
|
+
return r.ok ? [r.profile] : [];
|
|
1023
|
+
});
|
|
1024
|
+
const hosts = new Set(hostProfiles.map((p) => p.id));
|
|
1025
|
+
const verdicts = rawVerdicts.flatMap((v) => {
|
|
1026
|
+
const r = readVerdict(v, hosts);
|
|
1027
|
+
return r === undefined ? [] : [r];
|
|
1028
|
+
});
|
|
1029
|
+
return {
|
|
1030
|
+
hostProfiles,
|
|
1031
|
+
verdicts,
|
|
1032
|
+
dropped: rawProfiles.length - hostProfiles.length + (rawVerdicts.length - verdicts.length),
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/**
|
|
1037
|
+
* Build a document at THIS build's version.
|
|
1038
|
+
*
|
|
1039
|
+
* The only supported way to create one: it stamps `format` and `formatVersion` so no call
|
|
1040
|
+
* site can write a document claiming a version its content does not match, and it sorts
|
|
1041
|
+
* plugins by URI so two runs over the same host produce a diffable file.
|
|
1042
|
+
*/
|
|
1043
|
+
export function buildMeasurementDocument(
|
|
1044
|
+
run: MeasurementRun,
|
|
1045
|
+
plugins: readonly MeasuredPlugin[],
|
|
1046
|
+
judged?: { readonly hostProfiles: readonly HostProfile[]; readonly verdicts: readonly DocumentVerdict[] },
|
|
1047
|
+
): MeasurementDocument {
|
|
1048
|
+
return {
|
|
1049
|
+
format: MEASUREMENT_FORMAT_ID,
|
|
1050
|
+
formatVersion: MEASUREMENT_FORMAT_VERSION,
|
|
1051
|
+
run,
|
|
1052
|
+
plugins: [...plugins].sort((a, b) => (a.uri < b.uri ? -1 : a.uri > b.uri ? 1 : 0)),
|
|
1053
|
+
...(judged === undefined ? {} : { hostProfiles: judged.hostProfiles, verdicts: judged.verdicts }),
|
|
1054
|
+
};
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
/** Serialise a document: 2-space JSON with a trailing newline, stable across runs. */
|
|
1058
|
+
export function serialiseMeasurementDocument(document: MeasurementDocument): string {
|
|
1059
|
+
return `${JSON.stringify(document, null, 2)}\n`;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/** Every dimension a run may declare, in presentation order. Re-exported for consumers
|
|
1063
|
+
* that need the list without reaching into the vocabulary module. */
|
|
1064
|
+
export { MEASUREMENT_DIMENSIONS, type MeasurementDimension };
|