@openmixer/plugin-qualify 0.18.5 → 0.19.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 (88) hide show
  1. package/README.md +19 -730
  2. package/bin/plugin-qualify-sync.mjs +1 -1
  3. package/bin/plugin-qualify.mjs +7 -0
  4. package/dist/clap-qualify.d.ts +1 -1
  5. package/dist/compare.d.ts +311 -0
  6. package/dist/compare.js +502 -0
  7. package/dist/compare.js.map +1 -0
  8. package/dist/distributed-records.d.ts +0 -2
  9. package/dist/distributed-records.js +0 -2
  10. package/dist/distributed-records.js.map +1 -1
  11. package/dist/host-admission.d.ts +2 -2
  12. package/dist/host-admission.js +7 -0
  13. package/dist/host-admission.js.map +1 -1
  14. package/dist/hosting-codes.generated.d.ts +1 -0
  15. package/dist/hosting-codes.generated.js +1 -0
  16. package/dist/hosting-codes.generated.js.map +1 -1
  17. package/dist/hosting-suitability.d.ts +3 -1
  18. package/dist/hosting-suitability.js +9 -0
  19. package/dist/hosting-suitability.js.map +1 -1
  20. package/dist/index.d.ts +1 -0
  21. package/dist/index.js +1 -0
  22. package/dist/index.js.map +1 -1
  23. package/dist/lv2-loader.js +6 -5
  24. package/dist/lv2-loader.js.map +1 -1
  25. package/dist/measurement/format.d.ts +15 -0
  26. package/dist/measurement/format.js +19 -0
  27. package/dist/measurement/format.js.map +1 -1
  28. package/dist/measurement/sweep-jsonl.js +5 -1
  29. package/dist/measurement/sweep-jsonl.js.map +1 -1
  30. package/dist/measurement-grid.js +1 -1
  31. package/dist/pins-file.d.ts +22 -2
  32. package/dist/pins-file.js +118 -17
  33. package/dist/pins-file.js.map +1 -1
  34. package/dist/qualification-record.d.ts +26 -0
  35. package/dist/qualification-record.js +21 -0
  36. package/dist/qualification-record.js.map +1 -1
  37. package/dist/qualify-declarations.d.ts +6 -0
  38. package/dist/qualify-declarations.js +8 -0
  39. package/dist/qualify-declarations.js.map +1 -1
  40. package/dist/qualify-isolated.d.ts +16 -3
  41. package/dist/qualify-isolated.js +31 -3
  42. package/dist/qualify-isolated.js.map +1 -1
  43. package/dist/qualify-pool.d.ts +129 -0
  44. package/dist/qualify-pool.js +290 -0
  45. package/dist/qualify-pool.js.map +1 -0
  46. package/dist/qualify-rejudge.js +7 -0
  47. package/dist/qualify-rejudge.js.map +1 -1
  48. package/dist/qualify-sync.d.ts +36 -1
  49. package/dist/qualify-sync.js +144 -20
  50. package/dist/qualify-sync.js.map +1 -1
  51. package/dist/records-package.d.ts +5 -0
  52. package/dist/records-package.js +25 -8
  53. package/dist/records-package.js.map +1 -1
  54. package/fixtures/clap-scan/fake.json +8 -2
  55. package/fixtures/probe-output/success-msi.json +2 -2
  56. package/package.json +3 -3
  57. package/src/clap-qualify.ts +1 -1
  58. package/src/compare.ts +699 -0
  59. package/src/distributed-records.ts +0 -3
  60. package/src/host-admission.ts +9 -2
  61. package/src/hosting-codes.generated.ts +1 -0
  62. package/src/hosting-suitability.ts +14 -0
  63. package/src/index.ts +1 -0
  64. package/src/lv2-loader.ts +5 -4
  65. package/src/measurement/format.ts +32 -0
  66. package/src/measurement/sweep-jsonl.ts +5 -1
  67. package/src/measurement-grid.ts +1 -1
  68. package/src/pins-file.ts +109 -16
  69. package/src/qualification-record.ts +38 -0
  70. package/src/qualify-declarations.ts +8 -0
  71. package/src/qualify-isolated.ts +31 -5
  72. package/src/qualify-pool.ts +318 -0
  73. package/src/qualify-rejudge.ts +8 -0
  74. package/src/qualify-sync.ts +140 -19
  75. package/src/records-package.ts +25 -8
  76. package/tools/benchmark.py +9 -3
  77. package/tools/compare-host.mjs +432 -0
  78. package/tools/compare-run.mjs +355 -0
  79. package/tools/descriptors.py +27 -0
  80. package/tools/isolated-step.mjs +3 -1
  81. package/tools/lilv_load.py +25 -0
  82. package/tools/lv2_resolve.py +3 -1
  83. package/tools/pool-step.mjs +186 -0
  84. package/tools/scan.py +16 -20
  85. package/tools/audit-overlap.sh +0 -250
  86. package/tools/commit-message-check.sh +0 -128
  87. package/tools/commit-message-patterns.json +0 -41
  88. package/tools/private-refs-check.sh +0 -90
