@openmixer/plugin-qualify 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +203 -2
  3. package/bin/plugin-qualify.mjs +77 -0
  4. package/dist/clap-loader.d.ts +147 -0
  5. package/dist/clap-loader.js +252 -0
  6. package/dist/clap-loader.js.map +1 -0
  7. package/dist/clap-qualify.d.ts +126 -0
  8. package/dist/clap-qualify.js +272 -0
  9. package/dist/clap-qualify.js.map +1 -0
  10. package/dist/host-profiles.d.ts +42 -0
  11. package/dist/host-profiles.js +106 -0
  12. package/dist/host-profiles.js.map +1 -0
  13. package/dist/hosting-codes.d.ts +62 -0
  14. package/dist/hosting-codes.js +72 -0
  15. package/dist/hosting-codes.js.map +1 -0
  16. package/dist/hosting-suitability.d.ts +354 -0
  17. package/dist/hosting-suitability.js +654 -0
  18. package/dist/hosting-suitability.js.map +1 -0
  19. package/dist/index.d.ts +17 -0
  20. package/dist/index.js +20 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/loader.d.ts +26 -0
  23. package/dist/loader.js +9 -0
  24. package/dist/loader.js.map +1 -0
  25. package/dist/lv2-loader.d.ts +7 -0
  26. package/dist/lv2-loader.js +50 -0
  27. package/dist/lv2-loader.js.map +1 -0
  28. package/dist/measurement/cli.d.ts +168 -0
  29. package/dist/measurement/cli.js +353 -0
  30. package/dist/measurement/cli.js.map +1 -0
  31. package/dist/measurement/fixtures.d.ts +23 -0
  32. package/dist/measurement/fixtures.js +129 -0
  33. package/dist/measurement/fixtures.js.map +1 -0
  34. package/dist/measurement/format.d.ts +483 -0
  35. package/dist/measurement/format.js +627 -0
  36. package/dist/measurement/format.js.map +1 -0
  37. package/dist/measurement/guards.d.ts +33 -0
  38. package/dist/measurement/guards.js +61 -0
  39. package/dist/measurement/guards.js.map +1 -0
  40. package/dist/measurement/index.d.ts +36 -0
  41. package/dist/measurement/index.js +39 -0
  42. package/dist/measurement/index.js.map +1 -0
  43. package/dist/measurement/merge.d.ts +143 -0
  44. package/dist/measurement/merge.js +315 -0
  45. package/dist/measurement/merge.js.map +1 -0
  46. package/dist/measurement/plan.d.ts +127 -0
  47. package/dist/measurement/plan.js +141 -0
  48. package/dist/measurement/plan.js.map +1 -0
  49. package/dist/measurement/probe-json.d.ts +61 -0
  50. package/dist/measurement/probe-json.js +253 -0
  51. package/dist/measurement/probe-json.js.map +1 -0
  52. package/dist/measurement/provenance.d.ts +91 -0
  53. package/dist/measurement/provenance.js +173 -0
  54. package/dist/measurement/provenance.js.map +1 -0
  55. package/dist/measurement/sweep-jsonl.d.ts +40 -0
  56. package/dist/measurement/sweep-jsonl.js +154 -0
  57. package/dist/measurement/sweep-jsonl.js.map +1 -0
  58. package/dist/measurement/vocabulary.d.ts +125 -0
  59. package/dist/measurement/vocabulary.js +217 -0
  60. package/dist/measurement/vocabulary.js.map +1 -0
  61. package/dist/qualify-cli.d.ts +83 -0
  62. package/dist/qualify-cli.js +224 -0
  63. package/dist/qualify-cli.js.map +1 -0
  64. package/dist/qualify-declarations.d.ts +98 -0
  65. package/dist/qualify-declarations.js +76 -0
  66. package/dist/qualify-declarations.js.map +1 -0
  67. package/dist/qualify-options.d.ts +22 -0
  68. package/dist/qualify-options.js +36 -0
  69. package/dist/qualify-options.js.map +1 -0
  70. package/dist/rating.d.ts +32 -0
  71. package/dist/rating.js +30 -0
  72. package/dist/rating.js.map +1 -0
  73. package/dist/tool-paths.d.ts +12 -0
  74. package/dist/tool-paths.js +29 -0
  75. package/dist/tool-paths.js.map +1 -0
  76. package/fixtures/clap-scan/controls.json +8 -0
  77. package/fixtures/clap-scan/fake.json +16 -0
  78. package/fixtures/clap-scan/mixed.json +18 -0
  79. package/fixtures/zero-path.lv2/manifest.ttl +3 -0
  80. package/fixtures/zero-path.lv2/zero-path.c +85 -0
  81. package/fixtures/zero-path.lv2/zero-path.ttl +21 -0
  82. package/package.json +91 -3
  83. package/src/clap-loader.ts +393 -0
  84. package/src/clap-qualify.ts +387 -0
  85. package/src/host-profiles.ts +122 -0
  86. package/src/hosting-codes.ts +71 -0
  87. package/src/hosting-suitability.ts +982 -0
  88. package/src/index.ts +19 -0
  89. package/src/loader.ts +30 -0
  90. package/src/lv2-loader.ts +50 -0
  91. package/src/measurement/cli.ts +458 -0
  92. package/src/measurement/fixtures.ts +141 -0
  93. package/src/measurement/format.ts +1064 -0
  94. package/src/measurement/guards.ts +64 -0
  95. package/src/measurement/index.ts +39 -0
  96. package/src/measurement/merge.ts +434 -0
  97. package/src/measurement/plan.ts +219 -0
  98. package/src/measurement/probe-json.ts +271 -0
  99. package/src/measurement/provenance.ts +220 -0
  100. package/src/measurement/sweep-jsonl.ts +173 -0
  101. package/src/measurement/vocabulary.ts +298 -0
  102. package/src/qualify-cli.ts +277 -0
  103. package/src/qualify-declarations.ts +117 -0
  104. package/src/qualify-options.ts +49 -0
  105. package/src/rating.ts +53 -0
  106. package/src/tool-paths.ts +32 -0
  107. package/tools/_declarations.py +21 -0
  108. package/tools/benchmark.py +2462 -0
  109. package/tools/gen-python-declarations.d.mts +4 -0
  110. package/tools/gen-python-declarations.mjs +92 -0
  111. package/tools/hosting-sweep.mjs +51 -0
  112. package/tools/lv2-measure.mjs +204 -0
  113. package/tools/lv2_presets.py +132 -0
  114. package/tools/scan.py +432 -0
