@openmixer/plugin-qualify 0.10.1 → 0.12.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 (82) hide show
  1. package/README.md +52 -10
  2. package/bin/plugin-qualify.mjs +61 -28
  3. package/dist/clap-loader.d.ts +1 -1
  4. package/dist/clap-loader.js +3 -3
  5. package/dist/clap-loader.js.map +1 -1
  6. package/dist/clap-qualify.d.ts +60 -51
  7. package/dist/clap-qualify.js +143 -91
  8. package/dist/clap-qualify.js.map +1 -1
  9. package/dist/cost-fields.d.ts +26 -0
  10. package/dist/cost-fields.js +30 -0
  11. package/dist/cost-fields.js.map +1 -1
  12. package/dist/hosting-codes.d.ts +1 -0
  13. package/dist/hosting-codes.js +1 -0
  14. package/dist/hosting-codes.js.map +1 -1
  15. package/dist/hosting-suitability.d.ts +22 -3
  16. package/dist/hosting-suitability.js +25 -5
  17. package/dist/hosting-suitability.js.map +1 -1
  18. package/dist/index.d.ts +5 -0
  19. package/dist/index.js +5 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/judge-cli.d.ts +41 -0
  22. package/dist/judge-cli.js +114 -0
  23. package/dist/judge-cli.js.map +1 -0
  24. package/dist/measurement/cli.d.ts +7 -0
  25. package/dist/measurement/cli.js +14 -0
  26. package/dist/measurement/cli.js.map +1 -1
  27. package/dist/measurement/format.d.ts +44 -4
  28. package/dist/measurement/format.js +46 -3
  29. package/dist/measurement/format.js.map +1 -1
  30. package/dist/measurement/probe-json.js +4 -0
  31. package/dist/measurement/probe-json.js.map +1 -1
  32. package/dist/measurement/sweep-jsonl.js +2 -1
  33. package/dist/measurement/sweep-jsonl.js.map +1 -1
  34. package/dist/measurement/vocabulary.d.ts +9 -0
  35. package/dist/measurement/vocabulary.js +33 -0
  36. package/dist/measurement/vocabulary.js.map +1 -1
  37. package/dist/probe-judge.d.ts +65 -0
  38. package/dist/probe-judge.js +217 -0
  39. package/dist/probe-judge.js.map +1 -0
  40. package/dist/probe-output-fixtures.d.ts +25 -0
  41. package/dist/probe-output-fixtures.js +134 -0
  42. package/dist/probe-output-fixtures.js.map +1 -0
  43. package/dist/probe-output.d.ts +153 -0
  44. package/dist/probe-output.js +374 -0
  45. package/dist/probe-output.js.map +1 -0
  46. package/dist/probe-runner.d.ts +79 -0
  47. package/dist/probe-runner.js +191 -0
  48. package/dist/probe-runner.js.map +1 -0
  49. package/dist/qualification-record.d.ts +82 -0
  50. package/dist/qualification-record.js +85 -0
  51. package/dist/qualification-record.js.map +1 -0
  52. package/dist/qualify-cli.js +1 -1
  53. package/dist/qualify-cli.js.map +1 -1
  54. package/dist/qualify-declarations.d.ts +11 -2
  55. package/dist/qualify-declarations.js +14 -4
  56. package/dist/qualify-declarations.js.map +1 -1
  57. package/fixtures/clap-scan/fake.json +30 -6
  58. package/fixtures/probe-output/memory-limit-refused.json +190 -0
  59. package/fixtures/probe-output/plugin-crash.json +236 -0
  60. package/fixtures/probe-output/success-msi.json +363 -0
  61. package/package.json +5 -1
  62. package/src/clap-loader.ts +3 -2
  63. package/src/clap-qualify.ts +179 -138
  64. package/src/cost-fields.ts +39 -0
  65. package/src/hosting-codes.ts +1 -0
  66. package/src/hosting-suitability.ts +42 -7
  67. package/src/index.ts +5 -0
  68. package/src/judge-cli.ts +133 -0
  69. package/src/measurement/cli.ts +20 -0
  70. package/src/measurement/format.ts +80 -2
  71. package/src/measurement/probe-json.ts +4 -0
  72. package/src/measurement/sweep-jsonl.ts +2 -1
  73. package/src/measurement/vocabulary.ts +38 -0
  74. package/src/probe-judge.ts +272 -0
  75. package/src/probe-output-fixtures.ts +146 -0
  76. package/src/probe-output.ts +511 -0
  77. package/src/probe-runner.ts +244 -0
  78. package/src/qualification-record.ts +149 -0
  79. package/src/qualify-cli.ts +1 -1
  80. package/src/qualify-declarations.ts +16 -4
  81. package/tools/benchmark.py +116 -13
  82. package/tools/lv2-measure.mjs +2 -0
@@ -10,26 +10,22 @@
10
10
  * read, a prober that is absent, crashed or timed out, is a {@link ClapQualifyError}: nothing was
11
11
  * measured, and nothing is rated.
12
12
  */
13
- import { spawnSync } from "node:child_process";
14
13
  import { existsSync } from "node:fs";
15
14
 
