@openmixer/plugin-qualify 0.17.1 → 0.17.3

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 (42) hide show
  1. package/README.md +13 -6
  2. package/bin/plugin-qualify-sync.mjs +1 -1
  3. package/dist/host-admission.d.ts +25 -6
  4. package/dist/host-admission.js +45 -17
  5. package/dist/host-admission.js.map +1 -1
  6. package/dist/hostd-pin-files.d.ts +25 -0
  7. package/dist/hostd-pin-files.js +58 -0
  8. package/dist/hostd-pin-files.js.map +1 -0
  9. package/dist/probe-judge.d.ts +2 -0
  10. package/dist/probe-judge.js +3 -2
  11. package/dist/probe-judge.js.map +1 -1
  12. package/dist/probe-output-fixtures.js +4 -1
  13. package/dist/probe-output-fixtures.js.map +1 -1
  14. package/dist/probe-output.d.ts +2 -1
  15. package/dist/probe-output.js +4 -1
  16. package/dist/probe-output.js.map +1 -1
  17. package/dist/probe-runner.d.ts +5 -0
  18. package/dist/probe-runner.js +8 -3
  19. package/dist/probe-runner.js.map +1 -1
  20. package/dist/qualification-record.d.ts +16 -1
  21. package/dist/qualification-record.js +8 -2
  22. package/dist/qualification-record.js.map +1 -1
  23. package/dist/qualify-cli.js +10 -4
  24. package/dist/qualify-cli.js.map +1 -1
  25. package/dist/qualify-options.js +3 -3
  26. package/dist/qualify-options.js.map +1 -1
  27. package/dist/qualify-sync.d.ts +26 -4
  28. package/dist/qualify-sync.js +191 -53
  29. package/dist/qualify-sync.js.map +1 -1
  30. package/package.json +1 -1
  31. package/src/host-admission.ts +69 -23
  32. package/src/hostd-pin-files.ts +75 -0
  33. package/src/probe-judge.ts +5 -1
  34. package/src/probe-output-fixtures.ts +4 -1
  35. package/src/probe-output.ts +6 -2
  36. package/src/probe-runner.ts +13 -3
  37. package/src/qualification-record.ts +16 -2
  38. package/src/qualify-cli.ts +8 -4
  39. package/src/qualify-options.ts +3 -3
  40. package/src/qualify-sync.ts +197 -52
  41. package/tools/descriptors.py +8 -1
  42. package/tools/mod-host-admit.mjs +3 -1
@@ -22,9 +22,16 @@ export function admittedHostOf(profile: HostProfile): AdmittedHost | undefined {
22
22
  return ADMITTED_HOSTS.find((h) => h === profile.id);
23
23
  }
24
24
 
25
+ /**
26
+ * plugin-hostd's LV2 adapter, omx-clap-host: an LV2 plugin is run under it once, as under mod-host, and the record
27
+ * carries the outcome as its own step beside the verdicts (plugin-qualify#223), which it does not change: the
28
+ * omx-clap-host verdict of an LV2 plugin stays the in-process measurement. It is what the removal of mod-host counts.
29
+ */
30
+ export const ADAPTER_HOST = "omx-clap-host";
31
+
25
32
  /** What running the plugin under one host came to. */
