@openmixer/plugin-qualify 0.16.0 → 0.17.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 (68) hide show
  1. package/README.md +154 -7
  2. package/bin/plugin-qualify-records-package.mjs +31 -0
  3. package/bin/plugin-qualify-sync.mjs +6 -1
  4. package/bin/plugin-qualify.mjs +12 -1
  5. package/dist/distributed-records.d.ts +88 -0
  6. package/dist/distributed-records.js +210 -0
  7. package/dist/distributed-records.js.map +1 -0
  8. package/dist/host-profiles.d.ts +5 -0
  9. package/dist/host-profiles.js +13 -0
  10. package/dist/host-profiles.js.map +1 -1
  11. package/dist/hosting-suitability.js +9 -1
  12. package/dist/hosting-suitability.js.map +1 -1
  13. package/dist/index.d.ts +5 -0
  14. package/dist/index.js +5 -0
  15. package/dist/index.js.map +1 -1
  16. package/dist/measurement-grid.d.ts +49 -0
  17. package/dist/measurement-grid.js +76 -0
  18. package/dist/measurement-grid.js.map +1 -0
  19. package/dist/memory-bound.d.ts +23 -0
  20. package/dist/memory-bound.js +46 -0
  21. package/dist/memory-bound.js.map +1 -0
  22. package/dist/probe-judge.js +22 -1
  23. package/dist/probe-judge.js.map +1 -1
  24. package/dist/probe-merge.d.ts +30 -0
  25. package/dist/probe-merge.js +80 -0
  26. package/dist/probe-merge.js.map +1 -0
  27. package/dist/probe-output.d.ts +8 -0
  28. package/dist/probe-output.js +7 -0
  29. package/dist/probe-output.js.map +1 -1
  30. package/dist/probe-runner.d.ts +26 -2
  31. package/dist/probe-runner.js +38 -10
  32. package/dist/probe-runner.js.map +1 -1
  33. package/dist/qualification-record.d.ts +19 -1
  34. package/dist/qualification-record.js +30 -1
  35. package/dist/qualification-record.js.map +1 -1
  36. package/dist/qualify-cli.d.ts +9 -0
  37. package/dist/qualify-cli.js +30 -1
  38. package/dist/qualify-cli.js.map +1 -1
  39. package/dist/qualify-declarations.d.ts +15 -0
  40. package/dist/qualify-declarations.js +15 -0
  41. package/dist/qualify-declarations.js.map +1 -1
  42. package/dist/qualify-options.js +11 -1
  43. package/dist/qualify-options.js.map +1 -1
  44. package/dist/qualify-sync.d.ts +23 -3
  45. package/dist/qualify-sync.js +172 -16
  46. package/dist/qualify-sync.js.map +1 -1
  47. package/dist/records-package.d.ts +63 -0
  48. package/dist/records-package.js +300 -0
  49. package/dist/records-package.js.map +1 -0
  50. package/package.json +3 -2
  51. package/src/distributed-records.ts +251 -0
  52. package/src/host-profiles.ts +13 -0
  53. package/src/hosting-suitability.ts +11 -1
  54. package/src/index.ts +5 -0
  55. package/src/measurement-grid.ts +97 -0
  56. package/src/memory-bound.ts +55 -0
  57. package/src/probe-judge.ts +25 -1
  58. package/src/probe-merge.ts +89 -0
  59. package/src/probe-output.ts +13 -0
  60. package/src/probe-runner.ts +75 -13
  61. package/src/qualification-record.ts +44 -2
  62. package/src/qualify-cli.ts +32 -1
  63. package/src/qualify-declarations.ts +17 -0
  64. package/src/qualify-options.ts +11 -1
  65. package/src/qualify-sync.ts +175 -16
  66. package/src/records-package.ts +333 -0
  67. package/tools/{ai-trace-commit-check.sh → commit-message-check.sh} +30 -28
  68. package/tools/{ai-trace-patterns.json → commit-message-patterns.json} +1 -1
