@openmixer/plugin-qualify 0.0.0-stage → 0.4.1

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