@openmixer/plugin-qualify 0.5.0 → 0.6.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 (48) hide show
  1. package/README.md +19 -2
  2. package/dist/clap-qualify.d.ts +5 -2
  3. package/dist/clap-qualify.js +19 -14
  4. package/dist/clap-qualify.js.map +1 -1
  5. package/dist/cost-fields.d.ts +46 -0
  6. package/dist/cost-fields.js +46 -0
  7. package/dist/cost-fields.js.map +1 -0
  8. package/dist/hosting-codes.d.ts +2 -0
  9. package/dist/hosting-codes.js +2 -0
  10. package/dist/hosting-codes.js.map +1 -1
  11. package/dist/hosting-suitability.d.ts +15 -2
  12. package/dist/hosting-suitability.js +44 -12
  13. package/dist/hosting-suitability.js.map +1 -1
  14. package/dist/index.d.ts +2 -0
  15. package/dist/index.js +2 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/measure-host.d.ts +21 -0
  18. package/dist/measure-host.js +124 -0
  19. package/dist/measure-host.js.map +1 -0
  20. package/dist/measurement/cli.d.ts +47 -7
  21. package/dist/measurement/cli.js +117 -9
  22. package/dist/measurement/cli.js.map +1 -1
  23. package/dist/measurement/format.d.ts +5 -0
  24. package/dist/measurement/format.js +2 -0
  25. package/dist/measurement/format.js.map +1 -1
  26. package/dist/measurement/vocabulary.d.ts +7 -2
  27. package/dist/measurement/vocabulary.js +7 -2
  28. package/dist/measurement/vocabulary.js.map +1 -1
  29. package/dist/qualify-declarations.d.ts +50 -2
  30. package/dist/qualify-declarations.js +51 -2
  31. package/dist/qualify-declarations.js.map +1 -1
  32. package/dist/tool-paths.d.ts +13 -0
  33. package/dist/tool-paths.js +29 -2
  34. package/dist/tool-paths.js.map +1 -1
  35. package/package.json +1 -1
  36. package/src/clap-qualify.ts +28 -14
  37. package/src/cost-fields.ts +72 -0
  38. package/src/hosting-codes.ts +2 -0
  39. package/src/hosting-suitability.ts +58 -12
  40. package/src/index.ts +2 -0
  41. package/src/measure-host.ts +112 -0
  42. package/src/measurement/cli.ts +146 -15
  43. package/src/measurement/format.ts +7 -0
  44. package/src/measurement/vocabulary.ts +8 -2
  45. package/src/qualify-declarations.ts +58 -2
  46. package/src/tool-paths.ts +43 -2
  47. package/tools/benchmark.py +126 -6
  48. package/tools/lv2-measure.mjs +76 -9
@@ -306,6 +306,7 @@ export type PluginLv2Features = Omit<FeaturesMeasurement, "unmeasuredReason" | "
306
306
 
307
307
  export { DEFAULT_HOSTING_POLICY, type HostingPolicy } from "./qualify-declarations.js";
308
308
  import { DEFAULT_HOSTING_POLICY, type HostingPolicy } from "./qualify-declarations.js";
309
+ import { costFieldsMissing } from "./cost-fields.js";
309
310
 
310
311
  /** One declared rate's latency reading from the CLAP qualifier (CLAP spec §3, latency). */