16
- import {
17
- CLAP_QUALIFY_TIMEOUT_S,
18
- CLAP_QUALIFY_TOOL_NAME,
19
- CLAP_SWEEP_BLOCKS,
20
- CLAP_SWEEP_FRAMES,
21
- RT_INTERPOSE_NAME,
22
- } from "./qualify-declarations.js";
15
+ import { CLAP_QUALIFY_TOOL_NAME, RT_INTERPOSE_NAME } from "./qualify-declarations.js";
23
16
  import {
24
17
  HOSTING_CODE,
25
18
  clapRtReasons,
26
19
  contributedHostingRating,
27
20
  noteSweepReasons,
28
21
  rtSafetyReasons,
22
+ type ClapLatencyAtRate,
29
23
  type HostingReason,
30
24
  type PluginClapMeasurement,
31
25
  type PluginNoteSweepMeasurement,
32
26
  type PluginRtSafetyMeasurement,
27
+ type PluginStabilityMeasurement,
28
+ type QualifiedPlugin,
33
29
  } from "./hosting-suitability.js";
34
30
  import {
35
31
  OMX_CLAP_HOST_CONFIGURED_EXTENSIONS,
@@ -37,11 +33,12 @@ import {
37
33
  OPENMIXER_CONSOLE_PROFILE,
38
34
  type HostProfile,
39
35
  } from "./host-profiles.js";
40
- import type { PluginTarget } from "./loader.js";
41
36
  import type { QualifyVerdict } from "./qualify-cli.js";
42
37
  import { RATING_SEVERITY, worstRating, type SuitabilityRating } from "./rating.js";
43
38
  import { halfPopulatedRefusal, locateNativeTools } from "./tool-paths.js";
44
- import { readIntegrity, type IntegrityMeasurement } from "./measurement/format.js";
39
+ import { readIntegrity, type IntegrityMeasurement, type VoicesHeld } from "./measurement/format.js";
40
+ import { isVoiceBasis } from "./measurement/vocabulary.js";
41
+ import { coreFraction } from "./cost-fields.js";
45
42
 
46
43
  export class ClapQualifyError extends Error {
47
44
  constructor(message: string) {
@@ -69,6 +66,45 @@ export interface ClapProbedFacts extends PluginClapMeasurement {
69
66
  readonly nonFinite: boolean;
70
67
  }
71
68
 
69
+ /** One rate's cost: the block times in nanoseconds per sample, warm-up dropped, and the notes held for an instrument. */
70
+ export interface ClapCostFigure {
71
+ readonly nsPerSampleMedian: number;
72
+ readonly nsPerSampleP95: number;
73
+ readonly nsPerSampleMin: number;
74
+ readonly nsPerSampleMax: number;
75
+ /** The median of the dropped warm-up blocks: the evidence that dropping them was warranted. */
76
+ readonly warmupNsPerSampleMedian: number;
77
+ readonly blocks: number;
78
+ /** For an instrument: the notes held while it was timed. Absent for an effect. */
79
+ readonly voices?: VoicesHeld;
80
+ }
81
+
82
+ /** One rate the cost arm did not time, and why; an instrument's declared capacity is kept when it has one. */
83
+ export interface ClapCostUnmeasured {
84
+ readonly unmeasuredReason: string;
85
+ readonly unmeasuredDetail: string;
86
+ readonly voices?: { readonly capacity: number };
87
+ }
88
+
89
+ export type ClapCostAtRate = ClapCostFigure | ClapCostUnmeasured;
90
+
91
+ export function isClapCostFigure(at: ClapCostAtRate): at is ClapCostFigure {
92
+ return !("unmeasuredReason" in at);
93
+ }
94
+
95
+ /**
96
+ * The cost arm: every rate timed with every parameter swept, an effect fed noise and an instrument
97
+ * holding notes (`notes-held`), each rate's figures or the reason it has none.
98
+ */
99
+ export interface ClapCostReading {
100
+ readonly stimulus: "notes-held" | "noise";
101
+ readonly blockFrames: number;
102
+ readonly warmupBlocks: number;
103
+ /** The percentile `nsPerSampleP95` is taken at. */
104
+ readonly percentile: number;
105
+ readonly perRate: Readonly<Record<string, ClapCostAtRate>>;
106
+ }
107
+
72
108
  /** One plugin's line from `clap-qualify`. */
73
109
  export interface ClapQualifyLine {
74
110
  readonly uri: string;
@@ -80,6 +116,8 @@ export interface ClapQualifyLine {
80
116
  readonly rtSafety: ClapRtSafetyReading;
81
117
  /** The note sweep: the plugin played through its note input, counted apart. */
82
118
  readonly noteSweep: ClapNoteSweepReading;
119
+ /** The cost arm; absent when the plugin did not initialise and activate headless. */
120
+ readonly cost?: ClapCostReading;
83
121
  /**
84
122
  * The pin (spec §11): the `.clap`'s digest by its name in its directory and the layout fingerprint
85
123
  * the prober computed through plugin-hostd's `pin.h`, or the reason it took none.
@@ -122,10 +160,17 @@ function readClap(v: unknown): ClapProbedFacts {
122
160
  if (!isRecord(v)) return bad("no clap facts");
123
161
  const latencyIn = v["latency"];
124
162
  if (!isRecord(latencyIn)) return bad("clap has no latency map");
125
- const latency: Record<string, { declaredFrames: number; measuredFrames: number }> = {};
163
+ const latency: Record<string, ClapLatencyAtRate> = {};
126
164
  for (const [rate, at] of Object.entries(latencyIn)) {
127
165
  if (!/^[1-9]\d*$/.test(rate) || !isRecord(at)) return bad(`clap latency at ${rate}`);
128
- latency[rate] = { declaredFrames: count(at, "declaredFrames", `latency ${rate}`), measuredFrames: count(at, "measuredFrames", `latency ${rate}`) };
166
+ if (at["from"] !== undefined && at["from"] !== "note") return bad(`clap latency at ${rate} from ${JSON.stringify(at["from"])}`);
167
+ if (at["heard"] !== undefined && (at["from"] !== "note" || at["heard"] !== false)) return bad(`clap latency at ${rate} heard`);
168
+ latency[rate] = {
169
+ declaredFrames: count(at, "declaredFrames", `latency ${rate}`),
170
+ measuredFrames: count(at, "measuredFrames", `latency ${rate}`),
171
+ ...(at["from"] === "note" ? { from: "note" as const } : {}),
172
+ ...(at["heard"] === false ? { heard: false } : {}),
173
+ };
129
174
  }
130
175
  return {
131
176
  mainInputChannels: count(v, "mainInputChannels", "clap"),
@@ -183,6 +228,120 @@ function readNoteSweep(v: unknown): ClapNoteSweepReading {
183
228
  };
184
229
  }
185
230
 
231
+ function figure(o: Record<string, unknown>, key: string, where: string): number {
232
+ const v = o[key];
233
+ return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : bad(`${where} has no figure ${key}`);
234
+ }
235
+
236
+ function readVoices(v: unknown, where: string): { held?: number; basis?: VoicesHeld["basis"]; capacity?: number } {
237
+ if (!isRecord(v)) return bad(`${where} voices is not an object`);
238
+ const out: { held?: number; basis?: VoicesHeld["basis"]; capacity?: number } = {};
239
+ if ("held" in v || "basis" in v) {
240
+ const basis = v["basis"];
241
+ if (!isVoiceBasis(basis)) return bad(`${where} voices basis ${JSON.stringify(basis)}`);
242
+ const held = count(v, "held", `${where} voices`);
243
+ if (held === 0) return bad(`${where} holds no note`);
244
+ out.held = held;
245
+ out.basis = basis;
246
+ }
247
+ if ("capacity" in v) out.capacity = count(v, "capacity", `${where} voices`);
248
+ return out;
249
+ }
250
+
251
+ function readCostAtRate(v: unknown, where: string, notes: boolean): ClapCostAtRate {
252
+ if (!isRecord(v)) return bad(`${where} is not an object`);
253
+ const voices = v["voices"] === undefined ? undefined : readVoices(v["voices"], where);
254
+ if (voices !== undefined && !notes) return bad(`${where} holds notes for a plugin fed noise`);
255
+ if (v["unmeasuredReason"] !== undefined) {
256
+ if (voices?.held !== undefined) return bad(`${where} held notes and timed nothing`);
257
+ return {
258
+ unmeasuredReason: str(v, "unmeasuredReason", where),
259
+ unmeasuredDetail: str(v, "unmeasuredDetail", where),
260
+ ...(voices?.capacity === undefined ? {} : { voices: { capacity: voices.capacity } }),
261
+ };
262
+ }
263
+ if (notes && (voices === undefined || voices.held === undefined || voices.basis === undefined)) {
264
+ return bad(`${where} timed an instrument without saying what it held`);
265
+ }
266
+ const blocks = count(v, "blocks", where);
267
+ if (blocks === 0) return bad(`${where} timed no block`);
268
+ return {
269
+ nsPerSampleMedian: figure(v, "nsPerSampleMedian", where),
270
+ nsPerSampleP95: figure(v, "nsPerSampleP95", where),
271
+ nsPerSampleMin: figure(v, "nsPerSampleMin", where),
272
+ nsPerSampleMax: figure(v, "nsPerSampleMax", where),
273
+ warmupNsPerSampleMedian: figure(v, "warmupNsPerSampleMedian", where),
274
+ blocks,
275
+ ...(voices?.held === undefined || voices.basis === undefined
276
+ ? {}
277
+ : { voices: { held: voices.held, basis: voices.basis, ...(voices.capacity === undefined ? {} : { capacity: voices.capacity }) } }),
278
+ };
279
+ }
280
+
281
+ function readCost(v: unknown): ClapCostReading {
282
+ if (!isRecord(v)) return bad("cost is not an object");
283
+ const stimulus = str(v, "stimulus", "cost");
284
+ if (stimulus !== "notes-held" && stimulus !== "noise") return bad(`cost stimulus ${stimulus}`);
285
+ const perRateIn = v["perRate"];
286
+ if (!isRecord(perRateIn)) return bad("cost has no perRate map");
287
+ const perRate: Record<string, ClapCostAtRate> = {};
288
+ for (const [rate, at] of Object.entries(perRateIn)) {
289
+ if (!/^[1-9]\d*$/.test(rate)) return bad(`cost rate ${rate}`);
290
+ perRate[rate] = readCostAtRate(at, `cost at ${rate}`, stimulus === "notes-held");
291
+ }
292
+ return {
293
+ stimulus,
294
+ blockFrames: count(v, "blockFrames", "cost"),
295
+ warmupBlocks: count(v, "warmupBlocks", "cost"),
296
+ percentile: figure(v, "percentile", "cost"),
297
+ perRate,
298
+ };
299
+ }
300
+
301
+ /**
302
+ * The cost arm's figures as the classifier's cost gate reads them, per rate: the median and the P95
303
+ * per sample, and the P95 as a fraction of one core at that rate. A rate the arm did not time is absent.
304
+ */
305
+ export function clapCostPerRate(
306
+ cost: ClapCostReading | undefined,
307
+ ): Record<string, { coreFractionP95: number; nsPerSampleMedian: number; nsPerSampleP95: number; voices?: VoicesHeld }> {
308
+ const out: Record<string, { coreFractionP95: number; nsPerSampleMedian: number; nsPerSampleP95: number; voices?: VoicesHeld }> = {};
309
+ for (const [rate, at] of Object.entries(cost?.perRate ?? {})) {
310
+ if (!isClapCostFigure(at)) continue;
311
+ const fraction = coreFraction(at.nsPerSampleP95, Number(rate));
312
+ if (fraction === undefined) continue;
313
+ out[rate] = {
314
+ coreFractionP95: fraction,
315
+ nsPerSampleMedian: at.nsPerSampleMedian,
316
+ nsPerSampleP95: at.nsPerSampleP95,
317
+ ...(at.voices === undefined ? {} : { voices: at.voices }),
318
+ };
319
+ }
320
+ return out;
321
+ }
322
+
323
+ /**
324
+ * A prober line as the plugin the full classifier (`classifyPluginHosting`) reads: the CLAP facts, the
325
+ * interposer's counts with the note sweep beside them, and the cost arm's figures per rate. The prober
326
+ * measures no stability (that is the soak's), so the caller passes what it has; without it the verdict
327
+ * is `unknown` on stability, never a pass.
328
+ */
329
+ export function qualifiedPluginOfClapLine(line: ClapQualifyLine, stability?: PluginStabilityMeasurement): QualifiedPlugin {
330
+ const { events: _events, ...rtSafety } = line.rtSafety;
331
+ const { events: _noteEvents, ...noteSweep } = line.noteSweep;
332
+ const { nonFinite: _nonFinite, ...clap } = line.clap;
333
+ return {
334
+ format: "clap",
335
+ audioInputs: line.clap.mainInputChannels,
336
+ audioOutputs: line.clap.mainOutputChannels,
337
+ hasMidiIn: line.clap.noteInputs > 0,
338
+ clap,
339
+ rtSafety: { ...rtSafety, noteSweep },
340
+ cpuCost: { perRate: clapCostPerRate(line.cost) },
341
+ ...(stability === undefined ? {} : { stability }),
342
+ };
343
+ }
344
+
186
345
  /** One line of the prober's stdout, or a {@link ClapQualifyError} saying how it differs. */
187
346
  export function parseClapQualifyLine(text: string): ClapQualifyLine {
188
347
  let v: unknown;
@@ -208,6 +367,7 @@ export function parseClapQualifyLine(text: string): ClapQualifyLine {
208
367
  clap: readClap(v["clap"]),
209
368
  rtSafety: readRtSafety(v["rtSafety"]),
210
369
  noteSweep: readNoteSweep(v["noteSweep"]),
370
+ ...(v["cost"] === undefined ? {} : { cost: readCost(v["cost"]) }),
211
371
  integrity: readIntegrity(v["integrity"]) ?? bad("no integrity"),
212
372
  };
213
373
  }
@@ -223,9 +383,11 @@ export function parseClapQualifyOutput(stdout: string): ClapQualifyLine[] {
223
383
  * The document's schema name; a reader refuses any other it does not know. `/2` is `/1` with
224
384
  * `measured.noteSweep` added and the note sweep's reasons folded into `verdict` (a plugin with no
225
385
  * note input gets none, so its `/2` verdict is its `/1` verdict); every `/1` field is unchanged, so
226
- * a `/1` reader of `verdict.rating` and `verdict.rtSafe` reads a `/2` document right.
386
+ * a `/1` reader of `verdict.rating` and `verdict.rtSafe` reads a `/2` document right. `/3` is `/2`
387
+ * with `measured.cost` added (the cost arm, an instrument's with the notes it held), which no
388
+ * verdict reads.
227
389
  */
228
- export const RT_SAFETY_DOCUMENT_SCHEMA = "plugin-qualify.rt-safety/2";
390
+ export const RT_SAFETY_DOCUMENT_SCHEMA = "plugin-qualify.rt-safety/3";
229
391
 
230
392
  /**
231
393
  * The RT-safety verdict of one plugin binary for one console: keyed by the binary's SHA-256 and
@@ -254,6 +416,7 @@ export interface RtSafetyDocument {
254
416
  readonly clap: ClapProbedFacts;
255
417
  readonly rtSafety: ClapRtSafetyReading;
256
418
  readonly noteSweep: ClapNoteSweepReading;
419
+ readonly cost?: ClapCostReading;
257
420
  readonly integrity: IntegrityMeasurement;
258
421
  };
259
422
  }
@@ -296,6 +459,7 @@ export function rtSafetyDocument(line: ClapQualifyLine, profile: HostProfile = O
296
459
  clap: line.clap,
297
460
  rtSafety: line.rtSafety,
298
461
  noteSweep: line.noteSweep,
462
+ ...(line.cost === undefined ? {} : { cost: line.cost }),
299
463
  integrity: line.integrity,
300
464
  },
301
465
  };
@@ -318,47 +482,7 @@ export function rtSafetyDocumentName(doc: RtSafetyDocument): string {
318
482
  return `${doc.plugin.binary.sha256}.${doc.console}.rt-safety.json`;
319
483
  }
320
484
 
321
- // --- running the prober --------------------------------------------------------------------------
322
-
323
- /** What a run of the prober gave back; `error` is a spawn failure, `signal` a death. */
324
- export interface ClapQualifyRunResult {
325
- readonly status: number | null;
326
- readonly signal?: string | null;
327
- readonly stdout: string;
328
- readonly stderr?: string;
329
- readonly error?: Error;
330
- }
331
-
332
- export type ClapQualifyRun = (
333
- tool: string,
334
- args: readonly string[],
335
- env: Readonly<Record<string, string | undefined>>,
336
- timeoutMs: number,
337
- ) => ClapQualifyRunResult;
338
-
339
- const spawnQualify: ClapQualifyRun = (tool, args, env, timeoutMs) => {
340
- const r = spawnSync(tool, [...args], { encoding: "utf8", env: { ...env }, timeout: timeoutMs, maxBuffer: 64 << 20 });
341
- return {
342
- status: r.status,
343
- signal: r.signal,
344
- stdout: r.stdout ?? "",
345
- stderr: r.stderr ?? "",
346
- ...(r.error === undefined ? {} : { error: r.error }),
347
- };
348
- };
349
-
350
- export interface ClapQualifyOptions {
351
- /** The rates the plugin is activated and swept at. */
352
- readonly rates: readonly number[];
353
- /** Runs the prober; the default spawns it. */
354
- readonly run?: ClapQualifyRun;
355
- /** Where the two settings are read from; the default is `process.env`. */
356
- readonly env?: Readonly<Record<string, string | undefined>>;
357
- /** Whether a file exists; the default asks the filesystem. */
358
- readonly exists?: (path: string) => boolean;
359
- /** The host extensions the prober offers beyond the console's, each one of {@link OMX_CLAP_HOST_CONFIGURED_EXTENSIONS}. */
360
- readonly offer?: readonly string[];
361
- }
485
+ // --- the prober and the interposer a run uses ---------------------------------------------------
362
486
 
363
487
  /**
364
488
  * The prober and the interposer this run uses: each its setting, else both from one place, the
@@ -381,44 +505,6 @@ export function clapQualifyTools(
381
505
  return { tool: tool!, interposer: interposer! };
382
506
  }
383
507
 
384
- /**
385
- * Qualify the plugins of one `.clap` (all of them, or the one `id` names) under the interposer.
386
- * Absent tools, a crash, a timeout, a load failure or an unreadable line throw: none of them is a
387
- * measurement.
388
- */
389
- export function qualifyClap(binaryPath: string, id: string | undefined, options: ClapQualifyOptions): ClapQualifyLine[] {
390
- const env = options.env ?? process.env;
391
- const { tool, interposer } = clapQualifyTools(env, options.exists ?? existsSync);
392
- const args = [
393
- "--rates",
394
- options.rates.join(","),
395
- "--block",
396
- String(CLAP_SWEEP_FRAMES),
397
- "--blocks",
398
- String(CLAP_SWEEP_BLOCKS),
399
- ...(options.offer === undefined || options.offer.length === 0 ? [] : ["--offer", options.offer.join(",")]),
400
- binaryPath,
401
- ...(id === undefined ? [] : [id]),
402
- ];
403
- const r = (options.run ?? spawnQualify)(tool, args, { ...env, LD_PRELOAD: interposer }, CLAP_QUALIFY_TIMEOUT_S * 1000);
404
- if (r.error !== undefined) throw new ClapQualifyError(`cannot run ${tool}: ${r.error.message}`);
405
- if (r.signal) throw new ClapQualifyError(`${binaryPath} killed the prober (${r.signal})`);
406
- if (r.status !== 0) throw new ClapQualifyError(`${tool} exited ${String(r.status)} on ${binaryPath}: ${(r.stderr ?? "").trim()}`);
407
- const lines = parseClapQualifyOutput(r.stdout);
408
- if (lines.length === 0) throw new ClapQualifyError(`${tool} printed nothing for ${binaryPath}`);
409
- return lines;
410
- }
411
-
412
- // --- the CLI's CLAP pass -------------------------------------------------------------------------
413
-
414
- /** What the CLAP pass of one run produced: a document per plugin and console, and a row per verdict. */
415
- export interface ClapQualifyPass {
416
- readonly documents: readonly RtSafetyDocument[];
417
- readonly verdicts: readonly QualifyVerdict[];
418
- /** Each target that could not be measured, with why; its verdict rows read `unknown`, unmeasured. */
419
- readonly failures: readonly { readonly uri: string; readonly problem: string }[];
420
- }
421
-
422
508
  /**
423
509
  * What the prober must offer beyond the console's extensions to measure for `profile`: the extra
424
510
  * extensions of its list, when the prober can offer each and the list holds the console's; `undefined`
@@ -432,48 +518,3 @@ export function proberOffer(profile: HostProfile): readonly string[] | undefined
432
518
  if (!extra.every((e) => OMX_CLAP_HOST_CONFIGURED_EXTENSIONS.includes(e))) return undefined;
433
519
  return OMX_CLAP_HOST_CONFIGURED_EXTENSIONS.filter((e) => extra.includes(e));
434
520
  }
435
-
436
- /**
437
- * Qualify every CLAP target, one prober run per target and set of offered extensions (a crash takes
438
- * only that plugin), and judge each line under every host profile, from the run that offered that
439
- * profile's extensions. A target without a binary path (a bare `urn:clap:` id) has nothing to load
440
- * and is a failure, never a pass.
441
- */
442
- export function qualifyClapTargets(
443
- targets: readonly PluginTarget[],
444
- options: ClapQualifyOptions & { readonly profiles?: readonly HostProfile[] },
445
- ): ClapQualifyPass {
446
- const profiles = options.profiles ?? [OPENMIXER_CONSOLE_PROFILE];
447
- const documents: RtSafetyDocument[] = [];
448
- const verdicts: QualifyVerdict[] = [];
449
- const failures: { uri: string; problem: string }[] = [];
450
- for (const target of targets) {
451
- const id = target.uri.startsWith("urn:clap:") ? target.uri.slice("urn:clap:".length) : undefined;
452
- // one run per distinct offer, the console's (none) for a profile the prober cannot stand in for
453
- const offers = new Map<string, readonly string[]>();
454
- for (const profile of profiles) {
455
- const offer = proberOffer(profile) ?? [];
456
- offers.set(offer.join(","), offer);
457
- }
458
- const runs = new Map<string, ClapQualifyLine[]>();
459
- try {
460
- if (target.binaryPath === undefined) throw new ClapQualifyError(`${target.uri}: no .clap to load; name the file or a directory holding it`);
461
- for (const [key, offer] of offers) runs.set(key, qualifyClap(target.binaryPath, id, { ...options, offer }));
462
- } catch (e) {
463
- if (!(e instanceof ClapQualifyError)) throw e;
464
- failures.push({ uri: target.uri, problem: e.message });
465
- for (const profile of profiles) {
466
- verdicts.push({ uri: target.uri, host: profile.id, measured: false, rating: "unknown", qualified: false, deciding: "not-measured" });
467
- }
468
- continue;
469
- }
470
- for (const profile of profiles) {
471
- for (const line of runs.get((proberOffer(profile) ?? []).join(","))!) {
472
- const doc = rtSafetyDocument(line, profile);
473
- documents.push(doc);
474
- verdicts.push(rtSafetyVerdict(doc));
475
- }
476
- }
477
- }
478
- return { documents, verdicts, failures };
479
- }
@@ -70,3 +70,42 @@ export function classifierCostFromDocument(reading: CostAtRate, rate: number): C
70
70
  [COST_FIELDS.classifier.coreFraction]: fraction,
71
71
  };
72
72
  }
73
+
74
+ /**
75
+ * The key of one point of a cost grid: the rate and the quantum it was measured at. One place spells it,
76
+ * so the reader that fills a grid and the gate that looks a point up cannot disagree.
77
+ */
78
+ export function costPointKey(rate: number, quantum: number): string {
79
+ return `${rate}/${quantum}`;
80
+ }
81
+
82
+ /** The percentile the classifier's `nsPerSampleP95` is. A figure taken at another percentile is not that field. */
83
+ export const CLASSIFIER_PERCENTILE = 95;
84
+
85
+ /** One point of a grid as it is measured: the figures {@link gridCostReadings} turns into the classifier's. */
86
+ export interface CostGridPoint {
87
+ readonly rate: number;
88
+ readonly quantum: number;
89
+ readonly outcome: string;
90
+ readonly cost?: { readonly median: number; readonly percentile: { readonly p: number; readonly value: number } };
91
+ }
92
+
93
+ /**
94
+ * The classifier's cost readings of a grid, by {@link costPointKey}. A point that did not end `probe.ok`, has no
95
+ * cost, or whose percentile is not {@link CLASSIFIER_PERCENTILE} has no reading: the gate then reads the pair as
96
+ * unmeasured, which is the truth, where a figure under the wrong name would be judged as if it were the P95.
97
+ */
98
+ export function gridCostReadings(points: readonly CostGridPoint[], okOutcome: string): Record<string, ClassifierCostReading> {
99
+ const out: Record<string, ClassifierCostReading> = {};
100
+ for (const p of points) {
101
+ if (p.outcome !== okOutcome || p.cost === undefined || p.cost.percentile.p !== CLASSIFIER_PERCENTILE) continue;
102
+ const fraction = coreFraction(p.cost.percentile.value, p.rate);
103
+ if (fraction === undefined) continue;
104
+ out[costPointKey(p.rate, p.quantum)] = {
105
+ [COST_FIELDS.classifier.median]: p.cost.median,
106
+ [COST_FIELDS.classifier.percentile]: p.cost.percentile.value,
107
+ [COST_FIELDS.classifier.coreFraction]: fraction,
108
+ };
109
+ }
110
+ return out;
111
+ }
@@ -61,6 +61,7 @@ export const HOSTING_CODE = {
61
61
  clapFormatNotHosted: 'hosting.clap.format-not-hosted',
62
62
  clapExtensionsNotCovered: 'hosting.clap.extensions-not-covered',
63
63
  outputNonFinite: 'hosting.output.non-finite',
64
+ workerNonconforming: 'hosting.worker.nonconforming',
64
65
  // The pin (spec §11; one-contract §10.3): a host's refusal at add, by the daemon's code.
65
66
  integrityUnpinned: 'hosting.integrity.unpinned',
66
67
  integrityBinaryMismatch: 'hosting.integrity.binary-mismatch',
@@ -52,11 +52,19 @@ export interface QualifiedPlugin {
52
52
  readonly scalingClass?: string;
53
53
  readonly paramSweep?: unknown;
54
54
  };
55
- /** Measured cost per rate (Hz as a string key). */
55
+ /** Measured cost: per rate (Hz as a string key), or per rate AND quantum. */
56
56
  readonly cpuCost?: {
57
+ /** One figure per rate, at the block the prober ran: the old format. Read only when `perRateQuantum` is absent. */
57
58
  readonly perRate?: Readonly<
58
59
  Record<string, { readonly coreFractionP95: number; readonly nsPerSampleMedian: number; readonly nsPerSampleP95: number }>
59
60
  >;
61
+ /**
62
+ * One figure per rate and quantum, keyed by {@link costPointKey}: a probe output's grid. When this is
63
+ * present it is the only cost there is, so a pair it lacks is unmeasured, never read from `perRate`.
64
+ */
65
+ readonly perRateQuantum?: Readonly<
66
+ Record<string, { readonly coreFractionP95: number; readonly nsPerSampleMedian: number; readonly nsPerSampleP95: number }>
67
+ >;
60
68
  };
61
69
  readonly stability?: PluginStabilityMeasurement;
62
70
  readonly rtSafety?: PluginRtSafetyMeasurement;
@@ -306,14 +314,20 @@ export type PluginLv2Features = Omit<FeaturesMeasurement, "unmeasuredReason" | "
306
314
 
307
315
  export { DEFAULT_HOSTING_POLICY, type HostingPolicy } from "./qualify-declarations.js";
308
316
  import { DEFAULT_HOSTING_POLICY, type HostingPolicy } from "./qualify-declarations.js";
309
- import { costFieldsMissing } from "./cost-fields.js";
317
+ import { costFieldsMissing, costPointKey } from "./cost-fields.js";
310
318
 
311
319
  /** One declared rate's latency reading from the CLAP qualifier (CLAP spec §3, latency). */
312
320
  export interface ClapLatencyAtRate {
313
321
  /** `latency.get()` after `activate` at this rate; a plugin without the ext declares 0. */
314
322
  readonly declaredFrames: number;
315
- /** Where the qualifier's impulse landed through `process()`, in frames. */
323
+ /** Where the qualifier's impulse landed through `process()`, in frames; for an instrument, where a note's
324
+ * sound began. */
316
325
  readonly measuredFrames: number;
326
+ /** `note`: a plugin with no main audio input, measured from a note struck at frame 0 (the onset of its
327
+ * sound, which an attack envelope may put later than its latency). Absent: from an impulse. */
328
+ readonly from?: "note";
329
+ /** `false`: the note was struck and nothing was heard, so there is no onset to read. */
330
+ readonly heard?: boolean;
317
331
  }
318
332
 
319
333
  /**
@@ -700,7 +714,13 @@ export function hostingCostReasons(
700
714
  options: HostingClassifyOptions,
701
715
  policy: HostingPolicy,
702
716
  ): readonly HostingReason[] {
703
- const perRate = descriptor.cpuCost?.perRate?.[String(options.rate)];
717
+ const grid = descriptor.cpuCost?.perRateQuantum;
718
+ const perRate =
719
+ grid === undefined
720
+ ? descriptor.cpuCost?.perRate?.[String(options.rate)]
721
+ : options.quantum === undefined
722
+ ? undefined
723
+ : grid[costPointKey(options.rate, options.quantum)];
704
724
  if (perRate === undefined) {
705
725
  return [
706
726
  reason("cost", "objective", "unknown", HOSTING_CODE.costUnmeasured, {
@@ -761,6 +781,10 @@ export function hostingLatencyReasons(
761
781
  // one the benchmark declared unmeasurable, has not been judged and does not pass by
762
782
  // default. The oracle's first fixture omitted latency and still expected `suitable`,
763
783
  // which is how this branch was missing — a self-consistent suite proving nothing.
784
+ // A plugin whose worker interface was refused is not unmeasured by accident: it is not hostable.
785
+ if (typeof latency?.unmeasurable === "string" && latency.unmeasurable.includes(HOSTING_CODE.workerNonconforming)) {
786
+ return [reason("latency", "objective", "unsuitable", HOSTING_CODE.workerNonconforming)];
787
+ }
764
788
  if (latency === undefined || latency.unmeasurable !== undefined) {
765
789
  return [reason("latency", "objective", "unknown", HOSTING_CODE.latencyUnmeasured)];
766
790
  }
@@ -829,7 +853,10 @@ export function clapTopologyReasons(clap: PluginClapMeasurement | undefined, ins
829
853
  /**
830
854
  * The CLAP arm of `latency` (CLAP spec §3): declared = measured at EVERY declared rate earns;
831
855
  * one rate that lies refuses (`unsuitable` — compensation reads the figure as truth); no rate
832
- * measured is `unknown`.
856
+ * measured is `unknown`. An instrument has no input to put an impulse in, so its figure is the onset
857
+ * of a note struck at frame 0: an onset before the declared latency is the same lie, one at or after
858
+ * it is resolved (an attack envelope sounds later than the plugin's delay, and that is no latency),
859
+ * and a note that was never heard is unmeasured.
833
860
  */
834
861
  export function clapLatencyReasons(clap: PluginClapMeasurement | undefined): readonly HostingReason[] {
835
862
  const rates = clap ? Object.keys(clap.latency) : [];
@@ -838,7 +865,11 @@ export function clapLatencyReasons(clap: PluginClapMeasurement | undefined): rea
838
865
  }
839
866
  for (const rate of rates) {
840
867
  const at = clap.latency[rate]!;
841
- if (at.declaredFrames !== at.measuredFrames) {
868
+ if (at.from === "note" && at.heard === false) {
869
+ return [reason("latency", "objective", "unknown", HOSTING_CODE.latencyUnmeasured, { rate: Number(rate) })];
870
+ }
871
+ const lies = at.from === "note" ? at.measuredFrames < at.declaredFrames : at.declaredFrames !== at.measuredFrames;
872
+ if (lies) {
842
873
  return [
843
874
  reason("latency", "objective", "unsuitable", HOSTING_CODE.clapLatencyMismatch, {
844
875
  rate: Number(rate), declared: at.declaredFrames, measured: at.measuredFrames,
@@ -960,12 +991,15 @@ export function classifyPluginHosting(
960
991
  // report could agree with itself while every served descriptor read unknown.
961
992
  // The CLAP arm: the SAME six dimensions, fed from the
962
993
  // qualifier's CLAP facts where the LV2 scan's facts do not exist for the format. Stability,
963
- // the interposer's counts and the cost are format-blind and read unchanged.
994
+ // the interposer's counts and the cost are format-blind and read unchanged. A host that plays
995
+ // notes (an instrument host) also reads the note sweep: an instrument is clean at rest, so the
996
+ // parameter sweep alone says nothing about what a voice does.
964
997
  const reasons: HostingReason[] = descriptor.format === "clap"
965
998
  ? [
966
999
  ...stabilityReasons(descriptor.stability, policy, options.pluginBinary, sharesProcess, options.rate),
967
1000
  ...rtSafetyReasons(descriptor.rtSafety),
968
1001
  ...clapRtReasons(descriptor.clap),
1002
+ ...(profile.instruments ? noteSweepReasons(descriptor.rtSafety?.noteSweep, descriptor.clap?.noteInputs ?? (descriptor.hasMidiIn ? 1 : 0)) : []),
969
1003
  ...clapFeatureReasons(descriptor.clap, profile, descriptor.hasMidiIn),
970
1004
  ...hostingCostReasons(descriptor, options, policy),
971
1005
  ...clapLatencyReasons(descriptor.clap),
@@ -974,6 +1008,7 @@ export function classifyPluginHosting(
974
1008
  : [
975
1009
  ...stabilityReasons(descriptor.stability, policy, options.pluginBinary, sharesProcess, options.rate),
976
1010
  ...rtSafetyReasons(descriptor.rtSafety),
1011
+ ...(profile.instruments ? noteSweepReasons(descriptor.rtSafety?.noteSweep, descriptor.hasMidiIn ? 1 : 0) : []),
977
1012
  ...featureReasons(descriptor.lv2Features, descriptor, profile.lv2Features, options.quantum !== undefined, profile.instruments),
978
1013
  ...hostingCostReasons(descriptor, options, policy),
979
1014
  ...hostingLatencyReasons(descriptor, profile.latency),
package/src/index.ts CHANGED
@@ -20,3 +20,8 @@ export * from "./qualify-cli.js";
20
20
  export * from "./pins-file.js";
21
21
  export * from "./measure-host.js";
22
22
  export * from "./cost-fields.js";
23
+ export * from "./probe-output.js";
24
+ export * from "./probe-judge.js";
25
+ export * from "./qualification-record.js";
26
+ export * from "./probe-runner.js";
27
+ export * from "./judge-cli.js";