@@ -0,0 +1,219 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
+ /**
4
+ * What a run is going to do, decided before it starts.
5
+ *
6
+ * Two questions, answered in the same breath: *"Measure at least the rates this
7
+ * installation can actually use … but it must not silently fall back to a rate it does not
8
+ * use"*, and *"show progress"* — which needs a duration, which nobody can honestly produce
9
+ * for a machine they have never seen.
10
+ *
11
+ * ## Rates are data, not a constant
12
+ *
13
+ * There is no default rate list in this module and there must never be one. A console that
14
+ * runs 48 k and 96 k does not need 192 k figures; a console whose driver only offers 44.1 k
15
+ * must not be handed a 48 k measurement wearing its label. The plan is derived from what the
16
+ * installation reports — its live rate and its device's supported list — and a request for a
17
+ * rate the hardware does not offer is DROPPED and reported, never honoured.
18
+ *
19
+ * A plan with no rates is a legitimate outcome: it means nobody has told us what this
20
+ * installation runs at, and the correct behaviour is to refuse and say so rather than pick
21
+ * 48 000 because it is a common number.
22
+ *
23
+ * ## Duration is an estimate or it is nothing
24
+ *
25
+ * {@link estimateRunSeconds} scales a REFERENCE run's measured wall time by the work the new
26
+ * plan asks for. With no reference run — or a reference that never recorded its own duration
27
+ * — it returns `undefined`. There is no fallback constant: "about 15 minutes" measured on a
28
+ * Core Ultra 9 is not an estimate for a NUC, and a wrong progress estimate is how an operator
29
+ * ends up cancelling a run that was nearly done.
30
+ */
31
+
32
+ import { type MeasurementDimension, MEASUREMENT_DIMENSIONS } from "./vocabulary.js";
33
+ import type { MeasurementDocument, MeasurementRun } from "./format.js";
34
+
35
+ /** What the installation knows about its own clock, as far as the plan is concerned. */
36
+ export interface InstallationRates {
37
+ /** The rate the graph is running at right now, when the console knows it. */
38
+ readonly liveRate?: number;
39
+ /** Rates the active device actually offers. Empty/absent = the console has not probed. */
40
+ readonly supportedRates?: readonly number[];
41
+ /** Rates the operator asked for. Absent = "whatever this installation can use". */
42
+ readonly requestedRates?: readonly number[];
43
+ }
44
+
45
+ /** The rates a run will measure at, and what was left out on the way there. */
46
+ export interface RatePlan {
47
+ /** Rates to measure, ascending. Empty when nothing could be resolved. */
48
+ readonly rates: readonly number[];
49
+ /**
50
+ * Rates the operator asked for that the installation does not offer. Reported, not
51
+ * measured — a figure at a rate the rig cannot run is a figure nobody will ever use, and
52
+ * quietly measuring it would make the run longer for nothing.
53
+ */
54
+ readonly dropped: readonly number[];
55
+ /**
56
+ * Set when {@link rates} is empty: `no-rates-known`. The console could name neither a
57
+ * live rate nor a supported list, so there is nothing to measure and no safe guess.
58
+ */
59
+ readonly problem?: string;
60
+ }
61
+
62
+ /**
63
+ * Resolve the rates a local run should measure at.
64
+ *
65
+ * The live rate is always included when known — a console must have figures for the rate it
66
+ * is running at, whatever else the operator asked for. Beyond that: the requested rates
67
+ * intersected with what the device offers, or, absent a request, the whole supported list.
68
+ */
69
+ export function ratesForInstallation(input: InstallationRates): RatePlan {
70
+ const supported = [...new Set((input.supportedRates ?? []).filter((r) => Number.isFinite(r) && r > 0))];
71
+ const live = input.liveRate !== undefined && Number.isFinite(input.liveRate) && input.liveRate > 0
72
+ ? input.liveRate
73
+ : undefined;
74
+ // What this installation can actually use: the device list, plus the rate it is demonstrably
75
+ // running at (a live rate absent from the probed list is the device telling us the list is
76
+ // incomplete, not the other way round).
77
+ const usable = new Set(supported);
78
+ if (live !== undefined) usable.add(live);
79
+
80
+ const requested = input.requestedRates?.filter((r) => Number.isFinite(r) && r > 0);
81
+ if (requested === undefined || requested.length === 0) {
82
+ const rates = [...usable].sort((a, b) => a - b);
83
+ return rates.length > 0
84
+ ? { rates, dropped: [] }
85
+ : { rates: [], dropped: [], problem: "no-rates-known" };
86
+ }
87
+
88
+ const wanted = new Set(requested);
89
+ if (live !== undefined) wanted.add(live);
90
+ const rates = [...wanted].filter((r) => usable.has(r)).sort((a, b) => a - b);
91
+ const dropped = [...wanted].filter((r) => !usable.has(r)).sort((a, b) => a - b);
92
+ return rates.length > 0
93
+ ? { rates, dropped }
94
+ : { rates: [], dropped, problem: "no-rates-known" };
95
+ }
96
+
97
+ /** A run, fully specified, before anything is probed. */
98
+ export interface MeasurementPlan {
99
+ readonly rates: readonly number[];
100
+ readonly dimensions: readonly MeasurementDimension[];
101
+ /** Rates asked for and dropped as unusable here — carried through so the UI can say so. */
102
+ readonly droppedRates: readonly number[];
103
+ /** How many plugins the run will visit, when a scan has already told us. */
104
+ readonly pluginCount?: number;
105
+ /** Set when the plan cannot run: `no-rates-known` / `no-dimensions`. */
106
+ readonly problem?: string;
107
+ }
108
+
109
+ /** Build a plan from resolved rates and the dimensions the operator chose. */
110
+ export function measurementPlan(
111
+ ratePlan: RatePlan,
112
+ dimensions: readonly MeasurementDimension[],
113
+ pluginCount?: number,
114
+ ): MeasurementPlan {
115
+ const wanted = MEASUREMENT_DIMENSIONS.filter((d) => dimensions.includes(d));
116
+ const problem = ratePlan.problem ?? (wanted.length === 0 ? "no-dimensions" : undefined);
117
+ return {
118
+ rates: ratePlan.rates,
119
+ dimensions: wanted,
120
+ droppedRates: ratePlan.dropped,
121
+ ...(pluginCount === undefined ? {} : { pluginCount }),
122
+ ...(problem === undefined ? {} : { problem }),
123
+ };
124
+ }
125
+
126
+ /** True when a plan has enough to run: at least one rate and at least one dimension. */
127
+ export function isRunnablePlan(plan: MeasurementPlan): boolean {
128
+ return plan.problem === undefined && plan.rates.length > 0 && plan.dimensions.length > 0;
129
+ }
130
+
131
+ /**
132
+ * The unit of work a plan represents: plugins × rates × dimensions.
133
+ *
134
+ * Crude on purpose. A convolution reverb costs more than a gain, but the prober caps each
135
+ * plugin's wall time, so over a whole catalogue the product is close enough to scale a
136
+ * measured duration by — and a more elaborate model would imply a precision the estimate
137
+ * does not have.
138
+ */
139
+ export function planWorkUnits(plan: MeasurementPlan): number | undefined {
140
+ if (plan.pluginCount === undefined) return undefined;
141
+ return plan.pluginCount * plan.rates.length * plan.dimensions.length;
142
+ }
143
+
144
+ /** A duration estimate, with the run it was extrapolated from named. */
145
+ export interface RunEstimate {
146
+ readonly seconds: number;
147
+ /** The reference run's id, host and duration — everything needed to render "on <host>,
148
+ * <n> plugins took <t>", so the operator can judge the extrapolation themselves. */
149
+ readonly basis: {
150
+ readonly runId: string;
151
+ readonly host: string;
152
+ readonly seconds: number;
153
+ readonly workUnits: number;
154
+ };
155
+ }
156
+
157
+ /** The work a completed run represents, from its own document. */
158
+ function runWorkUnits(document: MeasurementDocument): number | undefined {
159
+ const units = document.plugins.length * document.run.rates.length * document.run.dimensions.length;
160
+ return units > 0 ? units : undefined;
161
+ }
162
+
163
+ /**
164
+ * How long this plan is likely to take, extrapolated from a run that actually happened.
165
+ *
166
+ * `undefined` whenever it cannot be derived — no reference document, the reference never
167
+ * recorded `elapsedSeconds`, or the plan does not yet know its plugin count. An unknown
168
+ * duration is reported as unknown; there is no constant to fall back on and inventing one
169
+ * would be the exact dishonesty the provenance discipline exists to prevent.
170
+ *
171
+ * When the reference ran on ANOTHER machine the estimate is still only an order of
172
+ * magnitude — which is why {@link RunEstimate.basis} names the host rather than hiding it.
173
+ */
174
+ export function estimateRunSeconds(
175
+ plan: MeasurementPlan,
176
+ reference: MeasurementDocument | undefined,
177
+ ): RunEstimate | undefined {
178
+ if (reference === undefined) return undefined;
179
+ const elapsed = reference.run.elapsedSeconds;
180
+ if (elapsed === undefined || !(elapsed > 0)) return undefined;
181
+ const referenceUnits = runWorkUnits(reference);
182
+ const planUnits = planWorkUnits(plan);
183
+ if (referenceUnits === undefined || planUnits === undefined || planUnits === 0) return undefined;
184
+ return {
185
+ seconds: (elapsed * planUnits) / referenceUnits,
186
+ basis: {
187
+ runId: reference.run.id,
188
+ host: reference.run.host.hostname,
189
+ seconds: elapsed,
190
+ workUnits: referenceUnits,
191
+ },
192
+ };
193
+ }
194
+
195
+ /**
196
+ * Rates a completed run covers that the installation no longer cares about, and rates the
197
+ * installation now uses that the run never covered.
198
+ *
199
+ * The second list is the rate-change case: the operator moved the rig to 96 kHz, so the
200
+ * figures for 96 kHz are MISSING rather than wrong, and the console should offer to measure
201
+ * them instead of interpolating across a boundary 19 nonconforming plugins prove cannot be
202
+ * interpolated across.
203
+ */
204
+ export interface RateCoverage {
205
+ readonly covered: readonly number[];
206
+ readonly missing: readonly number[];
207
+ readonly extra: readonly number[];
208
+ }
209
+
210
+ /** Compare a run's rates against the rates an installation now uses. */
211
+ export function rateCoverage(run: MeasurementRun | undefined, needed: readonly number[]): RateCoverage {
212
+ const have = new Set(run?.rates ?? []);
213
+ const want = [...new Set(needed)].sort((a, b) => a - b);
214
+ return {
215
+ covered: want.filter((rate) => have.has(rate)),
216
+ missing: want.filter((rate) => !have.has(rate)),
217
+ extra: [...have].filter((rate) => !want.includes(rate)).sort((a, b) => a - b),
218
+ };
219
+ }
@@ -0,0 +1,271 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
+ /**
4
+ * The prober's JSON → the interchange format.
5
+ *
6
+ * `tools/scan.py` and `tools/benchmark.py` write a flat array of annotated descriptors —
7
+ * their own working shape, which predates this format and carries fields (curation tiers,
8
+ * parameter lists, RPM ownership) that have nothing to do with a measurement. This module
9
+ * is the one place that knows both shapes, and it is where three deliberate conversions
10
+ * happen:
11
+ *
12
+ * 1. **Free text becomes vocabulary.** The prober says `"worker crashed (…)"`,
13
+ * `"probe timed out"`, `"non-finite output"` — useful to a human, unmergeable between
14
+ * projects. Each is mapped to a term from {@link ./vocabulary.ts}, and the prober's own
15
+ * sentence is preserved verbatim next to it so the coarser code never destroys the
16
+ * detail.
17
+ * 2. **Derived figures are dropped.** `coreFractionP95` and `instancesPerCoreP95` are
18
+ * `nsPerSample × rate` and its reciprocal. Storing them invites the two to disagree
19
+ * after an edit; the format keeps the measured primitive and lets consumers derive.
20
+ * 3. **`nsPerSampleP95` becomes `nsPerSamplePercentile`.** The percentile is a property of
21
+ * the RUN, declared once in {@link ../format.CostMethod}. Baking `95` into every field
22
+ * name would make a p99 run unrepresentable.
23
+ *
24
+ * The reader is structural: it validates the fields it needs and ignores everything else,
25
+ * so it survives the prober growing new ones.
26
+ */
27
+
28
+ import type {
29
+ CostAtRate,
30
+ CostMeasurement,
31
+ LatencyAtRate,
32
+ LatencyMeasurement,
33
+ MeasuredParamSweep,
34
+ MeasuredPlugin,
35
+ PluginTopology,
36
+ } from "./format.js";
37
+ import { booleanValue, finiteNumber, isRecord, nonEmptyString, optional, positiveNumber } from "./guards.js";
38
+ import type { MeasurementDimension } from "./vocabulary.js";
39
+
40
+ /** One free-text prober failure, and the vocabulary term it means. */
41
+ interface ReasonRule {
42
+ /** Matched against the prober's lowercased text. */
43
+ readonly match: RegExp;
44
+ readonly code: string;
45
+ }
46
+
47
+ /**
48
+ * The prober's words → the controlled vocabulary. Ordered: the first match wins, so the
49
+ * specific patterns come before the general ones.
50
+ *
51
+ * A table rather than a switch precisely so the next prober — someone else's — can be
52
+ * supported by adding rows, which is the whole ambition of publishing a format.
53
+ */
54
+ export const PROBE_REASON_RULES: readonly ReasonRule[] = [
55
+ { match: /^no-audio-in$/, code: "no-audio-in" },
56
+ { match: /^no-audio-out$/, code: "no-audio-out" },
57
+ { match: /^silent-output$/, code: "silent-output" },
58
+ { match: /worker crashed|worker desync|worker unavailable/, code: "probe-crashed" },
59
+ { match: /timed out|timeout/, code: "probe-timeout" },
60
+ { match: /non-finite/, code: "non-finite-output" },
61
+ { match: /not installed/, code: "not-installed" },
62
+ { match: /refus|instantiat/, code: "refused-instantiation" },
63
+ ];
64
+
65
+ /**
66
+ * The vocabulary code for a prober reason.
67
+ *
68
+ * Anything unmatched becomes `probe-error` — never a guessed structural reason. A reason we
69
+ * cannot classify must not be allowed to claim the plugin has nothing to measure, because
70
+ * that claim would override a good figure from another machine ({@link ./merge.ts}).
71
+ */
72
+ export function probeReasonCode(text: string): string {
73
+ const lower = text.toLowerCase();
74
+ return PROBE_REASON_RULES.find((rule) => rule.match.test(lower))?.code ?? "probe-error";
75
+ }
76
+
77
+ function reasonFields(text: string | undefined): { unmeasuredReason?: string; unmeasuredDetail?: string } {
78
+ if (text === undefined) return {};
79
+ const code = probeReasonCode(text);
80
+ return {
81
+ unmeasuredReason: code,
82
+ // Keep the prober's sentence only when it says more than the code already does.
83
+ ...(text === code ? {} : { unmeasuredDetail: text }),
84
+ };
85
+ }
86
+
87
+ function readTopology(entry: Record<string, unknown>): PluginTopology | undefined {
88
+ const audioInputs = finiteNumber(entry.audioInputs);
89
+ const audioOutputs = finiteNumber(entry.audioOutputs);
90
+ if (audioInputs === undefined || audioOutputs === undefined) return undefined;
91
+ const midiInputs = entry.hasMidiIn === true ? 1 : entry.hasMidiIn === false ? 0 : undefined;
92
+ const controlInputs = Array.isArray(entry.params)
93
+ ? entry.params.filter((p) => isRecord(p) && p.kind === "control").length
94
+ : undefined;
95
+ return {
96
+ audioInputs,
97
+ audioOutputs,
98
+ ...optional("midiInputs", midiInputs),
99
+ ...optional("controlInputs", controlInputs),
100
+ };
101
+ }
102
+
103
+ function readSweep(value: unknown): MeasuredParamSweep | undefined {
104
+ if (!isRecord(value)) return undefined;
105
+ const rate = positiveNumber(value.rate);
106
+ const defaultFrames = finiteNumber(value.defaultFrames);
107
+ const minFrames = finiteNumber(value.minFrames);
108
+ const maxFrames = finiteNumber(value.maxFrames);
109
+ if (rate === undefined || defaultFrames === undefined || minFrames === undefined || maxFrames === undefined) {
110
+ return undefined;
111
+ }
112
+ const controls: Record<string, { minFrames: number; maxFrames: number }> = {};
113
+ if (isRecord(value.controls)) {
114
+ for (const [symbol, span] of Object.entries(value.controls)) {
115
+ if (!isRecord(span)) continue;
116
+ const lo = finiteNumber(span.minFrames);
117
+ const hi = finiteNumber(span.maxFrames);
118
+ if (lo === undefined || hi === undefined) continue;
119
+ controls[symbol] = { minFrames: lo, maxFrames: hi };
120
+ }
121
+ }
122
+ return { rate, defaultFrames, minFrames, maxFrames, controls };
123
+ }
124
+
125
+ /**
126
+ * Rates the prober condemned, as `rate → reason`. In the catalog shape these live in a
127
+ * parallel `unreliableRates` array; the format puts the exclusion ON the reading it
128
+ * disqualifies, where a consumer cannot fail to notice it.
129
+ */
130
+ function excludedRates(value: unknown): ReadonlyMap<string, string> {
131
+ const out = new Map<string, string>();
132
+ if (!Array.isArray(value)) return out;
133
+ for (const item of value) {
134
+ if (!isRecord(item)) continue;
135
+ const rate = positiveNumber(item.rate);
136
+ const reason = nonEmptyString(item.reason);
137
+ if (rate === undefined || reason === undefined) continue;
138
+ out.set(String(rate), reason);
139
+ }
140
+ return out;
141
+ }
142
+
143
+ function readLatency(value: unknown): LatencyMeasurement | undefined {
144
+ if (!isRecord(value)) return undefined;
145
+ const excluded = excludedRates(value.unreliableRates);
146
+ const perRate: Record<string, LatencyAtRate> = {};
147
+ if (isRecord(value.perRate)) {
148
+ for (const [key, reading] of Object.entries(value.perRate)) {
149
+ const rate = positiveNumber(Number(key));
150
+ if (rate === undefined || !isRecord(reading)) continue;
151
+ const frames = finiteNumber(reading.frames);
152
+ const ms = finiteNumber(reading.ms);
153
+ if (frames === undefined || ms === undefined) continue;
154
+ perRate[String(rate)] = {
155
+ frames,
156
+ ms,
157
+ ...optional("stimulus", nonEmptyString(value.stimulus)),
158
+ ...optional("excludedReason", excluded.get(String(rate))),
159
+ };
160
+ }
161
+ }
162
+ const unmeasurable = nonEmptyString(value.unmeasurable);
163
+ const measurement: LatencyMeasurement = {
164
+ perRate,
165
+ ...optional("scalingClass", nonEmptyString(value.scalingClass)),
166
+ ...optional("scalingNote", nonEmptyString(value.scalingNote)),
167
+ ...optional("declaredPortSymbol", nonEmptyString(value.portSymbol)),
168
+ ...optional("declaredFrames", finiteNumber(value.reportedFrames)),
169
+ ...optional("declaredMismatch", booleanValue(value.declaredMismatch)),
170
+ ...optional("paramSweep", readSweep(value.paramSweep)),
171
+ // A reason only when there is no reading: a figure and a reason are mutually exclusive.
172
+ ...(Object.keys(perRate).length === 0 ? reasonFields(unmeasurable) : {}),
173
+ };
174
+ return measurement;
175
+ }
176
+
177
+ function readCost(value: unknown): CostMeasurement | undefined {
178
+ if (!isRecord(value)) return undefined;
179
+ const perRate: Record<string, CostAtRate> = {};
180
+ if (isRecord(value.perRate)) {
181
+ for (const [key, reading] of Object.entries(value.perRate)) {
182
+ const rate = positiveNumber(Number(key));
183
+ if (rate === undefined || !isRecord(reading)) continue;
184
+ const median = finiteNumber(reading.nsPerSampleMedian);
185
+ const high = finiteNumber(reading.nsPerSampleP95);
186
+ const blocks = finiteNumber(reading.blocks);
187
+ const blockFrames = positiveNumber(reading.blockFrames);
188
+ if (median === undefined || high === undefined || blocks === undefined || blockFrames === undefined) {
189
+ continue;
190
+ }
191
+ perRate[String(rate)] = {
192
+ nsPerSampleMedian: median,
193
+ nsPerSamplePercentile: high,
194
+ blocks,
195
+ blockFrames,
196
+ ...optional("nsPerSampleMin", finiteNumber(reading.nsPerSampleMin)),
197
+ ...optional("nsPerSampleMax", finiteNumber(reading.nsPerSampleMax)),
198
+ ...optional("warmupNsPerSampleMedian", finiteNumber(reading.warmupNsPerSampleMedian)),
199
+ ...optional("silentOutput", booleanValue(reading.silentOutput)),
200
+ };
201
+ }
202
+ }
203
+ const reason = nonEmptyString(value.unmeasurable) ?? nonEmptyString(value.failed);
204
+ return {
205
+ perRate,
206
+ ...(Object.keys(perRate).length === 0 ? reasonFields(reason) : {}),
207
+ };
208
+ }
209
+
210
+ /**
211
+ * The plugin's own version, `"<minor>.<micro>"`, when the scan recorded one.
212
+ *
213
+ * Absent rather than `"0.0"` when it did not: a version of zero is a real LV2 answer
214
+ * (unstable/development), so it must not double as "we do not know".
215
+ */
216
+ function readVersion(entry: Record<string, unknown>): string | undefined {
217
+ const minor = finiteNumber(entry.minorVersion);
218
+ const micro = finiteNumber(entry.microVersion);
219
+ if (minor === undefined || micro === undefined) return nonEmptyString(entry.version);
220
+ return `${minor}.${micro}`;
221
+ }
222
+
223
+ /**
224
+ * One prober descriptor as a {@link MeasuredPlugin}, or `undefined` when it is not one.
225
+ *
226
+ * `dimensions` says which dimensions the run covered: a dimension NOT covered yields no
227
+ * block at all (the run never asked), while a covered dimension always yields one, carrying
228
+ * either readings or a reason. That distinction is what makes a partial run readable. The
229
+ * prober's JSON carries only `latency` and `cost`; a hosting dimension listed here yields no
230
+ * block, because this reader has nothing to read it from (`sweep-jsonl.ts` does).
231
+ */
232
+ export function measuredPluginFromProbeEntry(
233
+ value: unknown,
234
+ dimensions: readonly MeasurementDimension[],
235
+ ): MeasuredPlugin | undefined {
236
+ if (!isRecord(value)) return undefined;
237
+ const uri = nonEmptyString(value.uri);
238
+ if (uri === undefined) return undefined;
239
+ const topology = readTopology(value);
240
+ if (topology === undefined) return undefined;
241
+
242
+ const latency = dimensions.includes("latency")
243
+ ? readLatency(value.latency) ?? { perRate: {}, unmeasuredReason: "not-attempted" }
244
+ : undefined;
245
+ const cost = dimensions.includes("cost")
246
+ ? readCost(value.cpuCost) ?? { perRate: {}, unmeasuredReason: "not-attempted" }
247
+ : undefined;
248
+
249
+ return {
250
+ uri,
251
+ topology,
252
+ ...optional("name", nonEmptyString(value.name)),
253
+ ...optional("version", readVersion(value)),
254
+ ...optional("bundle", nonEmptyString(value.bundlePath)),
255
+ ...optional("lv2Class", nonEmptyString(value.lv2Class)),
256
+ ...optional("latency", latency),
257
+ ...optional("cost", cost),
258
+ };
259
+ }
260
+
261
+ /** Every readable descriptor in a prober output array, as measured plugins. */
262
+ export function measuredPluginsFromProbeOutput(
263
+ value: unknown,
264
+ dimensions: readonly MeasurementDimension[],
265
+ ): MeasuredPlugin[] {
266
+ if (!Array.isArray(value)) return [];
267
+ return value.flatMap((entry) => {
268
+ const plugin = measuredPluginFromProbeEntry(entry, dimensions);
269
+ return plugin === undefined ? [] : [plugin];
270
+ });
271
+ }