311
312
  export interface ClapLatencyAtRate {
@@ -404,12 +405,25 @@ export function deadAgainstBest(levelDbfs: number, bestDbfs: number, policy: Hos
404
405
  return bestDbfs > -Infinity && levelDbfs < bestDbfs - policy.soakDeadDropDb;
405
406
  }
406
407
 
408
+ /** The classes of soak failure, as the strings `failures` is built from. */
409
+ export type SoakFailureKind = "died" | "non-finite" | "dead" | "mostly-silent" | "startup-blast" | "timed-out";
410
+
411
+ export interface SoakFailure {
412
+ readonly rate: number;
413
+ readonly kind: SoakFailureKind;
414
+ /** Extra words for the string form, e.g. `20dBFS`. */
415
+ readonly detail?: string;
416
+ }
417
+
407
418
  /** A soak's reading: what voided it, if anything, and what it proved when nothing did. */
408
419
  export interface SoakFindings {
409
420
  /** The SHORTEST rate's seconds — a soak proves no more than its weakest rate ran. */
410
421
  readonly seconds: number;
411
- /** Every rate that voids the soak, as `<why>@<rate>` — `died@`, `non-finite@`, `dead@`, `mostly-silent@`, `startup-blast@`. */
422
+ /** Every rate that voids the soak, as `<why>@<rate>` — `died@`, `non-finite@`, `dead@`, `mostly-silent@`, `startup-blast@`, `timed-out@`. */
412
423
  readonly failures: readonly string[];
424
+ /** The same failures, structured — what the per-rate verdict reads. `timed-out` is the supervisor's
425
+ * cut, a fact about the harness: it voids the soak but is never a failure of the plugin. */
426
+ readonly failed: readonly SoakFailure[];
413
427
  readonly ratesTested: number;
414
428
  /** The largest RSS growth across rates, for the record; not judged. */
415
429
  readonly rssGrowthKb: number;
@@ -432,35 +446,43 @@ export function soakFindings(soak: SoakReading | undefined, policy: HostingPolic
432
446
  if (soak === undefined) return undefined;
433
447
  const rates = Object.entries(soak.perRate);
434
448
  if (rates.length === 0) return undefined;
435
- const gone = (v: SoakAtRate): boolean => v.died === true || v.instantiated === false;
449
+ const gone = (v: SoakAtRate): boolean => v.timedOut !== true && (v.died === true || v.instantiated === false);
450
+ const cut = (v: SoakAtRate): boolean => v.timedOut === true;
436
451
  const settled = rates.map(([, v]) =>
437
- gone(v) || (v.lastRmsDbfs ?? -Infinity) > BLAST_DBFS ? -Infinity : v.lastRmsDbfs ?? -Infinity,
452
+ gone(v) || cut(v) || (v.lastRmsDbfs ?? -Infinity) > BLAST_DBFS ? -Infinity : v.lastRmsDbfs ?? -Infinity,
438
453
  );
439
454
  const best = Math.max(...settled);
440
- const failures: string[] = [];
441
- for (const [rate, v] of rates) {
455
+ const failed: SoakFailure[] = [];
456
+ for (const [key, v] of rates) {
457
+ const rate = Number(key);
458
+ if (cut(v)) {
459
+ failed.push({ rate, kind: "timed-out" });
460
+ continue;
461
+ }
442
462
  if (gone(v)) {
443
- failures.push(`died@${rate}`);
463
+ failed.push({ rate, kind: "died" });
444
464
  continue;
445
465
  }
446
466
  if ((v.nonFiniteWindows ?? 0) > 0) {
447
- failures.push(`non-finite@${rate}`);
467
+ failed.push({ rate, kind: "non-finite" });
448
468
  continue;
449
469
  }
450
470
  if (deadAgainstBest(v.lastRmsDbfs ?? -Infinity, best, policy)) {
451
- failures.push(`dead@${rate}`);
471
+ failed.push({ rate, kind: "dead" });
452
472
  continue;
453
473
  }
454
474
  if ((v.silentWindows ?? 0) > (v.windows ?? 1) * 0.5) {
455
- failures.push(`mostly-silent@${rate}`);
475
+ failed.push({ rate, kind: "mostly-silent" });
456
476
  continue;
457
477
  }
458
478
  const first = v.firstRmsDbfs ?? -99;
459
- if (first > BLAST_DBFS) failures.push(`startup-blast@${rate}(${first.toFixed(0)}dBFS)`);
479
+ if (first > BLAST_DBFS) failed.push({ rate, kind: "startup-blast", detail: `${first.toFixed(0)}dBFS` });
460
480
  }
481
+ const failures = failed.map((f) => `${f.kind}@${f.rate}${f.detail === undefined ? "" : `(${f.detail})`}`);
461
482
  return {
462
483
  seconds: Math.min(...rates.map(([, v]) => v.seconds ?? 0)),
463
484
  failures,
485
+ failed,
464
486
  ratesTested: rates.length,
465
487
  rssGrowthKb: Math.max(...rates.map(([, v]) => v.rssGrowthKb ?? 0)),
466
488
  };
@@ -480,6 +502,8 @@ export function stabilityReasons(
480
502
  /** Whether the plugin shares its process with others (profile isolation, §3a): threads it
481
503
  * spawns and a GUI toolkit it links are cautions only there. */
482
504
  sharesProcess = true,
505
+ /** The console's rate: a soak failure measured at it is the plugin's verdict there. */
506
+ rate?: number,
483
507
  ): readonly HostingReason[] {
484
508
  const out: HostingReason[] = [];
485
509
  if (m === undefined || m.lifecycle === undefined) {
@@ -519,6 +543,18 @@ export function stabilityReasons(
519
543
  }),
520
544
  );
521
545
  }
546
+ // A failure MEASURED at the rate being judged is that plugin's verdict at that rate: unsuitable.
547
+ // At any other rate it only means the soak did not hold everywhere (unsoaked, above). A
548
+ // supervisor timeout is the harness's fact, never the plugin's.
549
+ for (const f of soak?.failed ?? []) {
550
+ if (f.kind === "timed-out" || rate === undefined || f.rate !== rate) continue;
551
+ out.push(
552
+ reason("stability", "objective", "unsuitable", HOSTING_CODE.stabilitySoakFailedAtRate, {
553
+ failure: f.kind,
554
+ rate: f.rate,
555
+ }),
556
+ );
557
+ }
522
558
  const spawned = sharesProcess ? threadsSpawned(m.threads) : 0;
523
559
  if (spawned > 0) {
524
560
  out.push(
@@ -651,6 +687,16 @@ export function hostingCostReasons(
651
687
  }),
652
688
  ];
653
689
  }
690
+ // Refuse before comparing: `undefined > ceiling` is false, which is how a gate passes by absence.
691
+ const missing = costFieldsMissing(perRate);
692
+ if (missing.length > 0) {
693
+ return [
694
+ reason("cost", "objective", "unknown", HOSTING_CODE.costFieldsMissing, {
695
+ rate: options.rate,
696
+ missing: missing.join(","),
697
+ }),
698
+ ];
699
+ }
654
700
  const out: HostingReason[] = [];
655
701
  const p95 = perRate.coreFractionP95;
656
702
  // The per-instance ceiling is the HOST's budget (§3a), not the policy's.
@@ -864,7 +910,7 @@ export function classifyPluginHosting(
864
910
  // the interposer's counts and the cost are format-blind and read unchanged.
865
911
  const reasons: HostingReason[] = descriptor.format === "clap"
866
912
  ? [
867
- ...stabilityReasons(descriptor.stability, policy, options.pluginBinary, sharesProcess),
913
+ ...stabilityReasons(descriptor.stability, policy, options.pluginBinary, sharesProcess, options.rate),
868
914
  ...rtSafetyReasons(descriptor.rtSafety),
869
915
  ...clapRtReasons(descriptor.clap),
870
916
  ...clapFeatureReasons(descriptor.clap, profile, descriptor.hasMidiIn),
@@ -873,7 +919,7 @@ export function classifyPluginHosting(
873
919
  ...clapTopologyReasons(descriptor.clap, profile.instruments),
874
920
  ]
875
921
  : [
876
- ...stabilityReasons(descriptor.stability, policy, options.pluginBinary, sharesProcess),
922
+ ...stabilityReasons(descriptor.stability, policy, options.pluginBinary, sharesProcess, options.rate),
877
923
  ...rtSafetyReasons(descriptor.rtSafety),
878
924
  ...featureReasons(descriptor.lv2Features, descriptor, profile.lv2Features, options.quantum !== undefined, profile.instruments),
879
925
  ...hostingCostReasons(descriptor, options, policy),
package/src/index.ts CHANGED
@@ -17,3 +17,5 @@ export * from "./clap-loader.js";
17
17
  export * from "./clap-qualify.js";
18
18
  export * from "./qualify-options.js";
19
19
  export * from "./qualify-cli.js";
20
+ export * from "./measure-host.js";
21
+ export * from "./cost-fields.js";
@@ -0,0 +1,112 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
+ /**
4
+ * Reading what the measuring host (`lv2_measure`) prints and supervising how long it may take
5
+ * (`2026-09-25-plugin-qualify.md` §2a). Pure functions: the runner spawns, these decide.
6
+ */
7
+ import { isRecord } from "./measurement/guards.js";
8
+ import { SOAK_SWEEP_DB, SOAK_SWEEP_RATIO, SOAK_TIMEOUT_FLOOR_S, SOAK_TIMEOUT_MARGIN } from "./qualify-declarations.js";
9
+
10
+ /**
11
+ * Where `text` stops being JSON, scanning ONE value from `i`; returns the index just past the
12
+ * value, or `-(failing index) - 1` when it is not (negative means failure). A tolerant reader
13
+ * has to know the exact offset of the first bad character, which `JSON.parse` does not report
14
+ * portably.
15
+ */
16
+ function scanValue(t: string, i: number): number {
17
+ const ws = (k: number): number => {
18
+ while (k < t.length && (t[k] === " " || t[k] === "\t" || t[k] === "\r" || t[k] === "\n")) k++;
19
+ return k;
20
+ };
21
+ i = ws(i);
22
+ const c = t[i];
23
+ if (c === "{" || c === "[") {
24
+ const close = c === "{" ? "}" : "]";
25
+ let k = ws(i + 1);
26
+ if (t[k] === close) return k + 1;
27
+ for (;;) {
28
+ if (c === "{") {
29
+ if (t[k] !== '"') return -k - 1;
30
+ const key = scanValue(t, k);
31
+ if (key < 0) return key;
32
+ k = ws(key);
33
+ if (t[k] !== ":") return -k - 1;
34
+ k++;
35
+ }
36
+ const v = scanValue(t, k);
37
+ if (v < 0) return v;
38
+ k = ws(v);
39
+ if (t[k] === close) return k + 1;
40
+ if (t[k] !== ",") return -k - 1;
41
+ k = ws(k + 1);
42
+ }
43
+ }
44
+ if (c === '"') {
45
+ let k = i + 1;
46
+ while (k < t.length) {
47
+ if (t[k] === "\\") k += 2;
48
+ else if (t[k] === '"') return k + 1;
49
+ else if (t[k] === "\n") return -k - 1;
50
+ else k++;
51
+ }
52
+ return -t.length - 1;
53
+ }
54
+ const lit = /^(?:-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null)/.exec(t.slice(i, i + 40));
55
+ return lit === null ? -i - 1 : i + lit[0].length;
56
+ }
57
+
58
+ /**
59
+ * The tool's one JSON record out of a stdout a plugin has also printed on (Guitarix:
60
+ * `using block size: 0`, `convolver disabled`, written by the plugin into the middle of the
61
+ * tool's own buffered line). The record is the last `{`-leading line that repairs to one valid
62
+ * object: wherever the text stops being JSON, drop through the end of that line — the
63
+ * plugin's banner — and join what follows. `undefined` when nothing repairs: the caller
64
+ * reports a `parse-error`, never a death.
65
+ */
66
+ export function extractJsonRecord(stdout: string): Record<string, unknown> | undefined {
67
+ const lines = stdout.split("\n");
68
+ let offset = 0;
69
+ const starts: number[] = [];
70
+ for (const line of lines) {
71
+ if (line.startsWith("{")) starts.push(offset);
72
+ offset += line.length + 1;
73
+ }
74
+ for (const start of starts.reverse()) {
75
+ let text = stdout.slice(start);
76
+ for (let repairs = 0; repairs < 64; repairs++) {
77
+ const end = scanValue(text, 0);
78
+ if (end >= 0) {
79
+ try {
80
+ const parsed: unknown = JSON.parse(text.slice(0, end));
81
+ return isRecord(parsed) ? parsed : undefined;
82
+ } catch {
83
+ break;
84
+ }
85
+ }
86
+ const bad = -end - 1;
87
+ const eol = text.indexOf("\n", bad);
88
+ if (eol < 0) break;
89
+ text = text.slice(0, bad) + text.slice(eol + 1);
90
+ }
91
+ }
92
+ return undefined;
93
+ }
94
+
95
+ /**
96
+ * The wall-clock limit for a soak of `soakAudioS` audio seconds, from the calibration soak's
97
+ * measured speed: `wallPerAudio * soakAudioS * SOAK_TIMEOUT_MARGIN + SOAK_TIMEOUT_FLOOR_S`,
98
+ * rounded up. Refuses (throws) on a calibration that measured nothing: a guessed limit is the
99
+ * fixed 900 s again.
100
+ */
101
+ export function soakTimeoutSeconds(calibration: { wallSeconds: number; audioSeconds: number }, soakAudioS: number): number {
102
+ const { wallSeconds, audioSeconds } = calibration;
103
+ if (!(wallSeconds > 0) || !(audioSeconds > 0) || !(soakAudioS > 0)) {
104
+ throw new Error("soakTimeoutSeconds: calibration measured no speed");
105
+ }
106
+ return Math.ceil((wallSeconds / audioSeconds) * soakAudioS * SOAK_TIMEOUT_MARGIN + SOAK_TIMEOUT_FLOOR_S);
107
+ }
108
+
109
+ /** The environment that bounds the host's soak sweep to the declared ranges. */
110
+ export function soakSweepEnv(): Record<string, string> {
111
+ return { OMX_SWEEP_DB: String(SOAK_SWEEP_DB), OMX_SWEEP_RATIO: String(SOAK_SWEEP_RATIO) };
112
+ }
@@ -23,6 +23,13 @@
23
23
  * mildly hazardous thing to start by accident — a plugin can and does crash its host — so it
24
24
  * is gated behind `--run` (or `LV2_MEASURE_RUN=1`), exactly as `benchmark.py` gates itself.
25
25
  * `--dry-run` prints the plan and probes nothing.
26
+ *
27
+ * ## The hosting pass
28
+ *
29
+ * `stability`, `rtSafety` and `features` are measured by the native measurer (`native/lv2-measure.c`,
30
+ * `lv2_measure`), run once per plugin under the interposer, in its own process so a crash is a
31
+ * reading and never the run's end. Its lines are the hosting sweep's, read by `sweep-jsonl.ts`.
32
+ * The plugins are the scan's, or exactly the `--plugins` named, which needs no scan.
26
33
  */
27
34
 
28
35
  import { join } from "node:path";
@@ -32,7 +39,10 @@ import { buildMeasurementDocument } from "./format.js";
32
39
  import { measuredPluginsFromProbeOutput } from "./probe-json.js";
33
40
  import { buildRun, costMethodFromProbeProvenance, hostFromProbeProvenance } from "./provenance.js";
34
41
  import type { MeasurementHost, LatencyMethod } from "./format.js";
35
- import { type MeasurementDimension, PROBED_DIMENSIONS } from "./vocabulary.js";
42
+ import { measuredPluginsFromSweep } from "./sweep-jsonl.js";
43
+ import { isRecord, nonEmptyString } from "./guards.js";
44
+ import { HOSTING_PASS_DIMENSIONS, type MeasurementDimension, PROBED_DIMENSIONS } from "./vocabulary.js";
45
+ import { LV2_HOSTING_BLOCK_FRAMES, LV2_HOSTING_LIFECYCLES, LV2_HOSTING_REPEATS } from "../qualify-declarations.js";
36
46
 
37
47
  /** Name and version this tool stamps into every document it writes. */
38
48
  export const TOOL_NAME = "lv2-measure";
@@ -52,6 +62,8 @@ export interface MeasureOptions {
52
62
  readonly dimensions: readonly MeasurementDimension[];
53
63
  /** An existing prober scan to reuse instead of scanning. */
54
64
  readonly scanPath?: string;
65
+ /** The plugins the hosting pass measures, instead of every plugin the scan finds. */
66
+ readonly plugins?: readonly string[];
55
67
  /** Where intermediates are written. */
56
68
  readonly workDir: string;
57
69
  /** Also sweep latency-bearing controls to find parameter-dependent latency. */
@@ -71,11 +83,15 @@ export interface MeasureOptions {
71
83
 
72
84
  /** One subprocess the pipeline runs, and what it leaves behind. */
73
85
  export interface PipelineStep {
74
- /** Stable id: `scan` / `latency-declared` / `latency-measured` / `cost`. */
86
+ /** Stable id: `scan` / `latency-declared` / `latency-measured` / `cost` / `hosting`. */
75
87
  readonly id: string;
76
88
  readonly command: string;
77
89
  readonly args: readonly string[];
78
- /** The catalog JSON this step writes — the next step's input. */
90
+ /** Run once per plugin, the plugin's URI before `args` (the hosting pass). */
91
+ readonly perPlugin?: boolean;
92
+ /** Set in the step's environment, over the caller's. */
93
+ readonly env?: Readonly<Record<string, string>>;
94
+ /** The catalog JSON this step writes — the next step's input; the hosting pass's JSONL. */
79
95
  readonly produces: string;
80
96
  /** The dimension this step measures, when it measures one. */
81
97
  readonly dimension?: MeasurementDimension;
@@ -84,8 +100,10 @@ export interface PipelineStep {
84
100
  /** The full plan: the steps, and where the run's artefacts land. */
85
101
  export interface Pipeline {
86
102
  readonly steps: readonly PipelineStep[];
87
- /** The catalog JSON carrying every annotation once the last step has run. */
88
- readonly finalCatalog: string;
103
+ /** The catalog JSON carrying every annotation once the last step has run; absent when nothing is scanned. */
104
+ readonly finalCatalog?: string;
105
+ /** The scan the hosting pass takes its plugins from, when `--plugins` does not name them. */
106
+ readonly hostingScan?: string;
89
107
  /** Where the cost pass writes its machine provenance, when cost is measured. */
90
108
  readonly costProvenance?: string;
91
109
  }
@@ -98,13 +116,16 @@ export interface Pipeline {
98
116
  * ends with one file carrying both — which is also why `finalCatalog` is a property of the
99
117
  * pipeline rather than a fixed name.
100
118
  */
101
- export function buildPipeline(options: MeasureOptions): Pipeline {
119
+ export function buildPipeline(options: MeasureOptions, native?: HostingTools): Pipeline {
102
120
  const steps: PipelineStep[] = [];
103
121
  const benchmark = join(options.toolDir, "benchmark.py");
104
122
  const rates = [...options.rates].sort((a, b) => a - b).join(",");
123
+ const probed = options.dimensions.some((d) => (PROBED_DIMENSIONS as readonly string[]).includes(d));
124
+ const hosting = options.dimensions.some((d) => (HOSTING_PASS_DIMENSIONS as readonly string[]).includes(d));
125
+ if (hosting && native === undefined) throw new Error("the hosting pass needs the native measurer and the interposer");
105
126
 
106
127
  let current = options.scanPath;
107
- if (current === undefined) {
128
+ if (current === undefined && (probed || options.plugins === undefined)) {
108
129
  current = join(options.workDir, "scan.json");
109
130
  steps.push({
110
131
  id: "scan",
@@ -114,7 +135,8 @@ export function buildPipeline(options: MeasureOptions): Pipeline {
114
135
  });
115
136
  }
116
137
 
117
- if (options.dimensions.includes("latency")) {
138
+ // a prober dimension always has a scan: --plugins is refused beside one
139
+ if (options.dimensions.includes("latency") && current !== undefined) {
118
140
  // Declared first: the measured pass sizes its capture window from this figure and
119
141
  // computes declaredMismatch against it. Reversing the two silently degrades both.
120
142
  const declared = join(options.workDir, "latency-declared.json");
@@ -148,7 +170,7 @@ export function buildPipeline(options: MeasureOptions): Pipeline {
148
170
  }
149
171
 
150
172
  let costProvenance: string | undefined;
151
- if (options.dimensions.includes("cost")) {
173
+ if (options.dimensions.includes("cost") && current !== undefined) {
152
174
  costProvenance = join(options.workDir, "cost-provenance.json");
153
175
  const cost = join(options.workDir, "cost.json");
154
176
  steps.push({
@@ -174,13 +196,77 @@ export function buildPipeline(options: MeasureOptions): Pipeline {
174
196
  current = cost;
175
197
  }
176
198
 
199
+ if (hosting && native !== undefined) {
200
+ steps.push({
201
+ id: "hosting",
202
+ command: native.tool,
203
+ args: [
204
+ String(Math.max(...options.rates)),
205
+ String(LV2_HOSTING_BLOCK_FRAMES),
206
+ String(LV2_HOSTING_LIFECYCLES),
207
+ String(LV2_HOSTING_REPEATS),
208
+ ],
209
+ perPlugin: true,
210
+ env: { LD_PRELOAD: native.interposer, OMX_STIM: "noise" },
211
+ produces: join(options.workDir, "hosting.jsonl"),
212
+ });
213
+ }
214
+
177
215
  return {
178
216
  steps,
179
- finalCatalog: current,
217
+ ...(probed && current !== undefined ? { finalCatalog: current } : {}),
218
+ ...(hosting && options.plugins === undefined && current !== undefined ? { hostingScan: current } : {}),
180
219
  ...(costProvenance === undefined ? {} : { costProvenance }),
181
220
  };
182
221
  }
183
222
 
223
+ /** The native measurer and the interposer it runs under, as located for this run. */
224
+ export interface HostingTools {
225
+ readonly tool: string;
226
+ readonly interposer: string;
227
+ }
228
+
229
+ /** The plugin URIs of a scan (a flat array of descriptors), in its order, each once. */
230
+ export function urisFromScan(scan: unknown): string[] {
231
+ if (!Array.isArray(scan)) return [];
232
+ const uris = scan.flatMap((entry) => {
233
+ const uri = isRecord(entry) ? nonEmptyString(entry.uri) : undefined;
234
+ return uri === undefined ? [] : [uri];
235
+ });
236
+ return [...new Set(uris)];
237
+ }
238
+
239
+ /** How one run of the native measurer ended. `signal` is a death, `timedOut` the run killed for its time. */
240
+ export interface HostingRunResult {
241
+ readonly status: number | null;
242
+ readonly signalNumber?: number;
243
+ readonly timedOut?: boolean;
244
+ readonly stdout: string;
245
+ }
246
+
247
+ /**
248
+ * One plugin's run of the native measurer as the hosting sweep's JSONL line: the LAST line of its
249
+ * stdout that is a JSON object (a plugin may print its own banner first), with the exit status
250
+ * beside it, a death written as 128 + its signal and a timeout as 124. A run that printed no line
251
+ * is `crashed`, its exit kept: what that exit means is the reader's (`sweep-jsonl.ts`) to say.
252
+ */
253
+ export function hostingLine(uri: string, run: HostingRunResult): Record<string, unknown> {
254
+ const exit = run.timedOut === true ? 124 : run.signalNumber !== undefined ? 128 + run.signalNumber : (run.status ?? 1);
255
+ const last = run.stdout
256
+ .split("\n")
257
+ .filter((line) => line.startsWith("{"))
258
+ .pop();
259
+ if (last !== undefined) {
260
+ try {
261
+ const row: unknown = JSON.parse(last);
262
+ if (isRecord(row)) return { ...row, exit };
263
+ } catch {
264
+ return { uri, exit, parseError: true };
265
+ }
266
+ }
267
+ return { uri, exit, crashed: true };
268
+ }
269
+
184
270
  // ---------------------------------------------------------------------------
185
271
  // Arguments
186
272
  // ---------------------------------------------------------------------------
@@ -198,9 +284,12 @@ function parseRates(text: string): number[] | undefined {
198
284
  return rates.length > 0 ? [...new Set(rates)].sort((a, b) => a - b) : undefined;
199
285
  }
200
286
 
287
+ /** What `--dimensions` takes: the prober's, and the hosting pass's. */
288
+ export const MEASURED_DIMENSIONS: readonly MeasurementDimension[] = [...PROBED_DIMENSIONS, ...HOSTING_PASS_DIMENSIONS];
289
+
201
290
  function parseDimensions(text: string): MeasurementDimension[] | undefined {
202
291
  const wanted = text.split(",").map((part) => part.trim());
203
- const dimensions = PROBED_DIMENSIONS.filter((d) => wanted.includes(d));
292
+ const dimensions = MEASURED_DIMENSIONS.filter((d) => wanted.includes(d));
204
293
  return dimensions.length === wanted.filter((w) => w.length > 0).length && dimensions.length > 0
205
294
  ? [...dimensions]
206
295
  : undefined;
@@ -222,6 +311,7 @@ export function parseMeasureArgs(
222
311
  let rates: readonly number[] | undefined;
223
312
  let dimensions: readonly MeasurementDimension[] = PROBED_DIMENSIONS;
224
313
  let scanPath: string | undefined;
314
+ let plugins: readonly string[] | undefined;
225
315
  let workDir = defaults.workDir;
226
316
  let sweep = false;
227
317
  let costCpu: number | undefined;
@@ -255,7 +345,7 @@ export function parseMeasureArgs(
255
345
  const value = next();
256
346
  const parsed = value === undefined ? undefined : parseDimensions(value);
257
347
  if (parsed === undefined) {
258
- return { ok: false, error: `--dimensions accepts ${PROBED_DIMENSIONS.join(",")}` };
348
+ return { ok: false, error: `--dimensions accepts ${MEASURED_DIMENSIONS.join(",")}` };
259
349
  }
260
350
  dimensions = parsed;
261
351
  break;
@@ -266,6 +356,13 @@ export function parseMeasureArgs(
266
356
  scanPath = value;
267
357
  break;
268
358
  }
359
+ case "--plugins": {
360
+ const value = next();
361
+ const uris = value === undefined ? [] : [...new Set(value.split(",").map((u) => u.trim()).filter((u) => u !== ""))];
362
+ if (uris.length === 0) return { ok: false, error: "--plugins needs a comma-separated list of plugin URIs" };
363
+ plugins = uris;
364
+ break;
365
+ }
269
366
  case "--work-dir": {
270
367
  const value = next();
271
368
  if (value === undefined) return { ok: false, error: "--work-dir needs a path" };
@@ -317,6 +414,12 @@ export function parseMeasureArgs(
317
414
  error: "--rates is required: a measurement with no stated rate is not a measurement",
318
415
  };
319
416
  }
417
+ if (plugins !== undefined && dimensions.some((d) => (PROBED_DIMENSIONS as readonly string[]).includes(d))) {
418
+ return {
419
+ ok: false,
420
+ error: `--plugins names the hosting pass's plugins; ${PROBED_DIMENSIONS.join(" and ")} measure every plugin the scan finds`,
421
+ };
422
+ }
320
423
  return {
321
424
  ok: true,
322
425
  options: {
@@ -330,6 +433,7 @@ export function parseMeasureArgs(
330
433
  dryRun,
331
434
  ...(outPath === undefined ? {} : { outPath }),
332
435
  ...(scanPath === undefined ? {} : { scanPath }),
436
+ ...(plugins === undefined ? {} : { plugins }),
333
437
  ...(costCpu === undefined ? {} : { costCpu }),
334
438
  ...(label === undefined ? {} : { label }),
335
439
  },
@@ -402,8 +506,10 @@ export function advanceProgress(state: ProgressState, event: ProbeProgress): Pro
402
506
  /** Everything {@link assembleDocument} needs beyond the probe output. */
403
507
  export interface AssemblyInput {
404
508
  readonly options: MeasureOptions;
405
- /** The final catalog JSON, already parsed. */
406
- readonly catalog: unknown;
509
+ /** The final catalog JSON, already parsed. Absent when the run measured no prober dimension. */
510
+ readonly catalog?: unknown;
511
+ /** The hosting pass's lines ({@link hostingLine}), one per plugin. Absent when it did not run. */
512
+ readonly hosting?: readonly unknown[];
407
513
  /** The cost pass's provenance JSON, already parsed. Absent on a latency-only run. */
408
514
  readonly costProvenance?: unknown;
409
515
  /** Host facts, for a run whose provenance the cost pass did not write. */
@@ -437,7 +543,10 @@ export const DEFAULT_LATENCY_BLOCK_FRAMES = 512;
437
543
  * no cost pass do the caller's collected facts stand in.
438
544
  */
439
545
  export function assembleDocument(input: AssemblyInput): MeasurementDocument {
440
- const plugins: MeasuredPlugin[] = measuredPluginsFromProbeOutput(input.catalog, input.options.dimensions);
546
+ const probed = input.options.dimensions.filter((d) => (PROBED_DIMENSIONS as readonly string[]).includes(d));
547
+ const plugins: MeasuredPlugin[] =
548
+ probed.length > 0 && input.catalog !== undefined ? measuredPluginsFromProbeOutput(input.catalog, probed) : [];
549
+ if (input.hosting !== undefined) mergeHosting(plugins, measuredPluginsFromSweep({ measurements: input.hosting }), input.options.dimensions);
441
550
  const costMethod = costMethodFromProbeProvenance(input.costProvenance);
442
551
  const host = hostFromProbeProvenance(input.costProvenance) ?? input.host;
443
552
  const run: MeasurementRun = buildRun({
@@ -456,3 +565,25 @@ export function assembleDocument(input: AssemblyInput): MeasurementDocument {
456
565
  });
457
566
  return buildMeasurementDocument(run, plugins);
458
567
  }
568
+
569
+ /**
570
+ * The hosting pass's plugins folded into the prober's, by URI: each keeps the hosting dimensions the
571
+ * run asked for and the pin, and a plugin only the hosting pass measured joins with its own topology.
572
+ */
573
+ function mergeHosting(plugins: MeasuredPlugin[], hosted: readonly MeasuredPlugin[], dimensions: readonly MeasurementDimension[]): void {
574
+ for (const h of hosted) {
575
+ const blocks = {
576
+ ...(dimensions.includes("stability") && h.stability !== undefined ? { stability: h.stability } : {}),
577
+ ...(dimensions.includes("rtSafety") && h.rtSafety !== undefined ? { rtSafety: h.rtSafety } : {}),
578
+ ...(dimensions.includes("features") && h.features !== undefined ? { features: h.features } : {}),
579
+ ...(h.integrity === undefined ? {} : { integrity: h.integrity }),
580
+ };
581
+ const at = plugins.findIndex((p) => p.uri === h.uri);
582
+ if (at < 0) {
583
+ plugins.push({ uri: h.uri, ...(h.version === undefined ? {} : { version: h.version }), topology: h.topology, ...blocks });
584
+ } else {
585
+ const p = plugins[at]!;
586
+ plugins[at] = { ...p, ...(p.version === undefined && h.version !== undefined ? { version: h.version } : {}), ...blocks };
587
+ }
588
+ }
589
+ }
@@ -335,6 +335,11 @@ export interface SoakAtRate {
335
335
  /** The process died at this rate. Every other field is then absent. */
336
336
  readonly died?: boolean;
337
337
  readonly instantiated?: boolean;
338
+ /** The SUPERVISOR stopped the render at its wall-clock limit: a fact about the harness, never a
339
+ * failure of the plugin (and never `died`). */
340
+ readonly timedOut?: boolean;
341
+ /** The wall-clock limit that cut it, in seconds. */
342
+ readonly timeoutSeconds?: number;
338
343
  readonly seconds?: number;
339
344
  readonly blockFrames?: number;
340
345
  readonly windows?: number;
@@ -888,6 +893,8 @@ export function readSoakAtRate(value: unknown): SoakAtRate | undefined {
888
893
  return {
889
894
  ...optional("died", booleanValue(value.died)),
890
895
  ...optional("instantiated", booleanValue(value.instantiated)),
896
+ ...optional("timedOut", booleanValue(value.timedOut)),
897
+ ...optional("timeoutSeconds", finiteNumber(value.timeoutSeconds)),
891
898
  ...optional("seconds", finiteNumber(value.seconds)),
892
899
  ...optional("blockFrames", positiveNumber(value.blockFrames)),
893
900
  ...optional("windows", finiteNumber(value.windows)),
@@ -273,11 +273,17 @@ export const MEASUREMENT_DIMENSIONS = ["latency", "cost", "stability", "rtSafety
273
273
  /**
274
274
  * The dimensions the standalone prober (`scan.py` + `benchmark.py`, driven by `lv2-measure.mjs`
275
275
  * and the console's Setup analysis) can measure. The three hosting dimensions come from a
276
- * different tool — the `lv2-measure.c` sweep, read by `sweep-jsonl.ts` — so a run asked of the
277
- * prober for `stability` would be asking it for something it cannot take.
276
+ * different tool — the native measurer `native/lv2-measure.c`, read by `sweep-jsonl.ts` — so a
277
+ * run asked of the prober for `stability` would be asking it for something it cannot take.
278
278
  */
279
279
  export const PROBED_DIMENSIONS = ["latency", "cost"] as const satisfies readonly (typeof MEASUREMENT_DIMENSIONS)[number][];
280
280
 
281
+ /**
282
+ * The dimensions `lv2-measure.mjs`'s hosting pass measures: one run of the native measurer per
283
+ * plugin, under the interposer, writes all three.
284
+ */
285
+ export const HOSTING_PASS_DIMENSIONS = ["stability", "rtSafety", "features"] as const satisfies readonly (typeof MEASUREMENT_DIMENSIONS)[number][];
286
+
281
287
  /** One measurable dimension. */
282
288
  export type MeasurementDimension = (typeof MEASUREMENT_DIMENSIONS)[number];
283
289