@openmixer/plugin-qualify 0.10.1 → 0.11.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 (69) hide show
  1. package/README.md +46 -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 +0 -47
  7. package/dist/clap-qualify.js +2 -88
  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 +11 -1
  16. package/dist/hosting-suitability.js +11 -2
  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/probe-json.js +1 -0
  25. package/dist/measurement/probe-json.js.map +1 -1
  26. package/dist/measurement/vocabulary.js +6 -0
  27. package/dist/measurement/vocabulary.js.map +1 -1
  28. package/dist/probe-judge.d.ts +60 -0
  29. package/dist/probe-judge.js +213 -0
  30. package/dist/probe-judge.js.map +1 -0
  31. package/dist/probe-output-fixtures.d.ts +25 -0
  32. package/dist/probe-output-fixtures.js +134 -0
  33. package/dist/probe-output-fixtures.js.map +1 -0
  34. package/dist/probe-output.d.ts +150 -0
  35. package/dist/probe-output.js +369 -0
  36. package/dist/probe-output.js.map +1 -0
  37. package/dist/probe-runner.d.ts +79 -0
  38. package/dist/probe-runner.js +191 -0
  39. package/dist/probe-runner.js.map +1 -0
  40. package/dist/qualification-record.d.ts +80 -0
  41. package/dist/qualification-record.js +85 -0
  42. package/dist/qualification-record.js.map +1 -0
  43. package/dist/qualify-cli.js +1 -1
  44. package/dist/qualify-cli.js.map +1 -1
  45. package/dist/qualify-declarations.d.ts +11 -2
  46. package/dist/qualify-declarations.js +14 -4
  47. package/dist/qualify-declarations.js.map +1 -1
  48. package/fixtures/clap-scan/fake.json +30 -6
  49. package/fixtures/probe-output/memory-limit-refused.json +190 -0
  50. package/fixtures/probe-output/plugin-crash.json +236 -0
  51. package/fixtures/probe-output/success-msi.json +363 -0
  52. package/package.json +5 -1
  53. package/src/clap-loader.ts +3 -2
  54. package/src/clap-qualify.ts +2 -133
  55. package/src/cost-fields.ts +39 -0
  56. package/src/hosting-codes.ts +1 -0
  57. package/src/hosting-suitability.ts +21 -3
  58. package/src/index.ts +5 -0
  59. package/src/judge-cli.ts +133 -0
  60. package/src/measurement/probe-json.ts +1 -0
  61. package/src/measurement/vocabulary.ts +7 -0
  62. package/src/probe-judge.ts +264 -0
  63. package/src/probe-output-fixtures.ts +146 -0
  64. package/src/probe-output.ts +505 -0
  65. package/src/probe-runner.ts +244 -0
  66. package/src/qualification-record.ts +142 -0
  67. package/src/qualify-cli.ts +1 -1
  68. package/src/qualify-declarations.ts +16 -4
  69. package/tools/benchmark.py +12 -0
