@openmixer/plugin-qualify 0.16.1 → 0.17.1

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 (61) hide show
  1. package/README.md +153 -6
  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/index.d.ts +5 -0
  9. package/dist/index.js +5 -0
  10. package/dist/index.js.map +1 -1
  11. package/dist/measurement-grid.d.ts +49 -0
  12. package/dist/measurement-grid.js +76 -0
  13. package/dist/measurement-grid.js.map +1 -0
  14. package/dist/memory-bound.d.ts +23 -0
  15. package/dist/memory-bound.js +46 -0
  16. package/dist/memory-bound.js.map +1 -0
  17. package/dist/probe-judge.js +22 -1
  18. package/dist/probe-judge.js.map +1 -1
  19. package/dist/probe-merge.d.ts +30 -0
  20. package/dist/probe-merge.js +80 -0
  21. package/dist/probe-merge.js.map +1 -0
  22. package/dist/probe-output.d.ts +8 -0
  23. package/dist/probe-output.js +7 -0
  24. package/dist/probe-output.js.map +1 -1
  25. package/dist/probe-runner.d.ts +26 -2
  26. package/dist/probe-runner.js +38 -10
  27. package/dist/probe-runner.js.map +1 -1
  28. package/dist/qualification-record.d.ts +19 -1
  29. package/dist/qualification-record.js +30 -1
  30. package/dist/qualification-record.js.map +1 -1
  31. package/dist/qualify-cli.d.ts +9 -0
  32. package/dist/qualify-cli.js +30 -1
  33. package/dist/qualify-cli.js.map +1 -1
  34. package/dist/qualify-declarations.d.ts +15 -0
  35. package/dist/qualify-declarations.js +15 -0
  36. package/dist/qualify-declarations.js.map +1 -1
  37. package/dist/qualify-options.js +11 -1
  38. package/dist/qualify-options.js.map +1 -1
  39. package/dist/qualify-sync.d.ts +18 -1
  40. package/dist/qualify-sync.js +142 -12
  41. package/dist/qualify-sync.js.map +1 -1
  42. package/dist/records-package.d.ts +63 -0
  43. package/dist/records-package.js +300 -0
  44. package/dist/records-package.js.map +1 -0
  45. package/package.json +3 -2
  46. package/src/distributed-records.ts +251 -0
  47. package/src/index.ts +5 -0
  48. package/src/measurement-grid.ts +97 -0
  49. package/src/memory-bound.ts +55 -0
  50. package/src/probe-judge.ts +25 -1
  51. package/src/probe-merge.ts +89 -0
  52. package/src/probe-output.ts +13 -0
  53. package/src/probe-runner.ts +75 -13
  54. package/src/qualification-record.ts +44 -2
  55. package/src/qualify-cli.ts +32 -1
  56. package/src/qualify-declarations.ts +17 -0
  57. package/src/qualify-options.ts +11 -1
  58. package/src/qualify-sync.ts +147 -11
  59. package/src/records-package.ts +333 -0
  60. package/tools/{ai-trace-commit-check.sh → commit-message-check.sh} +16 -16
  61. package/tools/{ai-trace-patterns.json → commit-message-patterns.json} +1 -1
