@openmixer/plugin-qualify 0.14.1 → 0.16.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 (88) hide show
  1. package/README.md +127 -14
  2. package/bin/plugin-qualify-sync.mjs +57 -0
  3. package/bin/plugin-qualify.mjs +66 -15
  4. package/dist/adjust-cli.d.ts +83 -0
  5. package/dist/adjust-cli.js +214 -0
  6. package/dist/adjust-cli.js.map +1 -0
  7. package/dist/clap-loader.d.ts +3 -3
  8. package/dist/clap-loader.js +11 -11
  9. package/dist/clap-loader.js.map +1 -1
  10. package/dist/history-adjust.d.ts +77 -0
  11. package/dist/history-adjust.js +99 -0
  12. package/dist/history-adjust.js.map +1 -0
  13. package/dist/history-file.d.ts +73 -0
  14. package/dist/history-file.js +133 -0
  15. package/dist/history-file.js.map +1 -0
  16. package/dist/host-admission.d.ts +53 -0
  17. package/dist/host-admission.js +96 -0
  18. package/dist/host-admission.js.map +1 -0
  19. package/dist/index.d.ts +6 -0
  20. package/dist/index.js +6 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/lv2-loader.d.ts +16 -2
  23. package/dist/lv2-loader.js +168 -17
  24. package/dist/lv2-loader.js.map +1 -1
  25. package/dist/measurement/cli.d.ts +6 -4
  26. package/dist/measurement/cli.js +12 -19
  27. package/dist/measurement/cli.js.map +1 -1
  28. package/dist/pins-file.d.ts +8 -2
  29. package/dist/pins-file.js +17 -3
  30. package/dist/pins-file.js.map +1 -1
  31. package/dist/probe-judge.d.ts +11 -1
  32. package/dist/probe-judge.js +20 -1
  33. package/dist/probe-judge.js.map +1 -1
  34. package/dist/probe-output-fixtures.d.ts +10 -0
  35. package/dist/probe-output-fixtures.js +86 -0
  36. package/dist/probe-output-fixtures.js.map +1 -1
  37. package/dist/probe-output.d.ts +2 -0
  38. package/dist/probe-output.js +1 -0
  39. package/dist/probe-output.js.map +1 -1
  40. package/dist/probe-runner.d.ts +9 -3
  41. package/dist/probe-runner.js +36 -12
  42. package/dist/probe-runner.js.map +1 -1
  43. package/dist/qualification-record.d.ts +109 -3
  44. package/dist/qualification-record.js +79 -13
  45. package/dist/qualification-record.js.map +1 -1
  46. package/dist/qualify-cli.d.ts +0 -1
  47. package/dist/qualify-cli.js +2 -4
  48. package/dist/qualify-cli.js.map +1 -1
  49. package/dist/qualify-declarations.d.ts +8 -1
  50. package/dist/qualify-declarations.js +8 -1
  51. package/dist/qualify-declarations.js.map +1 -1
  52. package/dist/qualify-options.js +1 -2
  53. package/dist/qualify-options.js.map +1 -1
  54. package/dist/qualify-sync.d.ts +215 -0
  55. package/dist/qualify-sync.js +794 -0
  56. package/dist/qualify-sync.js.map +1 -0
  57. package/dist/whole-file.d.ts +3 -0
  58. package/dist/whole-file.js +36 -0
  59. package/dist/whole-file.js.map +1 -0
  60. package/fixtures/admission/2026-10-26.json +19 -0
  61. package/fixtures/clap-scan/fake.json +24 -24
  62. package/package.json +5 -4
  63. package/src/adjust-cli.ts +262 -0
  64. package/src/clap-loader.ts +9 -13
  65. package/src/history-adjust.ts +156 -0
  66. package/src/history-file.ts +177 -0
  67. package/src/host-admission.ts +136 -0
  68. package/src/index.ts +6 -0
  69. package/src/lv2-loader.ts +150 -15
  70. package/src/measurement/cli.ts +11 -17
  71. package/src/pins-file.ts +17 -3
  72. package/src/probe-judge.ts +27 -1
  73. package/src/probe-output-fixtures.ts +86 -0
  74. package/src/probe-output.ts +3 -0
  75. package/src/probe-runner.ts +45 -14
  76. package/src/qualification-record.ts +174 -15
  77. package/src/qualify-cli.ts +2 -5
  78. package/src/qualify-declarations.ts +9 -1
  79. package/src/qualify-options.ts +1 -2
  80. package/src/qualify-sync.ts +889 -0
  81. package/src/whole-file.ts +35 -0
  82. package/tools/_declarations.py +3 -1
  83. package/tools/ai-trace-commit-check.sh +26 -14
  84. package/tools/ai-trace-patterns.json +10 -2
  85. package/tools/gen-hosting-codes.mjs +1 -1
  86. package/tools/gen-python-declarations.mjs +2 -0
  87. package/tools/install-roundtrip.sh +173 -0
  88. package/tools/mod-host-admit.mjs +74 -0