@@ -0,0 +1,363 @@
1
+ {
2
+ "format": "plugin-hostd.probe-output",
3
+ "formatVersion": "1.0",
4
+ "run": {
5
+ "id": "msi-20261017T221500Z-3569027",
6
+ "startedAt": "2026-10-17T22:15:00+02:00",
7
+ "elapsedMs": 184220,
8
+ "complete": true,
9
+ "outcome": "probe.ok",
10
+ "reason": "reference",
11
+ "protocol": {
12
+ "name": "plugin-hostd.probe-protocol",
13
+ "version": 1
14
+ },
15
+ "tools": {
16
+ "runner": "plugin-hostd-probe 0.1.0",
17
+ "child": "plugin-hostd-probe-clap 0.1.0",
18
+ "clapCore": "0.9.0",
19
+ "interposer": "rt-interpose 1"
20
+ },
21
+ "via": "runner"
22
+ },
23
+ "plugin": {
24
+ "format": "clap",
25
+ "id": "org.example.plate",
26
+ "name": "Plate",
27
+ "vendor": "Example",
28
+ "version": "1.4.2",
29
+ "ownerPackage": "example-plate-1.4.2-1.fc44.x86_64"
30
+ },
31
+ "binary": {
32
+ "path": "/usr/lib64/clap/example-plate.clap",
33
+ "sha256": "356902794a4eec2122989e072271ef995968131145db918d9a3084ce582f244b",
34
+ "files": [
35
+ {
36
+ "path": "example-plate.clap",
37
+ "sha256": "356902794a4eec2122989e072271ef995968131145db918d9a3084ce582f244b"
38
+ }
39
+ ],
40
+ "layout": {
41
+ "scheme": "omx-layout/1",
42
+ "sha256": "0a3e01399262321514e05af0575ebb0b207a35567ac262a060fde655ee069805"
43
+ }
44
+ },
45
+ "descriptor": {
46
+ "path": "descriptors/2/clap/org.example.plate@1.4.2-356902794a4e.json",
47
+ "sha256": "cc312bdd5a9aa62796ebcb5a648c354c5ee45a6a99892425495ccb58a31bc44e",
48
+ "bytes": 9412
49
+ },
50
+ "machine": {
51
+ "class": {
52
+ "arch": "x86_64",
53
+ "family": "intel",
54
+ "coreBand": "17-32",
55
+ "clockBand": "4.5GHz+",
56
+ "preemptRt": false,
57
+ "cstatesLimited": true
58
+ },
59
+ "profile": {
60
+ "governor": "performance",
61
+ "turbo": true,
62
+ "smt": true,
63
+ "preempt": "PREEMPT_DYNAMIC",
64
+ "cpuidle": [
65
+ {
66
+ "cpu": 22,
67
+ "state": "POLL",
68
+ "latencyUs": 0,
69
+ "disabled": false,
70
+ "usageDelta": 118
71
+ },
72
+ {
73
+ "cpu": 22,
74
+ "state": "C1_ACPI",
75
+ "latencyUs": 1,
76
+ "disabled": false,
77
+ "usageDelta": 40210
78
+ },
79
+ {
80
+ "cpu": 22,
81
+ "state": "C2_ACPI",
82
+ "latencyUs": 127,
83
+ "disabled": true,
84
+ "usageDelta": 0
85
+ },
86
+ {
87
+ "cpu": 22,
88
+ "state": "C3_ACPI",
89
+ "latencyUs": 1048,
90
+ "disabled": true,
91
+ "usageDelta": 0
92
+ }
93
+ ],
94
+ "meetsProtocol": true,
95
+ "declaredName": "omx-live-rig"
96
+ },
97
+ "pinnedCpus": "22",
98
+ "reference": {
99
+ "rig": "msi",
100
+ "image": "omx-ref-2026-10"
101
+ }
102
+ },
103
+ "offer": {
104
+ "id": "omx-clap-host",
105
+ "clapExtensions": [
106
+ "clap.audio-ports",
107
+ "clap.latency",
108
+ "clap.log",
109
+ "clap.params",
110
+ "clap.state",
111
+ "clap.thread-check",
112
+ "clap.preset-load/2",
113
+ "clap.track-info/1",
114
+ "clap.remote-controls/2"
115
+ ],
116
+ "realtime": "none"
117
+ },
118
+ "limits": {
119
+ "set": "default",
120
+ "applied": [
121
+ {
122
+ "limit": "wall-clock",
123
+ "value": 600,
124
+ "unit": "s",
125
+ "by": "supervisor"
126
+ },
127
+ {
128
+ "limit": "cpu-time",
129
+ "value": 540,
130
+ "unit": "s",
131
+ "by": "rlimit"
132
+ },
133
+ {
134
+ "limit": "memory",
135
+ "value": 2147483648,
136
+ "unit": "bytes",
137
+ "by": "cgroup"
138
+ },
139
+ {
140
+ "limit": "tasks",
141
+ "value": 64,
142
+ "unit": "count",
143
+ "by": "cgroup"
144
+ },
145
+ {
146
+ "limit": "files",
147
+ "value": 256,
148
+ "unit": "count",
149
+ "by": "rlimit"
150
+ },
151
+ {
152
+ "limit": "file-size",
153
+ "value": 67108864,
154
+ "unit": "bytes",
155
+ "by": "rlimit"
156
+ },
157
+ {
158
+ "limit": "core-dump",
159
+ "value": 0,
160
+ "unit": "bytes",
161
+ "by": "rlimit"
162
+ },
163
+ {
164
+ "limit": "rt-priority",
165
+ "value": 0,
166
+ "by": "rlimit"
167
+ },
168
+ {
169
+ "limit": "cpus",
170
+ "value": "22",
171
+ "by": "affinity"
172
+ },
173
+ {
174
+ "limit": "no-new-privs",
175
+ "by": "prctl"
176
+ },
177
+ {
178
+ "limit": "network",
179
+ "value": "none",
180
+ "by": "netns"
181
+ }
182
+ ],
183
+ "unavailable": []
184
+ },
185
+ "steps": [
186
+ {
187
+ "step": "hash",
188
+ "outcome": "probe.ok",
189
+ "elapsedMs": 4
190
+ },
191
+ {
192
+ "step": "instantiate",
193
+ "outcome": "probe.ok",
194
+ "elapsedMs": 31
195
+ },
196
+ {
197
+ "step": "lifecycle",
198
+ "outcome": "probe.ok",
199
+ "elapsedMs": 2210
200
+ },
201
+ {
202
+ "step": "latency",
203
+ "rate": 48000,
204
+ "quantum": 128,
205
+ "outcome": "probe.ok",
206
+ "elapsedMs": 310
207
+ },
208
+ {
209
+ "step": "cost",
210
+ "rate": 48000,
211
+ "quantum": 128,
212
+ "outcome": "probe.ok",
213
+ "elapsedMs": 30020
214
+ },
215
+ {
216
+ "step": "rt-safety",
217
+ "rate": 48000,
218
+ "quantum": 128,
219
+ "outcome": "probe.ok",
220
+ "elapsedMs": 4100
221
+ },
222
+ {
223
+ "step": "latency",
224
+ "rate": 96000,
225
+ "quantum": 128,
226
+ "outcome": "probe.ok",
227
+ "elapsedMs": 305
228
+ },
229
+ {
230
+ "step": "cost",
231
+ "rate": 96000,
232
+ "quantum": 128,
233
+ "outcome": "probe.ok",
234
+ "elapsedMs": 30011
235
+ },
236
+ {
237
+ "step": "rt-safety",
238
+ "rate": 96000,
239
+ "quantum": 128,
240
+ "outcome": "probe.ok",
241
+ "elapsedMs": 4080
242
+ },
243
+ {
244
+ "step": "note-sweep",
245
+ "outcome": "probe.not-attempted",
246
+ "elapsedMs": 0,
247
+ "detail": "no note input"
248
+ }
249
+ ],
250
+ "grid": [
251
+ {
252
+ "rate": 48000,
253
+ "quantum": 128,
254
+ "outcome": "probe.ok",
255
+ "latency": {
256
+ "declaredFrames": 0,
257
+ "measuredFrames": 0,
258
+ "method": "impulse-onset"
259
+ },
260
+ "cost": {
261
+ "unit": "ns-per-sample",
262
+ "median": 41.2,
263
+ "percentile": {
264
+ "p": 95,
265
+ "value": 47.9
266
+ },
267
+ "min": 39.8,
268
+ "max": 112.4,
269
+ "iqr": 3.1,
270
+ "blocks": 11250,
271
+ "warmupBlocks": 64,
272
+ "warmupMedian": 58.0,
273
+ "timingFloorNsPerBlock": 180
274
+ },
275
+ "rtSafety": {
276
+ "interposer": true,
277
+ "swept": true,
278
+ "sweepApplicable": true,
279
+ "blocks": 512,
280
+ "allocations": 0,
281
+ "locks": 0,
282
+ "syscalls": 0,
283
+ "threadViolations": 0,
284
+ "processErrors": 0,
285
+ "events": []
286
+ }
287
+ },
288
+ {
289
+ "rate": 96000,
290
+ "quantum": 128,
291
+ "outcome": "probe.ok",
292
+ "latency": {
293
+ "declaredFrames": 0,
294
+ "measuredFrames": 0,
295
+ "method": "impulse-onset"
296
+ },
297
+ "cost": {
298
+ "unit": "ns-per-sample",
299
+ "median": 40.7,
300
+ "percentile": {
301
+ "p": 95,
302
+ "value": 46.3
303
+ },
304
+ "min": 39.1,
305
+ "max": 98.0,
306
+ "iqr": 2.8,
307
+ "blocks": 22500,
308
+ "warmupBlocks": 64,
309
+ "timingFloorNsPerBlock": 180,
310
+ "samples": {
311
+ "name": "cost-96000-128",
312
+ "path": "probes/org.example.plate@1.4.2-356902794a4e.cost-96000-128.bin",
313
+ "sha256": "5f649130dafd22f1edc70441f9455415d0856f0f7d26cf7274463b82ac2c5813",
314
+ "bytes": 180000
315
+ }
316
+ },
317
+ "rtSafety": {
318
+ "interposer": true,
319
+ "swept": true,
320
+ "sweepApplicable": true,
321
+ "blocks": 512,
322
+ "allocations": 0,
323
+ "locks": 0,
324
+ "syscalls": 0,
325
+ "threadViolations": 0,
326
+ "processErrors": 0,
327
+ "events": []
328
+ }
329
+ }
330
+ ],
331
+ "behaviour": {
332
+ "lifecycle": {
333
+ "cycles": 30,
334
+ "instantiated": 30,
335
+ "failed": 0
336
+ },
337
+ "threads": {
338
+ "before": 2,
339
+ "afterFirstInstantiate": 2,
340
+ "afterAllFreed": 2
341
+ },
342
+ "memory": {
343
+ "rssStartKb": 9120,
344
+ "rssMaxKb": 14988
345
+ },
346
+ "features": {
347
+ "extensionsAsked": [
348
+ "clap.audio-ports",
349
+ "clap.params",
350
+ "clap.state",
351
+ "clap.latency"
352
+ ]
353
+ }
354
+ },
355
+ "logs": [
356
+ {
357
+ "name": "stderr",
358
+ "path": "probes/org.example.plate@1.4.2-356902794a4e.stderr.log",
359
+ "sha256": "c46928bc87de23b9a8bd235aa1fe67ccb1542c8389ad3f02bf4ab5cff506e98f",
360
+ "bytes": 212
361
+ }
362
+ ]
363
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openmixer/plugin-qualify",
3
- "version": "0.10.1",
3
+ "version": "0.11.0",
4
4
  "description": "Qualify audio plugins for real-time hosting: scan, offline latency and cost measurement, a mod-host hosting sweep, and a fail-closed rating.",
5
5
  "type": "module",
6
6
  "license": "GPL-3.0-or-later",
@@ -67,6 +67,7 @@
67
67
  "dist",
68
68
  "src",
69
69
  "!src/**/*.test.ts",
70
+ "!src/**/*.support.*",
70
71
  "tools",
71
72
  "!tools/test_*.py",
72
73
  "!**/__pycache__",
@@ -93,5 +94,8 @@
93
94
  "publishConfig": {
94
95
  "access": "public",
95
96
  "provenance": true
97
+ },
98
+ "dependencies": {
99
+ "@openmixer/omx-contract": "^2.16.0"
96
100
  }
97
101
  }
