@openmixer/plugin-qualify 0.17.4 → 0.18.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 (85) hide show
  1. package/README.md +53 -15
  2. package/bin/plugin-qualify-sync.mjs +1 -1
  3. package/dist/clap-qualify.d.ts +3 -1
  4. package/dist/clap-qualify.js +7 -1
  5. package/dist/clap-qualify.js.map +1 -1
  6. package/dist/direct-limit.d.ts +27 -0
  7. package/dist/direct-limit.js +52 -0
  8. package/dist/direct-limit.js.map +1 -0
  9. package/dist/host-admission.d.ts +10 -2
  10. package/dist/host-admission.js +13 -2
  11. package/dist/host-admission.js.map +1 -1
  12. package/dist/host-profiles.d.ts +8 -0
  13. package/dist/host-profiles.js +16 -1
  14. package/dist/host-profiles.js.map +1 -1
  15. package/dist/hosting-codes.generated.d.ts +10 -0
  16. package/dist/hosting-codes.generated.js +10 -0
  17. package/dist/hosting-codes.generated.js.map +1 -1
  18. package/dist/hosting-suitability.d.ts +34 -8
  19. package/dist/hosting-suitability.js +92 -7
  20. package/dist/hosting-suitability.js.map +1 -1
  21. package/dist/index.d.ts +1 -0
  22. package/dist/index.js +1 -0
  23. package/dist/index.js.map +1 -1
  24. package/dist/measurement/format.d.ts +54 -8
  25. package/dist/measurement/format.js +54 -3
  26. package/dist/measurement/format.js.map +1 -1
  27. package/dist/measurement/plan.d.ts +3 -3
  28. package/dist/measurement/plan.js +1 -1
  29. package/dist/measurement-grid.d.ts +8 -0
  30. package/dist/measurement-grid.js +4 -0
  31. package/dist/measurement-grid.js.map +1 -1
  32. package/dist/memory-bound.d.ts +6 -0
  33. package/dist/memory-bound.js +8 -0
  34. package/dist/memory-bound.js.map +1 -1
  35. package/dist/memory-budget.d.ts +39 -0
  36. package/dist/memory-budget.js +32 -0
  37. package/dist/memory-budget.js.map +1 -0
  38. package/dist/probe-judge.js +22 -9
  39. package/dist/probe-judge.js.map +1 -1
  40. package/dist/probe-merge.js +6 -5
  41. package/dist/probe-merge.js.map +1 -1
  42. package/dist/probe-output-fixtures.d.ts +6 -0
  43. package/dist/probe-output-fixtures.js +8 -0
  44. package/dist/probe-output-fixtures.js.map +1 -1
  45. package/dist/probe-output.d.ts +4 -2
  46. package/dist/probe-output.js +2 -1
  47. package/dist/probe-output.js.map +1 -1
  48. package/dist/probe-runner.js +6 -3
  49. package/dist/probe-runner.js.map +1 -1
  50. package/dist/qualification-record.d.ts +26 -2
  51. package/dist/qualification-record.js +17 -18
  52. package/dist/qualification-record.js.map +1 -1
  53. package/dist/qualify-declarations.d.ts +30 -11
  54. package/dist/qualify-declarations.js +38 -2
  55. package/dist/qualify-declarations.js.map +1 -1
  56. package/dist/qualify-sync.d.ts +20 -0
  57. package/dist/qualify-sync.js +101 -10
  58. package/dist/qualify-sync.js.map +1 -1
  59. package/package.json +1 -1
  60. package/src/clap-qualify.ts +8 -1
  61. package/src/direct-limit.ts +68 -0
  62. package/src/host-admission.ts +15 -2
  63. package/src/host-profiles.ts +17 -1
  64. package/src/hosting-codes.generated.ts +10 -0
  65. package/src/hosting-suitability.ts +105 -11
  66. package/src/index.ts +1 -0
  67. package/src/measurement/format.ts +97 -3
  68. package/src/measurement/plan.ts +3 -3
  69. package/src/measurement-grid.ts +5 -0
  70. package/src/memory-bound.ts +10 -0
  71. package/src/memory-budget.ts +55 -0
  72. package/src/probe-judge.ts +21 -8
  73. package/src/probe-merge.ts +6 -5
  74. package/src/probe-output-fixtures.ts +10 -1
  75. package/src/probe-output.ts +5 -2
  76. package/src/probe-runner.ts +5 -3
  77. package/src/qualification-record.ts +22 -4
  78. package/src/qualify-declarations.ts +49 -3
  79. package/src/qualify-sync.ts +103 -9
  80. package/tools/_declarations.py +1 -0
  81. package/tools/benchmark.py +25 -2
  82. package/tools/gen-python-declarations.mjs +1 -0
  83. package/tools/lv2-measure.mjs +7 -4
  84. package/tools/lv2_presets.py +1 -1
  85. package/tools/mod-host-admit.mjs +2 -1