@@ -17,9 +17,12 @@ import { tmpdir } from "node:os";
17
17
  import { join } from "node:path";
18
18
 
19
19
  import { ClapQualifyError, clapQualifyTools, proberOffer, type RtSafetyDocument } from "./clap-qualify.js";
20
+ import { locateNativeTool } from "./tool-paths.js";
20
21
  import type { HostProfile } from "./host-profiles.js";
21
- import { OPENMIXER_CONSOLE_PROFILE } from "./host-profiles.js";
22
+ import { OPENMIXER_CONSOLE_PROFILE, shippedHostProfile } from "./host-profiles.js";
23
+ import { IN_PROCESS_TIER_HOST, ISOLATED_TIER_HOST } from "./qualification-record.js";
22
24
  import type { PluginTarget } from "./loader.js";
25
+ import { SPAWNED_ADMISSIONS, admitPlugin, admittedHostOf, type AdmissionRuns, type HostAdmission } from "./host-admission.js";
23
26
  import { judgeProbeOutput, judgmentVerdicts, type Judgment } from "./probe-judge.js";
24
27
  import { readProbeOutput, type ProbeOutput } from "./probe-output.js";
25
28
  import type { QualifyVerdict } from "./qualify-cli.js";
@@ -29,6 +32,7 @@ import {
29
32
  CLAP_SWEEP_FRAMES,
30
33
  LV2_HOSTING_BLOCK_FRAMES,
31
34
  LV2_HOSTING_TIMEOUT_S,
35
+ LV2_MEASURE_TOOL_NAME,
32
36
  PROBE_RUNNER_DEFAULT,
33
37
  PROBE_RUNNER_ENV,
34
38
  } from "./qualify-declarations.js";
