@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,64 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
+ /**
4
+ * The only shape primitives this directory uses.
5
+ *
6
+ * `src/measurement/` is written to be lifted out as a directory copy, so it imports NOTHING from its parent package — not even the catalog's
7
+ * own `guards.ts`, whose header explains the same reasoning one level up. Keep it that
8
+ * way: any import that reaches outside this directory turns a copy into a port.
9
+ *
10
+ * Every reader here returns `undefined` for input it cannot vouch for, and every builder
11
+ * omits a field it has no value for. Both halves of the format's central rule: an absent
12
+ * field means "not determined", never "zero" and never "false".
13
+ */
14
+
15
+ /** Non-null, non-array plain object — the only shape a validator may index into. */
16
+ export function isRecord(value: unknown): value is Record<string, unknown> {
17
+ return typeof value === "object" && value !== null && !Array.isArray(value);
18
+ }
19
+
20
+ /** A finite number, or `undefined` for anything else (including `NaN` and `Infinity`). */
21
+ export function finiteNumber(value: unknown): number | undefined {
22
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
23
+ }
24
+
25
+ /** A finite number strictly greater than zero, or `undefined`. */
26
+ export function positiveNumber(value: unknown): number | undefined {
27
+ const n = finiteNumber(value);
28
+ return n !== undefined && n > 0 ? n : undefined;
29
+ }
30
+
31
+ /** A non-empty string, or `undefined`. An empty string is absence spelled differently. */
32
+ export function nonEmptyString(value: unknown): string | undefined {
33
+ return typeof value === "string" && value.length > 0 ? value : undefined;
34
+ }
35
+
36
+ /** A boolean, or `undefined` — never a coerced truthiness. */
37
+ export function booleanValue(value: unknown): boolean | undefined {
38
+ return typeof value === "boolean" ? value : undefined;
39
+ }
40
+
41
+ /** Every element of an array that a reader accepted, in order; `undefined` if not an array. */
42
+ export function mapArray<T>(value: unknown, read: (item: unknown) => T | undefined): T[] | undefined {
43
+ if (!Array.isArray(value)) return undefined;
44
+ const out: T[] = [];
45
+ for (const item of value) {
46
+ const parsed = read(item);
47
+ if (parsed !== undefined) out.push(parsed);
48
+ }
49
+ return out;
50
+ }
51
+
52
+ /**
53
+ * `{ [key]: value }` when `value` is defined, `{}` otherwise — the spread helper every
54
+ * builder uses for an optional field.
55
+ *
56
+ * Written as a function rather than an inline ternary at ~40 call sites so the intent
57
+ * (omit, do not null) is named once and cannot drift.
58
+ */
59
+ export function optional<K extends string, V>(key: K, value: V | undefined): { [P in K]?: V } {
60
+ if (value === undefined) return {};
61
+ const out: { [P in K]?: V } = {};
62
+ out[key] = value;
63
+ return out;
64
+ }
@@ -0,0 +1,39 @@
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, and the tool that produces it.**
5
+ *
6
+ * This directory is written to stand alone. The idea is that measured LV2 plugin
7
+ * characteristics — per-rate latency with its scaling law, per-rate CPU cost, topology, and
8
+ * above all the PROVENANCE without which none of it is comparable — should be publishable
9
+ * in a shape any LV2 host can read, rather than re-derived by each project from a prober it
10
+ * wrote itself. The OpenMixer console is the format's first consumer, which is what keeps it
11
+ * honest; it is not meant to be the only one.
12
+ *
13
+ * ## What is in here, and what is deliberately not
14
+ *
15
+ * | module | role |
16
+ * |---|---|
17
+ * | `format.ts` | the schema: types, version strategy, cast-free readers, builders |
18
+ * | `vocabulary.ts` | the controlled vocabularies — why a figure is absent, how latency scales |
19
+ * | `provenance.ts` | the run record: machine facts read rather than assumed |
20
+ * | `probe-json.ts` | `benchmark.py`'s working JSON → the format |
21
+ * | `sweep-jsonl.ts` | the hosting sweep's JSONL (`lv2-measure.c`) → the three hosting dimensions |
22
+ * | `merge.ts` | shipped defaults with local measurements over them, each figure knowing its source |
23
+ * | `plan.ts` | which rates to measure, and how long it might take |
24
+ * | `cli.ts` | `lv2-measure`'s decisions, with the subprocess spawning left to `tools/lv2-measure.mjs` |
25
+ *
26
+ * Nothing in this directory imports anything outside it — not a catalog's descriptor
27
+ * types, not suitability rules, not a console's core library. Extraction is a directory copy
28
+ * plus `tools/{scan,benchmark}.py` and `tools/lv2-measure.mjs`. Adapters that turn a merged
29
+ * view into a host's own descriptors belong in that host, on purpose.
30
+ */
31
+
32
+ export * from "./format.js";
33
+ export * from "./vocabulary.js";
34
+ export * from "./provenance.js";
35
+ export * from "./probe-json.js";
36
+ export * from "./merge.js";
37
+ export * from "./plan.js";
38
+ export * from "./cli.js";
39
+ export * from "./sweep-jsonl.js";
@@ -0,0 +1,434 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
+ /**
4
+ * **Shipped defaults, overridden by local measurement** — and the operator can
5
+ * always see which is which.
6
+ *
7
+ * The rule is one sentence: *what we ship is a sensible default; a local run overrides it.*
8
+ * Everything below is what that sentence costs once you refuse to lie about the result.
9
+ *
10
+ * ## Why the merge is per plugin per DIMENSION, and not per rate
11
+ *
12
+ * The tempting rule — "take the local figure wherever there is one, rate by rate" — would
13
+ * produce a plugin whose 48 kHz cost came from the customer's NUC and whose 96 kHz cost came
14
+ * from our reference laptop. The scaling class then gets fitted across two machines, and the
15
+ * ratio between the two rates, the one thing per-rate data exists to express, becomes an
16
+ * artefact of the hardware difference. It would look like data and behave like noise.
17
+ *
18
+ * So a local run REPLACES a plugin's whole dimension or none of it. A rate the local run did
19
+ * not cover comes back as MISSING, not as the shipped number wearing a local label
20
+ * ({@link MergedMeasurements.missingRates}) — which is what a reader wants when the
21
+ * operator later adds a rate: *"the affected figures are missing rather than wrong — surface
22
+ * that, and offer to measure the new rate rather than quietly interpolating."*
23
+ *
24
+ * ## Why a failed local probe does NOT override
25
+ *
26
+ * A local run that crashed on a plugin has learned nothing about that plugin's latency. If
27
+ * the failure overrode, one segfault would delete a good shipped figure and the operator's
28
+ * catalogue would get worse the more they measured. So the vocabulary's `kind` decides
29
+ * ({@link ../vocabulary.unmeasuredKind}):
30
+ *
31
+ * - `nothing-to-measure` (no audio in, no audio out, silent) — a property of the PLUGIN, as
32
+ * portable as a measurement. It overrides.
33
+ * - `probe-failed` (crashed, timed out, refused, non-finite) — a property of THIS RUN. The
34
+ * shipped figure stands, and the local failure is still reported
35
+ * ({@link MergedPluginMeasurement.localProbeFailed}) so the operator sees that their
36
+ * machine could not reproduce it.
37
+ * - `not-attempted` — a partial run. Not a finding at all.
38
+ *
39
+ * An UNKNOWN reason code from a third-party document resolves to `probe-failed`: a word we
40
+ * cannot interpret must never be allowed to delete a real measurement.
41
+ */
42
+
43
+ import type {
44
+ CostMeasurement,
45
+ FeaturesMeasurement,
46
+ LatencyMeasurement,
47
+ MeasurementDocument,
48
+ MeasuredPlugin,
49
+ MeasurementRun,
50
+ RtSafetyMeasurement,
51
+ StabilityMeasurement,
52
+ } from "./format.js";
53
+ import { type MeasurementDimension, MEASUREMENT_DIMENSIONS, unmeasuredKind } from "./vocabulary.js";
54
+
55
+ /** Where a figure came from. Two words, because the operator only ever has two questions. */
56
+ export type FigureSource = "local" | "shipped";
57
+
58
+ /** Every source, in the order precedence resolves them (highest first). */
59
+ export const FIGURE_SOURCES: readonly FigureSource[] = ["local", "shipped"];
60
+
61
+ /** One dimension's resolved figure, with the run that produced it named. */
62
+ export interface ResolvedFigure<T> {
63
+ readonly source: FigureSource;
64
+ /** {@link MeasurementRun.id} of the run this came from. */
65
+ readonly runId: string;
66
+ /** The run's host, denormalised so a surface can label a figure without a second lookup. */
67
+ readonly host: string;
68
+ readonly measuredAt: string;
69
+ readonly value: T;
70
+ /** Rates this figure actually covers, ascending. */
71
+ readonly rates: readonly number[];
72
+ }
73
+
74
+ /** One plugin as the merged view sees it. */
75
+ export interface MergedPluginMeasurement {
76
+ readonly uri: string;
77
+ /** Best available identity — the local run's, falling back to the shipped one's. */
78
+ readonly plugin: MeasuredPlugin;
79
+ readonly latency?: ResolvedFigure<LatencyMeasurement>;
80
+ readonly cost?: ResolvedFigure<CostMeasurement>;
81
+ readonly stability?: ResolvedFigure<StabilityMeasurement>;
82
+ readonly rtSafety?: ResolvedFigure<RtSafetyMeasurement>;
83
+ readonly features?: ResolvedFigure<FeaturesMeasurement>;
84
+ /**
85
+ * Dimensions where the LOCAL run tried and failed (crashed, timed out, refused), keyed
86
+ * by dimension with the reason code. The shipped figure is still in use for these —
87
+ * that is the point — but the operator is told their machine could not reproduce it.
88
+ */
89
+ readonly localProbeFailed?: Readonly<Partial<Record<MeasurementDimension, string>>>;
90
+ /** Present only on a plugin installed here that the shipped catalogue never saw. */
91
+ readonly localOnly?: true;
92
+ }
93
+
94
+ /** Counts a Setup screen can render without walking the catalogue itself. */
95
+ export interface MergeSummary {
96
+ readonly plugins: number;
97
+ /** Plugins whose figure for this dimension came from each source. */
98
+ readonly bySource: Readonly<Record<MeasurementDimension, Readonly<Record<FigureSource, number>>>>;
99
+ /** Plugins with no figure at all for this dimension, from either source. */
100
+ readonly unresolved: Readonly<Record<MeasurementDimension, number>>;
101
+ /** Plugins present locally and absent from the shipped catalogue. */
102
+ readonly localOnly: number;
103
+ /** Plugins the local run tried and failed on, per dimension. */
104
+ readonly localProbeFailures: Readonly<Record<MeasurementDimension, number>>;
105
+ }
106
+
107
+ /** One dimension's block, whatever the dimension. */
108
+ type AnyMeasurement =
109
+ | LatencyMeasurement
110
+ | CostMeasurement
111
+ | StabilityMeasurement
112
+ | RtSafetyMeasurement
113
+ | FeaturesMeasurement;
114
+
115
+ /** Each dimension's block type, so a resolved figure keeps the type of the block it holds. */
116
+ interface MeasurementOf {
117
+ readonly latency: LatencyMeasurement;
118
+ readonly cost: CostMeasurement;
119
+ readonly stability: StabilityMeasurement;
120
+ readonly rtSafety: RtSafetyMeasurement;
121
+ readonly features: FeaturesMeasurement;
122
+ }
123
+
124
+ function dimensionOf<D extends MeasurementDimension>(
125
+ plugin: MeasuredPlugin,
126
+ dimension: D,
127
+ ): MeasurementOf[D] | undefined {
128
+ const blocks: { readonly [K in MeasurementDimension]: MeasurementOf[K] | undefined } = {
129
+ latency: plugin.latency,
130
+ cost: plugin.cost,
131
+ stability: plugin.stability,
132
+ rtSafety: plugin.rtSafety,
133
+ features: plugin.features,
134
+ };
135
+ return blocks[dimension];
136
+ }
137
+
138
+ /** A merged entry's resolved figure for one dimension. */
139
+ function resolvedOf<D extends MeasurementDimension>(
140
+ entry: MergedPluginMeasurement | undefined,
141
+ dimension: D,
142
+ ): ResolvedFigure<MeasurementOf[D]> | undefined {
143
+ if (entry === undefined) return undefined;
144
+ const figures: { readonly [K in MeasurementDimension]: ResolvedFigure<MeasurementOf[K]> | undefined } = {
145
+ latency: entry.latency,
146
+ cost: entry.cost,
147
+ stability: entry.stability,
148
+ rtSafety: entry.rtSafety,
149
+ features: entry.features,
150
+ };
151
+ return figures[dimension];
152
+ }
153
+
154
+ /** Rates a measurement actually carries, ascending. Excluded readings still count as
155
+ * covered: the rate WAS measured, and the exclusion is the finding. A soak covers the rates
156
+ * it rendered, an interposer count the one rate it ran at, and a feature list no rate at all. */
157
+ function ratesOf(measurement: AnyMeasurement): number[] {
158
+ const keys =
159
+ "perRate" in measurement
160
+ ? Object.keys(measurement.perRate)
161
+ : "soak" in measurement && measurement.soak !== undefined
162
+ ? Object.keys(measurement.soak.perRate)
163
+ : "rate" in measurement && measurement.rate !== undefined
164
+ ? [String(measurement.rate)]
165
+ : [];
166
+ return keys
167
+ .map(Number)
168
+ .filter((rate) => Number.isFinite(rate))
169
+ .sort((a, b) => a - b);
170
+ }
171
+
172
+ /**
173
+ * True when a measurement carries a READING rather than only a reason. Per dimension, because
174
+ * what a reading is differs: latency and cost need a rate; a stability block needs its
175
+ * lifecycle, an rt-safety block all three counts, a features block its required list.
176
+ */
177
+ function hasReadings(dimension: MeasurementDimension, measurement: AnyMeasurement | undefined): boolean {
178
+ if (measurement === undefined) return false;
179
+ switch (dimension) {
180
+ case "latency":
181
+ case "cost":
182
+ return "perRate" in measurement && Object.keys(measurement.perRate).length > 0;
183
+ case "stability":
184
+ return "lifecycle" in measurement && measurement.lifecycle !== undefined;
185
+ case "rtSafety":
186
+ return (
187
+ "allocationsInRun" in measurement &&
188
+ measurement.allocationsInRun !== undefined &&
189
+ "locksInRun" in measurement &&
190
+ measurement.locksInRun !== undefined &&
191
+ "syscallsInRun" in measurement &&
192
+ measurement.syscallsInRun !== undefined
193
+ );
194
+ case "features":
195
+ return "required" in measurement && measurement.required !== undefined;
196
+ }
197
+ }
198
+
199
+ function figure<T extends AnyMeasurement>(
200
+ source: FigureSource,
201
+ run: MeasurementRun,
202
+ value: T,
203
+ ): ResolvedFigure<T> {
204
+ return {
205
+ source,
206
+ runId: run.id,
207
+ host: run.host.hostname,
208
+ measuredAt: run.measuredAt,
209
+ value,
210
+ rates: ratesOf(value),
211
+ };
212
+ }
213
+
214
+ /** `{ [dimension]: figure }` when there is one, `{}` otherwise. */
215
+ function optionalFigure<K extends MeasurementDimension, T>(
216
+ dimension: K,
217
+ value: ResolvedFigure<T> | undefined,
218
+ ): { [P in K]?: ResolvedFigure<T> } {
219
+ if (value === undefined) return {};
220
+ const out: { [P in K]?: ResolvedFigure<T> } = {};
221
+ out[dimension] = value;
222
+ return out;
223
+ }
224
+
225
+ /**
226
+ * The precedence decision for ONE plugin and ONE dimension, expressed once.
227
+ *
228
+ * Returns the winning figure, plus the local failure reason when the local run tried and
229
+ * could not — the two outputs are independent, because "the shipped figure won" and "your
230
+ * machine crashed on it" are both true at the same time and the operator needs both.
231
+ */
232
+ function resolveDimension<T extends AnyMeasurement>(
233
+ dimension: MeasurementDimension,
234
+ local: { readonly run: MeasurementRun; readonly measurement: T | undefined } | undefined,
235
+ shipped: { readonly run: MeasurementRun; readonly measurement: T | undefined } | undefined,
236
+ ): { readonly resolved?: ResolvedFigure<T>; readonly localFailure?: string } {
237
+ const shippedFigure =
238
+ shipped?.measurement !== undefined && hasReadings(dimension, shipped.measurement)
239
+ ? figure("shipped", shipped.run, shipped.measurement)
240
+ : undefined;
241
+
242
+ if (local === undefined || local.measurement === undefined) {
243
+ return shippedFigure === undefined ? {} : { resolved: shippedFigure };
244
+ }
245
+ // The local run covered this dimension and measured this plugin: it wins outright.
246
+ if (hasReadings(dimension, local.measurement)) {
247
+ return { resolved: figure("local", local.run, local.measurement) };
248
+ }
249
+ // No readings locally. What the local run learned depends entirely on WHY.
250
+ const kind = unmeasuredKind(local.measurement.unmeasuredReason);
251
+ if (kind === "nothing-to-measure") {
252
+ // A plugin property, as portable as a measurement — it overrides, with no number.
253
+ return { resolved: figure("local", local.run, local.measurement) };
254
+ }
255
+ if (kind === "probe-failed") {
256
+ return {
257
+ ...(shippedFigure === undefined ? {} : { resolved: shippedFigure }),
258
+ ...(local.measurement.unmeasuredReason === undefined
259
+ ? {}
260
+ : { localFailure: local.measurement.unmeasuredReason }),
261
+ };
262
+ }
263
+ // not-attempted: the local run has no opinion here.
264
+ return shippedFigure === undefined ? {} : { resolved: shippedFigure };
265
+ }
266
+
267
+ /**
268
+ * The merged view an installation reads: shipped defaults with local measurements laid over
269
+ * them, every figure still knowing where it came from.
270
+ *
271
+ * Built once from two documents and then queried — the precedence rule runs at construction,
272
+ * so no consumer can accidentally re-implement half of it. A missing local document is the
273
+ * normal state (nobody has measured yet) and yields a view that is entirely `shipped`.
274
+ */
275
+ export class MergedMeasurements {
276
+ private readonly merged: ReadonlyMap<string, MergedPluginMeasurement>;
277
+
278
+ constructor(
279
+ private readonly shipped: MeasurementDocument | undefined,
280
+ private readonly local: MeasurementDocument | undefined,
281
+ ) {
282
+ const shippedByUri = new Map((shipped?.plugins ?? []).map((p) => [p.uri, p]));
283
+ const localByUri = new Map((local?.plugins ?? []).map((p) => [p.uri, p]));
284
+ const uris = [...new Set([...shippedByUri.keys(), ...localByUri.keys()])].sort();
285
+
286
+ const merged = new Map<string, MergedPluginMeasurement>();
287
+ for (const uri of uris) {
288
+ const shippedPlugin = shippedByUri.get(uri);
289
+ const localPlugin = localByUri.get(uri);
290
+ const identity = localPlugin ?? shippedPlugin;
291
+ if (identity === undefined) continue;
292
+
293
+ const resolve = <D extends MeasurementDimension>(dimension: D) =>
294
+ resolveDimension<MeasurementOf[D]>(
295
+ dimension,
296
+ local !== undefined && localPlugin !== undefined
297
+ ? { run: local.run, measurement: dimensionOf(localPlugin, dimension) }
298
+ : undefined,
299
+ shipped !== undefined && shippedPlugin !== undefined
300
+ ? { run: shipped.run, measurement: dimensionOf(shippedPlugin, dimension) }
301
+ : undefined,
302
+ );
303
+ const latency = resolve("latency");
304
+ const cost = resolve("cost");
305
+ const stability = resolve("stability");
306
+ const rtSafety = resolve("rtSafety");
307
+ const features = resolve("features");
308
+ const failures: Partial<Record<MeasurementDimension, string>> = {};
309
+ const outcomes = { latency, cost, stability, rtSafety, features };
310
+ for (const dimension of MEASUREMENT_DIMENSIONS) {
311
+ const failure = outcomes[dimension].localFailure;
312
+ if (failure !== undefined) failures[dimension] = failure;
313
+ }
314
+
315
+ merged.set(uri, {
316
+ uri,
317
+ plugin: identity,
318
+ ...optionalFigure("latency", latency.resolved),
319
+ ...optionalFigure("cost", cost.resolved),
320
+ ...optionalFigure("stability", stability.resolved),
321
+ ...optionalFigure("rtSafety", rtSafety.resolved),
322
+ ...optionalFigure("features", features.resolved),
323
+ ...(Object.keys(failures).length > 0 ? { localProbeFailed: failures } : {}),
324
+ ...(shippedPlugin === undefined ? { localOnly: true } : {}),
325
+ });
326
+ }
327
+ this.merged = merged;
328
+ }
329
+
330
+ /** The run behind each source, when that source has a document. */
331
+ runFor(source: FigureSource): MeasurementRun | undefined {
332
+ return source === "local" ? this.local?.run : this.shipped?.run;
333
+ }
334
+
335
+ /** Every merged plugin, URI-ascending. */
336
+ all(): MergedPluginMeasurement[] {
337
+ return [...this.merged.values()];
338
+ }
339
+
340
+ get(uri: string): MergedPluginMeasurement | undefined {
341
+ return this.merged.get(uri);
342
+ }
343
+
344
+ get size(): number {
345
+ return this.merged.size;
346
+ }
347
+
348
+ /**
349
+ * Where this plugin's figure for `dimension` came from, or `undefined` when there is no
350
+ * figure at all. THE question a UI asks per row, so it is one call and not a walk.
351
+ */
352
+ sourceOf(uri: string, dimension: MeasurementDimension): FigureSource | undefined {
353
+ return resolvedOf(this.merged.get(uri), dimension)?.source;
354
+ }
355
+
356
+ /** Every URI whose `dimension` figure came from `source`, URI-ascending. */
357
+ urisFrom(source: FigureSource, dimension: MeasurementDimension): string[] {
358
+ return this.all()
359
+ .filter((entry) => resolvedOf(entry, dimension)?.source === source)
360
+ .map((entry) => entry.uri);
361
+ }
362
+
363
+ /**
364
+ * Rates in `wanted` this plugin has NO figure for, on the dimension asked about.
365
+ *
366
+ * The honest answer to "the rig now runs at 96 kHz too": the rates the resolved figure
367
+ * does not cover are missing, and a surface should offer to measure them rather than
368
+ * project across a boundary the data says cannot be projected across.
369
+ */
370
+ missingRates(uri: string, dimension: MeasurementDimension, wanted: readonly number[]): number[] {
371
+ const resolved = resolvedOf(this.merged.get(uri), dimension);
372
+ if (resolved === undefined) return [...wanted].sort((a, b) => a - b);
373
+ const covered = new Set(resolved.rates);
374
+ return wanted.filter((rate) => !covered.has(rate)).sort((a, b) => a - b);
375
+ }
376
+
377
+ /**
378
+ * Plugins whose resolved figure does not cover every rate in `wanted`, per dimension.
379
+ * What a "your local run does not cover 96 kHz" banner counts.
380
+ */
381
+ pluginsMissingRates(dimension: MeasurementDimension, wanted: readonly number[]): string[] {
382
+ return this.all()
383
+ .filter((entry) => {
384
+ const resolved = resolvedOf(entry, dimension);
385
+ // A plugin with nothing to measure is not missing a rate; it has a reason.
386
+ if (resolved !== undefined && resolved.value.unmeasuredReason !== undefined) return false;
387
+ return this.missingRates(entry.uri, dimension, wanted).length > 0;
388
+ })
389
+ .map((entry) => entry.uri);
390
+ }
391
+
392
+ /** The counts a Setup screen renders. */
393
+ summary(): MergeSummary {
394
+ const zeroes = (): Record<MeasurementDimension, number> => ({
395
+ latency: 0,
396
+ cost: 0,
397
+ stability: 0,
398
+ rtSafety: 0,
399
+ features: 0,
400
+ });
401
+ const bySource: Record<MeasurementDimension, Record<FigureSource, number>> = {
402
+ latency: { local: 0, shipped: 0 },
403
+ cost: { local: 0, shipped: 0 },
404
+ stability: { local: 0, shipped: 0 },
405
+ rtSafety: { local: 0, shipped: 0 },
406
+ features: { local: 0, shipped: 0 },
407
+ };
408
+ const unresolved = zeroes();
409
+ const localProbeFailures = zeroes();
410
+ let localOnly = 0;
411
+
412
+ for (const entry of this.merged.values()) {
413
+ if (entry.localOnly === true) localOnly += 1;
414
+ for (const dimension of MEASUREMENT_DIMENSIONS) {
415
+ const resolved = resolvedOf(entry, dimension);
416
+ if (resolved === undefined) unresolved[dimension] += 1;
417
+ else bySource[dimension][resolved.source] += 1;
418
+ if (entry.localProbeFailed?.[dimension] !== undefined) localProbeFailures[dimension] += 1;
419
+ }
420
+ }
421
+ return { plugins: this.merged.size, bySource, unresolved, localOnly, localProbeFailures };
422
+ }
423
+ }
424
+
425
+ /** Convenience over the constructor, for call sites that read better as a function. */
426
+ export function mergeMeasurements(
427
+ shipped: MeasurementDocument | undefined,
428
+ local: MeasurementDocument | undefined,
429
+ ): MergedMeasurements {
430
+ return new MergedMeasurements(shipped, local);
431
+ }
432
+
433
+ /** Re-exported so a consumer of the merge does not also have to import the format. */
434
+ export { dimensionOf as measurementForDimension };