@@ -36,7 +36,7 @@ import {
36
36
  import type { QualifyVerdict } from "./qualify-cli.js";
37
37
  import { RATING_SEVERITY, worstRating, type SuitabilityRating } from "./rating.js";
38
38
  import { halfPopulatedRefusal, locateNativeTools } from "./tool-paths.js";
39
- import { readIntegrity, type IntegrityMeasurement, type VoicesHeld } from "./measurement/format.js";
39
+ import { readIntegrity, readMemoryFootprint, type IntegrityMeasurement, type MemoryFootprint, type VoicesHeld } from "./measurement/format.js";
40
40
  import { isVoiceBasis } from "./measurement/vocabulary.js";
41
41
  import { coreFraction } from "./cost-fields.js";
42
42
 
@@ -103,6 +103,8 @@ export interface ClapCostReading {
103
103
  /** The percentile `nsPerSampleP95` is taken at. */
104
104
  readonly percentile: number;
105
105
  readonly perRate: Readonly<Record<string, ClapCostAtRate>>;
106
+ /** What one instance held at each rate (`memory` on the prober's per-rate object), read apart from the timing. */
107
+ readonly memory?: Readonly<Record<string, MemoryFootprint>>;
106
108
  }
107
109
 
108
110
  /** One plugin's line from `clap-qualify`. */
@@ -285,9 +287,12 @@ function readCost(v: unknown): ClapCostReading {
285
287
  const perRateIn = v["perRate"];
286
288
  if (!isRecord(perRateIn)) return bad("cost has no perRate map");
287
289
  const perRate: Record<string, ClapCostAtRate> = {};
290
+ const memory: Record<string, MemoryFootprint> = {};
288
291
  for (const [rate, at] of Object.entries(perRateIn)) {
289
292
  if (!/^[1-9]\d*$/.test(rate)) return bad(`cost rate ${rate}`);
290
293
  perRate[rate] = readCostAtRate(at, `cost at ${rate}`, stimulus === "notes-held");
294
+ const footprint = isRecord(at) ? readMemoryFootprint(at["memory"]) : undefined;
295
+ if (footprint !== undefined) memory[rate] = footprint;
291
296
  }
292
297
  return {
293
298
  stimulus,
@@ -295,6 +300,7 @@ function readCost(v: unknown): ClapCostReading {
295
300
  warmupBlocks: count(v, "warmupBlocks", "cost"),
296
301
  percentile: figure(v, "percentile", "cost"),
297
302
  perRate,
303
+ ...(Object.keys(memory).length === 0 ? {} : { memory }),
298
304
  };
299
305
  }
300
306
 
@@ -338,6 +344,7 @@ export function qualifiedPluginOfClapLine(line: ClapQualifyLine, stability?: Plu
338
344
  clap,
339
345
  rtSafety: { ...rtSafety, noteSweep },
340
346
  cpuCost: { perRate: clapCostPerRate(line.cost) },
347
+ ...(line.cost?.memory === undefined ? {} : { memory: { perRate: line.cost.memory } }),
341
348
  ...(stability === undefined ? {} : { stability }),
342
349
  };
343
350
  }
@@ -0,0 +1,68 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
+ /**
4
+ * The hard limit of a process plugin-qualify spawns directly. Through plugin-hostd's runner a measured child has `memory.max`;
5
+ * a direct spawn has no runner, so it gets `RLIMIT_AS`, which needs no privilege. It is off for a sanitizer build, which
6
+ * reserves far more address space than it uses and would fail at start under any limit worth having.
7
+ *
8
+ * Hitting the limit is an outcome: the interposer ends the child with `MEMORY_BOUND_EXIT_STATUS` on the first refused
9
+ * allocation when `MEMORY_BOUND_EXIT_ENV` is set ({@link addressSpaceLimited} sets it), and {@link directOutcome} names it.
10
+ */
11
+ import { readFileSync } from "node:fs";
12
+ import { totalmem } from "node:os";
13
+
14
+ import { ADDRESS_SPACE_LIMIT_RAM_FRACTION, MEMORY_BOUND_EXIT_ENV, MEMORY_BOUND_EXIT_STATUS } from "./qualify-declarations.js";
15
+
16
+ /** The outcome of a direct spawn that hit its limit. */
17
+ export const MEMORY_LIMIT_EXCEEDED = "memory-limit-exceeded";
18
+ export const ADDRESS_SPACE_MECHANISM = "rlimit-as";
19
+
20
+ const SANITIZER_ENTRY = ["__asan_init", "__tsan_init", "__msan_init"];
21
+
22
+ /** Whether the program is built with a sanitizer that reserves address space: its own init symbol is in the binary. */
23
+ export function isSanitizerBuild(tool: string, read: (path: string) => Buffer = (p) => readFileSync(p)): boolean {
24
+ let bytes: Buffer;
25
+ try {
26
+ bytes = read(tool);
27
+ } catch {
28
+ return false;
29
+ }
30
+ return SANITIZER_ENTRY.some((s) => bytes.includes(s));
31
+ }
32
+
33
+ export interface DirectSpawn {
34
+ readonly command: string;
35
+ readonly args: readonly string[];
36
+ readonly env: Readonly<Record<string, string>>;
37
+ /** The limit it runs under, in bytes; absent for a sanitizer build. */
38
+ readonly limitBytes?: number;
39
+ }
40
+
41
+ /** The limit a direct spawn takes by default: `ADDRESS_SPACE_LIMIT_RAM_FRACTION` of the machine's RAM. */
42
+ export const defaultAddressSpaceLimit = (ramBytes: number = totalmem()): number => Math.floor(ramBytes * ADDRESS_SPACE_LIMIT_RAM_FRACTION);
43
+
44
+ /**
45
+ * How to spawn `tool` under the limit: the shell sets it and execs the program, since node has no way to set a child's
46
+ * rlimit. The program's own process stays the one spawned, so a signal or a timeout reaches it as before.
47
+ */
48
+ export function addressSpaceLimited(
49
+ tool: string,
50
+ args: readonly string[],
51
+ limitBytes: number = defaultAddressSpaceLimit(),
52
+ sanitizer: boolean = isSanitizerBuild(tool),
53
+ ): DirectSpawn {
54
+ if (sanitizer) return { command: tool, args, env: {} };
55
+ const kb = Math.ceil(limitBytes / 2 ** 10);
56
+ return {
57
+ command: "/bin/sh",
58
+ args: ["-c", `ulimit -v ${String(kb)} && exec "$@"`, "sh", tool, ...args],
59
+ env: { [MEMORY_BOUND_EXIT_ENV]: "1" },
60
+ limitBytes,
61
+ };
62
+ }
63
+
64
+ /** What the end of a direct spawn under a limit comes to: the outcome and the limit when it hit it, else nothing. */
65
+ export function directOutcome(status: number | null, spawn: DirectSpawn): { readonly outcome: string; readonly memoryLimit: { readonly bytes: number; readonly mechanism: string } } | undefined {
66
+ if (spawn.limitBytes === undefined || status !== MEMORY_BOUND_EXIT_STATUS) return undefined;
67
+ return { outcome: MEMORY_LIMIT_EXCEEDED, memoryLimit: { bytes: spawn.limitBytes, mechanism: ADDRESS_SPACE_MECHANISM } };
68
+ }
@@ -24,11 +24,20 @@ export function admittedHostOf(profile: HostProfile): AdmittedHost | undefined {
24
24
 
25
25
  /**
26
26
  * plugin-hostd's LV2 adapter, omx-clap-host: an LV2 plugin is run under it once, as under mod-host, and the record
27
- * carries the outcome as its own step beside the verdicts (plugin-qualify#223), which it does not change: the
28
- * omx-clap-host verdict of an LV2 plugin stays the in-process measurement. It is what the removal of mod-host counts.
27
+ * carries the outcome as its own step (plugin-qualify#223). The run decides the omx-clap-host verdict of an LV2
28
+ * plugin, the isolated tier, as a mod-host run decides mod-host's; a CLAP plugin's stays the measurement.
29
29
  */
30
30
  export const ADAPTER_HOST = "omx-clap-host";
31
31
 
32
+ /**
33
+ * Whether this run decides the profile's verdict for a plugin of this format. A mod-host or jalv run always does, an
34
+ * unmeasured one making the verdict unknown; the adapter's only when it measured, since the omx-clap-host verdict
35
+ * otherwise stands on the measurement.
36
+ */
37
+ export function runDecides(profile: HostProfile, format: string, admission: HostAdmission): boolean {
38
+ return admittedHostOf(profile) !== undefined || (profile.id === ADAPTER_HOST && format === "lv2" && admission.measured);
39
+ }
40
+
32
41
  /** What running the plugin under one host came to. */
33
42
  export interface HostAdmission {
34
43
  readonly host: AdmittedHost | typeof ADAPTER_HOST;
@@ -62,12 +71,16 @@ export interface ModHostStep {
62
71
  /** What `tools/mod-host-admit.mjs` printed. */
63
72
  export interface ModHostRun {
64
73
  readonly installed: boolean;
74
+ /** False when the host never took a connection (omx-clap-host with no JACK server): no plugin was loaded. */
75
+ readonly started?: boolean;
65
76
  readonly steps: readonly ModHostStep[];
66
77
  }
67
78
 
68
79
  /** The verdict of a run of a host that speaks mod-host's protocol: the first step that did not answer `resp 0` decides. */
69
80
  export function classifyProtocolRun(host: ProtocolHost, run: ModHostRun): HostAdmission {
70
81
  if (!run.installed) return notInstalled(host);
82
+ // the host never listened, so the plugin was never loaded: nothing was learned about it
83
+ if (run.started === false) return { host, measured: false, admitted: false, code: `${host}.not-started`, params: {} };
71
84
  for (const s of run.steps) {
72
85
  if (s.died !== undefined) {
73
86
  if (s.died.startsWith("SIG")) return refused(host, `signal-${s.died}`, { step: s.cmd });
@@ -164,8 +164,24 @@ export const OMX_CLAP_HOST_PROFILE: HostProfile = {
164
164
  isolation: "shared-process",
165
165
  };
166
166
 
167
+ /**
168
+ * A Raspberry Pi with 4 GB, and with 8 GB: what runs a plugin there is omx-clap-host under plugin-hostd, so the same
169
+ * extensions, instruments and isolation, on the rates, quanta and memory of the board. Its memory budget is provisional
170
+ * ({@link HOST_BUDGETS}), so a plugin under it is `conditional` until the numbers are measured on a Pi, and the reason says
171
+ * so. Judged at every cell the board runs.
172
+ */
173
+ export const PI_4GB_PROFILE: HostProfile = { ...OMX_CLAP_HOST_PROFILE, id: "openmixer-pi-4gb", cost: HOST_BUDGETS["openmixer-pi-4gb"] };
174
+ export const PI_8GB_PROFILE: HostProfile = { ...OMX_CLAP_HOST_PROFILE, id: "openmixer-pi-8gb", cost: HOST_BUDGETS["openmixer-pi-8gb"] };
175
+
167
176
  /** Every profile the tool ships, by id. */
168
- export const HOST_PROFILES: readonly HostProfile[] = [OPENMIXER_CONSOLE_PROFILE, JALV_PROFILE, MOD_HOST_PROFILE, OMX_CLAP_HOST_PROFILE];
177
+ export const HOST_PROFILES: readonly HostProfile[] = [
178
+ OPENMIXER_CONSOLE_PROFILE,
179
+ JALV_PROFILE,
180
+ MOD_HOST_PROFILE,
181
+ OMX_CLAP_HOST_PROFILE,
182
+ PI_4GB_PROFILE,
183
+ PI_8GB_PROFILE,
184
+ ];
169
185
 
170
186
  /** A shipped profile by id, or `undefined`. */
171
187
  export function shippedHostProfile(id: string): HostProfile | undefined {
@@ -32,6 +32,10 @@ export const HOSTING_CODE = Object.freeze({
32
32
  "latencyParameterDependent": "hosting.latency.parameter-dependent",
33
33
  "latencyResolved": "hosting.latency.resolved",
34
34
  "latencyUnmeasured": "hosting.latency.unmeasured",
35
+ "memoryAboveCeiling": "hosting.memory.above-ceiling",
36
+ "memoryUnmeasured": "hosting.memory.unmeasured",
37
+ "memoryWithinCeiling": "hosting.memory.within-ceiling",
38
+ "memoryWithinProvisionalCeiling": "hosting.memory.within-provisional-ceiling",
35
39
  "outputNonFinite": "hosting.output.non-finite",
36
40
  "overrideIsolated": "hosting.override.curator-isolated",
37
41
  "patchBusy": "hosting.patch.busy",
@@ -45,6 +49,9 @@ export const HOSTING_CODE = Object.freeze({
45
49
  "noRealisation": "hosting.no-realisation",
46
50
  "realisedInProcess": "hosting.realised.in-process",
47
51
  "unjudged": "hosting.unjudged",
52
+ "resourceHintsOutOfRange": "hosting.resource-hints.out-of-range",
53
+ "resourceHintsUnknownHint": "hosting.resource-hints.unknown-hint",
54
+ "resourceHintsWrongUnit": "hosting.resource-hints.wrong-unit",
48
55
  "rtSafetyClean": "hosting.rt-safety.clean-under-sweep",
49
56
  "rtSafetyNotReproducible": "hosting.rt-safety.not-reproducible",
50
57
  "rtSafetyNotesClean": "hosting.rt-safety.clean-under-notes",
@@ -81,4 +88,7 @@ export const HOSTING_CODE = Object.freeze({
81
88
  "topologyWiderThanStrip": "hosting.topology.wider-than-strip",
82
89
  "workerNonconforming": "hosting.worker.nonconforming",
83
90
  "idleHoldRefused": "hosting.idle.hold-refused",
91
+ "windowNoDisplay": "hosting.window.no-display",
92
+ "windowUnsupported": "hosting.window.unsupported",
93
+ "windowRefused": "hosting.window.refused",
84
94
  } as const);
@@ -30,6 +30,7 @@ import type {
30
30
  SoakReading,
31
31
  ThreadReading,
32
32
  } from "./measurement/format.js";
33
+ import { memoryWithinBudget, type MemoryReadings } from "./memory-budget.js";
33
34
 
34
35
  /**
35
36
  * What a reason may interpolate: wire-serialisable scalars only. Structurally the same record as
@@ -71,6 +72,8 @@ export interface QualifiedPlugin {
71
72
  readonly lv2Features?: PluginLv2Features;
72
73
  /** The plugin's binary format, the scan's declared fact (CLAP spec §6); absent reads `lv2`. */
73
74
  readonly format?: "lv2" | "clap";
75
+ /** What one instance holds: per rate, or per rate AND quantum as {@link cpuCost} has it. A missing cell is unmeasured. */
76
+ readonly memory?: MemoryReadings;
74
77
  /** What the CLAP prober (`native/clap-qualify`) measured, for a `clap` plugin (CLAP spec §3). */
75
78
  readonly clap?: PluginClapMeasurement;
76
79
  }
@@ -78,14 +81,15 @@ export interface QualifiedPlugin {
78
81
  /** Where a plugin's audio is actually processed. */
79
82
  export type HostingPath = "in-process" | "isolated";
80
83
 
81
- /** The dimensions that decide a host. Six, folded worst-wins. */
84
+ /** The dimensions that decide a host, folded worst-wins. */
82
85
  export type HostingDimension =
83
86
  | "stability"
84
87
  | "rtSafety"
85
88
  | "features"
86
89
  | "cost"
87
90
  | "latency"
88
- | "topology";
91
+ | "topology"
92
+ | "memory";
89
93
 
90
94
  // The in-process feature list and the CLAP host extensions are the `openmixer-console` host
91
95
  // profile's; they live with the profiles (`host-profiles.ts`) and are re-exported here, where the
@@ -446,13 +450,15 @@ export function deadAgainstBest(levelDbfs: number, bestDbfs: number, policy: Hos
446
450
  }
447
451
 
448
452
  /** The classes of soak failure, as the strings `failures` is built from. */
449
- export type SoakFailureKind = "died" | "non-finite" | "dead" | "mostly-silent" | "startup-blast" | "timed-out";
453
+ export type SoakFailureKind = "died" | "non-finite" | "dead" | "mostly-silent" | "startup-blast" | "timed-out" | "leaks";
450
454
 
451
455
  export interface SoakFailure {
452
456
  readonly rate: number;
453
457
  readonly kind: SoakFailureKind;
454
458
  /** Extra words for the string form, e.g. `20dBFS`. */
455
459
  readonly detail?: string;
460
+ /** For `leaks`: what the growth was and the ceiling it passed, which the reason carries. */
461
+ readonly params?: CodedRefusalParams;
456
462
  }
457
463
 
458
464
  /** A soak's reading: what voided it, if anything, and what it proved when nothing did. */
@@ -465,7 +471,7 @@ export interface SoakFindings {
465
471
  * cut, a fact about the harness: it voids the soak but is never a failure of the plugin. */
466
472
  readonly failed: readonly SoakFailure[];
467
473
  readonly ratesTested: number;
468
- /** The largest RSS growth across rates, for the record; not judged. */
474
+ /** The largest RSS growth across rates, warm-up included, for the record; `leaks` is what is judged. */
469
475
  readonly rssGrowthKb: number;
470
476
  }
471
477
 
@@ -473,6 +479,17 @@ export interface SoakFindings {
473
479
  * settled above it cannot be the reference, or every sane rate reads dead against it. */
474
480
  const BLAST_DBFS = 6;
475
481
 
482
+ /**
483
+ * The growth a rate's soak is still showing, in KB per minute of audio: from halfway past warm-up to its highest reading,
484
+ * so that a library that finishes loading a little late is not a leak and a leak of any size is. `undefined` when the soak
485
+ * did not reach those readings: not read as flat.
486
+ */
487
+ export function soakGrowthKbPerMin(v: SoakAtRate): number | undefined {
488
+ const { rssMidKb, midSeconds, rssMaxKb, seconds } = v;
489
+ if (rssMidKb === undefined || midSeconds === undefined || rssMaxKb === undefined || seconds === undefined || seconds <= midSeconds) return undefined;
490
+ return (Math.max(rssMaxKb - rssMidKb, 0) * 60) / (seconds - midSeconds);
491
+ }
492
+
476
493
  /**
477
494
  * Read a soak, rate by rate. A rate voids it if the plugin did not instantiate or died there,
478
495
  * emitted a non-finite window, settled dead against its own best rate ({@link deadAgainstBest};
@@ -516,7 +533,23 @@ export function soakFindings(soak: SoakReading | undefined, policy: HostingPolic
516
533
  continue;
517
534
  }
518
535
  const first = v.firstRmsDbfs ?? -99;
519
- if (first > BLAST_DBFS) failed.push({ rate, kind: "startup-blast", detail: `${first.toFixed(0)}dBFS` });
536
+ if (first > BLAST_DBFS) {
537
+ failed.push({ rate, kind: "startup-blast", detail: `${first.toFixed(0)}dBFS` });
538
+ continue;
539
+ }
540
+ const growth = soakGrowthKbPerMin(v);
541
+ if (growth !== undefined && growth > policy.memoryGrowthKbPerMinCeiling) {
542
+ failed.push({
543
+ rate,
544
+ kind: "leaks",
545
+ detail: `${growth.toFixed(0)}KB/min`,
546
+ params: {
547
+ growthKbPerMin: Math.round(growth),
548
+ ceilingKbPerMin: policy.memoryGrowthKbPerMinCeiling,
549
+ ...(policy.memoryGrowthCeilingProvisional ? { provisional: "yes" as const } : {}),
550
+ },
551
+ });
552
+ }
520
553
  }
521
554
  const failures = failed.map((f) => `${f.kind}@${f.rate}${f.detail === undefined ? "" : `(${f.detail})`}`);
522
555
  return {
@@ -592,6 +625,7 @@ export function stabilityReasons(
592
625
  reason("stability", "objective", "unsuitable", HOSTING_CODE.stabilitySoakFailedAtRate, {
593
626
  failure: f.kind,
594
627
  rate: f.rate,
628
+ ...f.params,
595
629
  }),
596
630
  );
597
631
  }
@@ -650,9 +684,11 @@ export function rtSafetyReasons(
650
684
  if (m.variable === true) {
651
685
  return [reason("rtSafety", "objective", "unknown", HOSTING_CODE.rtSafetyNotReproducible)];
652
686
  }
687
+ // RT-unsafe is a hosting mode, never a refusal (#196): the plugin loads and runs in a worker of its own, never
688
+ // grouped with RT-safe plugins; conditional is isolated in the pin. Crashes, hangs and load failures stay unsuitable.
653
689
  if (rtSafetyViolating(m)) {
654
690
  return [
655
- reason("rtSafety", "objective", "unsuitable", HOSTING_CODE.rtSafetyViolations, {
691
+ reason("rtSafety", "objective", "conditional", HOSTING_CODE.rtSafetyViolations, {
656
692
  allocations: m.allocationsInRun ?? 0,
657
693
  syscalls: m.syscallsInRun ?? 0,
658
694
  locks: m.locksInRun ?? 0,
@@ -777,6 +813,60 @@ export function hostingCostReasons(
777
813
  return out;
778
814
  }
779
815
 
816
+ /**
817
+ * What a host's memory budget makes of a plugin. A host with no budget declared reads nothing here. Every cell the host
818
+ * runs is read; the worst stands, so a plugin is never read as fitting on the cell that happened to be measured. A missing
819
+ * figure is `unknown`, never a fit. Under a provisional budget a plugin below the ceiling is `conditional` with a code of its
820
+ * own, since the number it was held to was not measured on that kind of host; one above it is `unsuitable` and says the
821
+ * ceiling is provisional.
822
+ */
823
+ export function hostingMemoryReasons(
824
+ memory: MemoryReadings | undefined,
825
+ profile: HostProfile,
826
+ /** The live rate and quantum, for a host whose budget names no cells of its own; absent, the cell its cost is read at. */
827
+ live?: { readonly rate: number; readonly quantum?: number },
828
+ ): readonly HostingReason[] {
829
+ const budget = profile.cost.memory;
830
+ if (budget === undefined) return [];
831
+ const provisional: CodedRefusalParams = budget.provisional === true ? { provisional: "yes" } : {};
832
+ const cells = budget.cells ?? [{ rate: live?.rate ?? profile.cost.rate, quantum: live?.quantum ?? profile.cost.quantum }];
833
+ const reads = cells.map((c) => ({ ...c, read: memoryWithinBudget(memoryAt(memory, c.rate, c.quantum), budget) }));
834
+ const above = reads.find((r) => r.read.outcome === "above");
835
+ if (above !== undefined && above.read.outcome === "above") {
836
+ return [
837
+ reason("memory", "objective", "unsuitable", HOSTING_CODE.memoryAboveCeiling, {
838
+ rate: above.rate,
839
+ quantum: above.quantum,
840
+ figure: above.read.figure,
841
+ kb: above.read.kb,
842
+ ceilingKb: above.read.ceilingKb,
843
+ ...provisional,
844
+ }),
845
+ ];
846
+ }
847
+ const unmeasured = reads.find((r) => r.read.outcome === "unmeasured");
848
+ if (unmeasured !== undefined && unmeasured.read.outcome === "unmeasured") {
849
+ return [
850
+ reason("memory", "objective", "unknown", HOSTING_CODE.memoryUnmeasured, {
851
+ rate: unmeasured.rate,
852
+ quantum: unmeasured.quantum,
853
+ ...(unmeasured.read.unmeasuredReason === undefined ? {} : { unmeasuredReason: unmeasured.read.unmeasuredReason }),
854
+ }),
855
+ ];
856
+ }
857
+ const first = reads[0]!.read;
858
+ const params: CodedRefusalParams = first.outcome === "within" ? { perInstanceKb: first.perInstanceKb, firstInstanceKb: first.firstInstanceKb, ceilingKb: budget.perInstanceKb, consoleCeilingKb: budget.perConsoleKb } : {};
859
+ return budget.provisional === true
860
+ ? [reason("memory", "objective", "conditional", HOSTING_CODE.memoryWithinProvisionalCeiling, { ...params, ...provisional })]
861
+ : [reason("memory", "objective", "suitable", HOSTING_CODE.memoryWithinCeiling, params)];
862
+ }
863
+
864
+ /** The footprint at a rate and quantum: a grid's cell when the plugin has a grid, else its rate's. */
865
+ function memoryAt(memory: MemoryReadings | undefined, rate: number, quantum: number) {
866
+ if (memory?.perRateQuantum !== undefined) return memory.perRateQuantum[costPointKey(rate, quantum)];
867
+ return memory?.perRate?.[String(rate)];
868
+ }
869
+
780
870
  export function hostingLatencyReasons(
781
871
  descriptor: Pick<QualifiedPlugin, "latency">,
782
872
  handling: HostProfile["latency"] = "compensated",
@@ -924,7 +1014,8 @@ export function clapFeatureReasons(
924
1014
  /**
925
1015
  * The CLAP additions to `rtSafety` (CLAP spec §3): the interposer's counts are read by
926
1016
  * `rtSafetyReasons` exactly as for an LV2; these are the two violations only a CLAP can commit
927
- * — a strict thread-check violation and a `CLAP_PROCESS_ERROR` — each `unsuitable`. A sweep
1017
+ * — a strict thread-check violation (`conditional`: the plugin runs isolated, #196) and a `CLAP_PROCESS_ERROR`
1018
+ * (`unsuitable`). A sweep
928
1019
  * that made no `process()` call measured nothing.
929
1020
  */
930
1021
  export function clapRtReasons(clap: PluginClapMeasurement | undefined): readonly HostingReason[] {
@@ -933,7 +1024,7 @@ export function clapRtReasons(clap: PluginClapMeasurement | undefined): readonly
933
1024
  }
934
1025
  const out: HostingReason[] = [];
935
1026
  if (clap.threadViolations > 0) {
936
- out.push(reason("rtSafety", "objective", "unsuitable", HOSTING_CODE.clapThreadViolation, { violations: clap.threadViolations }));
1027
+ out.push(reason("rtSafety", "objective", "conditional", HOSTING_CODE.clapThreadViolation, { violations: clap.threadViolations }));
937
1028
  }
938
1029
  if (clap.processErrors > 0) {
939
1030
  out.push(reason("rtSafety", "objective", "unsuitable", HOSTING_CODE.clapProcessError, { errors: clap.processErrors }));
@@ -945,8 +1036,9 @@ export function clapRtReasons(clap: PluginClapMeasurement | undefined): readonly
945
1036
  * The rtSafety reasons of the note sweep, for a plugin with `noteInputs` note inputs: none for a
946
1037
  * plugin that takes no notes; `unknown` when it takes notes and was not played under the
947
1038
  * interposer (an instrument is cheap and clean at rest, so the parameter sweep alone says nothing
948
- * about a voice); `unsuitable` on any allocation, lock or syscall while it was played, or a process
949
- * error; `suitable` on a clean count.
1039
+ * about a voice); `conditional` (isolated in the pin: its own worker, never refused) on any
1040
+ * allocation, lock or syscall while it was played; `unsuitable` on a process error only (a crash);
1041
+ * `suitable` on a clean count.
950
1042
  */
951
1043
  export function noteSweepReasons(sweep: PluginNoteSweepMeasurement | undefined, noteInputs: number): readonly HostingReason[] {
952
1044
  if (noteInputs === 0) return [];
@@ -957,7 +1049,7 @@ export function noteSweepReasons(sweep: PluginNoteSweepMeasurement | undefined,
957
1049
  const out: HostingReason[] = [];
958
1050
  if (sweep.allocationsInRun + sweep.locksInRun + sweep.syscallsInRun > 0) {
959
1051
  out.push(
960
- reason("rtSafety", "objective", "unsuitable", HOSTING_CODE.rtSafetyNotesViolations, {
1052
+ reason("rtSafety", "objective", "conditional", HOSTING_CODE.rtSafetyNotesViolations, {
961
1053
  allocations: sweep.allocationsInRun,
962
1054
  syscalls: sweep.syscallsInRun,
963
1055
  locks: sweep.locksInRun,
@@ -1002,6 +1094,7 @@ export function classifyPluginHosting(
1002
1094
  ...(profile.instruments ? noteSweepReasons(descriptor.rtSafety?.noteSweep, descriptor.clap?.noteInputs ?? (descriptor.hasMidiIn ? 1 : 0)) : []),
1003
1095
  ...clapFeatureReasons(descriptor.clap, profile, descriptor.hasMidiIn),
1004
1096
  ...hostingCostReasons(descriptor, options, policy),
1097
+ ...hostingMemoryReasons(descriptor.memory, profile, options),
1005
1098
  ...clapLatencyReasons(descriptor.clap),
1006
1099
  ...clapTopologyReasons(descriptor.clap, profile.instruments),
1007
1100
  ]
@@ -1011,6 +1104,7 @@ export function classifyPluginHosting(
1011
1104
  ...(profile.instruments ? noteSweepReasons(descriptor.rtSafety?.noteSweep, descriptor.hasMidiIn ? 1 : 0) : []),
1012
1105
  ...featureReasons(descriptor.lv2Features, descriptor, profile.lv2Features, options.quantum !== undefined, profile.instruments),
1013
1106
  ...hostingCostReasons(descriptor, options, policy),
1107
+ ...hostingMemoryReasons(descriptor.memory, profile, options),
1014
1108
  ...hostingLatencyReasons(descriptor, profile.latency),
1015
1109
  ...hostingTopologyReasons(descriptor, profile.instruments),
1016
1110
  ];
package/src/index.ts CHANGED
@@ -10,6 +10,7 @@ export * from "./tool-paths.js";
10
10
  export * from "./rating.js";
11
11
  export * from "./host-profiles.js";
12
12
  export * from "./hosting-suitability.js";
13
+ export * from "./memory-budget.js";
13
14
  export * from "./hosting-codes.js";
14
15
  export * from "./loader.js";
15
16
  export * from "./lv2-loader.js";
@@ -78,8 +78,10 @@ export const MEASUREMENT_FORMAT_MAJOR = 1;
78
78
  * {@link RtSafetyMeasurement}, {@link FeaturesMeasurement} — which a 1.0 reader skips.
79
79
  * `1.2` added the verdicts per host profile; `1.3` the {@link IntegrityMeasurement} block; `1.4` the notes
80
80
  * an instrument's cost was measured with ({@link CostAtRate.voices}); `1.5` the note sweep an instrument's
81
- * `run()` was counted under ({@link RtSafetyMeasurement.noteSweep}). */
82
- export const MEASUREMENT_FORMAT_MINOR = 5;
81
+ * `run()` was counted under ({@link RtSafetyMeasurement.noteSweep}); `1.6` an instance's memory footprint
82
+ * beside the cost reading ({@link CostAtRate.memory}), the soak's readings past warm-up
83
+ * ({@link SoakAtRate.rssAfterWarmupKb}), and a host profile's memory budget ({@link HostBudget.memory}). */
84
+ export const MEASUREMENT_FORMAT_MINOR = 6;
83
85
 
84
86
  /** The version string this build writes, e.g. `"1.0"`. */
85
87
  export const MEASUREMENT_FORMAT_VERSION = `${MEASUREMENT_FORMAT_MAJOR}.${MEASUREMENT_FORMAT_MINOR}`;
@@ -285,6 +287,28 @@ export interface VoicesHeld {
285
287
  readonly capacity?: number;
286
288
  }
287
289
 
290
+ /**
291
+ * 1.6: what one instance holds at this rate: how far the resident set of a process of its own grew from before the
292
+ * plugin loaded, in KB, at each step of its life. Raw readings, never a conclusion: `memory-budget.ts` reads them against
293
+ * a budget. `secondInstanceKb` is what a second instance added once it had done the same, so what one instance costs is
294
+ * told from what every instance shares (a sample library, a wavetable, an IR). `notesHeldKb` and its `basis` are an
295
+ * instrument's, at the notes the cost arm holds.
296
+ *
297
+ * A figure not taken is absent, never 0; `unmeasuredReason` (an {@link ../vocabulary.UNMEASURED_TERMS} code) says why
298
+ * one that should be there is not, and may stand beside the figures that were taken.
299
+ */
300
+ export interface MemoryFootprint {
301
+ readonly afterInstantiateKb?: number;
302
+ readonly afterActivateKb?: number;
303
+ /** Activated, after processing: what a long-running host carries. */
304
+ readonly atRestKb?: number;
305
+ readonly notesHeldKb?: number;
306
+ readonly secondInstanceKb?: number;
307
+ readonly basis?: VoiceBasis;
308
+ readonly unmeasuredReason?: string;
309
+ readonly unmeasuredDetail?: string;
310
+ }
311
+
288
312
  /** One rate's cost reading. Per SAMPLE, so it is independent of the block size. */
289
313
  export interface CostAtRate {
290
314
  readonly nsPerSampleMedian: number;
@@ -303,6 +327,8 @@ export interface CostAtRate {
303
327
  readonly silentOutput?: boolean;
304
328
  /** 1.4: for an instrument, the notes held while it was timed. Absent for an effect. */
305
329
  readonly voices?: VoicesHeld;
330
+ /** 1.6: this instance's memory footprint at the same rate. */
331
+ readonly memory?: MemoryFootprint;
306
332
  }
307
333
 
308
334
  /** Everything one run learned about one plugin's CPU cost. */
@@ -368,6 +394,12 @@ export interface SoakAtRate {
368
394
  readonly rssStartKb?: number;
369
395
  readonly rssMaxKb?: number;
370
396
  readonly rssGrowthKb?: number;
397
+ /** 1.6: the resident set past the settling `firstRmsDbfs` skips, and the audio-seconds to reach it. */
398
+ readonly rssAfterWarmupKb?: number;
399
+ readonly warmupSeconds?: number;
400
+ /** 1.6: the resident set halfway from there to the end, so that growth which stopped is told from growth still climbing. */
401
+ readonly rssMidKb?: number;
402
+ readonly midSeconds?: number;
371
403
  }
372
404
 
373
405
  /** A continuous soak, per rate, with whether the controls were swept while it ran. */
@@ -501,6 +533,22 @@ export interface HostBudget {
501
533
  readonly coreFractionCeiling: number;
502
534
  readonly rate: number;
503
535
  readonly quantum: number;
536
+ /** 1.6: what this host gives its plugins' memory; absent is a host with no budget declared, never a budget of 0. */
537
+ readonly memory?: HostMemoryBudget;
538
+ }
539
+
540
+ /**
541
+ * 1.6: a host's memory budget, and where it is read. `perInstanceKb` bounds what one more instance costs;
542
+ * `perConsoleKb` bounds all the plugins of the host, so the most one plugin may take with what its instances share.
543
+ * `provisional` says the numbers were not measured on that kind of host: a verdict that rests on them says so and does
544
+ * not say the plugin fits. `cells` are the rates and quanta the host runs, each judged, where it runs more than the one
545
+ * its cost is read at.
546
+ */
547
+ export interface HostMemoryBudget {
548
+ readonly perInstanceKb: number;
549
+ readonly perConsoleKb: number;
550
+ readonly provisional?: boolean;
551
+ readonly cells?: readonly { readonly rate: number; readonly quantum: number }[];
504
552
  }
505
553
 
506
554
  /**
@@ -554,6 +602,24 @@ const isPositive = (v: unknown): v is number => typeof v === "number" && Number.
554
602
  * typed; a missing or mistyped one is refused by name, and a host the reader was not told about
555
603
  * is never filled in from a default.
556
604
  */
605
+ /** A budget, `undefined` for none declared, or the words of what is wrong with it. */
606
+ function readHostMemoryBudget(value: unknown): HostMemoryBudget | undefined | string {
607
+ if (value === undefined) return undefined;
608
+ if (!isRecord(value)) return "cost.memory: an object, or absent";
609
+ if (!isPositive(value.perInstanceKb) || !isPositive(value.perConsoleKb)) return "cost.memory: perInstanceKb and perConsoleKb are positive numbers";
610
+ if (value.provisional !== undefined && typeof value.provisional !== "boolean") return "cost.memory.provisional: a boolean, or absent";
611
+ const cells = value.cells;
612
+ if (cells !== undefined && (!Array.isArray(cells) || cells.length === 0 || !cells.every((c) => isRecord(c) && Number.isInteger(c.rate) && Number.isInteger(c.quantum) && isPositive(c.rate) && isPositive(c.quantum)))) {
613
+ return "cost.memory.cells: rates and quanta as positive integers, or absent";
614
+ }
615
+ return {
616
+ perInstanceKb: value.perInstanceKb,
617
+ perConsoleKb: value.perConsoleKb,
618
+ ...optional("provisional", value.provisional as boolean | undefined),
619
+ ...optional("cells", (cells as readonly Record<string, number>[] | undefined)?.map((c) => ({ rate: c.rate!, quantum: c.quantum! }))),
620
+ };
621
+ }
622
+
557
623
  export function readHostProfile(value: unknown): HostProfileRead {
558
624
  if (!isRecord(value)) return { ok: false, problem: "a host profile is a JSON object" };
559
625
  const { id, lv2Features, clapExtensions, instruments, cost, latency, isolation } = value;
@@ -569,6 +635,8 @@ export function readHostProfile(value: unknown): HostProfileRead {
569
635
  if (!Number.isInteger(cost.rate) || !Number.isInteger(cost.quantum)) {
570
636
  return { ok: false, problem: "cost: rate and quantum are integers" };
571
637
  }
638
+ const memory = readHostMemoryBudget(cost.memory);
639
+ if (typeof memory === "string") return { ok: false, problem: memory };
572
640
  if (!(HOST_LATENCY_HANDLING as readonly unknown[]).includes(latency)) {
573
641
  return { ok: false, problem: `latency: one of ${HOST_LATENCY_HANDLING.join(", ")}` };
574
642
  }
@@ -582,7 +650,7 @@ export function readHostProfile(value: unknown): HostProfileRead {
582
650
  lv2Features,
583
651
  clapExtensions,
584
652
  instruments,
585
- cost: { coreFractionCeiling: cost.coreFractionCeiling, rate: cost.rate, quantum: cost.quantum },
653
+ cost: { coreFractionCeiling: cost.coreFractionCeiling, rate: cost.rate, quantum: cost.quantum, ...optional("memory", memory) },
586
654
  latency: latency as HostLatencyHandling,
587
655
  isolation: isolation as HostIsolation,
588
656
  },
@@ -851,6 +919,27 @@ function readLatency(value: unknown): LatencyMeasurement | undefined {
851
919
  };
852
920
  }
853
921
 
922
+ /** A KB figure: finite and never negative. */
923
+ function kilobytes(value: unknown): number | undefined {
924
+ const n = finiteNumber(value);
925
+ return n !== undefined && n >= 0 ? n : undefined;
926
+ }
927
+
928
+ /** A memory footprint, or `undefined` unless it carries at least one figure or a reason. */
929
+ export function readMemoryFootprint(value: unknown): MemoryFootprint | undefined {
930
+ if (!isRecord(value)) return undefined;
931
+ const footprint: MemoryFootprint = {
932
+ ...optional("afterInstantiateKb", kilobytes(value.afterInstantiateKb)),
933
+ ...optional("afterActivateKb", kilobytes(value.afterActivateKb)),
934
+ ...optional("atRestKb", kilobytes(value.atRestKb)),
935
+ ...optional("notesHeldKb", kilobytes(value.notesHeldKb)),
936
+ ...optional("secondInstanceKb", kilobytes(value.secondInstanceKb)),
937
+ ...optional("basis", isVoiceBasis(value.basis) ? value.basis : undefined),
938
+ ...reasonOf(value),
939
+ };
940
+ return Object.keys(footprint).length > 0 ? footprint : undefined;
941
+ }
942
+
854
943
  function readCostAtRate(value: unknown): CostAtRate | undefined {
855
944
  if (!isRecord(value)) return undefined;
856
945
  const nsPerSampleMedian = finiteNumber(value.nsPerSampleMedian);
@@ -875,6 +964,7 @@ function readCostAtRate(value: unknown): CostAtRate | undefined {
875
964
  ...optional("warmupNsPerSampleMedian", finiteNumber(value.warmupNsPerSampleMedian)),
876
965
  ...optional("silentOutput", booleanValue(value.silentOutput)),
877
966
  ...optional("voices", readVoicesHeld(value.voices)),
967
+ ...optional("memory", readMemoryFootprint(value.memory)),
878
968
  };
879
969
  }
880
970
 
@@ -957,6 +1047,10 @@ export function readSoakAtRate(value: unknown): SoakAtRate | undefined {
957
1047
  ...optional("rssStartKb", finiteNumber(value.rssStartKb)),
958
1048
  ...optional("rssMaxKb", finiteNumber(value.rssMaxKb)),
959
1049
  ...optional("rssGrowthKb", finiteNumber(value.rssGrowthKb)),
1050
+ ...optional("rssAfterWarmupKb", kilobytes(value.rssAfterWarmupKb)),
1051
+ ...optional("warmupSeconds", finiteNumber(value.warmupSeconds)),
1052
+ ...optional("rssMidKb", kilobytes(value.rssMidKb)),
1053
+ ...optional("midSeconds", finiteNumber(value.midSeconds)),
960
1054
  };
961
1055
  }
962
1056
 
@@ -38,7 +38,7 @@ export interface InstallationRates {
38
38
  readonly liveRate?: number;
39
39
  /** Rates the active device actually offers. Empty/absent = the console has not probed. */
40
40
  readonly supportedRates?: readonly number[];
41
- /** Rates the operator asked for. Absent = "whatever this installation can use". */
41
+ /** Rates the caller asked for. Absent = "whatever this installation can use". */
42
42
  readonly requestedRates?: readonly number[];
43
43
  }
44
44
 
@@ -47,7 +47,7 @@ export interface RatePlan {
47
47
  /** Rates to measure, ascending. Empty when nothing could be resolved. */
48
48
  readonly rates: readonly number[];
49
49
  /**
50
- * Rates the operator asked for that the installation does not offer. Reported, not
50
+ * Rates the caller asked for that the installation does not offer. Reported, not
51
51
  * measured — a figure at a rate the rig cannot run is a figure nobody will ever use, and
52
52
  * quietly measuring it would make the run longer for nothing.
53
53
  */
@@ -63,7 +63,7 @@ export interface RatePlan {
63
63
  * Resolve the rates a local run should measure at.
64
64
  *
65
65
  * The live rate is always included when known — a console must have figures for the rate it
66
- * is running at, whatever else the operator asked for. Beyond that: the requested rates
66
+ * is running at, whatever else the caller asked for. Beyond that: the requested rates
67
67
  * intersected with what the device offers, or, absent a request, the whole supported list.
68
68
  */
69
69
  export function ratesForInstallation(input: InstallationRates): RatePlan {