26
33
  export interface HostAdmission {
27
- readonly host: AdmittedHost;
34
+ readonly host: AdmittedHost | typeof ADAPTER_HOST;
28
35
  /** False when the host could not be run (not installed): there is no evidence either way. */
29
36
  readonly measured: boolean;
30
37
  readonly admitted: boolean;
@@ -33,15 +40,16 @@ export interface HostAdmission {
33
40
  readonly params: Readonly<Record<string, string | number>>;
34
41
  }
35
42
 
36
- const admitted = (host: AdmittedHost): HostAdmission => ({ host, measured: true, admitted: true, params: {} });
37
- const refused = (host: AdmittedHost, code: string, params: HostAdmission["params"] = {}): HostAdmission => ({
43
+ type ProtocolHost = "mod-host" | typeof ADAPTER_HOST;
44
+ const admitted = (host: HostAdmission["host"]): HostAdmission => ({ host, measured: true, admitted: true, params: {} });
45
+ const refused = (host: HostAdmission["host"], code: string, params: HostAdmission["params"] = {}): HostAdmission => ({
38
46
  host,
39
47
  measured: true,
40
48
  admitted: false,
41
49
  code: `${host}.${code}`,
42
50
  params,
43
51
  });
44
- const notInstalled = (host: AdmittedHost): HostAdmission => ({ host, measured: false, admitted: false, code: `${host}.not-installed`, params: {} });
52
+ const notInstalled = (host: HostAdmission["host"]): HostAdmission => ({ host, measured: false, admitted: false, code: `${host}.not-installed`, params: {} });
45
53
 
46
54
  /** One command mod-host was sent and what came of it: its `resp` number, or how the host died. */
47
55
  export interface ModHostStep {
@@ -57,20 +65,25 @@ export interface ModHostRun {
57
65
  readonly steps: readonly ModHostStep[];
58
66
  }
59
67
 
60
- /** The verdict of a mod-host run: the first step that did not answer `resp 0` decides. */
61
- export function classifyModHostRun(run: ModHostRun): HostAdmission {
62
- if (!run.installed) return notInstalled("mod-host");
68
+ /** The verdict of a run of a host that speaks mod-host's protocol: the first step that did not answer `resp 0` decides. */
69
+ export function classifyProtocolRun(host: ProtocolHost, run: ModHostRun): HostAdmission {
70
+ if (!run.installed) return notInstalled(host);
63
71
  for (const s of run.steps) {
64
72
  if (s.died !== undefined) {
65
- if (s.died.startsWith("SIG")) return refused("mod-host", `signal-${s.died}`, { step: s.cmd });
66
- return s.cmd === "add" ? refused("mod-host", "died-on-add", { how: s.died }) : refused("mod-host", "died", { step: s.cmd, how: s.died });
73
+ if (s.died.startsWith("SIG")) return refused(host, `signal-${s.died}`, { step: s.cmd });
74
+ return s.cmd === "add" ? refused(host, "died-on-add", { how: s.died }) : refused(host, "died", { step: s.cmd, how: s.died });
67
75
  }
68
- if (s.resp === undefined) return refused("mod-host", "no-answer", { step: s.cmd });
69
- // mod-host answers an error as a negative number (`resp -102`); the code carries its magnitude
70
- if (s.resp < 0) return refused("mod-host", `resp-${-s.resp}`, { step: s.cmd, resp: s.resp });
76
+ if (s.resp === undefined) return refused(host, "no-answer", { step: s.cmd });
77
+ // the host answers an error as a negative number (`resp -102`); the code carries its magnitude
78
+ if (s.resp < 0) return refused(host, `resp-${-s.resp}`, { step: s.cmd, resp: s.resp });
71
79
  }
72
- if (run.steps.length === 0) return refused("mod-host", "no-answer");
73
- return admitted("mod-host");
80
+ if (run.steps.length === 0) return refused(host, "no-answer");
81
+ return admitted(host);
82
+ }
83
+
84
+ /** The verdict of a mod-host run. */
85
+ export function classifyModHostRun(run: ModHostRun): HostAdmission {
86
+ return classifyProtocolRun("mod-host", run);
74
87
  }
75
88
 
76
89
  /** What `jalv -n <name> <uri>` did in its window. */
@@ -101,36 +114,69 @@ export const JALV_HOSTED_MS = 3000;
101
114
  /** The wall clock of one whole mod-host admission, in milliseconds. */
102
115
  export const MOD_HOST_ADMISSION_MS = 30000;
103
116
 
117
+ /**
118
+ * The runs of one plugin under the installed hosts. `env` is the environment each host starts with: its `LV2_PATH`
119
+ * must be the device's LV2 directories, the ones the host sees in use (plugin-qualify#224: a mod-host started with no
120
+ * `LV2_PATH` sees only lilv's defaults and answers -101 for a plugin it runs every day).
121
+ */
104
122
  export interface AdmissionRuns {
105
- readonly modHost: (uri: string) => ModHostRun;
106
- readonly jalv: (uri: string) => JalvRun;
123
+ readonly modHost: (uri: string, env?: Readonly<Record<string, string | undefined>>) => ModHostRun;
124
+ readonly jalv: (uri: string, env?: Readonly<Record<string, string | undefined>>) => JalvRun;
125
+ /** The run under plugin-hostd's LV2 adapter; a set without it records no adapter step. */
126
+ readonly adapter?: (uri: string, env?: Readonly<Record<string, string | undefined>>) => ModHostRun;
107
127
  }
108
128
 
109
129
  const MOD_HOST_ADMIT = fileURLToPath(new URL("../tools/mod-host-admit.mjs", import.meta.url));
110
130
 
111
131
  /** The runs against the installed hosts. */
112
132
  export const SPAWNED_ADMISSIONS: AdmissionRuns = {
113
- modHost(uri) {
114
- const r = spawnSync(process.execPath, [MOD_HOST_ADMIT, uri], { encoding: "utf8", timeout: MOD_HOST_ADMISSION_MS });
133
+ adapter(uri, env) {
134
+ const r = spawnSync(process.execPath, [MOD_HOST_ADMIT, uri, ADAPTER_HOST], { encoding: "utf8", timeout: MOD_HOST_ADMISSION_MS, ...(env === undefined ? {} : { env: { ...env } }) });
135
+ if (r.error !== undefined || r.status !== 0) {
136
+ throw new Error(`${ADAPTER_HOST} admission of ${uri} gave no answer (${r.error?.message ?? `exit ${String(r.status)}`}): ${(r.stderr ?? "").trim()}`);
137
+ }
138
+ return JSON.parse(r.stdout) as ModHostRun;
139
+ },
140
+ modHost(uri, env) {
141
+ const r = spawnSync(process.execPath, [MOD_HOST_ADMIT, uri], { encoding: "utf8", timeout: MOD_HOST_ADMISSION_MS, ...(env === undefined ? {} : { env: { ...env } }) });
115
142
  if (r.error !== undefined || r.status !== 0) {
116
143
  throw new Error(`mod-host admission of ${uri} gave no answer (${r.error?.message ?? `exit ${String(r.status)}`}): ${(r.stderr ?? "").trim()}`);
117
144
  }
118
145
  return JSON.parse(r.stdout) as ModHostRun;
119
146
  },
120
- jalv(uri) {
121
- const r = spawnSync("jalv", ["-n", "plugin-qualify", uri], { encoding: "utf8", timeout: JALV_HOSTED_MS, input: "" });
147
+ jalv(uri, env) {
148
+ const r = spawnSync("jalv", ["-n", "plugin-qualify", uri], { encoding: "utf8", timeout: JALV_HOSTED_MS, input: "", ...(env === undefined ? {} : { env: { ...env } }) });
122
149
  const code = (r.error as NodeJS.ErrnoException | undefined)?.code;
123
150
  if (code === "ENOENT") return { installed: false, stillRunning: false, status: null, signal: null, stderr: "" };
124
151
  return { installed: true, stillRunning: code === "ETIMEDOUT", status: r.status, signal: code === "ETIMEDOUT" ? null : r.signal, stderr: r.stderr ?? "" };
125
152
  },
126
153
  };
127
154
 
128
- /** One admission of `uri` under `host`. */
129
- export function admitPlugin(host: AdmittedHost, uri: string, runs: AdmissionRuns = SPAWNED_ADMISSIONS): HostAdmission {
155
+ /** One admission of `uri` under `host`, the host started with `env` (its `LV2_PATH` the device's LV2 directories). */
156
+ export function admitPlugin(
157
+ host: AdmittedHost,
158
+ uri: string,
159
+ runs: AdmissionRuns = SPAWNED_ADMISSIONS,
160
+ env?: Readonly<Record<string, string | undefined>>,
161
+ ): HostAdmission {
130
162
  try {
131
- return host === "mod-host" ? classifyModHostRun(runs.modHost(uri)) : classifyJalvRun(runs.jalv(uri));
163
+ return host === "mod-host" ? classifyModHostRun(runs.modHost(uri, env)) : classifyJalvRun(runs.jalv(uri, env));
132
164
  } catch (e) {
133
165
  // the run itself broke: nothing was learned about the plugin, so nothing is rated
134
166
  return { host, measured: false, admitted: false, code: `${host}.admission-failed`, params: { problem: e instanceof Error ? e.message : String(e) } };
135
167
  }
136
168
  }
169
+
170
+ /** The run of an LV2 plugin under plugin-hostd's LV2 adapter, `undefined` when the runs have none (plugin-qualify#223). */
171
+ export function admitUnderAdapter(
172
+ uri: string,
173
+ runs: AdmissionRuns = SPAWNED_ADMISSIONS,
174
+ env?: Readonly<Record<string, string | undefined>>,
175
+ ): HostAdmission | undefined {
176
+ if (runs.adapter === undefined) return undefined;
177
+ try {
178
+ return classifyProtocolRun(ADAPTER_HOST, runs.adapter(uri, env));
179
+ } catch (e) {
180
+ return { host: ADAPTER_HOST, measured: false, admitted: false, code: `${ADAPTER_HOST}.admission-failed`, params: { problem: e instanceof Error ? e.message : String(e) } };
181
+ }
182
+ }
@@ -0,0 +1,75 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
+ /**
4
+ * The files a pin holds, as plugin-hostd collects them (plugin-qualify#228). One definition of a plugin's files: the
5
+ * daemon's, the list an add checks before it hashes them (`pins_check`), which `plugin-hostd --pin-files <uri>` prints
6
+ * (plugin-hostd 0.15.0 and later). This module never works the list out itself: it runs the daemon on the device's LV2
7
+ * directories, reads `root <dir>` and the `file <name>` lines, and hashes exactly those files.
8
+ */
9
+ import { spawnSync } from "node:child_process";
10
+ import { createHash } from "node:crypto";
11
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+
15
+ /** The daemon binary, else `plugin-hostd` on the PATH. */
16
+ export const HOSTD_ENV = "PLUGIN_HOSTD";
17
+
18
+ export interface PinFilesRunResult {
19
+ readonly status: number | null;
20
+ readonly stdout: string;
21
+ readonly stderr: string;
22
+ /** The daemon is not installed. */
23
+ readonly missing?: boolean;
24
+ }
25
+
26
+ /** One run of `plugin-hostd --pin-files <uri>` with `lv2_path` set to `lv2Dirs`. */
27
+ export type PinFilesRun = (uri: string, lv2Dirs: readonly string[], env: Readonly<Record<string, string | undefined>>) => PinFilesRunResult;
28
+
29
+ export const SPAWNED_PIN_FILES: PinFilesRun = (uri, lv2Dirs, env) => {
30
+ const dir = mkdtempSync(join(tmpdir(), "plugin-qualify-pin-files-"));
31
+ try {
32
+ const conf = join(dir, "plugin-hostd.conf");
33
+ writeFileSync(conf, `lv2_path ${lv2Dirs.join(":")}\n`);
34
+ const r = spawnSync(env[HOSTD_ENV] ?? "plugin-hostd", ["-c", conf, "--pin-files", uri], { encoding: "utf8", timeout: 30000, env: { ...env } });
35
+ const missing = (r.error as NodeJS.ErrnoException | undefined)?.code === "ENOENT";
36
+ return { status: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? "", ...(missing ? { missing } : {}) };
37
+ } finally {
38
+ rmSync(dir, { recursive: true, force: true });
39
+ }
40
+ };
41
+
42
+ export type PinFiles =
43
+ | { readonly ok: true; readonly root: string; readonly files: readonly { readonly path: string; readonly sha256: string }[] }
44
+ | { readonly ok: false; readonly detail: string };
45
+
46
+ /** The files plugin-hostd collects for `uri`, each hashed; why not, when it cannot say. */
47
+ export function hostdPinFiles(
48
+ uri: string,
49
+ lv2Dirs: readonly string[],
50
+ env: Readonly<Record<string, string | undefined>> = process.env,
51
+ run: PinFilesRun = SPAWNED_PIN_FILES,
52
+ read: (path: string) => Buffer = (p) => readFileSync(p),
53
+ ): PinFiles {
54
+ const r = run(uri, lv2Dirs, env);
55
+ if (r.missing) return { ok: false, detail: "plugin-hostd is not installed: the files of a pin are plugin-hostd's to name" };
56
+ if (r.status !== 0) {
57
+ const why = r.stdout.startsWith("Usage:")
58
+ ? "this plugin-hostd has no --pin-files (plugin-hostd 0.15.0 or later names the files of a pin)"
59
+ : r.stderr.trim().split("\n").pop() || `plugin-hostd --pin-files exited ${String(r.status)}`;
60
+ return { ok: false, detail: why };
61
+ }
62
+ let root: string | undefined;
63
+ const names: string[] = [];
64
+ for (const line of r.stdout.split("\n")) {
65
+ if (line.startsWith("root ")) root = line.slice(5);
66
+ else if (line.startsWith("file ")) names.push(line.slice(5));
67
+ }
68
+ if (root === undefined || names.length === 0) return { ok: false, detail: "plugin-hostd --pin-files printed no root or no file" };
69
+ try {
70
+ const files = names.map((path) => ({ path, sha256: createHash("sha256").update(read(join(root!, path))).digest("hex") }));
71
+ return { ok: true, root, files: files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)) };
72
+ } catch (e) {
73
+ return { ok: false, detail: `a file plugin-hostd names cannot be read: ${e instanceof Error ? e.message : String(e)}` };
74
+ }
75
+ }
@@ -133,7 +133,9 @@ export function clapLineFromProbeOutput(output: ProbeOutput, facts: JudgeFacts =
133
133
  const points = okPoints(output);
134
134
  const latency: Record<string, { declaredFrames: number; measuredFrames: number }> = {};
135
135
  for (const p of points) {
136
- if (p.latency !== undefined && latency[String(p.rate)] === undefined) latency[String(p.rate)] = p.latency;
136
+ if (p.latency?.measuredFrames !== undefined && latency[String(p.rate)] === undefined) {
137
+ latency[String(p.rate)] = { declaredFrames: p.latency.declaredFrames, measuredFrames: p.latency.measuredFrames };
138
+ }
137
139
  }
138
140
  const withRt = points.filter((p) => p.rtSafety !== undefined);
139
141
  return {
@@ -188,6 +190,8 @@ export interface HostJudgment {
188
190
 
189
191
  export interface Judgment {
190
192
  readonly output: ProbeOutput;
193
+ /** An LV2 plugin's run under plugin-hostd's LV2 adapter, beside the verdicts (plugin-qualify#223). */
194
+ readonly adapter?: HostAdmission;
191
195
  readonly hosts: readonly HostJudgment[];
192
196
  /** For a CLAP plugin, the document of each host that today's judge writes; the pins are taken from these. */
193
197
  readonly documents: readonly RtSafetyDocument[];
@@ -198,7 +198,10 @@ export function probeOutputOfLv2Line(line: Lv2MeasureLine): Record<string, any>
198
198
  method: latency.from === "note" ? "note-onset" : "impulse-onset",
199
199
  ...(latency.from === "note" ? { heard: true } : {}),
200
200
  };
201
- } else absent("latency", latency);
201
+ } else {
202
+ absent("latency", latency);
203
+ if (latency?.declaredFrames !== undefined) point["latency"] = { declaredFrames: latency.declaredFrames };
204
+ }
202
205
  if (cost?.nsPerSampleMedian !== undefined) {
203
206
  steps.push({ step: "cost", rate, quantum, outcome: "probe.ok", elapsedMs: 1 });
204
207
  point["cost"] = {
@@ -91,7 +91,8 @@ export interface ProbeGridPoint {
91
91
  readonly rate: number;
92
92
  readonly quantum: number;
93
93
  readonly outcome: string;
94
- readonly latency?: { readonly declaredFrames: number; readonly measuredFrames: number };
94
+ /** `measuredFrames` is absent where the probe did not measure; the declared figure is kept whenever the plugin loaded (#201). */
95
+ readonly latency?: { readonly declaredFrames: number; readonly measuredFrames?: number };
95
96
  readonly cost?: ProbeCost;
96
97
  readonly rtSafety?: ProbeRtSafety;
97
98
  readonly noteSweep?: ProbeNoteSweep;
@@ -296,7 +297,10 @@ function readPoint(v: unknown, i: number): ProbeGridPoint {
296
297
  let latency: ProbeGridPoint["latency"];
297
298
  if (latencyIn !== undefined) {
298
299
  const l = record(latencyIn, `${where} latency`);
299
- latency = { declaredFrames: count(l, "declaredFrames", `${where} latency`), measuredFrames: count(l, "measuredFrames", `${where} latency`) };
300
+ latency = {
301
+ declaredFrames: count(l, "declaredFrames", `${where} latency`),
302
+ ...(l["measuredFrames"] === undefined ? {} : { measuredFrames: count(l, "measuredFrames", `${where} latency`) }),
303
+ };
300
304
  }
301
305
  return {
302
306
  rate: posInt(o, "rate", where),
@@ -22,7 +22,7 @@ import type { HostProfile } from "./host-profiles.js";
22
22
  import { OPENMIXER_CONSOLE_PROFILE, shippedHostProfile } from "./host-profiles.js";
23
23
  import { IN_PROCESS_TIER_HOST, ISOLATED_TIER_HOST } from "./qualification-record.js";
24
24
  import type { PluginTarget } from "./loader.js";
25
- import { SPAWNED_ADMISSIONS, admitPlugin, admittedHostOf, type AdmissionRuns, type HostAdmission } from "./host-admission.js";
25
+ import { SPAWNED_ADMISSIONS, admitPlugin, admitUnderAdapter, admittedHostOf, type AdmissionRuns, type HostAdmission } from "./host-admission.js";
26
26
  import { judgeProbeOutput, judgmentVerdicts, type Judgment } from "./probe-judge.js";
27
27
  import { readProbeOutput, type ProbeOutput } from "./probe-output.js";
28
28
  import { budgetCells, gridCells, runnerInvocations, type GridCell, type GridSpec, type RunnerInvocation } from "./measurement-grid.js";
@@ -261,6 +261,11 @@ export function qualifyTargets(
261
261
  options: ProbeTargetOptions & {
262
262
  readonly profiles?: readonly HostProfile[];
263
263
  readonly admissions?: AdmissionRuns;
264
+ /**
265
+ * The LV2 directories the hosts of an admission see (`LV2_PATH`), the device's as the sync's settings name them;
266
+ * absent, they get the run's environment as it is.
267
+ */
268
+ readonly admissionLv2Path?: readonly string[];
264
269
  /** How the grid is chosen; with it, each target's cells come from the hosts it is judged under. */
265
270
  readonly grid?: GridSpec;
266
271
  /** The quanta of a `full` grid; else `QUALIFY_QUANTA`. */
@@ -271,6 +276,8 @@ export function qualifyTargets(
271
276
  ): ProbePass {
272
277
  const profiles = options.profiles ?? [OPENMIXER_CONSOLE_PROFILE];
273
278
  const admissionRuns = options.admissions ?? SPAWNED_ADMISSIONS;
279
+ const runEnvironment = options.env ?? process.env;
280
+ const admissionEnv = options.admissionLv2Path === undefined ? runEnvironment : { ...runEnvironment, LV2_PATH: options.admissionLv2Path.join(":") };
274
281
  const runs: { bytes: Buffer; judgment: Judgment }[] = [];
275
282
  const verdicts: QualifyVerdict[] = [];
276
283
  const documents: RtSafetyDocument[] = [];
@@ -289,10 +296,12 @@ export function qualifyTargets(
289
296
  const measured = cells === undefined ? {} : { cells: budgetCells(cells, options.cellsPerPluginBudget) };
290
297
  // an LV2 plugin is run under each measured host once, whichever probe output it is judged with
291
298
  const admissions: Record<string, HostAdmission> = {};
299
+ // an LV2 plugin is also run once under plugin-hostd's LV2 adapter, recorded beside the verdicts (#223)
300
+ const adapter = target.format === "lv2" ? admitUnderAdapter(target.uri, admissionRuns, admissionEnv) : undefined;
292
301
  if (target.format === "lv2") {
293
302
  for (const profile of judgedProfiles) {
294
303
  const host = admittedHostOf(profile);
295
- if (host !== undefined) admissions[profile.id] = admitPlugin(host, target.uri, admissionRuns);
304
+ if (host !== undefined) admissions[profile.id] = admitPlugin(host, target.uri, admissionRuns, admissionEnv);
296
305
  }
297
306
  }
298
307
  for (const profile of judgedProfiles) {
@@ -309,7 +318,8 @@ export function qualifyTargets(
309
318
  try {
310
319
  for (const { offer, profiles: forOffer } of byOffer.values()) {
311
320
  const probed = probeTarget(target, { ...options, ...measured, offer });
312
- const judgment = judgeProbeOutput(probed.output, forOffer, { admissions });
321
+ const plain = judgeProbeOutput(probed.output, forOffer, { admissions });
322
+ const judgment = adapter === undefined ? plain : { ...plain, adapter };
313
323
  targetRuns.push({ bytes: probed.bytes, judgment });
314
324
  for (const v of judgmentVerdicts(judgment)) judged.set(v.host, v);
315
325
  }
@@ -93,13 +93,15 @@ export function unmeasuredFigures(output: ProbeOutput): readonly UnmeasuredFigur
93
93
 
94
94
  /** A cell's latency when the run took no figure: the contract's unmeasured word and the hosting code a reader classifies by. */
95
95
  export interface UnmeasuredLatency {
96
+ /** The figure the plugin declared (LV2 latency port, CLAP latency extension) when it loaded but the probe did not measure. */
97
+ readonly declaredFrames?: number;
96
98
  readonly unmeasuredReason: string;
97
99
  readonly code: string;
98
100
  }
99
101
 
100
102
  /** A grid point as the record writes it: every cell carries `latency`, the figures or why there are none (#201). */
101
103
  export type RecordGridPoint = Omit<ProbeGridPoint, "latency"> & {
102
- readonly latency: NonNullable<ProbeGridPoint["latency"]> | UnmeasuredLatency;
104
+ readonly latency: (Omit<NonNullable<ProbeGridPoint["latency"]>, "measuredFrames"> & { readonly measuredFrames: number }) | UnmeasuredLatency;
103
105
  };
104
106
 
105
107
  const UNMEASURED_WORDS: ReadonlySet<string> = new Set(Object.values(MEASUREMENT_UNMEASURED_REASONS).map((r) => r.word));
@@ -120,7 +122,13 @@ const OUTCOME_WORD: Readonly<Record<string, string>> = {
120
122
  * older than the latency arm writes it.
121
123
  */
122
124
  export function latencyOfCell(output: ProbeOutput, point: ProbeGridPoint): RecordGridPoint["latency"] {
123
- if (point.latency !== undefined) return point.latency;
125
+ const had = point.latency;
126
+ if (had?.measuredFrames !== undefined) return { ...had, measuredFrames: had.measuredFrames };
127
+ const why = unmeasuredOfCell(output, point);
128
+ return had === undefined ? why : { declaredFrames: had.declaredFrames, ...why };
129
+ }
130
+
131
+ function unmeasuredOfCell(output: ProbeOutput, point: ProbeGridPoint): UnmeasuredLatency {
124
132
  const code = HOSTING_CODE.latencyUnmeasured;
125
133
  if (point.outcome !== PROBE_OK) return { unmeasuredReason: OUTCOME_WORD[point.outcome] ?? word("probeError"), code };
126
134
  const step = output.steps.find((st) => st.step === "latency" && st.rate === point.rate && (st.quantum === undefined || st.quantum === point.quantum));
@@ -162,6 +170,11 @@ export interface QualificationRecord {
162
170
  readonly testedVersion?: string;
163
171
  readonly binary: ProbeOutput["binary"];
164
172
  readonly verdicts: readonly RecordVerdict[];
173
+ /**
174
+ * An LV2 plugin run under plugin-hostd's LV2 adapter (omx-clap-host), as under mod-host: admitted, or refused with
175
+ * its code, or `measured: false` when it was not tried. Beside the verdicts, which it does not change (#223).
176
+ */
177
+ readonly adapter?: { readonly host: string; readonly measured: boolean; readonly admitted: boolean; readonly code?: string; readonly params: Readonly<Record<string, string | number>> };
165
178
  readonly tiers: RecordTiers;
166
179
  /** The cost reasons at each host's rate and quantum, beside the verdicts; an instrument's with the notes it held. */
167
180
  readonly cost: readonly {
@@ -266,6 +279,7 @@ export function buildQualificationRecord(
266
279
  ...(h.verdict.deciding === undefined ? {} : { deciding: h.verdict.deciding }),
267
280
  reasons: h.reasons,
268
281
  })),
282
+ ...(judgment.adapter === undefined ? {} : { adapter: judgment.adapter }),
269
283
  tiers: tiersOf(judgment),
270
284
  cost: judgment.hosts.map((h) => ({ host: h.verdict.host, ...h.cost })),
271
285
  measurements: {
@@ -20,7 +20,7 @@ import { classifyPluginHosting, type QualifiedPlugin } from "./hosting-suitabili
20
20
  import type { SuitabilityRating } from "./rating.js";
21
21
  import type { PluginLoader, PluginTarget } from "./loader.js";
22
22
  import { EXIT_CODE, OUTPUT_FORMATS, QUALIFY_OPTIONS, type OutputFormat } from "./qualify-options.js";
23
- import { GRID_HOSTS, parseGridSpec, type GridSpec } from "./measurement-grid.js";
23
+ import { GRID_FULL, parseGridSpec, type GridSpec } from "./measurement-grid.js";
24
24
  import { parseMemoryLimit } from "./memory-bound.js";
25
25
 
26
26
  export interface QualifyArgs {
@@ -91,7 +91,7 @@ export function parseQualifyArgs(argv: readonly string[]): ParseResult {
91
91
  if (quanta === undefined) return { ok: false, error: "--quanta must be positive integers, comma-separated" };
92
92
  const gridText = seen.get("--grid") as string | undefined;
93
93
  if (gridText !== undefined && seen.has("--grid-hosts") && gridText.trim() !== "hosts") return { ok: false, error: "--grid-hosts and --grid name two grids; give one" };
94
- const grid = gridText === undefined ? { ok: true as const, spec: GRID_HOSTS } : parseGridSpec(gridText);
94
+ const grid = gridText === undefined && seen.has("--grid-hosts") ? parseGridSpec("hosts") : gridText === undefined ? { ok: true as const, spec: GRID_FULL } : parseGridSpec(gridText);
95
95
  if (!grid.ok) return { ok: false, error: `--grid: ${grid.error}` };
96
96
  const count = (flag: string, min: number): number | undefined => {
97
97
  const v = seen.get(flag) as string | undefined;
@@ -175,11 +175,15 @@ export function resolveTargets(targets: readonly string[], loaders: readonly Plu
175
175
  continue;
176
176
  }
177
177
  for (const p of found) {
178
- const prior = plugins.get(p.uri);
178
+ // Two files can hold a plugin of the same id (a bundle's formats, or copies of it): the file is part of the key.
179
+ const key = `${p.uri}\0${p.binaryPath ?? ""}`;
180
+ const prior = plugins.get(key);
179
181
  // A URI and its bundle name the same plugin: keep the richer record (bundle, binary).
180
- if (prior === undefined || (prior.binary === undefined && p.binary !== undefined)) plugins.set(p.uri, p);
182
+ if (prior === undefined || (prior.binary === undefined && p.binary !== undefined)) plugins.set(key, p);
181
183
  }
182
184
  }
185
+ // a bare uri is the same plugin as a file's that holds it: it adds nothing once a file of that id is named
186
+ for (const [key, p] of plugins) if (p.binaryPath === undefined && [...plugins.values()].some((q) => q.uri === p.uri && q.binaryPath !== undefined)) plugins.delete(key);
183
187
  return refused.length > 0 ? { ok: false, refused } : { ok: true, plugins: [...plugins.values()] };
184
188
  }
185
189
 
@@ -38,10 +38,10 @@ export const QUALIFY_OPTIONS: readonly QualifyOption[] = [
38
38
  {
39
39
  flag: "--grid",
40
40
  value: "hosts|full|cells",
41
- describe: "the cells measured: hosts = each judged host's rate/quantum, then 512 at every rate; full = every rate at every quantum; or <rate>/<quantum>,...",
42
- default: "hosts",
41
+ describe: "the cells measured: full = every rate at every quantum; hosts = each judged host's rate/quantum, then 512 at every rate (the fast option); or <rate>/<quantum>,...",
42
+ default: "full",
43
43
  },
44
- { flag: "--grid-hosts", describe: "the same as --grid hosts" },
44
+ { flag: "--grid-hosts", describe: "the fast grid, the same as --grid hosts" },
45
45
  { flag: "--cells-per-plugin-budget", value: "n", describe: "measure at most the first n cells of the grid a plugin (0: every cell)", default: "0" },
46
46
  { flag: "--blocks", value: "n", describe: "blocks of the CLAP sweep a cell (the LV2 pass has a fixed shape)", default: String(CLAP_SWEEP_BLOCKS) },
47
47
  { flag: "--memory-limit", value: "size|off", describe: "the memory bound of every run (bytes, or with K, M, G); past it a cell is probe.oom", default: "half of the RAM" },