@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,220 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
+ /**
4
+ * Where a run happened, and under what settings — assembled from what the machine will
5
+ * actually tell us.
6
+ *
7
+ * The discipline this module enforces is the one `data/CPU_COST_PROVENANCE.md` states in
8
+ * prose: *"realtime priority / mlock — no, both need privileges the benchmark must not
9
+ * require; recorded as `false` rather than assumed."* Every fact here is READ at run time
10
+ * or omitted. Nothing is defaulted, nothing is inferred from the platform, and a `/sys`
11
+ * file that will not open produces an absent field rather than a plausible value.
12
+ *
13
+ * The two `false`s are the exception and they are required fields: `realtimePriority` and
14
+ * `memoryLocked` say "we did not run with this", which is a finding a comparison needs,
15
+ * and leaving them absent would let a reader assume the good case.
16
+ */
17
+
18
+ import { hostname as osHostname, cpus as osCpus, release as osRelease, type as osType } from "node:os";
19
+ import { readFileSync } from "node:fs";
20
+
21
+ import type { CostMethod, LatencyMethod, MeasurementHost, MeasurementRun } from "./format.js";
22
+ import { booleanValue, finiteNumber, isRecord, nonEmptyString, optional } from "./guards.js";
23
+ import { type MeasurementDimension, MEASUREMENT_DIMENSIONS } from "./vocabulary.js";
24
+
25
+ /** The machine facts this module reads, injectable so the collector is testable. */
26
+ export interface HostProbes {
27
+ hostname(): string;
28
+ kernelRelease(): string;
29
+ osName(): string;
30
+ cpuModel(): string | undefined;
31
+ cpuCount(): number;
32
+ /** Contents of a `/sys` or `/proc` file, or `undefined` when it cannot be read. */
33
+ readSystemFile(path: string): string | undefined;
34
+ }
35
+
36
+ /** The real machine. */
37
+ export const SYSTEM_HOST_PROBES: HostProbes = {
38
+ hostname: () => osHostname(),
39
+ kernelRelease: () => osRelease(),
40
+ osName: () => osType(),
41
+ cpuModel: () => osCpus()[0]?.model,
42
+ cpuCount: () => osCpus().length,
43
+ readSystemFile: (path) => {
44
+ try {
45
+ return readFileSync(path, "utf8").trim();
46
+ } catch {
47
+ return undefined;
48
+ }
49
+ },
50
+ };
51
+
52
+ /** Facts the caller knows that the machine cannot be asked for. */
53
+ export interface HostFactInput {
54
+ /** The core the prober pinned to, when it pinned. */
55
+ readonly pinnedCpu?: number;
56
+ /** Whether the prober ran with realtime scheduling — a claim the caller must make. */
57
+ readonly realtimePriority: boolean;
58
+ /** Whether the prober's pages were locked. */
59
+ readonly memoryLocked: boolean;
60
+ /** Versions of what did the probing, e.g. `{ python: "3.14.6" }`. */
61
+ readonly toolchain?: Readonly<Record<string, string>>;
62
+ }
63
+
64
+ /**
65
+ * Read the governor, the max frequency and the turbo state for a core, plus the CPU model
66
+ * and count. Any file that will not open yields an absent field.
67
+ *
68
+ * `pinnedCpuKind` deliberately reports what the kernel exposes rather than a classification
69
+ * of our own: on a hybrid CPU the two `cpuinfo_max_freq` populations are what distinguishes
70
+ * a performance core from an efficiency one, so the frequency IS the evidence and the label
71
+ * is only offered when the split is unambiguous.
72
+ */
73
+ export function collectHostFacts(input: HostFactInput, probes: HostProbes = SYSTEM_HOST_PROBES): MeasurementHost {
74
+ const cpu = input.pinnedCpu;
75
+ const base = cpu === undefined ? undefined : `/sys/devices/system/cpu/cpu${cpu}/cpufreq`;
76
+ const governor = base === undefined ? undefined : nonEmptyString(probes.readSystemFile(`${base}/scaling_governor`));
77
+ const maxKHzText = base === undefined ? undefined : probes.readSystemFile(`${base}/cpuinfo_max_freq`);
78
+ const maxKHz = maxKHzText === undefined ? undefined : finiteNumber(Number(maxKHzText));
79
+ const noTurbo = probes.readSystemFile("/sys/devices/system/cpu/intel_pstate/no_turbo");
80
+ const turboEnabled = noTurbo === undefined ? undefined : noTurbo.trim() === "0";
81
+ const cpuCount = probes.cpuCount();
82
+
83
+ return {
84
+ hostname: probes.hostname(),
85
+ realtimePriority: input.realtimePriority,
86
+ memoryLocked: input.memoryLocked,
87
+ ...optional("kernel", nonEmptyString(probes.kernelRelease())),
88
+ ...optional("os", nonEmptyString(probes.osName())),
89
+ ...optional("cpuModel", nonEmptyString(probes.cpuModel())),
90
+ ...optional("cpuCount", cpuCount > 0 ? cpuCount : undefined),
91
+ ...optional("pinnedCpu", cpu),
92
+ ...optional("pinnedCpuMaxKHz", maxKHz),
93
+ ...optional("governor", governor),
94
+ ...optional("turboEnabled", turboEnabled),
95
+ ...optional("toolchain", input.toolchain),
96
+ };
97
+ }
98
+
99
+ /**
100
+ * The host block from `benchmark.py --cost-provenance`'s JSON, or `undefined`.
101
+ *
102
+ * That file is the model this format generalises, so the mapping is mostly renaming. The
103
+ * one judgement: its `realtimePriority` / `memoryLocked` are already explicit booleans, and
104
+ * a file that omits them is treated as not having run privileged — the safe reading, and
105
+ * the one that matches what the prober can actually do without privileges.
106
+ */
107
+ export function hostFromProbeProvenance(value: unknown): MeasurementHost | undefined {
108
+ if (!isRecord(value)) return undefined;
109
+ const host = nonEmptyString(value.host);
110
+ if (host === undefined) return undefined;
111
+ const python = nonEmptyString(value.python);
112
+ return {
113
+ hostname: host,
114
+ realtimePriority: booleanValue(value.realtimePriority) ?? false,
115
+ memoryLocked: booleanValue(value.memoryLocked) ?? false,
116
+ ...optional("kernel", nonEmptyString(value.kernel)),
117
+ ...optional("cpuModel", nonEmptyString(value.cpuModel)),
118
+ ...optional("cpuCount", finiteNumber(value.cpuCount)),
119
+ ...optional("pinnedCpu", finiteNumber(value.pinnedCpu)),
120
+ ...optional("pinnedCpuKind", nonEmptyString(value.pinnedCpuKind)),
121
+ ...optional("pinnedCpuMaxKHz", finiteNumber(value.pinnedCpuMaxKHz)),
122
+ ...optional("governor", nonEmptyString(value.governor)),
123
+ ...optional("turboEnabled", booleanValue(value.turboEnabled)),
124
+ ...optional("toolchain", python === undefined ? undefined : { python }),
125
+ };
126
+ }
127
+
128
+ /** The cost method block from `benchmark.py --cost-provenance`'s JSON, or `undefined`. */
129
+ export function costMethodFromProbeProvenance(value: unknown): CostMethod | undefined {
130
+ if (!isRecord(value)) return undefined;
131
+ const blockFrames = finiteNumber(value.blockFrames);
132
+ const warmupBlocks = finiteNumber(value.warmupBlocks);
133
+ const percentile = finiteNumber(value.percentile);
134
+ const stimulus = nonEmptyString(value.stimulus);
135
+ if (blockFrames === undefined || warmupBlocks === undefined || percentile === undefined || stimulus === undefined) {
136
+ return undefined;
137
+ }
138
+ return {
139
+ method: "block-time-percentile",
140
+ blockFrames,
141
+ warmupBlocks,
142
+ percentile,
143
+ stimulus,
144
+ controls: nonEmptyString(value.controls) ?? "lv2:default on every control input",
145
+ ...optional("minTimedBlocks", finiteNumber(value.minTimedBlocks)),
146
+ ...optional("maxTimedBlocks", finiteNumber(value.maxTimedBlocks)),
147
+ ...optional("targetSecondsPerPlugin", finiteNumber(value.targetSecondsPerPlugin)),
148
+ ...optional("timingFloorNsPerBlock", finiteNumber(value.timingFloorNsPerBlock)),
149
+ };
150
+ }
151
+
152
+ /** The caveat that rides on every run carrying cost figures. */
153
+ export const RELATIVE_RANKING_NOTE =
154
+ "A RELATIVE ranking measured on one machine, not a guarantee on any other. Different silicon, " +
155
+ "a different governor or a busier machine will produce different absolute figures; what carries " +
156
+ "over is the ORDER and the rough ratios.";
157
+
158
+ /** The caveat on a run carrying only latency figures — portable, but not unconditionally. */
159
+ export const LATENCY_ONLY_NOTE =
160
+ "Latency is mostly algorithmic, so these figures travel between machines better than cost does. " +
161
+ "They are still tied to the PLUGIN VERSIONS recorded here: a new release moves an FFT window and " +
162
+ "the figure becomes wrong without becoming absent.";
163
+
164
+ /** The caveat on a run carrying the hosting dimensions — survival, RT-safety, features. */
165
+ export const HOSTING_NOTE =
166
+ "Whether a plugin survives and keeps run() free of allocations, locks and syscalls was measured " +
167
+ "on the host named here, against its libc, lilv and the plugin builds installed at the time. An " +
168
+ "upgrade of any of them is a different host: carry the figures across and re-judge, never trust.";
169
+
170
+ /** Everything {@link buildRun} needs that is not read off the machine. */
171
+ export interface RunInput {
172
+ readonly id: string;
173
+ readonly measuredAt: string;
174
+ readonly dimensions: readonly MeasurementDimension[];
175
+ readonly rates: readonly number[];
176
+ readonly tool: { readonly name: string; readonly version: string };
177
+ readonly host: MeasurementHost;
178
+ readonly latencyMethod?: LatencyMethod;
179
+ readonly costMethod?: CostMethod;
180
+ readonly elapsedSeconds?: number;
181
+ readonly label?: string;
182
+ }
183
+
184
+ /**
185
+ * Assemble a run record.
186
+ *
187
+ * `relativeRankingOnly` is derived, not passed: it is true exactly when the run carries cost
188
+ * figures, and letting a caller set it would let a caller unset it. The note follows the
189
+ * same rule — a latency-only run gets the version caveat, a cost run gets the machine one,
190
+ * and a run carrying both gets both, because both apply.
191
+ */
192
+ export function buildRun(input: RunInput): MeasurementRun {
193
+ const dimensions = MEASUREMENT_DIMENSIONS.filter((d) => input.dimensions.includes(d));
194
+ const hasCost = dimensions.includes("cost");
195
+ const hasLatency = dimensions.includes("latency");
196
+ const hasHosting = dimensions.some((d) => d === "stability" || d === "rtSafety" || d === "features");
197
+ const note = [
198
+ hasCost ? RELATIVE_RANKING_NOTE : undefined,
199
+ hasLatency ? LATENCY_ONLY_NOTE : undefined,
200
+ hasHosting ? HOSTING_NOTE : undefined,
201
+ ]
202
+ .filter((part) => part !== undefined)
203
+ .join(" ");
204
+ return {
205
+ id: input.id,
206
+ measuredAt: input.measuredAt,
207
+ dimensions,
208
+ rates: [...input.rates].sort((a, b) => a - b),
209
+ tool: input.tool,
210
+ host: input.host,
211
+ method: {
212
+ ...optional("latency", input.latencyMethod),
213
+ ...optional("cost", input.costMethod),
214
+ },
215
+ relativeRankingOnly: hasCost,
216
+ note: note.length > 0 ? note : RELATIVE_RANKING_NOTE,
217
+ ...optional("elapsedSeconds", input.elapsedSeconds),
218
+ ...optional("label", input.label),
219
+ };
220
+ }
@@ -0,0 +1,173 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
+ /**
4
+ * The hosting sweep's JSONL → the interchange format's three hosting dimensions.
5
+ *
6
+ * `lv2-measure` (a C lilv host, run under an
7
+ * allocation-and-syscall interposer) writes one JSON line per plugin, and the sweep ran it in THREE passes a
8
+ * reader has to put back together:
9
+ *
10
+ * - the QUICK pass (`all-measurements.jsonl`): every plugin, 30 lifecycles, the interposer's
11
+ * counts, the required features, the threads;
12
+ * - the DEEP lifecycle pass (`lifecycle-1000.jsonl`): the candidates again at 1000 lifecycles —
13
+ * where present it SUPERSEDES the quick pass's lifecycle and threads, and a crash in it is a
14
+ * crash the quick pass could not have seen;
15
+ * - the SOAK (`soak*.jsonl`): a show-length render per rate with every control swept slowly
16
+ * across its range (`lv2-measure.c`'s soak mode), one line per plugin.
17
+ *
18
+ * Like `probe-json.ts`, this is a TRANSLATION and never a judgement. An exit status of 139 is
19
+ * written as the signal it is (128 + SIGSEGV); whether that crash is the plugin's fault, whether a
20
+ * soak held, whether a count is a measurement at all — those are the consumer's rules, and a
21
+ * reader that decided them would be a second classifier ahead of the real one.
22
+ */
23
+
24
+ import type {
25
+ CrashReading,
26
+ FeaturesMeasurement,
27
+ MeasuredPlugin,
28
+ RtSafetyMeasurement,
29
+ SoakReading,
30
+ StabilityMeasurement,
31
+ } from "./format.js";
32
+ import { readLifecycle, readSoakAtRate, readThreads } from "./format.js";
33
+ import { booleanValue, finiteNumber, isRecord, mapArray, nonEmptyString, optional, positiveNumber } from "./guards.js";
34
+
35
+ /** The sweep's three passes, each a list of parsed JSONL lines. Only `measurements` is required. */
36
+ export interface SweepPasses {
37
+ readonly measurements: readonly unknown[];
38
+ readonly lifecycle?: readonly unknown[];
39
+ readonly soak?: readonly unknown[];
40
+ }
41
+
42
+ /** A process exit status that IS a signal death, as the signal's name. Anything else is not a
43
+ * crash this reader can name — a timeout (124) or an early refusal is the run failing, not the
44
+ * plugin dying, and it is not written as a crash. */
45
+ const SIGNAL_EXITS: Readonly<Record<number, string>> = {
46
+ 134: "SIGABRT",
47
+ 135: "SIGBUS",
48
+ 139: "SIGSEGV",
49
+ };
50
+
51
+ function crashOf(row: Record<string, unknown>): CrashReading | undefined {
52
+ if (row.crashed !== true) return undefined;
53
+ const exit = finiteNumber(row.exit);
54
+ const signal = exit === undefined ? undefined : SIGNAL_EXITS[exit];
55
+ return signal === undefined ? undefined : { signal };
56
+ }
57
+
58
+ /** One soak line's per-rate map. The tool writes `block` and a redundant `rate` inside each
59
+ * reading; the rate is the key, and `block` is the format's `blockFrames`. */
60
+ function soakOf(row: unknown): SoakReading | undefined {
61
+ if (!isRecord(row) || !isRecord(row.perRate)) return undefined;
62
+ const perRate: Record<string, NonNullable<ReturnType<typeof readSoakAtRate>>> = {};
63
+ for (const [key, reading] of Object.entries(row.perRate)) {
64
+ const rate = positiveNumber(Number(key));
65
+ if (rate === undefined || !Number.isInteger(rate) || !isRecord(reading)) continue;
66
+ const parsed = readSoakAtRate({ ...reading, blockFrames: reading.block });
67
+ if (parsed !== undefined) perRate[String(rate)] = parsed;
68
+ }
69
+ // lv2-measure's soak mode sweeps every control across its range for the whole render.
70
+ return { sweptParams: true, perRate };
71
+ }
72
+
73
+ function stabilityOf(
74
+ quick: Record<string, unknown>,
75
+ deep: Record<string, unknown> | undefined,
76
+ soakRow: unknown,
77
+ ): StabilityMeasurement {
78
+ const lifecycle = readLifecycle(deep?.lifecycle) ?? readLifecycle(quick.lifecycle);
79
+ if (lifecycle === undefined) return { unmeasuredReason: "probe-error", unmeasuredDetail: "no lifecycle in the sweep line" };
80
+ const threads = readThreads(deep?.threads) ?? readThreads(quick.threads);
81
+ const crashes = [crashOf(quick), deep === undefined ? undefined : crashOf(deep)].filter(
82
+ (c): c is CrashReading => c !== undefined,
83
+ );
84
+ const soak = soakRow === undefined ? undefined : soakOf(soakRow);
85
+ return {
86
+ lifecycle,
87
+ ...optional("threads", threads),
88
+ crashes,
89
+ ...optional("soak", soak),
90
+ };
91
+ }
92
+
93
+ function rtSafetyOf(row: Record<string, unknown>): RtSafetyMeasurement {
94
+ const rt = row.rtSafety;
95
+ if (!isRecord(rt)) return { unmeasuredReason: "probe-error", unmeasuredDetail: "no interposer block in the sweep line" };
96
+ return {
97
+ ...optional("rate", positiveNumber(row.rate)),
98
+ ...optional("blockFrames", positiveNumber(row.block)),
99
+ ...optional("blocks", finiteNumber(rt.blocks)),
100
+ ...optional("repeats", finiteNumber(rt.repeats)),
101
+ ...optional("interposer", booleanValue(rt.interposer)),
102
+ ...optional("swept", booleanValue(rt.swept)),
103
+ ...optional("sweepApplicable", booleanValue(rt.sweepApplicable)),
104
+ ...optional("allocationsInRun", finiteNumber(rt.allocationsInRun)),
105
+ ...optional("allocationsMin", finiteNumber(rt.allocationsMin)),
106
+ ...optional("locksInRun", finiteNumber(rt.locksInRun)),
107
+ ...optional("syscallsInRun", finiteNumber(rt.syscallsInRun)),
108
+ ...optional("variable", booleanValue(rt.variable)),
109
+ };
110
+ }
111
+
112
+ function featuresOf(row: Record<string, unknown>): FeaturesMeasurement {
113
+ const required = mapArray(row.requiredFeatures, nonEmptyString);
114
+ if (required === undefined) return { unmeasuredReason: "probe-error", unmeasuredDetail: "no feature list in the sweep line" };
115
+ return {
116
+ required,
117
+ ...optional("optional", mapArray(row.optionalFeatures, nonEmptyString)),
118
+ ...optional("cvPorts", finiteNumber(row.cvPorts)),
119
+ };
120
+ }
121
+
122
+ function byUri(rows: readonly unknown[] | undefined): Map<string, Record<string, unknown>> {
123
+ const out = new Map<string, Record<string, unknown>>();
124
+ for (const row of rows ?? []) {
125
+ if (!isRecord(row)) continue;
126
+ const uri = nonEmptyString(row.uri);
127
+ if (uri !== undefined) out.set(uri, row);
128
+ }
129
+ return out;
130
+ }
131
+
132
+ /**
133
+ * Every plugin the sweep INSTANTIATED, as a {@link MeasuredPlugin} carrying the three hosting
134
+ * dimensions.
135
+ *
136
+ * A line for a plugin the tool could not find or instantiate carries no topology, and the
137
+ * format's plugin entry requires one (rule 2: no invented numbers). It is left out rather than
138
+ * written with a reason: its three blocks would all be `probe-failed`, which by the merge's own
139
+ * rule override nothing — a line with no reading and no power to change a figure.
140
+ */
141
+ export function measuredPluginsFromSweep(passes: SweepPasses): MeasuredPlugin[] {
142
+ const deep = byUri(passes.lifecycle);
143
+ const soak = byUri(passes.soak);
144
+ const out: MeasuredPlugin[] = [];
145
+ for (const row of passes.measurements) {
146
+ if (!isRecord(row)) continue;
147
+ const uri = nonEmptyString(row.uri);
148
+ const audioInputs = finiteNumber(row.audioInputs);
149
+ const audioOutputs = finiteNumber(row.audioOutputs);
150
+ if (uri === undefined || audioInputs === undefined || audioOutputs === undefined) continue;
151
+ out.push({
152
+ uri,
153
+ topology: {
154
+ audioInputs,
155
+ audioOutputs,
156
+ ...optional("controlInputs", finiteNumber(row.controlInputs)),
157
+ },
158
+ stability: stabilityOf(row, deep.get(uri), soak.get(uri)),
159
+ rtSafety: rtSafetyOf(row),
160
+ features: featuresOf(row),
161
+ });
162
+ }
163
+ return out;
164
+ }
165
+
166
+ /** Parse JSONL text: every line that is a JSON object, in order. A plugin's own stdout banner
167
+ * before the JSON is the sweep harness's problem and already stripped there. */
168
+ export function parseJsonl(text: string): unknown[] {
169
+ return text
170
+ .split("\n")
171
+ .filter((line) => line.trim().startsWith("{"))
172
+ .map((line): unknown => JSON.parse(line));
173
+ }