@@ -29,9 +29,6 @@ export const DISTRIBUTED_DIR_SYSTEM = "/usr/share/omx/qualification";
29
29
  export const DISTRIBUTED_PROVENANCE_FILE = "provenance.json";
30
30
  export const DISTRIBUTED_PROVENANCE_SCHEMA = "plugin-qualify.distributed-provenance/1";
31
31
 
32
- /** The first plugin-qualify that reads the layer: what a package of records depends on. */
33
- export const DISTRIBUTED_LAYER_SINCE = "0.17.0";
34
-
35
32
  /** Where the records of a set come from. The keys below are always there; the rest is what the builder was given. */
36
33
  export interface DistributedProvenance {
37
34
  readonly schema: typeof DISTRIBUTED_PROVENANCE_SCHEMA;
@@ -45,10 +45,10 @@ export function runDecides(profile: HostProfile, format: string, admission: Host
45
45
  /** What running the plugin under one host came to. */
46
46
  export interface HostAdmission {
47
47
  readonly host: AdmittedHost | typeof ADAPTER_HOST;
48
- /** False when the host could not be run (not installed): there is no evidence either way. */
48
+ /** False when the host could not be run (not installed, no JACK client): there is no evidence either way. */
49
49
  readonly measured: boolean;
50
50
  readonly admitted: boolean;
51
- /** Why not: `<host>.resp-<n>`, `<host>.signal-<SIG>`, `mod-host.died-on-add`, `jalv.unknown-port-type`, `<host>.not-installed`, … */
51
+ /** Why not: `<host>.resp-<n>`, `<host>.signal-<SIG>`, `mod-host.died-on-add`, `jalv.unknown-port-type`, `<host>.not-installed`, `<host>.no-jack-client`, … */
52
52
  readonly code?: string;
53
53
  readonly params: Readonly<Record<string, string | number>>;
54
54
  }
@@ -103,6 +103,12 @@ export function admissionRating(admission: HostAdmission): "unsuitable" | "unkno
103
103
  return !admission.measured || UNPROVEN_SOUND.includes(admission.code ?? "") ? "unknown" : "unsuitable";
104
104
  }
105
105
 
106
+ /**
107
+ * ERR_JACK_CLIENT_CREATION and ERR_JACK_CLIENT_ACTIVATION (mod-host's host-errors.h): the add could not get a JACK client,
108
+ * which says nothing of the plugin. omx-clap-host opens JACK at the first add since 0.26.1, so a host with no server answers these.
109
+ */
110
+ const JACK_CLIENT_RESPS: readonly number[] = [-201, -202];
111
+
106
112
  /** The verdict of a run of a host that speaks mod-host's protocol: the first step that did not answer `resp 0` decides. */
107
113
  export function classifyProtocolRun(host: ProtocolHost, run: ModHostRun): HostAdmission {
108
114
  if (!run.installed) return notInstalled(host);
@@ -114,6 +120,7 @@ export function classifyProtocolRun(host: ProtocolHost, run: ModHostRun): HostAd
114
120
  return s.cmd === "add" ? refused(host, "died-on-add", { how: s.died }) : refused(host, "died", { step: s.cmd, how: s.died });
115
121
  }
116
122
  if (s.resp === undefined) return refused(host, "no-answer", { step: s.cmd });
123
+ if (s.cmd === "add" && s.resp !== undefined && JACK_CLIENT_RESPS.includes(s.resp)) return { host, measured: false, admitted: false, code: `${host}.no-jack-client`, params: { resp: s.resp } };
117
124
  // the host answers an error as a negative number (`resp -102`); the code carries its magnitude
118
125
  if (s.resp < 0) return refused(host, `resp-${-s.resp}`, { step: s.cmd, resp: s.resp });
119
126
  }