@@ -25,6 +25,9 @@ import type { PluginTarget } from "./loader.js";
25
25
  import { SPAWNED_ADMISSIONS, admitPlugin, 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
+ import { budgetCells, gridCells, runnerInvocations, type GridCell, type GridSpec, type RunnerInvocation } from "./measurement-grid.js";
29
+ import type { MemoryBound } from "./memory-bound.js";
30
+ import { mergeProbeRuns, ProbeMergeError } from "./probe-merge.js";
28
31
  import type { QualifyVerdict } from "./qualify-cli.js";
29
32
  import {
30
33
  CLAP_QUALIFY_TIMEOUT_S,
@@ -33,8 +36,10 @@ import {
33
36
  LV2_HOSTING_BLOCK_FRAMES,
34
37
  LV2_HOSTING_TIMEOUT_S,
35
38
  LV2_MEASURE_TOOL_NAME,
39
+ MEMORY_BOUND_EXIT_ENV,
36
40
  PROBE_RUNNER_DEFAULT,
37
41
  PROBE_RUNNER_ENV,
42
+ QUALIFY_QUANTA,
38
43
  } from "./qualify-declarations.js";
39
44
 
40
45
  export class ProbeRunnerError extends Error {
@@ -67,8 +72,14 @@ const spawnRunner: ProbeRun = (tool, args, env) => {
67
72
  };
68
73
 
69
74
  export interface ProbeTargetOptions {
70
- /** The rates the plugin is measured at. */
75
+ /** The rates the plugin is measured at; with no `cells`, each at `CLAP_SWEEP_FRAMES`. */
71
76
  readonly rates: readonly number[];
77
+ /** The cells of the grid this plugin is measured at (`measurement-grid.ts`); each format's runs are planned from them. */
78
+ readonly cells?: readonly GridCell[];
79
+ /** Blocks of the CLAP sweep a cell; else `CLAP_SWEEP_BLOCKS`. The LV2 pass has a fixed shape. */
80
+ readonly blocks?: number;
81
+ /** The memory bound every run is held to; none (the runner's own default) when absent. */
82
+ readonly memory?: MemoryBound;
72
83
  /** Host extensions to offer beyond the prober's own (a CLAP run). */
73
84
  readonly offer?: readonly string[];
74
85
  /** The wall clock the runner gives one run, in seconds; else the format's declared one. */
@@ -80,7 +91,17 @@ export interface ProbeTargetOptions {
80
91
 
81
92
  /** The runner's arguments for a CLAP file; `child` is the prober the runner starts. */
82
93
  export function clapRunnerArgs(
83
- options: { rates: readonly number[]; offer?: readonly string[]; wallS?: number; out: string; log: string; child: string },
94
+ options: {
95
+ rates: readonly number[];
96
+ quantum?: number;
97
+ blocks?: number;
98
+ dataBytes?: number;
99
+ offer?: readonly string[];
100
+ wallS?: number;
101
+ out: string;
102
+ log: string;
103
+ child: string;
104
+ },
84
105
  binaryPath: string,
85
106
  id: string | undefined,
86
107
  ): string[] {
@@ -91,12 +112,13 @@ export function clapRunnerArgs(
91
112
  "--rates",
92
113
  options.rates.join(","),
93
114
  "--quantum",
94
- String(CLAP_SWEEP_FRAMES),
115
+ String(options.quantum ?? CLAP_SWEEP_FRAMES),
95
116
  "--blocks",
96
- String(CLAP_SWEEP_BLOCKS),
117
+ String(options.blocks ?? CLAP_SWEEP_BLOCKS),
97
118
  ...(options.offer === undefined || options.offer.length === 0 ? [] : ["--offer", options.offer.join(",")]),
98
119
  "--wall-s",
99
120
  String(options.wallS ?? CLAP_QUALIFY_TIMEOUT_S),
121
+ ...(options.dataBytes === undefined ? [] : ["--data-bytes", String(options.dataBytes)]),
100
122
  "--out",
101
123
  options.out,
102
124
  "--log",
@@ -110,16 +132,20 @@ export function clapRunnerArgs(
110
132
  * The runner's arguments for an LV2 plugin, named as `lv2:<uri>` and measured by `lv2_measure`: the one this run
111
133
  * located when `child` names it, else the one the runner looks for where the packages install it.
112
134
  */
113
- export function lv2RunnerArgs(options: { rates: readonly number[]; wallS?: number; out: string; log: string; child?: string }, uri: string): string[] {
135
+ export function lv2RunnerArgs(
136
+ options: { rates: readonly number[]; quantum?: number; dataBytes?: number; wallS?: number; out: string; log: string; child?: string },
137
+ uri: string,
138
+ ): string[] {
114
139
  return [
115
140
  "run",
116
141
  ...(options.child === undefined ? [] : ["--child", options.child]),
117
142
  "--rates",
118
143
  options.rates.join(","),
119
144
  "--quantum",
120
- String(LV2_HOSTING_BLOCK_FRAMES),
145
+ String(options.quantum ?? LV2_HOSTING_BLOCK_FRAMES),
121
146
  "--wall-s",
122
147
  String(options.wallS ?? LV2_HOSTING_TIMEOUT_S),
148
+ ...(options.dataBytes === undefined ? [] : ["--data-bytes", String(options.dataBytes)]),
123
149
  "--out",
124
150
  options.out,
125
151
  "--log",
@@ -134,7 +160,7 @@ export interface ProbedTarget {
134
160
  readonly output: ProbeOutput;
135
161
  }
136
162
 
137
- function runOne(target: PluginTarget, options: ProbeTargetOptions): ProbedTarget {
163
+ function runOne(target: PluginTarget, options: ProbeTargetOptions, invocation: RunnerInvocation): ProbedTarget {
138
164
  const env = options.env ?? process.env;
139
165
  const tool = probeRunnerTool(env);
140
166
  const work = mkdtempSync(join(tmpdir(), "plugin-qualify-probe-"));
@@ -145,20 +171,28 @@ function runOne(target: PluginTarget, options: ProbeTargetOptions): ProbedTarget
145
171
  let runEnv: Record<string, string | undefined> = { ...env };
146
172
  let expectedId: string;
147
173
  const wall = options.wallS === undefined ? {} : { wallS: options.wallS };
174
+ const bound = options.memory === undefined ? {} : { dataBytes: options.memory.bytes };
175
+ const at = { rates: invocation.rates, quantum: invocation.quantum, ...bound };
148
176
  if (target.format === "clap") {
149
177
  if (target.binaryPath === undefined) throw new ProbeRunnerError(`${target.uri}: no .clap to load; name the file or a directory holding it`);
150
178
  expectedId = target.uri.startsWith("urn:clap:") ? target.uri.slice("urn:clap:".length) : target.uri;
151
179
  const { tool: child, interposer } = clapQualifyTools(env, options.exists ?? existsSync);
152
- args = clapRunnerArgs({ rates: options.rates, ...(options.offer === undefined ? {} : { offer: options.offer }), ...wall, out, log, child }, target.binaryPath, expectedId);
180
+ args = clapRunnerArgs(
181
+ { ...at, ...(options.blocks === undefined ? {} : { blocks: options.blocks }), ...(options.offer === undefined ? {} : { offer: options.offer }), ...wall, out, log, child },
182
+ target.binaryPath,
183
+ expectedId,
184
+ );
153
185
  runEnv = { ...env, LD_PRELOAD: interposer };
154
186
  } else {
155
187
  expectedId = target.uri;
156
188
  const child = locateNativeTool(LV2_MEASURE_TOOL_NAME, env, options.exists ?? existsSync).path;
157
189
  args = lv2RunnerArgs(
158
- { rates: options.rates, ...wall, out, log, ...(child === undefined ? {} : { child }) },
190
+ { ...at, ...wall, out, log, ...(child === undefined ? {} : { child }) },
159
191
  target.uri,
160
192
  );
161
193
  }
194
+ // under a bound, the interposer ends the child the moment an allocation is refused, with the status that names it
195
+ if (options.memory !== undefined) runEnv = { ...runEnv, [MEMORY_BOUND_EXIT_ENV]: "1" };
162
196
  const r = (options.run ?? spawnRunner)(tool, args, runEnv);
163
197
  if (r.error !== undefined) {
164
198
  throw new ProbeRunnerError(`cannot run the probe runner "${tool}" (${r.error.message}): install plugin-hostd or set ${PROBE_RUNNER_ENV} to plugin-hostd-probe`);
@@ -186,9 +220,23 @@ function runOne(target: PluginTarget, options: ProbeTargetOptions): ProbedTarget
186
220
  }
187
221
  }
188
222
 
189
- /** The probe output of one target, through the runner. Throws {@link ProbeRunnerError} when there is none to judge. */
223
+ /** The cells a target is measured at: the options' cells, else every rate at `CLAP_SWEEP_FRAMES`. */
224
+ export function targetCells(options: Pick<ProbeTargetOptions, "rates" | "cells">): readonly GridCell[] {
225
+ return options.cells ?? options.rates.map((rate) => ({ rate, quantum: CLAP_SWEEP_FRAMES }));
226
+ }
227
+
228
+ /**
229
+ * The probe output of one target over its grid, through the runner: one run per quantum (CLAP) or per cell (LV2),
230
+ * merged into one document (`probe-merge.ts`). Throws {@link ProbeRunnerError} when a run leaves nothing to judge.
231
+ */
190
232
  export function probeTarget(target: PluginTarget, options: ProbeTargetOptions): ProbedTarget {
191
- return runOne(target, options);
233
+ const parts = runnerInvocations(target.format, targetCells(options)).map((invocation) => ({ ...runOne(target, options, invocation), invocation }));
234
+ try {
235
+ return mergeProbeRuns(parts, options.memory);
236
+ } catch (e) {
237
+ if (e instanceof ProbeMergeError) throw new ProbeRunnerError(e.message);
238
+ throw e;
239
+ }
192
240
  }
193
241
 
194
242
  /** What the pass over a run's targets produced. */
@@ -210,7 +258,16 @@ export interface ProbePass {
210
258
  */
211
259
  export function qualifyTargets(
212
260
  targets: readonly PluginTarget[],
213
- options: ProbeTargetOptions & { readonly profiles?: readonly HostProfile[]; readonly admissions?: AdmissionRuns },
261
+ options: ProbeTargetOptions & {
262
+ readonly profiles?: readonly HostProfile[];
263
+ readonly admissions?: AdmissionRuns;
264
+ /** How the grid is chosen; with it, each target's cells come from the hosts it is judged under. */
265
+ readonly grid?: GridSpec;
266
+ /** The quanta of a `full` grid; else `QUALIFY_QUANTA`. */
267
+ readonly quanta?: readonly number[];
268
+ /** At most this many cells a plugin, the first of the grid's order; 0 or absent is every cell. */
269
+ readonly cellsPerPluginBudget?: number;
270
+ },
214
271
  ): ProbePass {
215
272
  const profiles = options.profiles ?? [OPENMIXER_CONSOLE_PROFILE];
216
273
  const admissionRuns = options.admissions ?? SPAWNED_ADMISSIONS;
@@ -225,6 +282,11 @@ export function qualifyTargets(
225
282
  .flatMap((id) => (id === undefined ? [] : [shippedHostProfile(id)]))
226
283
  .filter((p): p is HostProfile => p !== undefined);
227
284
  const judgedProfiles = [...profiles, ...tierProfiles.filter((t) => !profiles.some((p) => p.id === t.id))];
285
+ const cells =
286
+ options.grid === undefined
287
+ ? options.cells
288
+ : gridCells(options.grid, { rates: options.rates, quanta: options.quanta ?? QUALIFY_QUANTA, hosts: judgedProfiles.map((p) => ({ rate: p.cost.rate, quantum: p.cost.quantum })) });
289
+ const measured = cells === undefined ? {} : { cells: budgetCells(cells, options.cellsPerPluginBudget) };
228
290
  // an LV2 plugin is run under each measured host once, whichever probe output it is judged with
229
291
  const admissions: Record<string, HostAdmission> = {};
230
292
  if (target.format === "lv2") {
@@ -246,7 +308,7 @@ export function qualifyTargets(
246
308
  const targetRuns: { bytes: Buffer; judgment: Judgment }[] = [];
247
309
  try {
248
310
  for (const { offer, profiles: forOffer } of byOffer.values()) {
249
- const probed = probeTarget(target, { ...options, offer });
311
+ const probed = probeTarget(target, { ...options, ...measured, offer });
250
312
  const judgment = judgeProbeOutput(probed.output, forOffer, { admissions });
251
313
  targetRuns.push({ bytes: probed.bytes, judgment });
252
314
  for (const v of judgmentVerdicts(judgment)) judged.set(v.host, v);
@@ -21,6 +21,8 @@ import type { Judgment, RecordReason } from "./probe-judge.js";
21
21
  import { sha256Hex, PROBE_OK, type ProbeGridPoint, type ProbeOutput } from "./probe-output.js";
22
22
  import type { VoicesHeld } from "./measurement/format.js";
23
23
  import { writeFileWhole } from "./whole-file.js";
24
+ import { HOSTING_CODE } from "./hosting-codes.js";
25
+ import { MEASUREMENT_UNMEASURED_REASONS } from "@openmixer/omx-contract";
24
26
 
25
27
  export const QUALIFICATION_RECORD_SCHEMA = "plugin-qualify.qualification-record/1";
26
28
 
@@ -89,6 +91,44 @@ export function unmeasuredFigures(output: ProbeOutput): readonly UnmeasuredFigur
89
91
  }));
90
92
  }
91
93
 
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
+ export interface UnmeasuredLatency {
96
+ readonly unmeasuredReason: string;
97
+ readonly code: string;
98
+ }
99
+
100
+ /** A grid point as the record writes it: every cell carries `latency`, the figures or why there are none (#201). */
101
+ export type RecordGridPoint = Omit<ProbeGridPoint, "latency"> & {
102
+ readonly latency: NonNullable<ProbeGridPoint["latency"]> | UnmeasuredLatency;
103
+ };
104
+
105
+ const UNMEASURED_WORDS: ReadonlySet<string> = new Set(Object.values(MEASUREMENT_UNMEASURED_REASONS).map((r) => r.word));
106
+ const word = (key: keyof typeof MEASUREMENT_UNMEASURED_REASONS): string => MEASUREMENT_UNMEASURED_REASONS[key].word;
107
+
108
+ /** The unmeasured word of a cell whose run did not end well, by its outcome code. */
109
+ const OUTCOME_WORD: Readonly<Record<string, string>> = {
110
+ "probe.crashed": word("probeCrashed"),
111
+ "probe.timeout": word("probeTimeout"),
112
+ "probe.refused-instantiation": word("refusedInstantiation"),
113
+ "probe.non-finite-output": word("nonFiniteOutput"),
114
+ };
115
+
116
+ /**
117
+ * Why a cell has no latency figure. A cell whose run failed: its outcome's word. A latency step at the cell that did not
118
+ * end well: the child's own reason, which the runner writes first in the step's detail, when it is a contract word,
119
+ * else `probe-error`. No latency step at all: the run did not take the figure (`not-attempted`), as a runner or a child
120
+ * older than the latency arm writes it.
121
+ */
122
+ export function latencyOfCell(output: ProbeOutput, point: ProbeGridPoint): RecordGridPoint["latency"] {
123
+ if (point.latency !== undefined) return point.latency;
124
+ const code = HOSTING_CODE.latencyUnmeasured;
125
+ if (point.outcome !== PROBE_OK) return { unmeasuredReason: OUTCOME_WORD[point.outcome] ?? word("probeError"), code };
126
+ const step = output.steps.find((st) => st.step === "latency" && st.rate === point.rate && (st.quantum === undefined || st.quantum === point.quantum));
127
+ if (step === undefined) return { unmeasuredReason: word("notAttempted"), code };
128
+ const said = step.detail?.split(":")[0]?.trim() ?? "";
129
+ return { unmeasuredReason: UNMEASURED_WORDS.has(said) ? said : word("probeError"), code: step.hostingCode ?? code };
130
+ }
131
+
92
132
  /**
93
133
  * What the running history made of the measured verdicts, for one host class: the second verdict beside the measured one.
94
134
  * Written by `plugin-qualify adjust --history`; the measured `verdicts` above are never changed by it. A record without the
@@ -136,7 +176,7 @@ export interface QualificationRecord {
136
176
  * {declaredFrames, measuredFrames}, `cost`, `rtSafety`, `noteSweep`), and the figures the run could not take, named.
137
177
  */
138
178
  readonly measurements: {
139
- readonly grid: readonly ProbeGridPoint[];
179
+ readonly grid: readonly RecordGridPoint[];
140
180
  readonly behaviour?: Readonly<Record<string, unknown>>;
141
181
  readonly unmeasured?: readonly UnmeasuredFigure[];
142
182
  };
@@ -150,6 +190,8 @@ export interface QualificationRecord {
150
190
  readonly measuredAt: string;
151
191
  /** The second verdict, per host class of the history that was read; absent until `adjust --history` has run. */
152
192
  readonly history?: readonly HistoryBlock[];
193
+ /** Where the record came from when it was not measured on this device: the distributed set's provenance, as the set's package wrote it. */
194
+ readonly provenance?: Readonly<Record<string, unknown>>;
153
195
  /** The probe output this record judged. */
154
196
  readonly probe: {
155
197
  readonly file: string;
@@ -227,7 +269,7 @@ export function buildQualificationRecord(
227
269
  tiers: tiersOf(judgment),
228
270
  cost: judgment.hosts.map((h) => ({ host: h.verdict.host, ...h.cost })),
229
271
  measurements: {
230
- grid: o.grid,
272
+ grid: o.grid.map((p) => ({ ...p, latency: latencyOfCell(o, p) })),
231
273
  ...(o.behaviour === undefined ? {} : { behaviour: o.behaviour }),
232
274
  ...(unmeasuredFigures(o).length === 0 ? {} : { unmeasured: unmeasuredFigures(o) }),
233
275
  },
@@ -20,12 +20,22 @@ 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";
24
+ import { parseMemoryLimit } from "./memory-bound.js";
23
25
 
24
26
  export interface QualifyArgs {
25
27
  readonly all: boolean;
26
28
  readonly targets: readonly string[];
27
29
  readonly rates: readonly number[];
28
30
  readonly quanta: readonly number[];
31
+ /** How the grid is chosen (`--grid`, `--grid-hosts`). */
32
+ readonly grid: GridSpec;
33
+ /** `--cells-per-plugin-budget`: at most this many cells a plugin; 0 is every cell. */
34
+ readonly cellsPerPluginBudget: number;
35
+ /** `--blocks`: blocks of the CLAP sweep a cell, when given. */
36
+ readonly blocks?: number;
37
+ /** `--memory-limit`: bytes, or `off`; absent is the default, half of the RAM. */
38
+ readonly memoryLimit?: number | "off";
29
39
  readonly out?: string;
30
40
  /** `--pins`: where the pins file is written. */
31
41
  readonly pins?: string;
@@ -79,6 +89,23 @@ export function parseQualifyArgs(argv: readonly string[]): ParseResult {
79
89
  if (rates === undefined) return { ok: false, error: "--rates must be positive integers, comma-separated" };
80
90
  const quanta = numberList((seen.get("--quanta") as string | undefined) ?? defaultOf("--quanta"));
81
91
  if (quanta === undefined) return { ok: false, error: "--quanta must be positive integers, comma-separated" };
92
+ const gridText = seen.get("--grid") as string | undefined;
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);
95
+ if (!grid.ok) return { ok: false, error: `--grid: ${grid.error}` };
96
+ const count = (flag: string, min: number): number | undefined => {
97
+ const v = seen.get(flag) as string | undefined;
98
+ if (v === undefined) return undefined;
99
+ const n = Number(v);
100
+ return /^\d+$/.test(v.trim()) && Number.isSafeInteger(n) && n >= min ? n : NaN;
101
+ };
102
+ const cellsPerPluginBudget = count("--cells-per-plugin-budget", 0) ?? 0;
103
+ if (Number.isNaN(cellsPerPluginBudget)) return { ok: false, error: "--cells-per-plugin-budget must be a whole number, 0 or more" };
104
+ const blocks = count("--blocks", 1);
105
+ if (blocks !== undefined && Number.isNaN(blocks)) return { ok: false, error: "--blocks must be a whole number, 1 or more" };
106
+ const memoryText = seen.get("--memory-limit") as string | undefined;
107
+ const memory = memoryText === undefined ? undefined : parseMemoryLimit(memoryText);
108
+ if (memory !== undefined && !memory.ok) return { ok: false, error: `--memory-limit: ${memory.error}` };
82
109
  const format = (seen.get("--format") as string | undefined) ?? defaultOf("--format");
83
110
  if (!(OUTPUT_FORMATS as readonly string[]).includes(format)) {
84
111
  return { ok: false, error: `--format must be one of ${OUTPUT_FORMATS.join(", ")}` };
@@ -94,6 +121,10 @@ export function parseQualifyArgs(argv: readonly string[]): ParseResult {
94
121
  targets,
95
122
  rates,
96
123
  quanta,
124
+ grid: grid.spec,
125
+ cellsPerPluginBudget,
126
+ ...(blocks === undefined ? {} : { blocks }),
127
+ ...(memory === undefined ? {} : { memoryLimit: memory.bytes }),
97
128
  ...(out === undefined ? {} : { out }),
98
129
  ...(pins === undefined ? {} : { pins }),
99
130
  format: format as OutputFormat,
@@ -108,7 +139,7 @@ export function helpText(): string {
108
139
  const rows = QUALIFY_OPTIONS.map((o) => {
109
140
  const left = o.value === undefined ? o.flag : `${o.flag} <${o.value}>`;
110
141
  const def = o.default === undefined ? "" : ` (default: ${o.default})`;
111
- return ` ${left.padEnd(28)}${o.describe}${def}`;
142
+ return ` ${left.padEnd(36)}${o.describe}${def}`;
112
143
  });
113
144
  return [
114
145
  "plugin-qualify — qualify audio plugins for real-time hosting",
@@ -133,6 +133,23 @@ export const NATIVE_TOOL_DIRS: readonly string[] = ["/usr/libexec/plugin-qualify
133
133
  export const CLAP_SWEEP_BLOCKS = 512;
134
134
  export const CLAP_SWEEP_FRAMES = 512;
135
135
 
136
+ /**
137
+ * The measurement grid (#193). Every record written before the grid was measured at `GRID_CONTINUITY_QUANTUM`, so
138
+ * the hosts' grid also takes that quantum at every rate: a new record stays comparable with an old one.
139
+ */
140
+ export const GRID_CONTINUITY_QUANTUM = CLAP_SWEEP_FRAMES;
141
+
142
+ /**
143
+ * The memory bound of a run (#193). The runner's data limit (`--data-bytes`, `RLIMIT_DATA` on the measuring child) is
144
+ * `MEMORY_LIMIT_RAM_FRACTION` of the machine's RAM unless `--memory-limit` says otherwise. With `MEMORY_BOUND_EXIT_ENV`
145
+ * set, the interposer ends the child with `MEMORY_BOUND_EXIT_STATUS` the moment an allocation is refused, so a run
146
+ * that outgrew the bound is told from a crash by an exit status, never by words; `native/rt-interpose.h` holds the
147
+ * same two (src/memory-bound.test.ts holds them together).
148
+ */
149
+ export const MEMORY_LIMIT_RAM_FRACTION = 0.5;
150
+ export const MEMORY_BOUND_EXIT_ENV = "OMX_RT_OOM_EXIT";
151
+ export const MEMORY_BOUND_EXIT_STATUS = 121;
152
+
136
153
  /** Seconds one `.clap` may take to qualify at every rate before the prober is killed. */
137
154
  export const CLAP_QUALIFY_TIMEOUT_S = 120;
138
155
 
@@ -4,7 +4,7 @@
4
4
  * The `plugin-qualify` CLI's OPTIONS, declared once. The parser, `--help` and the tests all read this table; every default is a declaration
5
5
  * from `declarations.ts`, never a literal here.
6
6
  */
7
- import { QUALIFY_QUANTA, QUALIFY_RATES } from "./qualify-declarations.js";
7
+ import { CLAP_SWEEP_BLOCKS, QUALIFY_QUANTA, QUALIFY_RATES } from "./qualify-declarations.js";
8
8
  import { HOST_PROFILES, OPENMIXER_CONSOLE_PROFILE } from "./host-profiles.js";
9
9
 
10
10
  /** How the result is printed. `text` = plugin, verdict, the reason that decided it. */
@@ -35,6 +35,16 @@ export const QUALIFY_OPTIONS: readonly QualifyOption[] = [
35
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
+ {
39
+ flag: "--grid",
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",
43
+ },
44
+ { flag: "--grid-hosts", describe: "the same as --grid hosts" },
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
+ { 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
+ { 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" },
38
48
  { flag: "--out", value: "dir", describe: "write one interchange JSON document per run into this directory" },
39
49
  { flag: "--format", value: OUTPUT_FORMATS.join("|"), describe: "json = the verdicts as JSON; text = plugin, verdict, deciding reason", default: "text" },
40
50
  { flag: "--isolate", describe: "a fresh worker per plugin, so a crash or hang takes only that plugin" },