@@ -67,6 +71,8 @@ export interface ProbeTargetOptions {
67
71
  readonly rates: readonly number[];
68
72
  /** Host extensions to offer beyond the prober's own (a CLAP run). */
69
73
  readonly offer?: readonly string[];
74
+ /** The wall clock the runner gives one run, in seconds; else the format's declared one. */
75
+ readonly wallS?: number;
70
76
  readonly run?: ProbeRun;
71
77
  readonly env?: Readonly<Record<string, string | undefined>>;
72
78
  readonly exists?: (path: string) => boolean;
@@ -74,7 +80,7 @@ export interface ProbeTargetOptions {
74
80
 
75
81
  /** The runner's arguments for a CLAP file; `child` is the prober the runner starts. */
76
82
  export function clapRunnerArgs(
77
- options: { rates: readonly number[]; offer?: readonly string[]; out: string; log: string; child: string },
83
+ options: { rates: readonly number[]; offer?: readonly string[]; wallS?: number; out: string; log: string; child: string },
78
84
  binaryPath: string,
79
85
  id: string | undefined,
80
86
  ): string[] {
@@ -90,7 +96,7 @@ export function clapRunnerArgs(
90
96
  String(CLAP_SWEEP_BLOCKS),
91
97
  ...(options.offer === undefined || options.offer.length === 0 ? [] : ["--offer", options.offer.join(",")]),
92
98
  "--wall-s",
93
- String(CLAP_QUALIFY_TIMEOUT_S),
99
+ String(options.wallS ?? CLAP_QUALIFY_TIMEOUT_S),
94
100
  "--out",
95
101
  options.out,
96
102
  "--log",
@@ -101,19 +107,19 @@ export function clapRunnerArgs(
101
107
  }
102
108
 
103
109
  /**
104
- * The runner's arguments for an LV2 plugin, named as `lv2:<uri>` and measured by the runner's own LV2
105
- * child. The runner of plugin-hostd 0.4.0 starts a CLAP prober only (the LV2 child, plugin-hostd#76, is in no release yet), so
106
- * against it this run ends with the runner's own refusal, which {@link runOne} reports.
110
+ * The runner's arguments for an LV2 plugin, named as `lv2:<uri>` and measured by `lv2_measure`: the one this run
111
+ * located when `child` names it, else the one the runner looks for where the packages install it.
107
112
  */
108
- export function lv2RunnerArgs(options: { rates: readonly number[]; out: string; log: string }, uri: string): string[] {
113
+ export function lv2RunnerArgs(options: { rates: readonly number[]; wallS?: number; out: string; log: string; child?: string }, uri: string): string[] {
109
114
  return [
110
115
  "run",
116
+ ...(options.child === undefined ? [] : ["--child", options.child]),
111
117
  "--rates",
112
118
  options.rates.join(","),
113
119
  "--quantum",
114
120
  String(LV2_HOSTING_BLOCK_FRAMES),
115
121
  "--wall-s",
116
- String(LV2_HOSTING_TIMEOUT_S),
122
+ String(options.wallS ?? LV2_HOSTING_TIMEOUT_S),
117
123
  "--out",
118
124
  options.out,
119
125
  "--log",
@@ -138,15 +144,20 @@ function runOne(target: PluginTarget, options: ProbeTargetOptions): ProbedTarget
138
144
  let args: string[];
139
145
  let runEnv: Record<string, string | undefined> = { ...env };
140
146
  let expectedId: string;
147
+ const wall = options.wallS === undefined ? {} : { wallS: options.wallS };
141
148
  if (target.format === "clap") {
142
149
  if (target.binaryPath === undefined) throw new ProbeRunnerError(`${target.uri}: no .clap to load; name the file or a directory holding it`);
143
150
  expectedId = target.uri.startsWith("urn:clap:") ? target.uri.slice("urn:clap:".length) : target.uri;
144
151
  const { tool: child, interposer } = clapQualifyTools(env, options.exists ?? existsSync);
145
- args = clapRunnerArgs({ rates: options.rates, ...(options.offer === undefined ? {} : { offer: options.offer }), out, log, child }, target.binaryPath, expectedId);
152
+ args = clapRunnerArgs({ rates: options.rates, ...(options.offer === undefined ? {} : { offer: options.offer }), ...wall, out, log, child }, target.binaryPath, expectedId);
146
153
  runEnv = { ...env, LD_PRELOAD: interposer };
147
154
  } else {
148
155
  expectedId = target.uri;
149
- args = lv2RunnerArgs({ rates: options.rates, out, log }, target.uri);
156
+ const child = locateNativeTool(LV2_MEASURE_TOOL_NAME, env, options.exists ?? existsSync).path;
157
+ args = lv2RunnerArgs(
158
+ { rates: options.rates, ...wall, out, log, ...(child === undefined ? {} : { child }) },
159
+ target.uri,
160
+ );
150
161
  }
151
162
  const r = (options.run ?? spawnRunner)(tool, args, runEnv);
152
163
  if (r.error !== undefined) {
@@ -199,16 +210,30 @@ export interface ProbePass {
199
210
  */
200
211
  export function qualifyTargets(
201
212
  targets: readonly PluginTarget[],
202
- options: ProbeTargetOptions & { readonly profiles?: readonly HostProfile[] },
213
+ options: ProbeTargetOptions & { readonly profiles?: readonly HostProfile[]; readonly admissions?: AdmissionRuns },
203
214
  ): ProbePass {
204
215
  const profiles = options.profiles ?? [OPENMIXER_CONSOLE_PROFILE];
216
+ const admissionRuns = options.admissions ?? SPAWNED_ADMISSIONS;
205
217
  const runs: { bytes: Buffer; judgment: Judgment }[] = [];
206
218
  const verdicts: QualifyVerdict[] = [];
207
219
  const documents: RtSafetyDocument[] = [];
208
220
  const failures: { uri: string; problem: string }[] = [];
209
221
  for (const target of targets) {
210
222
  const byOffer = new Map<string, { offer: readonly string[]; profiles: HostProfile[] }>();
211
- for (const profile of profiles) {
223
+ // the record carries the console's two tiers whatever hosts were asked about, so they are judged too
224
+ const tierProfiles = [IN_PROCESS_TIER_HOST, ISOLATED_TIER_HOST[target.format]]
225
+ .flatMap((id) => (id === undefined ? [] : [shippedHostProfile(id)]))
226
+ .filter((p): p is HostProfile => p !== undefined);
227
+ const judgedProfiles = [...profiles, ...tierProfiles.filter((t) => !profiles.some((p) => p.id === t.id))];
228
+ // an LV2 plugin is run under each measured host once, whichever probe output it is judged with
229
+ const admissions: Record<string, HostAdmission> = {};
230
+ if (target.format === "lv2") {
231
+ for (const profile of judgedProfiles) {
232
+ const host = admittedHostOf(profile);
233
+ if (host !== undefined) admissions[profile.id] = admitPlugin(host, target.uri, admissionRuns);
234
+ }
235
+ }
236
+ for (const profile of judgedProfiles) {
212
237
  const offer = target.format === "clap" ? (proberOffer(profile) ?? []) : [];
213
238
  const key = offer.join(",");
214
239
  const entry = byOffer.get(key) ?? { offer, profiles: [] };
@@ -222,7 +247,7 @@ export function qualifyTargets(
222
247
  try {
223
248
  for (const { offer, profiles: forOffer } of byOffer.values()) {
224
249
  const probed = probeTarget(target, { ...options, offer });
225
- const judgment = judgeProbeOutput(probed.output, forOffer);
250
+ const judgment = judgeProbeOutput(probed.output, forOffer, { admissions });
226
251
  targetRuns.push({ bytes: probed.bytes, judgment });
227
252
  for (const v of judgmentVerdicts(judgment)) judged.set(v.host, v);
228
253
  }
@@ -230,7 +255,13 @@ export function qualifyTargets(
230
255
  if (!(e instanceof ProbeRunnerError) && !(e instanceof ClapQualifyError)) throw e;
231
256
  failures.push({ uri: target.uri, problem: e.message });
232
257
  for (const profile of profiles) {
233
- verdicts.push({ uri: target.uri, host: profile.id, measured: false, rating: "unknown", qualified: false, deciding: "not-measured" });
258
+ // no probe output means no record, but a host that refused the plugin when it was run is still a measured refusal
259
+ const a = admissions[profile.id];
260
+ verdicts.push(
261
+ a !== undefined && a.measured && !a.admitted
262
+ ? { uri: target.uri, host: profile.id, measured: true, rating: "unsuitable", qualified: false, deciding: a.code ?? `${a.host}.not-admitted` }
263
+ : { uri: target.uri, host: profile.id, measured: false, rating: "unknown", qualified: false, deciding: "not-measured" },
264
+ );
234
265
  }
235
266
  continue;
236
267
  }
@@ -13,12 +13,14 @@
13
13
  * Not yet in the record: the fingerprint of the descriptor's controls, since the descriptor is
14
14
  * written by the scan (stage 5) and plugin-qualify does not read one yet.
15
15
  */
16
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
16
+ import { mkdirSync, readFileSync } from "node:fs";
17
17
  import { join } from "node:path";
18
18
 
19
+ import type { Adjustment } from "./history-adjust.js";
19
20
  import type { Judgment, RecordReason } from "./probe-judge.js";
20
- import { sha256Hex, type ProbeGridPoint, type ProbeOutput } from "./probe-output.js";
21
+ import { sha256Hex, PROBE_OK, type ProbeGridPoint, type ProbeOutput } from "./probe-output.js";
21
22
  import type { VoicesHeld } from "./measurement/format.js";
23
+ import { writeFileWhole } from "./whole-file.js";
22
24
 
23
25
  export const QUALIFICATION_RECORD_SCHEMA = "plugin-qualify.qualification-record/1";
24
26
 
@@ -30,11 +32,89 @@ export interface RecordVerdict {
30
32
  readonly rating: string;
31
33
  readonly qualified: boolean;
32
34
  readonly hosting: "in-process" | "isolated";
35
+ /** `run` when the plugin was run under this host (`jalv`, `mod-host`), `declaration` when the verdict is what the profile's declaration makes of the probe output. */
36
+ readonly arm: "run" | "declaration";
33
37
  /** The code that decided it; absent for `suitable`. */
34
38
  readonly deciding?: string;
35
39
  readonly reasons: readonly RecordReason[];
36
40
  }
37
41
 
42
+ /** One tier of the console as a verdict: the host it is judged under and whether the plugin qualified there. */
43
+ export interface TierVerdict {
44
+ readonly host: string;
45
+ readonly measured: boolean;
46
+ readonly qualified: boolean;
47
+ readonly rating: string;
48
+ readonly deciding?: string;
49
+ }
50
+
51
+ /** The console's two tiers (what the console's roster offers): offered where the verdict qualified, dimmed with the reason when neither did. */
52
+ export interface RecordTiers {
53
+ readonly inProcess?: TierVerdict;
54
+ readonly isolated?: TierVerdict;
55
+ readonly offeredOn: readonly ("in-process" | "isolated")[];
56
+ /** Why the plugin is offered on neither tier: the deciding codes, in-process first. */
57
+ readonly dimmed?: string;
58
+ }
59
+
60
+ /** The profile each format's isolated tier is judged under. */
61
+ export const ISOLATED_TIER_HOST: Readonly<Record<string, string>> = { lv2: "mod-host", clap: "omx-clap-host" };
62
+ export const IN_PROCESS_TIER_HOST = "openmixer-console";
63
+
64
+ /** A latency or cost figure the run could not take: which one, at what rate and quantum, and the code that says why. */
65
+ export interface UnmeasuredFigure {
66
+ readonly dimension: "latency" | "cost";
67
+ readonly rate?: number;
68
+ readonly quantum?: number;
69
+ /** The hosting code the run gave (`hosting.latency.unmeasured`), else the step's outcome code. */
70
+ readonly code: string;
71
+ /** The prober's own words, for a person; never what a reader classifies by. */
72
+ readonly detail?: string;
73
+ }
74
+
75
+ /**
76
+ * The figures the run could not take, from the steps that say so. A latency or cost step that did not end `probe.ok`
77
+ * leaves its figure out of the grid: this names the figure, so a grid point without a `latency` is never read as a plugin
78
+ * with none.
79
+ */
80
+ export function unmeasuredFigures(output: ProbeOutput): readonly UnmeasuredFigure[] {
81
+ return output.steps
82
+ .filter((s) => (s.step === "latency" || s.step === "cost") && s.outcome !== PROBE_OK)
83
+ .map((s) => ({
84
+ dimension: s.step as "latency" | "cost",
85
+ ...(s.rate === undefined ? {} : { rate: s.rate }),
86
+ ...(s.quantum === undefined ? {} : { quantum: s.quantum }),
87
+ code: s.hostingCode ?? s.outcome,
88
+ ...(s.detail === undefined ? {} : { detail: s.detail }),
89
+ }));
90
+ }
91
+
92
+ /**
93
+ * What the running history made of the measured verdicts, for one host class: the second verdict beside the measured one.
94
+ * Written by `plugin-qualify adjust --history`; the measured `verdicts` above are never changed by it. A record without the
95
+ * block was never adjusted.
96
+ */
97
+ export interface HistoryBlock {
98
+ /** The machine class of the history's lines, `arch/cores/clock`. */
99
+ readonly hostClass: string;
100
+ /** The history file as it was named. */
101
+ readonly file: string;
102
+ /** The UTC time the history was read at (ISO 8601). */
103
+ readonly at: string;
104
+ /** The line looked for: plugin-hostd's uri and the digest of the first file of the pin; absent when the record holds no whole pin. */
105
+ readonly key?: { readonly uri: string; readonly digest: string };
106
+ /** Whether the history holds a line of this plugin binary on this host class. */
107
+ readonly found: boolean;
108
+ /** The windows the line counts. */
109
+ readonly windows?: { readonly solo: number; readonly grouped: number };
110
+ /** What plugin-hostd's `history_get` would answer of the line. */
111
+ readonly summary?: { readonly crashed: boolean; readonly raisesXruns: boolean };
112
+ /** The keys of the line this tool does not know, as written. */
113
+ readonly unknownKeys?: Readonly<Record<string, string>>;
114
+ /** One per host profile adjusted: the measured pin verdict, the adjusted one, the move and the reason code. */
115
+ readonly verdicts: readonly ({ readonly host: string } & Adjustment)[];
116
+ }
117
+
38
118
  export interface QualificationRecord {
39
119
  readonly schema: typeof QUALIFICATION_RECORD_SCHEMA;
40
120
  readonly plugin: { readonly format: string; readonly id: string; readonly name?: string };
@@ -42,6 +122,7 @@ export interface QualificationRecord {
42
122
  readonly testedVersion?: string;
43
123
  readonly binary: ProbeOutput["binary"];
44
124
  readonly verdicts: readonly RecordVerdict[];
125
+ readonly tiers: RecordTiers;
45
126
  /** The cost reasons at each host's rate and quantum, beside the verdicts; an instrument's with the notes it held. */
46
127
  readonly cost: readonly {
47
128
  readonly host: string;
@@ -50,11 +131,25 @@ export interface QualificationRecord {
50
131
  readonly voices?: VoicesHeld;
51
132
  readonly reasons: readonly RecordReason[];
52
133
  }[];
53
- /** The figures as measured, per rate and quantum. */
54
- readonly measurements: { readonly grid: readonly ProbeGridPoint[]; readonly behaviour?: Readonly<Record<string, unknown>> };
134
+ /**
135
+ * The figures as measured, per rate and quantum: the same fields whichever format the plugin is (`latency`
136
+ * {declaredFrames, measuredFrames}, `cost`, `rtSafety`, `noteSweep`), and the figures the run could not take, named.
137
+ */
138
+ readonly measurements: {
139
+ readonly grid: readonly ProbeGridPoint[];
140
+ readonly behaviour?: Readonly<Record<string, unknown>>;
141
+ readonly unmeasured?: readonly UnmeasuredFigure[];
142
+ };
143
+ /**
144
+ * What the run that wrote this record did not measure, each with the code a verdict reads for it: the quick
145
+ * qualification of `plugin-qualify-sync` takes no soak, and says so here rather than leave it unstated.
146
+ */
147
+ readonly notMeasured?: readonly NotMeasured[];
55
148
  /** Who measured and when. */
56
149
  readonly tool: Readonly<Record<string, string>>;
57
150
  readonly measuredAt: string;
151
+ /** The second verdict, per host class of the history that was read; absent until `adjust --history` has run. */
152
+ readonly history?: readonly HistoryBlock[];
58
153
  /** The probe output this record judged. */
59
154
  readonly probe: {
60
155
  readonly file: string;
@@ -70,6 +165,12 @@ export interface QualificationRecord {
70
165
  };
71
166
  }
72
167
 
168
+ /** One dimension a run did not measure, and the code that says so. */
169
+ export interface NotMeasured {
170
+ readonly dimension: string;
171
+ readonly code: string;
172
+ }
173
+
73
174
  const safe = (text: string): string => text.replace(/[^A-Za-z0-9._-]/g, "_");
74
175
 
75
176
  /** `<id>@<version>-<sha256 first 12>.<offer>`: one binary and one offer, whatever the run. */
@@ -80,7 +181,33 @@ export function qualificationBaseName(output: ProbeOutput): string {
80
181
  export const recordFileName = (output: ProbeOutput): string => `${qualificationBaseName(output)}.qualification.json`;
81
182
  export const probeCopyFileName = (output: ProbeOutput): string => `${qualificationBaseName(output)}.probe.json`;
82
183
 
83
- export function buildQualificationRecord(judgment: Judgment, copy: { file: string; sha256: string; bytes: number }): QualificationRecord {
184
+ function tierOf(judgment: Judgment, host: string | undefined): TierVerdict | undefined {
185
+ const v = judgment.hosts.find((h) => h.verdict.host === host)?.verdict;
186
+ if (v === undefined) return undefined;
187
+ return { host: v.host, measured: v.measured, qualified: v.qualified, rating: v.rating, ...(v.deciding === undefined ? {} : { deciding: v.deciding }) };
188
+ }
189
+
190
+ /** The console's two tiers from the verdicts the judgment holds. A tier no profile judged is absent, and is not offered. */
191
+ export function tiersOf(judgment: Judgment): RecordTiers {
192
+ const inProcess = tierOf(judgment, IN_PROCESS_TIER_HOST);
193
+ const isolated = tierOf(judgment, ISOLATED_TIER_HOST[judgment.output.plugin.format]);
194
+ const offeredOn: ("in-process" | "isolated")[] = [];
195
+ if (inProcess?.qualified === true) offeredOn.push("in-process");
196
+ if (isolated?.qualified === true) offeredOn.push("isolated");
197
+ const why = [inProcess, isolated].flatMap((t) => (t === undefined ? [] : [`${t.host}: ${t.deciding ?? t.rating}`]));
198
+ return {
199
+ ...(inProcess === undefined ? {} : { inProcess }),
200
+ ...(isolated === undefined ? {} : { isolated }),
201
+ offeredOn,
202
+ ...(offeredOn.length === 0 ? { dimmed: why.length === 0 ? "no tier was judged" : why.join("; ") } : {}),
203
+ };
204
+ }
205
+
206
+ export function buildQualificationRecord(
207
+ judgment: Judgment,
208
+ copy: { file: string; sha256: string; bytes: number },
209
+ notMeasured: readonly NotMeasured[] = [],
210
+ ): QualificationRecord {
84
211
  const o = judgment.output;
85
212
  return {
86
213
  schema: QUALIFICATION_RECORD_SCHEMA,
@@ -93,11 +220,18 @@ export function buildQualificationRecord(judgment: Judgment, copy: { file: strin
93
220
  rating: h.verdict.rating,
94
221
  qualified: h.verdict.qualified,
95
222
  hosting: h.hosting,
223
+ arm: h.arm,
96
224
  ...(h.verdict.deciding === undefined ? {} : { deciding: h.verdict.deciding }),
97
225
  reasons: h.reasons,
98
226
  })),
227
+ tiers: tiersOf(judgment),
99
228
  cost: judgment.hosts.map((h) => ({ host: h.verdict.host, ...h.cost })),
100
- measurements: { grid: o.grid, ...(o.behaviour === undefined ? {} : { behaviour: o.behaviour }) },
229
+ measurements: {
230
+ grid: o.grid,
231
+ ...(o.behaviour === undefined ? {} : { behaviour: o.behaviour }),
232
+ ...(unmeasuredFigures(o).length === 0 ? {} : { unmeasured: unmeasuredFigures(o) }),
233
+ },
234
+ ...(notMeasured.length === 0 ? {} : { notMeasured }),
101
235
  tool: o.run.tools,
102
236
  measuredAt: o.run.startedAt,
103
237
  probe: {
@@ -119,11 +253,9 @@ export function qualificationRecordOf(probeBytes: Uint8Array, judgment: Judgment
119
253
  return buildQualificationRecord(judgment, { file: probeCopyFileName(judgment.output), sha256: sha256Hex(probeBytes), bytes: probeBytes.length });
120
254
  }
121
255
 
122
- /** A file whole or not at all: written beside its name and renamed over it. */
123
- function writeWhole(path: string, bytes: string | Uint8Array): void {
124
- const pending = `${path}.new`;
125
- writeFileSync(pending, bytes);
126
- renameSync(pending, path);
256
+ /** The record rewritten whole or not at all, with its `history` block; nothing else in it is changed by the caller. */
257
+ export function rewriteRecord(path: string, record: QualificationRecord): void {
258
+ writeFileWhole(path, `${JSON.stringify(record, null, 2)}\n`);
127
259
  }
128
260
 
129
261
  export interface WrittenQualification {
@@ -137,13 +269,40 @@ export interface WrittenQualification {
137
269
  * record names the SHA-256 of the copy as it is on disk, read back, so a record never names bytes that
138
270
  * are not there. A rescan of the same binary and offer replaces both.
139
271
  */
140
- export function writeQualification(dir: string, probeBytes: Uint8Array, judgment: Judgment): WrittenQualification {
272
+ export function writeQualification(
273
+ dir: string,
274
+ probeBytes: Uint8Array,
275
+ judgment: Judgment,
276
+ notMeasured: readonly NotMeasured[] = [],
277
+ ): WrittenQualification {
141
278
  mkdirSync(dir, { recursive: true });
142
279
  const probePath = join(dir, probeCopyFileName(judgment.output));
143
- writeWhole(probePath, probeBytes);
280
+ writeFileWhole(probePath, probeBytes);
144
281
  const copy = readFileSync(probePath);
145
- const record = buildQualificationRecord(judgment, { file: probeCopyFileName(judgment.output), sha256: sha256Hex(copy), bytes: copy.length });
282
+ const record = buildQualificationRecord(judgment, { file: probeCopyFileName(judgment.output), sha256: sha256Hex(copy), bytes: copy.length }, notMeasured);
146
283
  const recordPath = join(dir, recordFileName(judgment.output));
147
- writeWhole(recordPath, `${JSON.stringify(record, null, 2)}\n`);
284
+ writeFileWhole(recordPath, `${JSON.stringify(record, null, 2)}\n`);
148
285
  return { recordPath, probePath, record };
149
286
  }
287
+
288
+ /**
289
+ * Runs that name the same binary and offer share one record file (a CLAP judged with and without the extra extensions
290
+ * offered): their verdicts become one record over the first run's probe output, so the second is never written over the first.
291
+ */
292
+ export function mergeRunsByRecord<T extends { readonly bytes: Uint8Array; readonly judgment: Judgment }>(runs: readonly T[]): { bytes: Uint8Array; judgment: Judgment }[] {
293
+ const byName = new Map<string, { bytes: Uint8Array; judgment: Judgment }>();
294
+ for (const r of runs) {
295
+ const name = qualificationBaseName(r.judgment.output);
296
+ const first = byName.get(name);
297
+ byName.set(
298
+ name,
299
+ first === undefined
300
+ ? r
301
+ : {
302
+ bytes: first.bytes,
303
+ judgment: { ...first.judgment, hosts: [...first.judgment.hosts, ...r.judgment.hosts], documents: [...first.judgment.documents, ...r.judgment.documents] },
304
+ },
305
+ );
306
+ }
307
+ return [...byName.values()];
308
+ }
@@ -31,7 +31,6 @@ export interface QualifyArgs {
31
31
  readonly pins?: string;
32
32
  readonly format: OutputFormat;
33
33
  readonly isolate: boolean;
34
- readonly hostSweep: boolean;
35
34
  /** `--host-profile` specs (a shipped id or a profile file), in order, once each. */
36
35
  readonly hostProfiles: readonly string[];
37
36
  readonly help: boolean;
@@ -99,7 +98,6 @@ export function parseQualifyArgs(argv: readonly string[]): ParseResult {
99
98
  ...(pins === undefined ? {} : { pins }),
100
99
  format: format as OutputFormat,
101
100
  isolate: seen.has("--isolate"),
102
- hostSweep: !seen.has("--no-host-sweep"),
103
101
  hostProfiles,
104
102
  help,
105
103
  },
@@ -116,9 +114,8 @@ export function helpText(): string {
116
114
  "plugin-qualify — qualify audio plugins for real-time hosting",
117
115
  "",
118
116
  " plugin-qualify <uri|bundle-path>... exactly the named plugins; a .lv2 bundle = every plugin in it",
119
- " plugin-qualify --all every CLAP plugin on CLAP_PATH, then ~/.clap, then /usr/lib/clap;",
120
- " refuses (exit 2) when LV2 bundles are on the LV2 path (LV2_PATH),",
121
- " whose plugins --all does not enumerate: name them instead",
117
+ " plugin-qualify --all every LV2 plugin of the bundles on the LV2 path (LV2_PATH), then every CLAP plugin on",
118
+ " CLAP_PATH, then ~/.clap, then /usr/lib/clap; with --out, one record each",
122
119
  "",
123
120
  ...rows,
124
121
  "",
@@ -165,7 +165,14 @@ export const DESCRIPTOR_HASH_PREFIX = 12;
165
165
  */
166
166
  export const DESCRIPTOR_SCHEMA_ENV = "OMX_CONTRACT_SCHEMA";
167
167
  export const DESCRIPTOR_SCHEMA_INSTALLED = "/usr/share/omx-contract/omx-contract.schema.json";
168
- export const DESCRIPTOR_SCHEMA_FLOOR = ">= 3.0.0";
168
+ export const DESCRIPTOR_SCHEMA_FLOOR = ">= 3.4.0";
169
+
170
+ /**
171
+ * Two ids that `_safe` maps onto the same characters (`a b` and `a_b`) would otherwise name the
172
+ * same file at the same version. The id whose safe form differs from the raw id carries this many
173
+ * hex digits of the raw id's SHA-256 as well, so the two no longer collide.
174
+ */
175
+ export const DESCRIPTOR_ID_HASH_PREFIX = 8;
169
176
 
170
177
  /**
171
178
  * Why a scanned descriptor is not written. A file the schema refuses is omx-contract's own
@@ -173,6 +180,7 @@ export const DESCRIPTOR_SCHEMA_FLOOR = ">= 3.0.0";
173
180
  */
174
181
  export const DESCRIPTOR_CODE_SCHEMA = "contract.file.invalid";
175
182
  export const DESCRIPTOR_CODE_SCHEMA_MISSING = "qualify.descriptor.schema-missing";
183
+ export const DESCRIPTOR_CODE_SCHEMA_FLOOR = "qualify.descriptor.schema-floor";
176
184
  export const DESCRIPTOR_CODE_BINARY_UNHASHED = "qualify.descriptor.binary-unhashed";
177
185
  export const DESCRIPTOR_CODE_DIR_UNWRITABLE = "qualify.descriptor.dir-unwritable";
178
186
 
@@ -32,13 +32,12 @@ export interface QualifyOption {
32
32
  }
33
33
 
34
34
  export const QUALIFY_OPTIONS: readonly QualifyOption[] = [
35
- { flag: "--all", describe: "every CLAP plugin on the CLAP search path; refuses when LV2 bundles are on the LV2 path" },
35
+ { flag: "--all", describe: "every LV2 plugin on the LV2 search path and every CLAP plugin on the CLAP search path; with --out, one record each" },
36
36
  { flag: "--rates", value: "list", describe: "sample rates to measure at, comma-separated", default: QUALIFY_RATES.join(",") },
37
37
  { flag: "--quanta", value: "list", describe: "quanta (frames) to rate cost at, comma-separated", default: QUALIFY_QUANTA.join(",") },
38
38
  { flag: "--out", value: "dir", describe: "write one interchange JSON document per run into this directory" },
39
39
  { flag: "--format", value: OUTPUT_FORMATS.join("|"), describe: "json = the verdicts as JSON; text = plugin, verdict, deciding reason", default: "text" },
40
40
  { flag: "--isolate", describe: "a fresh worker per plugin, so a crash or hang takes only that plugin" },
41
- { flag: "--no-host-sweep", describe: "offline lilv measurement only; no mod-host hosting sweep" },
42
41
  {
43
42
  flag: "--host-profile",
44
43
  value: "file|id",