@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,654 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
+ /**
4
+ * Which HOST a plugin has earned: the console's own RT thread, or a `mod-host` process.
5
+ *
6
+ * "Thoroughly tested" is not a hand list and never an author's opinion. It is the derived
7
+ * verdict `hosting.rating === "suitable"`, computed on every read from measurements the
8
+ * catalog holds. `path` follows the rating and nothing else: `suitable` earns `in-process`,
9
+ * every rating below it is `isolated` with the reasons that decided it.
10
+ *
11
+ * The shape mirrors {@link import('./destination-suitability.js').classifyForRole} because
12
+ * the same law governs both — a standing is DERIVED, absence is an admission rather than a
13
+ * pass, and an override may only ever demote. What differs is the stake. A destination
14
+ * verdict that is wrong puts a reverb in a wedge; a hosting verdict that is wrong puts an
15
+ * untested plugin on the thread the whole desk's audio runs on, where its crash is not its
16
+ * own. So every dimension here fails CLOSED: no measurement means `unknown`, and `unknown`
17
+ * means isolated.
18
+ */
19
+ import { RATING_SEVERITY, worstRating, } from "./rating.js";
20
+ // The in-process feature list and the CLAP host extensions are the `openmixer-console` host
21
+ // profile's; they live with the profiles (`host-profiles.ts`) and are re-exported here, where the
22
+ // native test (`lv2-inprocess-features.sh`) and the contract-limits render read them.
23
+ export { OMX_CLAP_HOST_EXTENSIONS, OMX_INPROCESS_FEATURES, OPENMIXER_CONSOLE_PROFILE } from "./host-profiles.js";
24
+ import { OMX_CLAP_HOST_EXTENSIONS, OMX_INPROCESS_FEATURES, OPENMIXER_CONSOLE_PROFILE } from "./host-profiles.js";
25
+ /** The catalog key of a CLAP plugin is `urn:clap:<clap_plugin_descriptor.id>` (CLAP spec §6). */
26
+ export const CLAP_URI_PREFIX = "urn:clap:";
27
+ /** Whether `uri` is a CLAP catalog key — the key grammar, for a plugin the catalog may not hold. */
28
+ export function isClapUri(uri) {
29
+ return uri.startsWith(CLAP_URI_PREFIX);
30
+ }
31
+ /**
32
+ * Whether the plugin `uri` names is hosted through the CLAP path: the descriptor's DECLARED
33
+ * format where the catalog holds one, else the key grammar (a `urn:clap:` uri the catalog never
34
+ * scanned is still a CLAP the console has no LV2 host for).
35
+ */
36
+ export function isClapPlugin(uri, descriptor) {
37
+ return descriptor?.format === "clap" || (descriptor?.format === undefined && isClapUri(uri));
38
+ }
39
+ /** The CLAP descriptor feature that makes a plugin an audio effect (`clap/plugin-features.h`). */
40
+ export const CLAP_FEATURE_AUDIO_EFFECT = "audio-effect";
41
+ /** The CLAP descriptor feature that makes a plugin an instrument (`clap/plugin-features.h`). */
42
+ export const CLAP_FEATURE_INSTRUMENT = "instrument";
43
+ /**
44
+ * Features that disqualify outright rather than merely being unprovided: they need a host we
45
+ * are not and will not become inside the RT thread.
46
+ */
47
+ const DISQUALIFYING_FEATURES = [
48
+ "http://lv2plug.in/ns/ext/instance-access",
49
+ "http://lv2plug.in/ns/ext/data-access",
50
+ "http://lv2plug.in/ns/extensions/ui#idleInterface",
51
+ ];
52
+ /** Providable only while the console pins its quantum; without a pinned quantum they are
53
+ * promises the stage cannot keep, and §3 conditions them on exactly that. */
54
+ const QUANTUM_PINNED_FEATURES = [
55
+ "http://lv2plug.in/ns/ext/buf-size#fixedBlockLength",
56
+ "http://lv2plug.in/ns/ext/buf-size#powerOf2BlockLength",
57
+ ];
58
+ /** The verdict's message codes, declared in their union list file. */
59
+ import { HOSTING_CODE } from "./hosting-codes.js";
60
+ export { HOSTING_CODE };
61
+ /**
62
+ * The refusals libomx-clap-core returns from `open`, `activate` and the timed open, each named by the KEY of the
63
+ * `HOSTING_CODE` it answers with — never by the string, so no second spelling of a code exists. The console's
64
+ * generator renders them into omx-clap-host's `clap_host_limits.h` as `CLAP_HOST_CODE_<NAME>`, the left column's
65
+ * name in upper snake case. The facts the qualifier judges from a measurement (latency, thread violations, process
66
+ * errors, RT safety) are the console's and are not here.
67
+ */
68
+ export const CLAP_CORE_REFUSALS = {
69
+ headlessFailed: "clapHeadlessFailed",
70
+ notAudioEffect: "clapNotAudioEffect",
71
+ noteInput: "clapNoteInput",
72
+ noAudioInput: "topologyNoAudioInput",
73
+ noAudioOutput: "topologyNoAudioOutput",
74
+ widerThanStrip: "topologyWiderThanStrip",
75
+ extraInputs: "topologyExtraInputs",
76
+ crashedLive: "stabilityCrashedLive",
77
+ outputNonFinite: "outputNonFinite",
78
+ };
79
+ /**
80
+ * What a reason may contribute. Unlike the destination classifier, every reason here is
81
+ * objective — there is no taste in whether a plugin allocates inside `run()`. The cautions
82
+ * that look advisory (`spawns-threads`, `links-gui-toolkit`) are MEASURED facts, so they are
83
+ * objective too and cap the plugin at `conditional` by their own rating rather than by a kind
84
+ * rule. An advisory reason, if one is ever added, may never do worse than `conditional`.
85
+ */
86
+ export function contributedHostingRating(reason) {
87
+ if (reason.kind === "advisory" && RATING_SEVERITY[reason.rating] > RATING_SEVERITY.conditional) {
88
+ return "conditional";
89
+ }
90
+ return reason.rating;
91
+ }
92
+ /**
93
+ * Seeded from the crash notes of 2026-09-03/04. A raw crash matching NO row is
94
+ * `unknown` — the table blames a plugin only where a named investigation did.
95
+ */
96
+ export const CRASH_SIGNATURES = [
97
+ {
98
+ id: "pipewire-jack-close-double-free",
99
+ attribution: "host",
100
+ topFrame: "pw_memmap_free",
101
+ signal: "SIGSEGV",
102
+ note: "2026-09-04-pipewire-jack-client-close-double-free.md",
103
+ },
104
+ {
105
+ id: "pipewire-jack-close-double-free-abort",
106
+ attribution: "host",
107
+ topFrame: "tcache_double_free_verify",
108
+ signal: "SIGABRT",
109
+ note: "2026-09-04-pipewire-jack-client-close-double-free.md",
110
+ },
111
+ {
112
+ id: "mod-host-effects-remove",
113
+ attribution: "host",
114
+ topFrame: "effects_remove",
115
+ note: "2026-09-03-mod-host-crash-cluster2-3-investigation.md",
116
+ },
117
+ {
118
+ id: "plugin-instantiate-layout",
119
+ attribution: "plugin",
120
+ topFrame: "lilv_instance_instantiate",
121
+ note: "2026-09-03-mod-host-crash-cluster2-3-investigation.md",
122
+ },
123
+ ];
124
+ /**
125
+ * Map a raw crash to blame. Fails to `unknown`: a signature must match the frame AND, where
126
+ * the row names one, the signal. An unmatched crash never reads as the plugin's fault.
127
+ */
128
+ export function attributeCrash(crash, table = CRASH_SIGNATURES, pluginBinary) {
129
+ // A sanitizer caught a memory error and the faulting frame is in the plugin's OWN binary:
130
+ // that is the plugin's (plugin-qualify spec §2 — the #98 zero-size atom:Path overread).
131
+ if (crash.sanitizer !== undefined &&
132
+ pluginBinary !== undefined &&
133
+ crash.frameObject === pluginBinary) {
134
+ return "plugin";
135
+ }
136
+ if (crash.topFrame === undefined)
137
+ return "unknown";
138
+ for (const row of table) {
139
+ if (row.topFrame !== crash.topFrame)
140
+ continue;
141
+ if (row.signal !== undefined && crash.signal !== undefined && row.signal !== crash.signal) {
142
+ continue;
143
+ }
144
+ return row.attribution;
145
+ }
146
+ return "unknown";
147
+ }
148
+ export { DEFAULT_HOSTING_POLICY } from "./qualify-declarations.js";
149
+ import { DEFAULT_HOSTING_POLICY } from "./qualify-declarations.js";
150
+ function reason(dimension, kind, rating, code, params = {}) {
151
+ return { dimension, kind, rating, code, params };
152
+ }
153
+ /**
154
+ * Refuse an illegal override at the door. A curator may take a plugin OFF the RT thread and
155
+ * may never put one on it — no measurement, no in-process, and no "force" exists anywhere.
156
+ * Throws rather than coercing, because silently demoting a bad value would hide the mistake.
157
+ */
158
+ export function assertHostingOverride(value, where) {
159
+ if (value === undefined || value === null)
160
+ return undefined;
161
+ if (value === "isolated")
162
+ return "isolated";
163
+ throw new Error(`${where}: hostingOverride may only be "isolated" (got ${JSON.stringify(value)}). ` +
164
+ `An override can demote a plugin to the isolated host; it can never grant in-process ` +
165
+ `hosting, which is earned from measurements alone.`);
166
+ }
167
+ /**
168
+ * The level at which a rate is DEAD against the plugin's own best: `policy.soakDeadDropDb` or
169
+ * more below it. A ratio against the plugin itself, never an absolute floor — a quiet plugin is
170
+ * not a dead one. The one application of that threshold; the soak rule below and the report's
171
+ * short-render column both ask it.
172
+ */
173
+ export function deadAgainstBest(levelDbfs, bestDbfs, policy) {
174
+ return bestDbfs > -Infinity && levelDbfs < bestDbfs - policy.soakDeadDropDb;
175
+ }
176
+ /** A soak rate that settled or opened ABOVE this is blasting: it voids the soak, and a rate
177
+ * settled above it cannot be the reference, or every sane rate reads dead against it. */
178
+ const BLAST_DBFS = 6;
179
+ /**
180
+ * Read a soak, rate by rate. A rate voids it if the plugin did not instantiate or died there,
181
+ * emitted a non-finite window, settled dead against its own best rate ({@link deadAgainstBest};
182
+ * settled = the LAST window, never the first — Calf opens at +200 dBFS at 192 k), spent more
183
+ * than half its windows silent, or opened above +6 dBFS. A few silent windows in ten thousand is
184
+ * a gate closing on a swept threshold — the plugin working, not dying.
185
+ *
186
+ * `undefined` when there is no soak, or it covered no rate: nothing was read.
187
+ */
188
+ export function soakFindings(soak, policy) {
189
+ if (soak === undefined)
190
+ return undefined;
191
+ const rates = Object.entries(soak.perRate);
192
+ if (rates.length === 0)
193
+ return undefined;
194
+ const gone = (v) => v.died === true || v.instantiated === false;
195
+ const settled = rates.map(([, v]) => gone(v) || (v.lastRmsDbfs ?? -Infinity) > BLAST_DBFS ? -Infinity : v.lastRmsDbfs ?? -Infinity);
196
+ const best = Math.max(...settled);
197
+ const failures = [];
198
+ for (const [rate, v] of rates) {
199
+ if (gone(v)) {
200
+ failures.push(`died@${rate}`);
201
+ continue;
202
+ }
203
+ if ((v.nonFiniteWindows ?? 0) > 0) {
204
+ failures.push(`non-finite@${rate}`);
205
+ continue;
206
+ }
207
+ if (deadAgainstBest(v.lastRmsDbfs ?? -Infinity, best, policy)) {
208
+ failures.push(`dead@${rate}`);
209
+ continue;
210
+ }
211
+ if ((v.silentWindows ?? 0) > (v.windows ?? 1) * 0.5) {
212
+ failures.push(`mostly-silent@${rate}`);
213
+ continue;
214
+ }
215
+ const first = v.firstRmsDbfs ?? -99;
216
+ if (first > BLAST_DBFS)
217
+ failures.push(`startup-blast@${rate}(${first.toFixed(0)}dBFS)`);
218
+ }
219
+ return {
220
+ seconds: Math.min(...rates.map(([, v]) => v.seconds ?? 0)),
221
+ failures,
222
+ ratesTested: rates.length,
223
+ rssGrowthKb: Math.max(...rates.map(([, v]) => v.rssGrowthKb ?? 0)),
224
+ };
225
+ }
226
+ /** Threads the plugin's first instance brought with it. */
227
+ export function threadsSpawned(threads) {
228
+ return threads !== undefined && threads.afterFirstInstantiate > threads.before
229
+ ? threads.afterFirstInstantiate - threads.before
230
+ : 0;
231
+ }
232
+ export function stabilityReasons(m, policy, pluginBinary,
233
+ /** Whether the plugin shares its process with others (profile isolation, §3a): threads it
234
+ * spawns and a GUI toolkit it links are cautions only there. */
235
+ sharesProcess = true) {
236
+ const out = [];
237
+ if (m === undefined || m.lifecycle === undefined) {
238
+ return [reason("stability", "objective", "unknown", HOSTING_CODE.stabilityUnmeasured)];
239
+ }
240
+ if (m.crashedLive === true) {
241
+ out.push(reason("stability", "objective", "unsuitable", HOSTING_CODE.stabilityCrashedLive, {}));
242
+ }
243
+ const attributions = (m.crashes ?? []).map((c) => attributeCrash(c, CRASH_SIGNATURES, pluginBinary));
244
+ if (attributions.includes("plugin")) {
245
+ out.push(reason("stability", "objective", "unsuitable", HOSTING_CODE.stabilityCrashedAttributed));
246
+ }
247
+ else if (attributions.includes("unknown")) {
248
+ out.push(reason("stability", "objective", "unknown", HOSTING_CODE.stabilityCrashUnattributed));
249
+ }
250
+ const cycles = m.lifecycle.instantiated;
251
+ if (cycles < policy.cycleFloor) {
252
+ out.push(reason("stability", "objective", "unknown", HOSTING_CODE.stabilityBelowFloor, {
253
+ cycles,
254
+ floor: policy.cycleFloor,
255
+ }));
256
+ }
257
+ // Any failure voids the soak: the plugin has not proven a clean show, so it reads unsoaked —
258
+ // unknown, isolated — and never a crash, because silence at a rate is a measurement of
259
+ // audio, not of the process.
260
+ const soak = soakFindings(m.soak, policy);
261
+ const soaked = soak === undefined || soak.failures.length > 0 ? 0 : soak.seconds;
262
+ if (soaked < policy.soakSeconds || m.soak?.sweptParams !== true) {
263
+ out.push(reason("stability", "objective", "unknown", HOSTING_CODE.stabilityUnsoaked, {
264
+ soakSeconds: soaked,
265
+ required: policy.soakSeconds,
266
+ ...(soak !== undefined && soak.failures.length > 0 ? { failures: soak.failures.join(" ") } : {}),
267
+ }));
268
+ }
269
+ const spawned = sharesProcess ? threadsSpawned(m.threads) : 0;
270
+ if (spawned > 0) {
271
+ out.push(reason("stability", "objective", "conditional", HOSTING_CODE.stabilitySpawnsThreads, {
272
+ threads: spawned,
273
+ }));
274
+ }
275
+ if (sharesProcess && m.linksGuiToolkit === true) {
276
+ out.push(reason("stability", "objective", "conditional", HOSTING_CODE.stabilityLinksGuiToolkit));
277
+ }
278
+ if (out.length === 0) {
279
+ out.push(reason("stability", "objective", "suitable", HOSTING_CODE.stabilitySoaked, {
280
+ cycles,
281
+ soakSeconds: soaked,
282
+ }));
283
+ }
284
+ return out;
285
+ }
286
+ /**
287
+ * Whether an interposer count is a MEASUREMENT: the interposer was loaded, all three counts are
288
+ * present, and the controls were swept — or the plugin has none a sweep could move, so its
289
+ * at-rest count is the only state there is. Measured at rest otherwise is not measured.
290
+ */
291
+ export function rtSafetyMeasured(m) {
292
+ return (m !== undefined &&
293
+ m.interposer === true &&
294
+ (m.swept === true || m.sweepApplicable === false) &&
295
+ m.allocationsInRun !== undefined &&
296
+ m.syscallsInRun !== undefined &&
297
+ m.locksInRun !== undefined);
298
+ }
299
+ /** A measured count above zero — the disqualifier, asked in one place. */
300
+ export function rtSafetyViolating(m) {
301
+ return (rtSafetyMeasured(m) &&
302
+ (m?.allocationsInRun ?? 0) + (m?.syscallsInRun ?? 0) + (m?.locksInRun ?? 0) > 0);
303
+ }
304
+ export function rtSafetyReasons(m) {
305
+ if (m === undefined || !rtSafetyMeasured(m)) {
306
+ return [reason("rtSafety", "objective", "unknown", HOSTING_CODE.rtSafetyUnmeasured)];
307
+ }
308
+ if (m.variable === true) {
309
+ return [reason("rtSafety", "objective", "unknown", HOSTING_CODE.rtSafetyNotReproducible)];
310
+ }
311
+ if (rtSafetyViolating(m)) {
312
+ return [
313
+ reason("rtSafety", "objective", "unsuitable", HOSTING_CODE.rtSafetyViolations, {
314
+ allocations: m.allocationsInRun ?? 0,
315
+ syscalls: m.syscallsInRun ?? 0,
316
+ locks: m.locksInRun ?? 0,
317
+ }),
318
+ ];
319
+ }
320
+ return [
321
+ reason("rtSafety", "objective", "suitable", HOSTING_CODE.rtSafetyClean, {
322
+ blocks: m.blocks ?? 0,
323
+ }),
324
+ ];
325
+ }
326
+ export function featureReasons(features, descriptor, provided = OMX_INPROCESS_FEATURES, quantumPinned = false,
327
+ /** The host feeds MIDI input (an instrument host, §3a): a MIDI input is no caution there. */
328
+ instruments = false) {
329
+ const out = [];
330
+ if (features === undefined) {
331
+ out.push(reason("features", "objective", "unknown", HOSTING_CODE.featuresUnscanned));
332
+ }
333
+ else {
334
+ const required = features.required ?? [];
335
+ // A host that LISTS one of these provides it: refused are only those it does not list.
336
+ const refused = (quantumPinned ? DISQUALIFYING_FEATURES : [...DISQUALIFYING_FEATURES, ...QUANTUM_PINNED_FEATURES])
337
+ .filter((f) => !provided.includes(f));
338
+ const disqualifying = required.filter((f) => refused.includes(f));
339
+ if (disqualifying.length > 0) {
340
+ out.push(reason("features", "objective", "unsuitable", HOSTING_CODE.featuresDisqualifying, {
341
+ features: disqualifying.join(" "),
342
+ }));
343
+ }
344
+ const missing = required.filter((f) => !provided.includes(f) && !refused.includes(f) && !QUANTUM_PINNED_FEATURES.includes(f));
345
+ if (missing.length > 0) {
346
+ out.push(reason("features", "objective", "unsuitable", HOSTING_CODE.featuresMissing, {
347
+ features: missing.join(" "),
348
+ }));
349
+ }
350
+ if ((features.cvPorts ?? 0) > 0) {
351
+ out.push(reason("features", "objective", "unsuitable", HOSTING_CODE.featuresCv));
352
+ }
353
+ }
354
+ if (descriptor.hasMidiIn && !instruments) {
355
+ out.push(reason("features", "objective", "conditional", HOSTING_CODE.featuresMidiIn));
356
+ }
357
+ if (out.length === 0) {
358
+ out.push(reason("features", "objective", "suitable", HOSTING_CODE.featuresProvided));
359
+ }
360
+ return out;
361
+ }
362
+ export function hostingCostReasons(descriptor, options, policy) {
363
+ const perRate = descriptor.cpuCost?.perRate?.[String(options.rate)];
364
+ if (perRate === undefined) {
365
+ return [
366
+ reason("cost", "objective", "unknown", HOSTING_CODE.costUnmeasured, {
367
+ rate: options.rate,
368
+ quantum: options.quantum ?? 0,
369
+ }),
370
+ ];
371
+ }
372
+ const out = [];
373
+ const p95 = perRate.coreFractionP95;
374
+ // The per-instance ceiling is the HOST's budget (§3a), not the policy's.
375
+ const ceiling = (options.profile ?? OPENMIXER_CONSOLE_PROFILE).cost.coreFractionCeiling;
376
+ if (p95 > ceiling) {
377
+ out.push(reason("cost", "objective", "unsuitable", HOSTING_CODE.costAboveCeiling, {
378
+ coreFractionP95: p95,
379
+ ceiling,
380
+ }));
381
+ }
382
+ const median = perRate.nsPerSampleMedian;
383
+ const p95ns = perRate.nsPerSampleP95;
384
+ if (median > 0 && p95ns / median > policy.costTailRatioCeiling) {
385
+ out.push(reason("cost", "objective", "conditional", HOSTING_CODE.costSpiky, {
386
+ tailRatio: p95ns / median,
387
+ ceiling: policy.costTailRatioCeiling,
388
+ }));
389
+ }
390
+ if (out.length === 0) {
391
+ out.push(reason("cost", "objective", "suitable", HOSTING_CODE.costWithinCeiling, {
392
+ coreFractionP95: p95,
393
+ ceiling,
394
+ }));
395
+ }
396
+ return out;
397
+ }
398
+ export function hostingLatencyReasons(descriptor, handling = "compensated") {
399
+ const latency = descriptor.latency;
400
+ // Fails closed, like every other dimension: a plugin with no latency figure at all, or
401
+ // one the benchmark declared unmeasurable, has not been judged and does not pass by
402
+ // default. The oracle's first fixture omitted latency and still expected `suitable`,
403
+ // which is how this branch was missing — a self-consistent suite proving nothing.
404
+ if (latency === undefined || latency.unmeasurable !== undefined) {
405
+ return [reason("latency", "objective", "unknown", HOSTING_CODE.latencyUnmeasured)];
406
+ }
407
+ if (latency.scalingClass === "nonconforming") {
408
+ return [reason("latency", "objective", "unknown", HOSTING_CODE.latencyNonconforming)];
409
+ }
410
+ // A parameter-dependent latency is ALLOWED in-process: the live latency port is read every
411
+ // cycle, so a moving latency is reported rather than assumed. It is a caution, not a bar.
412
+ // A host that does not compensate aligns on nothing, so a moving figure costs it nothing.
413
+ if (latency.paramSweep !== undefined && handling === "compensated") {
414
+ return [reason("latency", "objective", "conditional", HOSTING_CODE.latencyParameterDependent)];
415
+ }
416
+ return [reason("latency", "objective", "suitable", HOSTING_CODE.latencyResolved)];
417
+ }
418
+ export function hostingTopologyReasons(descriptor, instruments = false) {
419
+ const { audioInputs: ins, audioOutputs: outs } = descriptor;
420
+ // An instrument host feeds MIDI: no audio input but a MIDI input and an audio output is an instrument.
421
+ if (ins === 0 && instruments && descriptor.hasMidiIn === true && outs > 0) {
422
+ return [reason("topology", "objective", "suitable", HOSTING_CODE.topologyInstrument, { ins, outs })];
423
+ }
424
+ if (ins === 0) {
425
+ return [reason("topology", "objective", "unsuitable", HOSTING_CODE.topologyNoAudioInput)];
426
+ }
427
+ if (outs === 0) {
428
+ return [reason("topology", "objective", "unsuitable", HOSTING_CODE.topologyNoAudioOutput)];
429
+ }
430
+ if ((ins === 1 && outs === 1) || (ins === 2 && outs === 2)) {
431
+ return [
432
+ reason("topology", "objective", "suitable", HOSTING_CODE.topologyMatched, { ins, outs }),
433
+ ];
434
+ }
435
+ return [
436
+ reason("topology", "objective", "conditional", HOSTING_CODE.topologyExtraInputs, { ins, outs }),
437
+ ];
438
+ }
439
+ /**
440
+ * The CLAP arm of `topology` (CLAP spec §3): the qualifier's main-port facts feed the same
441
+ * dimension the LV2 scan feeds. No measurement → `unknown` (`hosting.clap.unqualified`), never
442
+ * a guess from the descriptor.
443
+ */
444
+ export function clapTopologyReasons(clap, instruments = false) {
445
+ if (clap === undefined) {
446
+ return [reason("topology", "objective", "unknown", HOSTING_CODE.clapUnqualified)];
447
+ }
448
+ const ins = clap.mainInputChannels;
449
+ const outs = clap.mainOutputChannels;
450
+ if (ins === 0 && instruments && clap.noteInputs > 0 && outs > 0 && outs <= 2) {
451
+ return [reason("topology", "objective", "suitable", HOSTING_CODE.topologyInstrument, { ins, outs })];
452
+ }
453
+ if (ins === 0)
454
+ return [reason("topology", "objective", "unsuitable", HOSTING_CODE.topologyNoAudioInput)];
455
+ if (outs === 0)
456
+ return [reason("topology", "objective", "unsuitable", HOSTING_CODE.topologyNoAudioOutput)];
457
+ if (ins > 2 || outs > 2) {
458
+ return [reason("topology", "objective", "unsuitable", HOSTING_CODE.topologyWiderThanStrip, { ins, outs })];
459
+ }
460
+ if (clap.sidechainInputs > 0) {
461
+ return [reason("topology", "objective", "conditional", HOSTING_CODE.topologyExtraInputs, { ins, outs, sidechains: clap.sidechainInputs })];
462
+ }
463
+ return [reason("topology", "objective", "suitable", HOSTING_CODE.topologyMatched, { ins, outs })];
464
+ }
465
+ /**
466
+ * The CLAP arm of `latency` (CLAP spec §3): declared = measured at EVERY declared rate earns;
467
+ * one rate that lies refuses (`unsuitable` — compensation reads the figure as truth); no rate
468
+ * measured is `unknown`.
469
+ */
470
+ export function clapLatencyReasons(clap) {
471
+ const rates = clap ? Object.keys(clap.latency) : [];
472
+ if (clap === undefined || rates.length === 0) {
473
+ return [reason("latency", "objective", "unknown", HOSTING_CODE.latencyUnmeasured)];
474
+ }
475
+ for (const rate of rates) {
476
+ const at = clap.latency[rate];
477
+ if (at.declaredFrames !== at.measuredFrames) {
478
+ return [
479
+ reason("latency", "objective", "unsuitable", HOSTING_CODE.clapLatencyMismatch, {
480
+ rate: Number(rate), declared: at.declaredFrames, measured: at.measuredFrames,
481
+ }),
482
+ ];
483
+ }
484
+ }
485
+ return [reason("latency", "objective", "suitable", HOSTING_CODE.latencyResolved)];
486
+ }
487
+ /**
488
+ * The CLAP arm of `features` (CLAP spec §3): an audio effect that initialised and activated
489
+ * HEADLESS with only OMX_CLAP_HOST_EXTENSIONS offered, and no note input (the measurement's or the
490
+ * scan's `hasMidiIn`). Every miss is its own code; a note input is an instrument, `unsuitable`.
491
+ */
492
+ export function clapFeatureReasons(clap, profile = OPENMIXER_CONSOLE_PROFILE, scannedNoteInput = false) {
493
+ // A host with no CLAP path cannot run one, whatever it measured (§3a).
494
+ if (profile.clapExtensions === null) {
495
+ return [reason("features", "objective", "unsuitable", HOSTING_CODE.clapFormatNotHosted)];
496
+ }
497
+ if (clap === undefined) {
498
+ return [reason("features", "objective", "unknown", HOSTING_CODE.clapUnqualified)];
499
+ }
500
+ const out = [];
501
+ const offered = profile.clapExtensions;
502
+ const kindHosted = clap.features.includes(CLAP_FEATURE_AUDIO_EFFECT) || (profile.instruments && clap.features.includes(CLAP_FEATURE_INSTRUMENT));
503
+ if (!kindHosted) {
504
+ out.push(reason("features", "objective", "unsuitable", HOSTING_CODE.clapNotAudioEffect, { features: clap.features.join(" ") }));
505
+ }
506
+ if (!clap.headlessOk) {
507
+ out.push(reason("features", "objective", "unsuitable", HOSTING_CODE.clapHeadlessFailed));
508
+ }
509
+ else {
510
+ // The headless pass was taken with OMX_CLAP_HOST_EXTENSIONS offered: a host offering less
511
+ // has not been shown to initialise the plugin.
512
+ const uncovered = OMX_CLAP_HOST_EXTENSIONS.filter((e) => !offered.includes(e));
513
+ if (uncovered.length > 0) {
514
+ out.push(reason("features", "objective", "unknown", HOSTING_CODE.clapExtensionsNotCovered, { extensions: uncovered.join(" ") }));
515
+ }
516
+ }
517
+ if ((clap.noteInputs > 0 || scannedNoteInput) && !profile.instruments) {
518
+ out.push(reason("features", "objective", "unsuitable", HOSTING_CODE.clapNoteInput, { noteInputs: clap.noteInputs }));
519
+ }
520
+ if (out.length === 0)
521
+ out.push(reason("features", "objective", "suitable", HOSTING_CODE.featuresProvided));
522
+ return out;
523
+ }
524
+ /**
525
+ * The CLAP additions to `rtSafety` (CLAP spec §3): the interposer's counts are read by
526
+ * `rtSafetyReasons` exactly as for an LV2; these are the two violations only a CLAP can commit
527
+ * — a strict thread-check violation and a `CLAP_PROCESS_ERROR` — each `unsuitable`. A sweep
528
+ * that made no `process()` call measured nothing.
529
+ */
530
+ export function clapRtReasons(clap) {
531
+ if (clap === undefined || clap.processCalls === 0) {
532
+ return [reason("rtSafety", "objective", "unknown", HOSTING_CODE.clapUnqualified)];
533
+ }
534
+ const out = [];
535
+ if (clap.threadViolations > 0) {
536
+ out.push(reason("rtSafety", "objective", "unsuitable", HOSTING_CODE.clapThreadViolation, { violations: clap.threadViolations }));
537
+ }
538
+ if (clap.processErrors > 0) {
539
+ out.push(reason("rtSafety", "objective", "unsuitable", HOSTING_CODE.clapProcessError, { errors: clap.processErrors }));
540
+ }
541
+ return out;
542
+ }
543
+ /**
544
+ * The verdict. Worst-wins across every dimension, exactly as `classifyForRole` folds — with
545
+ * three gates that can poison the whole result before the fold is even read: a curator
546
+ * override, a sweep whose positive control did not fire, and figures taken on a different
547
+ * host than the console is running.
548
+ */
549
+ export function classifyPluginHosting(descriptor, options) {
550
+ const policy = options.policy ?? DEFAULT_HOSTING_POLICY;
551
+ const profile = options.profile ?? OPENMIXER_CONSOLE_PROFILE;
552
+ const sharesProcess = profile.isolation !== "per-process";
553
+ // The descriptor's own measured blocks and nothing else: they reached it through the
554
+ // interchange's one door (`measurement-catalog.ts`), whose per-dimension precedence is the
555
+ // only precedence there is. A second "passed measurements win" seam here was how an offline
556
+ // report could agree with itself while every served descriptor read unknown.
557
+ // The CLAP arm (2026-09-26-clap-hosting-path.md §3): the SAME six dimensions, fed from the
558
+ // qualifier's CLAP facts where the LV2 scan's facts do not exist for the format. Stability,
559
+ // the interposer's counts and the cost are format-blind and read unchanged.
560
+ const reasons = descriptor.format === "clap"
561
+ ? [
562
+ ...stabilityReasons(descriptor.stability, policy, options.pluginBinary, sharesProcess),
563
+ ...rtSafetyReasons(descriptor.rtSafety),
564
+ ...clapRtReasons(descriptor.clap),
565
+ ...clapFeatureReasons(descriptor.clap, profile, descriptor.hasMidiIn),
566
+ ...hostingCostReasons(descriptor, options, policy),
567
+ ...clapLatencyReasons(descriptor.clap),
568
+ ...clapTopologyReasons(descriptor.clap, profile.instruments),
569
+ ]
570
+ : [
571
+ ...stabilityReasons(descriptor.stability, policy, options.pluginBinary, sharesProcess),
572
+ ...rtSafetyReasons(descriptor.rtSafety),
573
+ ...featureReasons(descriptor.lv2Features, descriptor, profile.lv2Features, options.quantum !== undefined, profile.instruments),
574
+ ...hostingCostReasons(descriptor, options, policy),
575
+ ...hostingLatencyReasons(descriptor, profile.latency),
576
+ ...hostingTopologyReasons(descriptor, profile.instruments),
577
+ ];
578
+ // A sweep whose positive control did not fire proves nothing about anything it measured.
579
+ // The whole roster reads `unknown` — the false-signals law, made mechanical.
580
+ if (options.sweepControlFired === false) {
581
+ reasons.unshift(reason("stability", "objective", "unknown", HOSTING_CODE.sweepControlDidNotFire));
582
+ }
583
+ // A dnf upgrade must not carry a verdict forward. Different host, no `suitable`.
584
+ const figureHost = descriptor.stability?.runHost;
585
+ if (options.consoleHost !== undefined &&
586
+ figureHost !== undefined &&
587
+ figureHost !== options.consoleHost) {
588
+ reasons.unshift(reason("stability", "objective", "unknown", HOSTING_CODE.hostProvenanceDiffers, {
589
+ measuredOn: figureHost,
590
+ runningOn: options.consoleHost,
591
+ }));
592
+ }
593
+ if (options.override === "isolated") {
594
+ reasons.unshift(reason("stability", "objective", "unsuitable", HOSTING_CODE.overrideIsolated));
595
+ }
596
+ const rating = worstRating(reasons.map(contributedHostingRating));
597
+ const deciding = [...reasons]
598
+ .sort((a, b) => RATING_SEVERITY[contributedHostingRating(b)] - RATING_SEVERITY[contributedHostingRating(a)])
599
+ .find((r) => contributedHostingRating(r) === rating);
600
+ return {
601
+ rating,
602
+ // In-process is a place only an in-process host has; every other host runs it in its own.
603
+ path: rating === "suitable" && profile.isolation === "in-process" ? "in-process" : "isolated",
604
+ reasons,
605
+ ...(rating === "suitable" ? {} : { deciding }),
606
+ };
607
+ }
608
+ /**
609
+ * The REALISED host of one racked plugin — a different fact from the verdict, and kept in a
610
+ * different field for that reason.
611
+ *
612
+ * `classifyPluginHosting` answers what a plugin EARNED. This answers where it RUNS, which
613
+ * three things decide in order, each with its own reason so the operator reads the first one
614
+ * that applies rather than a fold:
615
+ *
616
+ * 1. the console's switch is off — nothing runs in-process, whatever it earned;
617
+ * 1a. the plugin is LINKED into the engine — it runs in-process wherever a body exists, with no
618
+ * verdict to read;
619
+ * 2. nothing could be judged (no verdict: the console has observed no rate and quantum) or the
620
+ * verdict is below `suitable` — the plugin's own deciding reason stands;
621
+ * 3. it earned in-process and there is no in-process body to run it in yet.
622
+ *
623
+ * Order matters and is the operator's, not the machine's: a console-wide fact is a better
624
+ * answer to "why is this plugin not on the fast path" than a per-plugin one, because it is the
625
+ * one the operator can act on.
626
+ */
627
+ export function realisedHosting(verdict, realisation) {
628
+ const rating = verdict?.rating ?? "unknown";
629
+ if (!realisation.inProcessEnabled) {
630
+ return { path: "isolated", rating, reason: HOSTING_CODE.disabled };
631
+ }
632
+ if (realisation.linked === true) {
633
+ return realisation.inProcessAvailable
634
+ ? { path: "in-process", rating, reason: HOSTING_CODE.linked }
635
+ : { path: "isolated", rating, reason: HOSTING_CODE.noRealisation };
636
+ }
637
+ if (verdict === undefined) {
638
+ return { path: "isolated", rating, reason: HOSTING_CODE.unjudged };
639
+ }
640
+ if (verdict.path === "isolated") {
641
+ return {
642
+ path: "isolated",
643
+ rating,
644
+ // `deciding` is present for every rating below `suitable`; the fall-back names the one
645
+ // state that could produce an isolated verdict with no reason at all — nothing judged.
646
+ reason: verdict.deciding?.code ?? HOSTING_CODE.unjudged,
647
+ };
648
+ }
649
+ if (!realisation.inProcessAvailable) {
650
+ return { path: "isolated", rating, reason: HOSTING_CODE.noRealisation };
651
+ }
652
+ return { path: "in-process", rating, reason: HOSTING_CODE.realisedInProcess };
653
+ }
654
+ //# sourceMappingURL=hosting-suitability.js.map