@@ -25,6 +25,7 @@ import {
25
25
  CLAP_SCAN_TOOL_DEFAULT,
26
26
  CLAP_SCAN_TOOL_ENV,
27
27
  CLAP_SYSTEM_DIR,
28
+ CLAP_SYSTEM_DIR_ENV,
28
29
  CLAP_USER_DIR,
29
30
  } from "./qualify-declarations.js";
30
31
 
@@ -428,11 +429,11 @@ export function clapLoader(options: ClapLoaderOptions = {}): PluginLoader {
428
429
 
429
430
  /**
430
431
  * The directories `--all` walks, in order and once each: every non-empty entry of `CLAP_PATH`, then
431
- * `~/.clap`, then `/usr/lib/clap`.
432
+ * `~/.clap`, then `/usr/lib/clap` (or the directory `OMX_CLAP_SYSTEM_DIR` names).
432
433
  */
433
434
  export function clapSearchPath(env: Readonly<Record<string, string | undefined>>, home: string): string[] {
434
435
  const named = (env[CLAP_PATH_ENV] ?? "").split(":").filter((d) => d !== "");
435
- return [...new Set([...named, join(home, CLAP_USER_DIR), CLAP_SYSTEM_DIR].map((d) => resolve(d)))];
436
+ return [...new Set([...named, join(home, CLAP_USER_DIR), env[CLAP_SYSTEM_DIR_ENV] || CLAP_SYSTEM_DIR].map((d) => resolve(d)))];
436
437
  }
437
438
 
438
439
  export interface ClapDiscoverOptions extends ClapLoaderOptions {
@@ -10,16 +10,9 @@
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,
@@ -37,7 +30,6 @@ import {
37
30
  OPENMIXER_CONSOLE_PROFILE,
38
31
  type HostProfile,
39
32
  } from "./host-profiles.js";
40
- import type { PluginTarget } from "./loader.js";
41
33
  import type { QualifyVerdict } from "./qualify-cli.js";
42
34
  import { RATING_SEVERITY, worstRating, type SuitabilityRating } from "./rating.js";
43
35
  import { halfPopulatedRefusal, locateNativeTools } from "./tool-paths.js";
@@ -318,47 +310,7 @@ export function rtSafetyDocumentName(doc: RtSafetyDocument): string {
318
310
  return `${doc.plugin.binary.sha256}.${doc.console}.rt-safety.json`;
319
311
  }
320
312
 
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
- }
313
+ // --- the prober and the interposer a run uses ---------------------------------------------------
362
314
 
363
315
  /**
364
316
  * The prober and the interposer this run uses: each its setting, else both from one place, the
@@ -381,44 +333,6 @@ export function clapQualifyTools(
381
333
  return { tool: tool!, interposer: interposer! };
382
334
  }
383
335
 
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
336
  /**
423
337
  * What the prober must offer beyond the console's extensions to measure for `profile`: the extra
424
338
  * extensions of its list, when the prober can offer each and the list holds the console's; `undefined`
@@ -432,48 +346,3 @@ export function proberOffer(profile: HostProfile): readonly string[] | undefined
432
346
  if (!extra.every((e) => OMX_CLAP_HOST_CONFIGURED_EXTENSIONS.includes(e))) return undefined;
433
347
  return OMX_CLAP_HOST_CONFIGURED_EXTENSIONS.filter((e) => extra.includes(e));
434
348
  }
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,7 +314,7 @@ 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 {
@@ -700,7 +708,13 @@ export function hostingCostReasons(
700
708
  options: HostingClassifyOptions,
701
709
  policy: HostingPolicy,
702
710
  ): readonly HostingReason[] {
703
- const perRate = descriptor.cpuCost?.perRate?.[String(options.rate)];
711
+ const grid = descriptor.cpuCost?.perRateQuantum;
712
+ const perRate =
713
+ grid === undefined
714
+ ? descriptor.cpuCost?.perRate?.[String(options.rate)]
715
+ : options.quantum === undefined
716
+ ? undefined
717
+ : grid[costPointKey(options.rate, options.quantum)];
704
718
  if (perRate === undefined) {
705
719
  return [
706
720
  reason("cost", "objective", "unknown", HOSTING_CODE.costUnmeasured, {
@@ -761,6 +775,10 @@ export function hostingLatencyReasons(
761
775
  // one the benchmark declared unmeasurable, has not been judged and does not pass by
762
776
  // default. The oracle's first fixture omitted latency and still expected `suitable`,
763
777
  // which is how this branch was missing — a self-consistent suite proving nothing.
778
+ // A plugin whose worker interface was refused is not unmeasured by accident: it is not hostable.
779
+ if (typeof latency?.unmeasurable === "string" && latency.unmeasurable.includes(HOSTING_CODE.workerNonconforming)) {
780
+ return [reason("latency", "objective", "unsuitable", HOSTING_CODE.workerNonconforming)];
781
+ }
764
782
  if (latency === undefined || latency.unmeasurable !== undefined) {
765
783
  return [reason("latency", "objective", "unknown", HOSTING_CODE.latencyUnmeasured)];
766
784
  }
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";