@@ -0,0 +1,97 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
+ /**
4
+ * The measurement grid (#193): the cells, each one rate and one quantum, a plugin is measured at, and the runs of the
5
+ * probe runner that measure them. A host is judged at its own cell, so the grid a run takes is chosen from the hosts it
6
+ * judges; README "The measurement grid" states the three ways of choosing it and the order the cells are taken in.
7
+ *
8
+ * Pure: no file, no process. `probe-runner.ts` runs what `runnerInvocations` plans.
9
+ */
10
+ import { GRID_CONTINUITY_QUANTUM } from "./qualify-declarations.js";
11
+
12
+ /** One cell of the grid. */
13
+ export interface GridCell {
14
+ readonly rate: number;
15
+ readonly quantum: number;
16
+ }
17
+
18
+ /** How the grid is chosen: the hosts' cells, the full product of the rates and quanta, or the cells named. */
19
+ export type GridSpec = { readonly kind: "hosts" } | { readonly kind: "full" } | { readonly kind: "cells"; readonly cells: readonly GridCell[] };
20
+
21
+ export const GRID_HOSTS: GridSpec = { kind: "hosts" };
22
+ export const GRID_FULL: GridSpec = { kind: "full" };
23
+
24
+ export const cellKey = (c: GridCell): string => `${String(c.rate)}/${String(c.quantum)}`;
25
+
26
+ const positive = (t: string): number | undefined => {
27
+ const n = Number(t);
28
+ return /^\d+$/.test(t) && Number.isSafeInteger(n) && n > 0 ? n : undefined;
29
+ };
30
+
31
+ /** `hosts`, `full`, or `<rate>/<quantum>,...`; anything else is refused with what was wrong. */
32
+ export function parseGridSpec(text: string): { ok: true; spec: GridSpec } | { ok: false; error: string } {
33
+ const t = text.trim();
34
+ if (t === "hosts") return { ok: true, spec: GRID_HOSTS };
35
+ if (t === "full") return { ok: true, spec: GRID_FULL };
36
+ const cells: GridCell[] = [];
37
+ for (const part of t.split(",").map((p) => p.trim())) {
38
+ const m = /^(\d+)\/(\d+)$/.exec(part);
39
+ const rate = m === null ? undefined : positive(m[1]!);
40
+ const quantum = m === null ? undefined : positive(m[2]!);
41
+ if (rate === undefined || quantum === undefined) {
42
+ return { ok: false, error: `the grid is hosts, full, or cells as <rate>/<quantum>, comma-separated; "${part}" is none of them` };
43
+ }
44
+ cells.push({ rate, quantum });
45
+ }
46
+ return { ok: true, spec: { kind: "cells", cells: unique(cells) } };
47
+ }
48
+
49
+ function unique(cells: readonly GridCell[]): GridCell[] {
50
+ const seen = new Set<string>();
51
+ return cells.filter((c) => (seen.has(cellKey(c)) ? false : (seen.add(cellKey(c)), true)));
52
+ }
53
+
54
+ const byRateThenQuantum = (a: GridCell, b: GridCell): number => a.rate - b.rate || a.quantum - b.quantum;
55
+
56
+ /**
57
+ * The cells of a grid, in the order they are taken: for `hosts`, each host's own cell in the order the hosts are given,
58
+ * then `GRID_CONTINUITY_QUANTUM` at every rate; for `full`, those cells first as well, then the rest of the product by
59
+ * rate and quantum; for named cells, as named. A cell is listed once.
60
+ */
61
+ export function gridCells(
62
+ spec: GridSpec,
63
+ context: { readonly rates: readonly number[]; readonly quanta: readonly number[]; readonly hosts: readonly GridCell[] },
64
+ ): GridCell[] {
65
+ if (spec.kind === "cells") return unique(spec.cells);
66
+ const continuity = [...context.rates].sort((a, b) => a - b).map((rate) => ({ rate, quantum: GRID_CONTINUITY_QUANTUM }));
67
+ const lead = unique([...context.hosts, ...continuity]);
68
+ if (spec.kind === "hosts") return lead;
69
+ const product = context.rates.flatMap((rate) => context.quanta.map((quantum) => ({ rate, quantum }))).sort(byRateThenQuantum);
70
+ const inProduct = new Set(product.map(cellKey));
71
+ // the full grid is the product: a host's cell outside --rates x --quanta is not part of it
72
+ return unique([...lead.filter((c) => inProduct.has(cellKey(c))), ...product]);
73
+ }
74
+
75
+ /** The first `budget` cells, or every one when the budget is absent or 0. */
76
+ export function budgetCells(cells: readonly GridCell[], budget: number | undefined): GridCell[] {
77
+ return budget === undefined || budget === 0 ? [...cells] : cells.slice(0, budget);
78
+ }
79
+
80
+ /** One run of the probe runner: the rates it is given, its quantum, and the cells it stands for. */
81
+ export interface RunnerInvocation {
82
+ readonly rates: readonly number[];
83
+ readonly quantum: number;
84
+ readonly cells: readonly GridCell[];
85
+ }
86
+
87
+ /**
88
+ * The runs that measure `cells`. A CLAP plugin is run once per quantum, at every rate of the grid at that quantum (the
89
+ * prober takes a list of rates and one block size). An LV2 plugin is run once per cell: plugin-hostd's runner measures
90
+ * an LV2 plugin at one rate a run, the highest of the rates it is given, so a list would measure only that one.
91
+ */
92
+ export function runnerInvocations(format: string, cells: readonly GridCell[]): RunnerInvocation[] {
93
+ if (format !== "clap") return cells.map((c) => ({ rates: [c.rate], quantum: c.quantum, cells: [c] }));
94
+ const byQuantum = new Map<number, GridCell[]>();
95
+ for (const c of cells) byQuantum.set(c.quantum, [...(byQuantum.get(c.quantum) ?? []), c]);
96
+ return [...byQuantum.entries()].map(([quantum, at]) => ({ rates: [...new Set(at.map((c) => c.rate))].sort((a, b) => a - b), quantum, cells: at }));
97
+ }
@@ -0,0 +1,55 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
+ /**
4
+ * The memory bound of a run (#193). A sampler loading its library can take all of a small machine's memory; unbounded,
5
+ * the kernel's out-of-memory killer ends it (or something else) and the run is a crash nobody can tell from a bug. So
6
+ * every run is bounded: the runner's data limit (`--data-bytes`, `RLIMIT_DATA` on the measuring child), with the
7
+ * interposer told to exit `MEMORY_BOUND_EXIT_STATUS` the moment an allocation is refused under it.
8
+ *
9
+ * A run is out of memory when one of its steps says the child exited with that status, or was killed by a `SIGKILL`
10
+ * the runner did not send (its own deadline kill is `probe.timeout`, never `probe.crashed`): the kernel's killer.
11
+ */
12
+ import { totalmem } from "node:os";
13
+
14
+ import type { ProbeOutput } from "./probe-output.js";
15
+ import { MEMORY_BOUND_EXIT_STATUS, MEMORY_LIMIT_RAM_FRACTION } from "./qualify-declarations.js";
16
+
17
+ /** How the bound is applied: the runner's data limit on the child. */
18
+ export const MEMORY_BOUND_MECHANISM = "rlimit-data";
19
+
20
+ /** The outcome of a run, and of each of its cells, that outgrew the bound. */
21
+ export const PROBE_OOM = "probe.oom";
22
+
23
+ export interface MemoryBound {
24
+ readonly bytes: number;
25
+ readonly mechanism: typeof MEMORY_BOUND_MECHANISM;
26
+ }
27
+
28
+ const UNITS: Readonly<Record<string, number>> = { "": 1, K: 2 ** 10, M: 2 ** 20, G: 2 ** 30, T: 2 ** 40 };
29
+
30
+ /** `off`, a number of bytes, or one with a K, M, G or T suffix (powers of two: K is 2 ** 10 bytes). */
31
+ export function parseMemoryLimit(text: string): { ok: true; bytes: number | "off" } | { ok: false; error: string } {
32
+ const t = text.trim();
33
+ if (t === "off") return { ok: true, bytes: "off" };
34
+ const m = /^(\d+)([KMGT]?)(?:i?B)?$/i.exec(t);
35
+ const bytes = m === null ? NaN : Number(m[1]) * UNITS[m[2]!.toUpperCase()]!;
36
+ if (!Number.isSafeInteger(bytes) || bytes <= 0) return { ok: false, error: `the memory limit is off, or bytes as 2147483648, 2048M or 2G; "${t}" is neither` };
37
+ return { ok: true, bytes };
38
+ }
39
+
40
+ /** The default bound: `MEMORY_LIMIT_RAM_FRACTION` of the machine's RAM. */
41
+ export function defaultMemoryLimit(ramBytes: number = totalmem()): number {
42
+ return Math.floor(ramBytes * MEMORY_LIMIT_RAM_FRACTION);
43
+ }
44
+
45
+ /** The bound a setting names: `undefined` (the default), `off`, or a size. */
46
+ export function memoryBoundOf(setting: number | "off" | undefined, ramBytes?: number): MemoryBound | undefined {
47
+ if (setting === "off") return undefined;
48
+ return { bytes: setting ?? defaultMemoryLimit(ramBytes), mechanism: MEMORY_BOUND_MECHANISM };
49
+ }
50
+
51
+ /** Whether a run under `bound` ended out of memory: the interposer's exit status, or a SIGKILL that was no deadline's. */
52
+ export function outOfMemory(output: ProbeOutput, bound: MemoryBound | undefined): boolean {
53
+ if (bound === undefined) return false;
54
+ return output.steps.some((s) => s.exitStatus === MEMORY_BOUND_EXIT_STATUS || (s.signal === "SIGKILL" && s.outcome === "probe.crashed"));
55
+ }
@@ -38,6 +38,7 @@ import { PROBE_NOT_ATTEMPTED, PROBE_OK, type ProbeGridPoint, type ProbeOutput }
38
38
  import type { QualifyVerdict } from "./qualify-cli.js";
