@openmixer/plugin-qualify 0.5.1 → 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.
- package/README.md +17 -0
- package/dist/clap-qualify.js +8 -10
- package/dist/clap-qualify.js.map +1 -1
- package/dist/cost-fields.d.ts +46 -0
- package/dist/cost-fields.js +46 -0
- package/dist/cost-fields.js.map +1 -0
- package/dist/hosting-codes.d.ts +2 -0
- package/dist/hosting-codes.js +2 -0
- package/dist/hosting-codes.js.map +1 -1
- package/dist/hosting-suitability.d.ts +15 -2
- package/dist/hosting-suitability.js +44 -12
- package/dist/hosting-suitability.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/measure-host.d.ts +21 -0
- package/dist/measure-host.js +124 -0
- package/dist/measure-host.js.map +1 -0
- package/dist/measurement/cli.d.ts +47 -7
- package/dist/measurement/cli.js +117 -9
- package/dist/measurement/cli.js.map +1 -1
- package/dist/measurement/format.d.ts +5 -0
- package/dist/measurement/format.js +2 -0
- package/dist/measurement/format.js.map +1 -1
- package/dist/measurement/vocabulary.d.ts +7 -2
- package/dist/measurement/vocabulary.js +7 -2
- package/dist/measurement/vocabulary.js.map +1 -1
- package/dist/qualify-declarations.d.ts +43 -5
- package/dist/qualify-declarations.js +44 -5
- package/dist/qualify-declarations.js.map +1 -1
- package/dist/tool-paths.d.ts +11 -0
- package/dist/tool-paths.js +18 -1
- package/dist/tool-paths.js.map +1 -1
- package/package.json +1 -1
- package/src/clap-qualify.ts +6 -11
- package/src/cost-fields.ts +72 -0
- package/src/hosting-codes.ts +2 -0
- package/src/hosting-suitability.ts +58 -12
- package/src/index.ts +2 -0
- package/src/measure-host.ts +112 -0
- package/src/measurement/cli.ts +146 -15
- package/src/measurement/format.ts +7 -0
- package/src/measurement/vocabulary.ts +8 -2
- package/src/qualify-declarations.ts +49 -5
- package/src/tool-paths.ts +29 -1
- package/tools/benchmark.py +126 -6
- 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
|
|
441
|
-
for (const [
|
|
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
|
-
|
|
463
|
+
failed.push({ rate, kind: "died" });
|
|
444
464
|
continue;
|
|
445
465
|
}
|
|
446
466
|
if ((v.nonFiniteWindows ?? 0) > 0) {
|
|
447
|
-
|
|
467
|
+
failed.push({ rate, kind: "non-finite" });
|
|
448
468
|
continue;
|
|
449
469
|
}
|
|
450
470
|
if (deadAgainstBest(v.lastRmsDbfs ?? -Infinity, best, policy)) {
|
|
451
|
-
|
|
471
|
+
failed.push({ rate, kind: "dead" });
|
|
452
472
|
continue;
|
|
453
473
|
}
|
|
454
474
|
if ((v.silentWindows ?? 0) > (v.windows ?? 1) * 0.5) {
|
|
455
|
-
|
|
475
|
+
failed.push({ rate, kind: "mostly-silent" });
|
|
456
476
|
continue;
|
|
457
477
|
}
|
|
458
478
|
const first = v.firstRmsDbfs ?? -99;
|
|
459
|
-
if (first > BLAST_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
|
@@ -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
|
+
}
|
package/src/measurement/cli.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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 ${
|
|
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
|
|
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
|
|
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
|
|
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
|
|