@@ -62,6 +62,7 @@ export const HOSTING_CODE = Object.freeze({
62
62
  "stabilityBelowFloor": "hosting.stability.cycles-below-floor",
63
63
  "stabilityCrashUnattributed": "hosting.stability.crash-unattributed",
64
64
  "stabilityCrashedAttributed": "hosting.stability.crashed-attributed-plugin",
65
+ "stabilityCalleeSavedClobbered": "hosting.stability.callee-saved-clobbered",
65
66
  "stabilityCrashedLive": "hosting.stability.crashed-live-on-this-rig",
66
67
  "stabilityLinksGuiToolkit": "hosting.stability.links-gui-toolkit",
67
68
  "stabilitySoakFailedAtRate": "hosting.stability.soak-failed-at-rate",
@@ -23,6 +23,7 @@ import {
23
23
  type SuitabilityRating,
24
24
  } from "./rating.js";
25
25
  import type {
26
+ AbiReading,
26
27
  FeaturesMeasurement,
27
28
  LifecycleReading,
28
29
  RtSafetyMeasurement,
@@ -299,6 +300,8 @@ export function attributeCrash(
299
300
  */
300
301
  export interface PluginStabilityMeasurement {
301
302
  readonly lifecycle?: LifecycleReading;
303
+ /** Whether a plugin call returned with a callee-saved register changed (the measurer's guard put it back). */
304
+ readonly abi?: AbiReading;
302
305
  readonly threads?: ThreadReading;
303
306
  readonly crashes?: readonly ObservedCrash[];
304
307
  /** A continuous render per rate with the controls swept — judged at EVERY rate it ran. */
@@ -587,6 +590,17 @@ export function stabilityReasons(
587
590
  reason("stability", "objective", "unsuitable", HOSTING_CODE.stabilityCrashedLive, {}),
588
591
  );
589
592
  }
593
+ // a plugin call that returned with a callee-saved register changed: the host's guard puts it back, but not the other
594
+ // members' state in the same process, so the plugin is a hosting mode, never a refusal: a worker of its own
595
+ if (m.abi?.clobbered === true) {
596
+ out.push(
597
+ reason("stability", "objective", "conditional", HOSTING_CODE.stabilityCalleeSavedClobbered, {
598
+ registers: (m.abi.registers ?? []).join(","),
599
+ call: m.abi.call ?? "",
600
+ block: m.abi.block ?? 0,
601
+ }),
602
+ );
603
+ }
590
604
  const attributions = (m.crashes ?? []).map((c) => attributeCrash(c, CRASH_SIGNATURES, pluginBinary));
591
605
  if (attributions.includes("plugin")) {
592
606
  out.push(reason("stability", "objective", "unsuitable", HOSTING_CODE.stabilityCrashedAttributed));
package/src/index.ts CHANGED
@@ -39,3 +39,4 @@ export * from "./host-admission.js";
39
39
  export * from "./history-file.js";
40
40
  export * from "./history-adjust.js";
41
41
  export * from "./adjust-cli.js";
42
+ export * from "./compare.js";
package/src/lv2-loader.ts CHANGED
@@ -3,8 +3,8 @@
3
3
  /**
4
4
  * The LV2 implementation of the one discovery seam (`loader.ts`, spec §7). A target that names an
5
5
  * existing directory is a bundle: every subject its `manifest.ttl` declares `a lv2:Plugin` is a
6
- * plugin, with its `lv2:binary` basename. Anything else shaped like a URI (`scheme:rest`) is one
7
- * plugin. A path that does not exist is not this loader's — the CLI refuses it by name.
6
+ * plugin, with its `lv2:binary` basename. Anything with a scheme (`scheme:rest`, other than `file:`) is one
7
+ * plugin, looked up by URI; a bare or `file:` argument is a path. A path that does not exist is not this loader's — the CLI refuses it by name.
8
8
  *
9
9
  * The manifest is read as plugin-hostd reads it to pin a plugin: statements of IRIs and prefixed names, with blank
10
10
  * nodes and collections stepped over, not a whole Turtle parser. A plugin is a subject typed `lv2:Plugin`; its files
@@ -169,8 +169,9 @@ export const lv2Loader: PluginLoader = {
169
169
  ...(p.binary === undefined ? {} : { binary: p.binary }),
170
170
  }));
171
171
  }
172
- if (target.includes("/") || target.endsWith(".lv2")) return undefined; // a path that is not there
173
- return URI_SHAPE.test(target) ? [{ format: "lv2", uri: target }] : undefined;
172
+ // A scheme (http, https, urn, ...) makes the argument a URI; a bare or `file:` argument is a path, and one that is not there is not this loader's.
173
+ if (!/^file:/i.test(target) && URI_SHAPE.test(target) && !/^[A-Za-z]:[\\/]/.test(target)) return [{ format: "lv2", uri: target }];
174
+ return undefined;
174
175
  },
175
176
  };
176
177
 
@@ -425,8 +425,22 @@ export interface SoakReading {
425
425
  }
426
426
 
427
427
  /** Everything one run learned about one plugin SURVIVING. Readable when it has a lifecycle. */
428
+ /**
429
+ * The plugin calls the measurer made through omx-clap-core's guard: whether one returned with a callee-saved register
430
+ * changed, and for the first, the registers, the plugin function and the run block; calls counts all that did. The
431
+ * guard put them back, so the measurement is whole; a plugin that clobbered runs only in a worker of its own.
432
+ */
433
+ export interface AbiReading {
434
+ readonly clobbered: boolean;
435
+ readonly registers?: readonly string[];
436
+ readonly call?: string;
437
+ readonly block?: number;
438
+ readonly calls?: number;
439
+ }
440
+
428
441
  export interface StabilityMeasurement {
429
442
  readonly lifecycle?: LifecycleReading;
443
+ readonly abi?: AbiReading;
430
444
  readonly threads?: ThreadReading;
431
445
  readonly crashes?: readonly CrashReading[];
432
446
  readonly soak?: SoakReading;
@@ -1078,10 +1092,28 @@ function readSoak(value: unknown): SoakReading | undefined {
1078
1092
  return { sweptParams, perRate: readRateMap(value.perRate, readSoakAtRate) };
1079
1093
  }
1080
1094
 
1095
+ /** The measurer's abi field: a boolean clobbered, and with it the first clobber's registers, call and block. */
1096
+ export function readAbi(value: unknown): AbiReading | undefined {
1097
+ if (!isRecord(value)) return undefined;
1098
+ const clobbered = booleanValue(value.clobbered);
1099
+ if (clobbered === undefined) return undefined;
1100
+ const registers = Array.isArray(value.registers)
1101
+ ? value.registers.filter((r): r is string => typeof r === "string" && r.length > 0)
1102
+ : undefined;
1103
+ return {
1104
+ clobbered,
1105
+ ...optional("registers", clobbered ? registers : undefined),
1106
+ ...optional("call", clobbered ? nonEmptyString(value.call) : undefined),
1107
+ ...optional("block", clobbered ? finiteNumber(value.block) : undefined),
1108
+ ...optional("calls", clobbered ? finiteNumber(value.calls) : undefined),
1109
+ };
1110
+ }
1111
+
1081
1112
  function readStability(value: unknown): StabilityMeasurement | undefined {
1082
1113
  if (!isRecord(value)) return undefined;
1083
1114
  return {
1084
1115
  ...optional("lifecycle", readLifecycle(value.lifecycle)),
1116
+ ...optional("abi", readAbi(value.abi)),
1085
1117
  ...optional("threads", readThreads(value.threads)),
1086
1118
  ...optional("crashes", mapArray(value.crashes, readCrash)),
1087
1119
  ...optional("soak", readSoak(value.soak)),
@@ -29,7 +29,7 @@ import type {
29
29
  SoakReading,
30
30
  StabilityMeasurement,
31
31
  } from "./format.js";
32
- import { readIntegrity, readLifecycle, readNoteSweep, readSoakAtRate, readThreads } from "./format.js";
32
+ import { readAbi, readIntegrity, readLifecycle, readNoteSweep, readSoakAtRate, readThreads } from "./format.js";
33
33
  import { booleanValue, finiteNumber, isRecord, mapArray, nonEmptyString, optional, positiveNumber } from "./guards.js";
34
34
 
35
35
  /** The sweep's three passes, each a list of parsed JSONL lines. Only `measurements` is required. */
@@ -82,8 +82,12 @@ function stabilityOf(
82
82
  (c): c is CrashReading => c !== undefined,
83
83
  );
84
84
  const soak = soakRow === undefined ? undefined : soakOf(soakRow);
85
+ // either pass's clobber is the plugin's: the deep pass runs the same plugin longer
86
+ const abis = [readAbi(quick.abi), deep === undefined ? undefined : readAbi(deep.abi)];
87
+ const abi = abis.find((a) => a?.clobbered === true) ?? abis.find((a) => a !== undefined);
85
88
  return {
86
89
  lifecycle,
90
+ ...optional("abi", abi),
87
91
  ...optional("threads", threads),
88
92
  crashes,
89
93
  ...optional("soak", soak),
@@ -3,7 +3,7 @@
3
3
  /**
4
4
  * The measurement grid (#193): the cells, each one rate and one quantum, a plugin is measured at, and the runs of the
5
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.
6
+ * judges; docs/overview.md "The measurement grid" states the three ways of choosing it and the order the cells are taken in.
7
7
  *
8
8
  * Pure: no file, no process. `probe-runner.ts` runs what `runnerInvocations` plans.
9
9
  */
package/src/pins-file.ts CHANGED
@@ -5,7 +5,10 @@
5
5
  * pin store's import): one line per qualified plugin, the SAME line as hostd's `pin_set` verb, with the
6
6
  * qualification verdict as a last word.
7
7
  *
8
- * pin_set <uri> <path>=<sha256>[,<path>=<sha256>...] <scheme>:<sha256> [verdict=<word>]
8
+ * pin_set <uri> <path>=<sha256>[,<path>=<sha256>...] <scheme>:<sha256> [verdict=<word>] [pool=<word>]
9
+ *
10
+ * A word is the protocol's (plugin-hostd-protocol's `word`): bare, or in double quotes when it holds a space, with `\"`
11
+ * for a quote inside. A CLAP plugin under a directory with a space in its name has a uri with a space in it: one quoted word.
9
12
  *
10
13
  * The digests and the layout are the prober's, raw (`IntegrityMeasurement`); nothing here computes a hash. A plugin
11
14
  * whose run took no whole pin has no line: a pins file never holds a partial pin. The grammar is plugin-hostd's
@@ -26,6 +29,13 @@ export const PIN_VERB = "pin_set";
26
29
  /** The key of the verdict word, hostd's `PHD_PIN_VERDICT_KEY`. */
27
30
  export const PIN_VERDICT_KEY = "verdict=";
28
31
 
32
+ /** The key of the pool word, hostd's `PHD_PIN_POOL_KEY`. */
33
+ export const PIN_POOL_KEY = "pool=";
34
+
35
+ /** The pool words hostd knows (`PHD_PIN_POOLS`): `cores` starts the plugin's worker with the CLAP thread pool on, `off` says it was measured and gains nothing. */
36
+ export const PIN_POOLS = ["cores", "off"] as const;
37
+ export type PinPool = (typeof PIN_POOLS)[number];
38
+
29
39
  /** The verdict words hostd knows (`PHD_PIN_VERDICTS`). */
30
40
  export const PIN_VERDICTS = ["in-process", "isolated", "refused"] as const;
31
41
  export type PinVerdict = (typeof PIN_VERDICTS)[number];
@@ -54,19 +64,84 @@ export interface PinEntry {
54
64
  readonly uri: string;
55
65
  readonly integrity: IntegrityMeasurement;
56
66
  readonly verdict?: PinVerdict;
67
+ /** What the pool step measured: the pool is the plugin's qualification to give, never assumed. A refused pin carries none. */
68
+ readonly pool?: PinPool;
57
69
  }
58
70
 
59
- /** The line of one pin, or `undefined` when the integrity block is not a whole pin. A uri with whitespace in it would be read back as more words: it is refused, by name. */
60
- export function pinLine(entry: PinEntry): string | undefined {
61
- const spaced = [entry.uri, ...(entry.integrity.files ?? []).map((f) => f.path)].find((w) => /\s/.test(w));
62
- if (spaced !== undefined) {
63
- throw new Error(`${entry.uri}: ${JSON.stringify(spaced)} has whitespace in it and cannot be a word of a pin line`);
71
+ /** A word of the protocol, `word` in its grammar: bare, or quoted when it holds a space or a double quote. A control character cannot be a word, nor can a quoted word end in a backslash (it would read as an escaped quote). */
72
+ export function pinWord(value: string): string {
73
+ if (value === "") throw new Error("an empty word cannot be a word of a pin line");
74
+ // eslint-disable-next-line no-control-regex
75
+ if (/[\x00-\x1f\x7f]/.test(value)) throw new Error(`${JSON.stringify(value)} has a control character in it and cannot be a word of a pin line`);
76
+ if (!/[ "]/.test(value)) return value;
77
+ if (value.endsWith("\\")) throw new Error(`${JSON.stringify(value)} ends in a backslash and cannot be a quoted word of a pin line`);
78
+ return `"${value.replaceAll('"', '\\"')}"`;
79
+ }
80
+
81
+ /** The words of a line, the protocol's: a quoted word with its quotes taken off and its `\"` read as a quote. */
82
+ export function pinWords(line: string): string[] {
83
+ const words: string[] = [];
84
+ let i = 0;
85
+ while (i < line.length) {
86
+ if (line[i] === " " || line[i] === "\t" || line[i] === "\r") {
87
+ i++;
88
+ } else if (line[i] === '"') {
89
+ let word = "";
90
+ for (i++; ; i++) {
91
+ if (i >= line.length) throw new Error("a quote is not closed");
92
+ if (line[i] === "\\" && line[i + 1] === '"') {
93
+ word += '"';
94
+ i++;
95
+ } else if (line[i] === '"') {
96
+ i++;
97
+ break;
98
+ } else {
99
+ word += line[i];
100
+ }
101
+ }
102
+ if (i < line.length && line[i] !== " " && line[i] !== "\t" && line[i] !== "\r") throw new Error("a quoted word is followed by more of the word");
103
+ words.push(word);
104
+ } else {
105
+ const start = i;
106
+ while (i < line.length && line[i] !== " " && line[i] !== "\t" && line[i] !== "\r") {
107
+ if (line[i] === '"') throw new Error("a quote inside a bare word");
108
+ i++;
109
+ }
110
+ words.push(line.slice(start, i));
111
+ }
64
112
  }
113
+ return words;
114
+ }
115
+
116
+ /** The uri of a pin line (or of a `pin_clear` line), with the verb or without: its first word after the verb. */
117
+ export function pinLineUri(line: string): string | undefined {
118
+ const words = pinWords(line);
119
+ if (words[0] === PIN_VERB || words[0] === "pin_clear") words.shift();
120
+ return words[0];
121
+ }
122
+
123
+ /** The line that takes a pin away: the uri is one word. */
124
+ export function pinClearLine(uri: string): string {
125
+ return `pin_clear ${pinWord(uri)}`;
126
+ }
127
+
128
+ /**
129
+ * The line of one pin, or `undefined` when the integrity block is not a whole pin. The uri is one word, quoted when it
130
+ * holds a space. A file path inside the bundle cannot hold whitespace, a comma or an equals sign (the protocol's PATH
131
+ * limit: they are the separators of the words of a pin), so a plugin with such a file has no pin, refused by name.
132
+ */
133
+ export function pinLine(entry: PinEntry): string | undefined {
65
134
  const { files, layout } = entry.integrity;
135
+ const uri = pinWord(entry.uri);
136
+ for (const f of files ?? []) {
137
+ const bad = /[,="\s]/.exec(f.path);
138
+ if (bad !== null) throw new Error(`${entry.uri}: the file ${JSON.stringify(f.path)} holds ${JSON.stringify(bad[0])}, which a pinned path cannot (a separator of the words of a pin, or a quote)`);
139
+ }
66
140
  if (files === undefined || files.length === 0 || layout === undefined) return undefined;
67
141
  const list = files.map((f) => `${f.path}=${f.sha256}`).join(",");
68
142
  const verdict = entry.verdict === undefined ? "" : ` ${PIN_VERDICT_KEY}${entry.verdict}`;
69
- return `${PIN_VERB} ${entry.uri} ${list} ${layout.scheme}:${layout.sha256}${verdict}`;
143
+ const pool = entry.pool === undefined || entry.verdict === "refused" ? "" : ` ${PIN_POOL_KEY}${entry.pool}`;
144
+ return `${PIN_VERB} ${uri} ${list} ${layout.scheme}:${layout.sha256}${verdict}${pool}`;
70
145
  }
71
146
 
72
147
  /** hostd's uri of a CLAP plugin the prober loaded: its file as it was handed, made absolute, and its id. */
@@ -82,7 +157,7 @@ export interface PinsFile {
82
157
  readonly written: readonly string[];
83
158
  /** The uris of plugins that took no whole pin: they have no line. */
84
159
  readonly skipped: readonly string[];
85
- /** The uris refused because a word of their line has whitespace in it, each with the reason: they have no line. */
160
+ /** The uris refused because a word of their line cannot be a word (a control character), each with the reason: they have no line. */
86
161
  readonly refused: readonly { readonly uri: string; readonly problem: string }[];
87
162
  }
88
163
 
@@ -175,6 +250,7 @@ export interface ParsedPin {
175
250
  readonly files: readonly { readonly path: string; readonly sha256: string }[];
176
251
  readonly layout: { readonly scheme: string; readonly sha256: string };
177
252
  readonly verdict?: PinVerdict;
253
+ readonly pool?: PinPool;
178
254
  }
179
255
 
180
256
  const HEX = /^[0-9a-f]{64}$/;
@@ -185,11 +261,11 @@ const HEX = /^[0-9a-f]{64}$/;
185
261
  * 64 lowercase hex digits, a layout without a scheme, a verdict outside the vocabulary, a path named twice).
186
262
  */
187
263
  export function parsePinLine(line: string): ParsedPin | undefined {
188
- const words = line.trim().split(/\s+/).filter((w) => w !== "");
264
+ const words = pinWords(line);
189
265
  if (words.length === 0 || words[0]!.startsWith("#")) return undefined;
190
266
  if (words[0] === PIN_VERB) words.shift();
191
- if (words.length !== 3 && words.length !== 4) throw new Error(`a pin line has 3 or 4 words, not ${words.length}`);
192
- const [uri, list, layout, verdictWord] = words as [string, string, string, string | undefined];
267
+ if (words.length < 3 || words.length > 5) throw new Error(`a pin line has 3 to 5 words, not ${words.length}`);
268
+ const [uri, list, layout, ...optional] = words as [string, string, string, ...string[]];
193
269
  const files = list.split(",").map((item) => {
194
270
  const eq = item.lastIndexOf("=");
195
271
  if (eq < 0) throw new Error(`${item}: no =`);
@@ -205,10 +281,27 @@ export function parsePinLine(line: string): ParsedPin | undefined {
205
281
  const colon = layout.lastIndexOf(":");
206
282
  if (colon <= 0 || !HEX.test(layout.slice(colon + 1))) throw new Error(`${layout}: not a layout pin`);
207
283
  let verdict: PinVerdict | undefined;
208
- if (verdictWord !== undefined) {
209
- const word = verdictWord.startsWith(PIN_VERDICT_KEY) ? verdictWord.slice(PIN_VERDICT_KEY.length) : "";
210
- if (!(PIN_VERDICTS as readonly string[]).includes(word)) throw new Error(`${verdictWord}: not a verdict`);
211
- verdict = word as PinVerdict;
284
+ let pool: PinPool | undefined;
285
+ for (const word of optional) {
286
+ if (word.startsWith(PIN_VERDICT_KEY)) {
287
+ if (verdict !== undefined) throw new Error(`${word}: a second verdict`);
288
+ const value = word.slice(PIN_VERDICT_KEY.length);
289
+ if (!(PIN_VERDICTS as readonly string[]).includes(value)) throw new Error(`${word}: not a verdict`);
290
+ verdict = value as PinVerdict;
291
+ } else if (word.startsWith(PIN_POOL_KEY)) {
292
+ if (pool !== undefined) throw new Error(`${word}: a second pool word`);
293
+ const value = word.slice(PIN_POOL_KEY.length);
294
+ if (!(PIN_POOLS as readonly string[]).includes(value)) throw new Error(`${word}: not a pool word`);
295
+ pool = value as PinPool;
296
+ } else {
297
+ throw new Error(`${word}: not a word of a pin`);
298
+ }
212
299
  }
213
- return { uri, files, layout: { scheme: layout.slice(0, colon), sha256: layout.slice(colon + 1) }, ...(verdict === undefined ? {} : { verdict }) };
300
+ return {
301
+ uri,
302
+ files,
303
+ layout: { scheme: layout.slice(0, colon), sha256: layout.slice(colon + 1) },
304
+ ...(verdict === undefined ? {} : { verdict }),
305
+ ...(pool === undefined ? {} : { pool }),
306
+ };
214
307
  }
@@ -76,6 +76,23 @@ export interface IsolatedFigures {
76
76
  readonly midiInputsLinked?: number;
77
77
  }
78
78
 
79
+ /**
80
+ * What the pool step measured. With real helper threads under the interposer: the allocations, locks and system calls
81
+ * counted while the plugin's tasks ran on them, and how many tasks the helpers ran. Under a real host's pool on a private
82
+ * server: the server's DSP load and the xruns of the window without the thread pool and of the one with it.
83
+ */
84
+ export interface PoolFigures {
85
+ readonly allocationsOn?: number;
86
+ readonly locksOn?: number;
87
+ readonly syscallsOn?: number;
88
+ readonly tasksOnHelpers?: number;
89
+ /** Percent of the cycle the whole graph took, over the last third of the window. */
90
+ readonly dspLoadOff?: number;
91
+ readonly dspLoadOn?: number;
92
+ readonly xrunsOff?: number;
93
+ readonly xrunsOn?: number;
94
+ }
95
+
79
96
  /** One step of a plugin's pass and how it ended. */
80
97
  export interface RunStep {
81
98
  readonly step: string;
@@ -83,6 +100,8 @@ export interface RunStep {
83
100
  readonly code?: string;
84
101
  readonly detail?: string;
85
102
  readonly figures?: IsolatedFigures;
103
+ /** The pool step's figures. */
104
+ readonly pool?: PoolFigures;
86
105
  }
87
106
 
88
107
  /** The pass that judged a record: its id, and every step that ran. */
@@ -150,6 +169,25 @@ export function unmeasuredFigures(output: ProbeOutput): readonly UnmeasuredFigur
150
169
  }));
151
170
  }
152
171
 
172
+ /**
173
+ * Whether the run that wrote the record never took the latency figure: the probe ended well and measured a point, yet no
174
+ * point holds a latency and none is named unmeasured. That is a probe from before the latency pass (plugin-hostd-probe
175
+ * 0.12 ran no latency pass on an LV2 plugin): an instrument's note onset, or an effect's impulse, was never asked for, so a
176
+ * reader shows "latency unknown" for a figure today's probe takes. Such a record owes a measurement; it is never read as
177
+ * a plugin with no latency.
178
+ */
179
+ export function latencyNeverTaken(record: Pick<QualificationRecord, "probe" | "measurements">): boolean {
180
+ if (record.probe?.outcome !== PROBE_OK) return false;
181
+ const ok = (record.measurements?.grid ?? []).filter((p) => p.outcome === PROBE_OK);
182
+ if (ok.length === 0) return false;
183
+ if ((record.measurements.unmeasured ?? []).some((u) => u.dimension === "latency")) return false;
184
+ // a point's latency is a figure, or the reason the probe gave for none; `not-attempted` with no step is the older probe's silence
185
+ return !ok.some((p) => {
186
+ const l = p.latency as Partial<{ measuredFrames: number; unmeasuredReason: string }> | undefined;
187
+ return l !== undefined && (l.measuredFrames !== undefined || (l.unmeasuredReason !== undefined && l.unmeasuredReason !== word("notAttempted")));
188
+ });
189
+ }
190
+
153
191
  /** A cell's latency when the run took no figure: the contract's unmeasured word and the hosting code a reader classifies by. */
154
192
  export interface UnmeasuredLatency {
155
193
  /** The figure the plugin declared (LV2 latency port, CLAP latency extension) when it loaded but the probe did not measure. */
@@ -107,6 +107,10 @@ export const LV2_HOST_PROBE_TOOL_ENV = "OMX_LV2_HOST_PROBE";
107
107
  export const ISOLATED_SIGNAL_TOOL_ENV = "OMX_ISOLATED_SIGNAL";
108
108
  /** `native/jack-peak.c`, the level of an instrument under a held note in the adapter run (`tools/mod-host-admit.mjs`). */
109
109
  export const JACK_PEAK_TOOL_ENV = "OMX_JACK_PEAK";
110
+ /** `native/rt-watch.c`, the interposer's window put around a JACK host's process callback: the host comparison's RT watch. */
111
+ export const RT_WATCH_ENV = "OMX_RT_WATCH";
112
+ /** `native/jack-compare.c`, the host comparison's ports, latency and stimulus on the JACK graph. */
113
+ export const JACK_COMPARE_TOOL_ENV = "OMX_JACK_COMPARE";
110
114
 
111
115
  /** The file names of the native tools, in every place they are looked for. */
112
116
  export const CLAP_QUALIFY_TOOL_NAME = "clap-qualify";
@@ -115,6 +119,8 @@ export const LV2_MEASURE_TOOL_NAME = "lv2_measure";
115
119
  export const RT_INTERPOSE_NAME = "rt_interpose.so";
116
120
  export const LV2_HOST_PROBE_TOOL_NAME = "lv2_host_probe";
117
121
  export const JACK_PEAK_TOOL_NAME = "jack_peak";
122
+ export const RT_WATCH_NAME = "rt_watch.so";
123
+ export const JACK_COMPARE_TOOL_NAME = "jack_compare";
118
124
 
119
125
  /**
120
126
  * The ONE list of the native tools the packages install, each with the setting that names it.
@@ -127,6 +133,8 @@ export const NATIVE_TOOLS: readonly { readonly name: string; readonly env: strin
127
133
  { name: LV2_HOST_PROBE_TOOL_NAME, env: LV2_HOST_PROBE_TOOL_ENV },
128
134
  { name: ISOLATED_SIGNAL_TOOL_NAME, env: ISOLATED_SIGNAL_TOOL_ENV },
129
135
  { name: JACK_PEAK_TOOL_NAME, env: JACK_PEAK_TOOL_ENV },
136
+ { name: RT_WATCH_NAME, env: RT_WATCH_ENV },
137
+ { name: JACK_COMPARE_TOOL_NAME, env: JACK_COMPARE_TOOL_ENV },
130
138
  ];
131
139
 
132
140
  /**
@@ -18,9 +18,10 @@ import { basename, join } from "node:path";
18
18
 
19
19
  import { ADAPTER_PASS_CELLS, startPrivateJack, type JackStarter } from "./qualify-adapter-pass.js";
20
20
  import type { GridCell } from "./measurement-grid.js";
21
- import type { PinEntry } from "./pins-file.js";
21
+ import { clapPinUri, type PinEntry } from "./pins-file.js";
22
22
  import { PROBE_OK, readProbeOutput } from "./probe-output.js";
23
- import type { IsolatedFigures, QualificationRecord, RunBlock, RunStep } from "./qualification-record.js";
23
+ import { HOSTING_CODE } from "./hosting-codes.js";
24
+ import type { IsolatedFigures, QualificationRecord, RecordVerdict, RunBlock, RunStep } from "./qualification-record.js";
24
25
  import { ISOLATED_SIGNAL_TOOL_ENV, ISOLATED_SIGNAL_TOOL_NAME } from "./qualify-declarations.js";
25
26
  import { locateNativeTool, toolsDir } from "./tool-paths.js";
26
27
  import { writeFileWhole } from "./whole-file.js";
@@ -97,9 +98,34 @@ function figuresOf(run: IsolatedRunFacts): IsolatedFigures | undefined {
97
98
  };
98
99
  }
99
100
 
100
- /** Whether a record's outcome calls for the isolated step: rated `conditional` by some host, or a probe that crashed or hung. */
101
+ /**
102
+ * A verdict whose only gap is memory nobody measured: it reads `unknown` and every reason that is not `suitable` is
103
+ * `hosting.memory.unmeasured`. Nothing says the plugin is unfit, so it is hosted in a worker of its own until its
104
+ * footprint is measured, and the isolated step is what shows it running alone.
105
+ */
106
+ export function memoryGapOnly(v: Pick<RecordVerdict, "measured" | "rating" | "reasons">): boolean {
107
+ if (!v.measured || v.rating !== "unknown") return false;
108
+ const open = v.reasons.filter((r) => r.rating !== "suitable");
109
+ return open.length > 0 && open.every((r) => r.code === HOSTING_CODE.memoryUnmeasured);
110
+ }
111
+
112
+ /** Whether a record's outcome calls for the isolated step: rated `conditional` by some host, a memory gap and nothing else, or a probe that crashed or hung. */
101
113
  export function owesIsolated(record: QualificationRecord): boolean {
102
- return PROBE_FAILED_HARD.has(record.probe.outcome) || record.verdicts.some((v) => v.measured && v.rating === "conditional");
114
+ return PROBE_FAILED_HARD.has(record.probe.outcome) || record.verdicts.some((v) => v.measured && (v.rating === "conditional" || memoryGapOnly(v)));
115
+ }
116
+
117
+ /**
118
+ * The pin a record can give back to a plugin whose state lost it: the bytes the probe pinned, and the word of a decided
119
+ * isolated step (`isolated` or `refused`). `undefined` when the record holds no whole pin or the step decided nothing.
120
+ */
121
+ export function pinFromRecord(record: QualificationRecord): PinEntry | undefined {
122
+ const step = record.run?.steps.find((s) => s.step === RUN_STEP.isolated);
123
+ if (step === undefined || step.result === ISOLATED_RESULT.notRun) return undefined;
124
+ const { files, layout } = record.binary;
125
+ if (files === undefined || files.length === 0 || layout === undefined) return undefined;
126
+ const sorted = [...files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
127
+ const uri = record.plugin.format === "clap" ? clapPinUri(record.binary.path, record.plugin.id) : record.plugin.id;
128
+ return pinAfterStep({ uri, integrity: { files: sorted, layout } }, step);
103
129
  }
104
130
 
105
131
  /** Whether the isolated step saw this plugin running alone. A record's word alone never says so. */
@@ -180,7 +206,7 @@ export function pinAfterStep(pin: PinEntry | undefined, step: RunStep): PinEntry
180
206
  }
181
207
 
182
208
  /** The run id of a record from before the steps: its probe output's, read from the copy beside it. */
183
- function runIdOf(record: QualificationRecord, dir: string): string | undefined {
209
+ export function runIdOf(record: QualificationRecord, dir: string): string | undefined {
184
210
  if (record.run !== undefined) return record.run.id;
185
211
  if (record.rejudged !== undefined) return record.rejudged.figures.runId;
186
212
  try {