39
39
  import { RATING_SEVERITY, worstRating, type SuitabilityRating } from "./rating.js";
40
40
  import type { IntegrityMeasurement, VoicesHeld } from "./measurement/format.js";
41
+ import { PROBE_OOM } from "./memory-bound.js";
41
42
 
42
43
  /** Facts of the descriptor, which the probe output repeats none of. */
43
44
  export interface JudgeFacts {
@@ -197,8 +198,25 @@ function decidingOf(reasons: readonly RecordReason[], rating: SuitabilityRating)
197
198
  return [...reasons].sort((a, b) => RATING_SEVERITY[b.rating] - RATING_SEVERITY[a.rating]).find((r) => r.rating === rating);
198
199
  }
199
200
 
201
+ /** The grid point at a host's own cell, whatever its outcome; `undefined` when the grid does not hold that cell. */
202
+ function cellOf(output: ProbeOutput, profile: HostProfile): ProbeGridPoint | undefined {
203
+ return output.grid.find((p) => p.rate === profile.cost.rate && p.quantum === profile.cost.quantum);
204
+ }
205
+
206
+ const memoryParams = (p: ProbeGridPoint): Record<string, number | string> =>
207
+ p.memoryLimit === undefined ? {} : { memoryLimitBytes: p.memoryLimit.bytes, memoryLimitMechanism: p.memoryLimit.mechanism };
208
+
209
+ /**
210
+ * The cost reasons at the host's rate and quantum. A cell that was measured is read against the host's budget; a cell
211
+ * whose run did not end well is `unknown` with that run's outcome (`probe.oom`, `probe.crashed`); only a cell the grid
212
+ * does not hold is `hosting.cost.unmeasured-at-rate-and-quantum`.
213
+ */
200
214
  function costOf(output: ProbeOutput, profile: HostProfile): HostJudgment["cost"] {
201
215
  const { rate, quantum } = profile.cost;
216
+ const cell = cellOf(output, profile);
217
+ if (cell !== undefined && cell.outcome !== PROBE_OK && cell.outcome !== PROBE_NOT_ATTEMPTED) {
218
+ return { rate, quantum, reasons: [{ dimension: "cost", rating: "unknown", code: cell.outcome, params: { rate, quantum, ...memoryParams(cell) } }] };
219
+ }
202
220
  const reasons = hostingCostReasons(
203
221
  { cpuCost: { perRateQuantum: gridCostReadings(output.grid, PROBE_OK) } },
204
222
  { rate, quantum, profile },
@@ -215,7 +233,8 @@ function costOf(output: ProbeOutput, profile: HostProfile): HostJudgment["cost"]
215
233
  */
216
234
  function failedOutcome(output: ProbeOutput): string | undefined {
217
235
  if (output.run.outcome !== PROBE_OK) return output.run.outcome;
218
- return output.grid.find((p) => p.outcome !== PROBE_OK && p.outcome !== PROBE_NOT_ATTEMPTED)?.outcome;
236
+ // a cell that outgrew the memory bound is a recorded outcome of that cell, judged per host below, not the run's end
237
+ return output.grid.find((p) => p.outcome !== PROBE_OK && p.outcome !== PROBE_NOT_ATTEMPTED && p.outcome !== PROBE_OOM)?.outcome;
219
238
  }
220
239
 
221
240
  /**
@@ -257,6 +276,11 @@ export function judgeProbeOutput(output: ProbeOutput, profiles: readonly HostPro
257
276
  const notes = noteSweepOfProbeOutput(output, rt);
258
277
  reasons = [...rtSafetyReasons(rt), ...noteSweepReasons(notes, facts.noteInputs ?? (notes.applicable ? 1 : 0))].map(asRecordReason);
259
278
  }
279
+ // the plugin did not run within the memory bound at this host's own rate and quantum: not hostable there as measured
280
+ const cell = cellOf(output, profile);
281
+ if (cell?.outcome === PROBE_OOM) {
282
+ reasons = [...reasons, { dimension: "memory", rating: "unknown", code: PROBE_OOM, params: { rate: cell.rate, quantum: cell.quantum, ...memoryParams(cell) } }];
283
+ }
260
284
  const admission = facts.admissions?.[profile.id];
261
285
  if (admission !== undefined && admittedHostOf(profile) !== undefined) {
262
286
  if (!admission.measured || !admission.admitted) {
@@ -0,0 +1,89 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
+ /**
4
+ * The runs of one plugin and one offer, made one probe output (#193). The grid takes several runs of the probe runner
5
+ * (one per quantum for a CLAP plugin, one per cell for an LV2 one); the judge and the record read one document. The
6
+ * merge is a translation, never a judgment:
7
+ *
8
+ * - the grid is every run's points, and, for a run that did not end `probe.ok`, a point at each of its cells that holds
9
+ * none, carrying the run's outcome (and, for `probe.oom`, the bound it ran under), so no cell is silently absent;
10
+ * - the steps are every run's, in order; the run's time is the sum, and it is complete when every run was;
11
+ * - the run's outcome is the first that was not `probe.ok` among the runs that did not outgrow the bound; a run that
12
+ * outgrew it leaves its cells `probe.oom` and the rest are still judged; when every run did, it is `probe.oom`;
13
+ * - `run.parts` names each run: its id, its outcome, its rates and quantum, its time.
14
+ *
15
+ * One run that ended well and needs nothing added is kept as the runner wrote it, byte for byte.
16
+ */
17
+ import { outOfMemory, PROBE_OOM, type MemoryBound } from "./memory-bound.js";
18
+ import { cellKey, type RunnerInvocation } from "./measurement-grid.js";
19
+ import { PROBE_OK, readProbeOutput, type ProbeOutput } from "./probe-output.js";
20
+
21
+ /** One run of the runner, as it was read. */
22
+ export interface ProbeRunPart {
23
+ readonly bytes: Buffer;
24
+ readonly output: ProbeOutput;
25
+ readonly invocation: RunnerInvocation;
26
+ }
27
+
28
+ export class ProbeMergeError extends Error {
29
+ constructor(message: string) {
30
+ super(message);
31
+ this.name = "ProbeMergeError";
32
+ }
33
+ }
34
+
35
+ type Json = Record<string, unknown>;
36
+
37
+ export function mergeProbeRuns(parts: readonly ProbeRunPart[], bound: MemoryBound | undefined): { bytes: Buffer; output: ProbeOutput } {
38
+ if (parts.length === 0) throw new ProbeMergeError("no run to merge");
39
+ const outcomes = parts.map((p) => (outOfMemory(p.output, bound) ? PROBE_OOM : p.output.run.outcome));
40
+ const first = parts[0]!;
41
+ for (const p of parts.slice(1)) {
42
+ if (p.output.plugin.id !== first.output.plugin.id || p.output.binary.sha256 !== first.output.binary.sha256 || p.output.offer.id !== first.output.offer.id) {
43
+ throw new ProbeMergeError(
44
+ `the runs of ${first.output.plugin.id} measured different things (${p.output.plugin.id}, ${p.output.binary.sha256.slice(0, 12)}, offer ${p.output.offer.id}): the plugin's files changed between them`,
45
+ );
46
+ }
47
+ }
48
+ const missingCell = (p: ProbeRunPart): boolean => p.invocation.cells.some((c) => !p.output.grid.some((g) => g.rate === c.rate && g.quantum === c.quantum));
49
+ if (parts.length === 1 && outcomes[0] === first.output.run.outcome && (outcomes[0] === PROBE_OK || !missingCell(first))) {
50
+ return { bytes: first.bytes, output: first.output };
51
+ }
52
+
53
+ const docs = parts.map((p) => JSON.parse(p.bytes.toString("utf8")) as Json);
54
+ const merged = structuredClone(docs[0]!);
55
+ const grid: Json[] = [];
56
+ const seen = new Set<string>();
57
+ const steps: unknown[] = [];
58
+ parts.forEach((p, i) => {
59
+ for (const point of (docs[i]!["grid"] as Json[] | undefined) ?? []) {
60
+ const key = cellKey({ rate: point["rate"] as number, quantum: point["quantum"] as number });
61
+ if (seen.has(key)) continue;
62
+ seen.add(key);
63
+ grid.push(point);
64
+ }
65
+ if (outcomes[i] !== PROBE_OK) {
66
+ for (const c of p.invocation.cells) {
67
+ if (seen.has(cellKey(c))) continue;
68
+ seen.add(cellKey(c));
69
+ grid.push({ rate: c.rate, quantum: c.quantum, outcome: outcomes[i], ...(outcomes[i] === PROBE_OOM && bound !== undefined ? { memoryLimit: { bytes: bound.bytes, mechanism: bound.mechanism } } : {}) });
70
+ }
71
+ }
72
+ steps.push(...((docs[i]!["steps"] as unknown[] | undefined) ?? []));
73
+ });
74
+ const judged = outcomes.filter((o) => o !== PROBE_OOM);
75
+ const run = merged["run"] as Json;
76
+ merged["run"] = {
77
+ ...run,
78
+ elapsedMs: parts.reduce((sum, p) => sum + p.output.run.elapsedMs, 0),
79
+ complete: parts.every((p, i) => p.output.run.complete && outcomes[i] === PROBE_OK),
80
+ outcome: judged.length === 0 ? PROBE_OOM : (judged.find((o) => o !== PROBE_OK) ?? PROBE_OK),
81
+ parts: parts.map((p, i) => ({ id: p.output.run.id, outcome: outcomes[i], rates: p.invocation.rates, quantum: p.invocation.quantum, elapsedMs: p.output.run.elapsedMs })),
82
+ };
83
+ merged["steps"] = steps;
84
+ merged["grid"] = grid;
85
+ const bytes = Buffer.from(`${JSON.stringify(merged, null, 2)}\n`);
86
+ const read = readProbeOutput(bytes.toString("utf8"));
87
+ if (!read.ok) throw new ProbeMergeError(`the merged runs of ${first.output.plugin.id} do not read as a probe output (${read.code}): ${read.problem}`);
88
+ return { bytes, output: read.output };
89
+ }
@@ -95,6 +95,8 @@ export interface ProbeGridPoint {
95
95
  readonly cost?: ProbeCost;
96
96
  readonly rtSafety?: ProbeRtSafety;
97
97
  readonly noteSweep?: ProbeNoteSweep;
98
+ /** A cell whose run outgrew the memory bound (`probe.oom`): the bound it ran under. */
99
+ readonly memoryLimit?: { readonly bytes: number; readonly mechanism: string };
98
100
  }
99
101
 
100
102
  export interface ProbeStep {
@@ -105,6 +107,9 @@ export interface ProbeStep {
105
107
  /** The hosting code the step carries when it says why a figure is absent (`hosting.latency.unmeasured`). */
106
108
  readonly hostingCode?: string;
107
109
  readonly detail?: string;
110
+ /** How the child ended, when this step is a child's end: its exit status, or the signal that killed it. */
111
+ readonly exitStatus?: number;
112
+ readonly signal?: string;
108
113
  }
109
114
 
110
115
  export interface ProbeIdleState {
@@ -301,9 +306,15 @@ function readPoint(v: unknown, i: number): ProbeGridPoint {
301
306
  ...(o["cost"] === undefined ? {} : { cost: readCost(o["cost"], `${where} cost`) }),
302
307
  ...(o["rtSafety"] === undefined ? {} : { rtSafety: readRtSafety(o["rtSafety"], `${where} rtSafety`) }),
303
308
  ...(o["noteSweep"] === undefined ? {} : { noteSweep: readNoteSweep(o["noteSweep"], `${where} noteSweep`) }),
309
+ ...(o["memoryLimit"] === undefined ? {} : { memoryLimit: readMemoryLimit(o["memoryLimit"], `${where} memoryLimit`) }),
304
310
  };
305
311
  }
306
312
 
313
+ function readMemoryLimit(v: unknown, where: string): { bytes: number; mechanism: string } {
314
+ const o = record(v, where);
315
+ return { bytes: posInt(o, "bytes", where), mechanism: str(o, "mechanism", where) };
316
+ }
317
+
307
318
  function readStep(v: unknown, i: number): ProbeStep {
308
319
  const where = `steps[${i}]`;
309
320
  const o = record(v, where);
@@ -314,6 +325,8 @@ function readStep(v: unknown, i: number): ProbeStep {
314
325
  ...optional("quantum", o["quantum"] === undefined ? undefined : posInt(o, "quantum", where)),
315
326
  ...optional("hostingCode", nonEmptyString(o["hostingCode"])),
316
327
  ...optional("detail", nonEmptyString(o["detail"])),
328
+ ...optional("exitStatus", typeof o["exitStatus"] === "number" && Number.isInteger(o["exitStatus"]) ? o["exitStatus"] : undefined),
329
+ ...optional("signal", nonEmptyString(o["signal"])),
317
330
  };
318
331
  }
319
332
 
@@ -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
  },