@openmixer/plugin-qualify 0.10.1 → 0.11.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 +46 -10
- package/bin/plugin-qualify.mjs +61 -28
- package/dist/clap-loader.d.ts +1 -1
- package/dist/clap-loader.js +3 -3
- package/dist/clap-loader.js.map +1 -1
- package/dist/clap-qualify.d.ts +0 -47
- package/dist/clap-qualify.js +2 -88
- package/dist/clap-qualify.js.map +1 -1
- package/dist/cost-fields.d.ts +26 -0
- package/dist/cost-fields.js +30 -0
- package/dist/cost-fields.js.map +1 -1
- package/dist/hosting-codes.d.ts +1 -0
- package/dist/hosting-codes.js +1 -0
- package/dist/hosting-codes.js.map +1 -1
- package/dist/hosting-suitability.d.ts +11 -1
- package/dist/hosting-suitability.js +11 -2
- package/dist/hosting-suitability.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/judge-cli.d.ts +41 -0
- package/dist/judge-cli.js +114 -0
- package/dist/judge-cli.js.map +1 -0
- package/dist/measurement/probe-json.js +1 -0
- package/dist/measurement/probe-json.js.map +1 -1
- package/dist/measurement/vocabulary.js +6 -0
- package/dist/measurement/vocabulary.js.map +1 -1
- package/dist/probe-judge.d.ts +60 -0
- package/dist/probe-judge.js +213 -0
- package/dist/probe-judge.js.map +1 -0
- package/dist/probe-output-fixtures.d.ts +25 -0
- package/dist/probe-output-fixtures.js +134 -0
- package/dist/probe-output-fixtures.js.map +1 -0
- package/dist/probe-output.d.ts +150 -0
- package/dist/probe-output.js +369 -0
- package/dist/probe-output.js.map +1 -0
- package/dist/probe-runner.d.ts +79 -0
- package/dist/probe-runner.js +191 -0
- package/dist/probe-runner.js.map +1 -0
- package/dist/qualification-record.d.ts +80 -0
- package/dist/qualification-record.js +85 -0
- package/dist/qualification-record.js.map +1 -0
- package/dist/qualify-cli.js +1 -1
- package/dist/qualify-cli.js.map +1 -1
- package/dist/qualify-declarations.d.ts +11 -2
- package/dist/qualify-declarations.js +14 -4
- package/dist/qualify-declarations.js.map +1 -1
- package/fixtures/clap-scan/fake.json +30 -6
- package/fixtures/probe-output/memory-limit-refused.json +190 -0
- package/fixtures/probe-output/plugin-crash.json +236 -0
- package/fixtures/probe-output/success-msi.json +363 -0
- package/package.json +5 -1
- package/src/clap-loader.ts +3 -2
- package/src/clap-qualify.ts +2 -133
- package/src/cost-fields.ts +39 -0
- package/src/hosting-codes.ts +1 -0
- package/src/hosting-suitability.ts +21 -3
- package/src/index.ts +5 -0
- package/src/judge-cli.ts +133 -0
- package/src/measurement/probe-json.ts +1 -0
- package/src/measurement/vocabulary.ts +7 -0
- package/src/probe-judge.ts +264 -0
- package/src/probe-output-fixtures.ts +146 -0
- package/src/probe-output.ts +505 -0
- package/src/probe-runner.ts +244 -0
- package/src/qualification-record.ts +142 -0
- package/src/qualify-cli.ts +1 -1
- package/src/qualify-declarations.ts +16 -4
- package/tools/benchmark.py +12 -0
package/src/judge-cli.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
// Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
|
|
3
|
+
/**
|
|
4
|
+
* `plugin-qualify judge <probe-output>...`: judge probe outputs already written (by
|
|
5
|
+
* `plugin-hostd-probe run`, by plugin-hostd's `judge_cmd`, or by hand) and write the qualification
|
|
6
|
+
* record beside a byte copy of each. `bin/plugin-qualify.mjs` only reads argv and prints.
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
import type { HostProfile } from "./host-profiles.js";
|
|
13
|
+
import { judgeProbeOutput, judgmentVerdicts } from "./probe-judge.js";
|
|
14
|
+
import { readProbeOutput } from "./probe-output.js";
|
|
15
|
+
import { writeQualification } from "./qualification-record.js";
|
|
16
|
+
import type { QualifyVerdict } from "./qualify-cli.js";
|
|
17
|
+
import { EXIT_CODE, OUTPUT_FORMATS, type OutputFormat } from "./qualify-options.js";
|
|
18
|
+
import { OPENMIXER_CONSOLE_PROFILE } from "./host-profiles.js";
|
|
19
|
+
|
|
20
|
+
export interface JudgeArgs {
|
|
21
|
+
readonly files: readonly string[];
|
|
22
|
+
readonly hostProfiles: readonly string[];
|
|
23
|
+
readonly qualificationDir?: string;
|
|
24
|
+
readonly format: OutputFormat;
|
|
25
|
+
readonly help: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type ParseJudgeResult = { ok: true; args: JudgeArgs } | { ok: false; error: string };
|
|
29
|
+
|
|
30
|
+
const VALUE_FLAGS = ["--host-profile", "--qualification-dir", "--format"];
|
|
31
|
+
|
|
32
|
+
export function parseJudgeArgs(argv: readonly string[]): ParseJudgeResult {
|
|
33
|
+
const files: string[] = [];
|
|
34
|
+
const specs: string[] = [];
|
|
35
|
+
let dir: string | undefined;
|
|
36
|
+
let format: string = "text";
|
|
37
|
+
let help = false;
|
|
38
|
+
for (let i = 0; i < argv.length; i++) {
|
|
39
|
+
const a = argv[i]!;
|
|
40
|
+
if (!a.startsWith("--")) {
|
|
41
|
+
files.push(a);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (a === "--help") {
|
|
45
|
+
help = true;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (!VALUE_FLAGS.includes(a)) return { ok: false, error: `unknown option ${a}` };
|
|
49
|
+
const v = argv[++i];
|
|
50
|
+
if (v === undefined || v.startsWith("--")) return { ok: false, error: `${a} needs a value` };
|
|
51
|
+
if (a === "--host-profile") specs.push(...v.split(",").map((t) => t.trim()).filter((t) => t !== ""));
|
|
52
|
+
else if (a === "--qualification-dir") dir = v;
|
|
53
|
+
else format = v;
|
|
54
|
+
}
|
|
55
|
+
if (!(OUTPUT_FORMATS as readonly string[]).includes(format)) return { ok: false, error: `--format must be one of ${OUTPUT_FORMATS.join(", ")}` };
|
|
56
|
+
if (!help && files.length === 0) return { ok: false, error: "name at least one probe output" };
|
|
57
|
+
return {
|
|
58
|
+
ok: true,
|
|
59
|
+
args: {
|
|
60
|
+
files,
|
|
61
|
+
hostProfiles: [...new Set(specs.length > 0 ? specs : [OPENMIXER_CONSOLE_PROFILE.id])],
|
|
62
|
+
...(dir === undefined ? {} : { qualificationDir: dir }),
|
|
63
|
+
format: format as OutputFormat,
|
|
64
|
+
help,
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function judgeHelpText(): string {
|
|
70
|
+
return [
|
|
71
|
+
"plugin-qualify judge — judge probe outputs and write the qualification records",
|
|
72
|
+
"",
|
|
73
|
+
" plugin-qualify judge [options] <probe-output.json>...",
|
|
74
|
+
"",
|
|
75
|
+
" --host-profile <file|id> judge against this host; repeat or comma-separate for one verdict per profile",
|
|
76
|
+
` (default: ${OPENMIXER_CONSOLE_PROFILE.id})`,
|
|
77
|
+
" --qualification-dir <dir> where the records and the copies of the probe outputs go",
|
|
78
|
+
" (default: $XDG_STATE_HOME/omx/qualification, else ~/.local/state/omx/qualification)",
|
|
79
|
+
" --format <json|text> json = the records; text = plugin, verdict, deciding reason (default: text)",
|
|
80
|
+
"",
|
|
81
|
+
`exit ${EXIT_CODE.allQualified} all qualified / ${EXIT_CODE.someNotQualified} some not / ${EXIT_CODE.couldNotMeasure} something could not be read or judged`,
|
|
82
|
+
"",
|
|
83
|
+
].join("\n");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The qualification directory when none is named: the state directory of owner spec section 6.9, per user. */
|
|
87
|
+
export function defaultQualificationDir(env: Readonly<Record<string, string | undefined>>, home: string = homedir()): string {
|
|
88
|
+
const state = env["XDG_STATE_HOME"];
|
|
89
|
+
return join(state !== undefined && state !== "" ? state : join(home, ".local", "state"), "omx", "qualification");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface JudgeFilesResult {
|
|
93
|
+
readonly verdicts: readonly QualifyVerdict[];
|
|
94
|
+
readonly records: readonly { readonly recordPath: string; readonly probePath: string }[];
|
|
95
|
+
/** Each file that was refused or could not be read, with the code or the reason. */
|
|
96
|
+
readonly failures: readonly { readonly file: string; readonly problem: string }[];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Judge each file under every profile and write its record; a file that does not read is a failure, and the others go on. */
|
|
100
|
+
export function judgeFiles(
|
|
101
|
+
files: readonly string[],
|
|
102
|
+
options: { profiles: readonly HostProfile[]; dir: string; read?: (path: string) => Buffer },
|
|
103
|
+
): JudgeFilesResult {
|
|
104
|
+
const read = options.read ?? ((p: string) => readFileSync(p));
|
|
105
|
+
const verdicts: QualifyVerdict[] = [];
|
|
106
|
+
const records: { recordPath: string; probePath: string }[] = [];
|
|
107
|
+
const failures: { file: string; problem: string }[] = [];
|
|
108
|
+
for (const file of files) {
|
|
109
|
+
let bytes: Buffer;
|
|
110
|
+
try {
|
|
111
|
+
bytes = read(file);
|
|
112
|
+
} catch (e) {
|
|
113
|
+
failures.push({ file, problem: `cannot read: ${e instanceof Error ? e.message : String(e)}` });
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const r = readProbeOutput(bytes.toString("utf8"));
|
|
117
|
+
if (!r.ok) {
|
|
118
|
+
failures.push({ file, problem: `${r.code}: ${r.problem}` });
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const judgment = judgeProbeOutput(r.output, options.profiles);
|
|
122
|
+
const w = writeQualification(options.dir, bytes, judgment);
|
|
123
|
+
records.push({ recordPath: w.recordPath, probePath: w.probePath });
|
|
124
|
+
verdicts.push(...judgmentVerdicts(judgment));
|
|
125
|
+
}
|
|
126
|
+
return { verdicts, records, failures };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 2 when a file could not be judged or a plugin was not measured, else 0 when every verdict qualifies, else 1. */
|
|
130
|
+
export function judgeExitCode(result: JudgeFilesResult): number {
|
|
131
|
+
if (result.failures.length > 0 || result.verdicts.length === 0 || result.verdicts.some((v) => !v.measured)) return EXIT_CODE.couldNotMeasure;
|
|
132
|
+
return result.verdicts.every((v) => v.qualified) ? EXIT_CODE.allQualified : EXIT_CODE.someNotQualified;
|
|
133
|
+
}
|
|
@@ -55,6 +55,7 @@ export const PROBE_REASON_RULES: readonly ReasonRule[] = [
|
|
|
55
55
|
{ match: /^no-audio-in$/, code: "no-audio-in" },
|
|
56
56
|
{ match: /^no-audio-out$/, code: "no-audio-out" },
|
|
57
57
|
{ match: /^silent-output$/, code: "silent-output" },
|
|
58
|
+
{ match: /hosting\.worker\.nonconforming/, code: "worker-nonconforming" },
|
|
58
59
|
{ match: /worker crashed|worker desync|worker unavailable/, code: "probe-crashed" },
|
|
59
60
|
{ match: /timed out|timeout/, code: "probe-timeout" },
|
|
60
61
|
{ match: /non-finite/, code: "non-finite-output" },
|
|
@@ -112,6 +112,13 @@ export const UNMEASURED_TERMS: readonly UnmeasuredTerm[] = [
|
|
|
112
112
|
"The URI was not present in this machine's LV2 world. A fact about this installation, " +
|
|
113
113
|
"not about the plugin — another host may well have it.",
|
|
114
114
|
},
|
|
115
|
+
{
|
|
116
|
+
code: "worker-nonconforming",
|
|
117
|
+
kind: "probe-failed",
|
|
118
|
+
description:
|
|
119
|
+
"The plugin's LV2_Worker_Interface leaves work or work_response NULL, which the LV2 worker " +
|
|
120
|
+
"specification does not allow: it was refused before any call, not measured.",
|
|
121
|
+
},
|
|
115
122
|
{
|
|
116
123
|
code: "probe-error",
|
|
117
124
|
kind: "probe-failed",
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
// Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
|
|
3
|
+
/**
|
|
4
|
+
* Judging from a probe output. The probe output holds facts (`probe-output.ts`); this module turns
|
|
5
|
+
* them into the facts the one judge already reads and lets that judge speak. There is no second
|
|
6
|
+
* judge: a CLAP plugin's verdict is {@link rtSafetyDocument}'s fold of the same reasons it folds
|
|
7
|
+
* for a prober line, an LV2 plugin's is {@link rtSafetyReasons}, and cost is read by
|
|
8
|
+
* {@link hostingCostReasons} at the host's rate and quantum.
|
|
9
|
+
*
|
|
10
|
+
* What a probe output does not carry, this module does not invent:
|
|
11
|
+
*
|
|
12
|
+
* - `noteInputs` is the descriptor's fact. Without one the run's note sweep stands in: a plugin
|
|
13
|
+
* whose sweep was applicable has at least one note input; one whose sweep was not applicable is
|
|
14
|
+
* read as having none, so an instrument that could not be played reads as an effect. The caller
|
|
15
|
+
* that holds the descriptor passes the real count.
|
|
16
|
+
* - The note sweep's block count is the parameter sweep's: both run in one prober process at one
|
|
17
|
+
* block count, and the schema holds the note sweep's counts but not its blocks.
|
|
18
|
+
*/
|
|
19
|
+
import { DEFAULT_HOSTING_POLICY } from "./qualify-declarations.js";
|
|
20
|
+
import {
|
|
21
|
+
rtSafetyDocument,
|
|
22
|
+
type ClapNoteSweepReading,
|
|
23
|
+
type ClapQualifyLine,
|
|
24
|
+
type ClapRtSafetyReading,
|
|
25
|
+
type RtSafetyDocument,
|
|
26
|
+
} from "./clap-qualify.js";
|
|
27
|
+
import { gridCostReadings } from "./cost-fields.js";
|
|
28
|
+
import {
|
|
29
|
+
hostingCostReasons,
|
|
30
|
+
rtSafetyReasons,
|
|
31
|
+
contributedHostingRating,
|
|
32
|
+
type HostingReason,
|
|
33
|
+
} from "./hosting-suitability.js";
|
|
34
|
+
import type { HostProfile } from "./host-profiles.js";
|
|
35
|
+
import { PROBE_NOT_ATTEMPTED, PROBE_OK, type ProbeGridPoint, type ProbeOutput } from "./probe-output.js";
|
|
36
|
+
import type { QualifyVerdict } from "./qualify-cli.js";
|
|
37
|
+
import { RATING_SEVERITY, worstRating, type SuitabilityRating } from "./rating.js";
|
|
38
|
+
import type { IntegrityMeasurement } from "./measurement/format.js";
|
|
39
|
+
|
|
40
|
+
/** Facts of the descriptor, which the probe output repeats none of. */
|
|
41
|
+
export interface JudgeFacts {
|
|
42
|
+
/** Note input ports the plugin declares. */
|
|
43
|
+
readonly noteInputs?: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A sum of one count over the points, or `undefined` when a point lacks it: a missing count is not zero. */
|
|
47
|
+
function total(points: readonly ProbeGridPoint[], count: (p: ProbeGridPoint) => number | undefined): number | undefined {
|
|
48
|
+
let sum = 0;
|
|
49
|
+
for (const p of points) {
|
|
50
|
+
const n = count(p);
|
|
51
|
+
if (n === undefined) return undefined;
|
|
52
|
+
sum += n;
|
|
53
|
+
}
|
|
54
|
+
return sum;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The points whose run ended well: a point that crashed measured nothing. */
|
|
58
|
+
function okPoints(output: ProbeOutput): ProbeGridPoint[] {
|
|
59
|
+
return output.grid.filter((p) => p.outcome === PROBE_OK);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The interposer's counts over every grid point that carries them. The interposer must have been
|
|
64
|
+
* loaded at every such point; the sweep counts as done when every point swept, and as applicable
|
|
65
|
+
* when any did. A count a point lacks leaves the total absent, and the judge reads that as
|
|
66
|
+
* unmeasured.
|
|
67
|
+
*/
|
|
68
|
+
export function rtSafetyOfProbeOutput(output: ProbeOutput): ClapRtSafetyReading {
|
|
69
|
+
const points = okPoints(output).filter((p) => p.rtSafety !== undefined);
|
|
70
|
+
if (points.length === 0 || points.some((p) => p.rtSafety?.interposer !== true)) return { interposer: false };
|
|
71
|
+
const reading = (p: ProbeGridPoint) => p.rtSafety;
|
|
72
|
+
const allocations = total(points, (p) => reading(p)?.allocations);
|
|
73
|
+
const locks = total(points, (p) => reading(p)?.locks);
|
|
74
|
+
const syscalls = total(points, (p) => reading(p)?.syscalls);
|
|
75
|
+
return {
|
|
76
|
+
interposer: true,
|
|
77
|
+
swept: points.every((p) => reading(p)?.swept === true),
|
|
78
|
+
sweepApplicable: points.some((p) => reading(p)?.sweepApplicable === true),
|
|
79
|
+
blocks: total(points, (p) => reading(p)?.blocks ?? 0) ?? 0,
|
|
80
|
+
blockFrames: points[0]?.quantum ?? 0,
|
|
81
|
+
...(allocations === undefined ? {} : { allocationsInRun: allocations }),
|
|
82
|
+
...(locks === undefined ? {} : { locksInRun: locks }),
|
|
83
|
+
...(syscalls === undefined ? {} : { syscallsInRun: syscalls }),
|
|
84
|
+
events: points.flatMap((p) => reading(p)?.events ?? []),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function noteSweepOfProbeOutput(output: ProbeOutput, rt: ClapRtSafetyReading): ClapNoteSweepReading {
|
|
89
|
+
const played = okPoints(output).find((p) => p.noteSweep?.applicable === true)?.noteSweep;
|
|
90
|
+
if (played === undefined) return { applicable: false };
|
|
91
|
+
const dialect: { dialect?: "clap" | "midi" } = {};
|
|
92
|
+
if (played.dialect === "clap" || played.dialect === "midi") dialect.dialect = played.dialect;
|
|
93
|
+
const base = { applicable: true, ...dialect, blocks: rt.blocks ?? 0, blockFrames: rt.blockFrames ?? 0, noteEvents: 0, processErrors: 0, nonFinite: false };
|
|
94
|
+
if (!rt.interposer) return { ...base, interposer: false };
|
|
95
|
+
return {
|
|
96
|
+
...base,
|
|
97
|
+
interposer: true,
|
|
98
|
+
...(played.allocations === undefined ? {} : { allocationsInRun: played.allocations }),
|
|
99
|
+
...(played.locks === undefined ? {} : { locksInRun: played.locks }),
|
|
100
|
+
...(played.syscalls === undefined ? {} : { syscallsInRun: played.syscalls }),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function integrityOfProbeOutput(output: ProbeOutput): IntegrityMeasurement {
|
|
105
|
+
const { files, layout, unpinnable } = output.binary;
|
|
106
|
+
if (layout === undefined || files.length === 0) {
|
|
107
|
+
return { unmeasuredReason: unpinnable === undefined ? "probe-error" : "unpinnable", unmeasuredDetail: unpinnable ?? "the probe output holds no layout pin" };
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
...(output.plugin.version === undefined ? {} : { version: output.plugin.version }),
|
|
111
|
+
files: files.slice().sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)),
|
|
112
|
+
layout,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* A CLAP probe output as the prober's line it stands for, so that {@link rtSafetyDocument} judges it.
|
|
118
|
+
* The facts the line holds and a probe output does not (the port layout, the features) are not read
|
|
119
|
+
* by the rtSafety fold and are left empty here.
|
|
120
|
+
*/
|
|
121
|
+
export function clapLineFromProbeOutput(output: ProbeOutput, facts: JudgeFacts = {}): ClapQualifyLine {
|
|
122
|
+
const rtSafety = rtSafetyOfProbeOutput(output);
|
|
123
|
+
const noteSweep = noteSweepOfProbeOutput(output, rtSafety);
|
|
124
|
+
const points = okPoints(output);
|
|
125
|
+
const latency: Record<string, { declaredFrames: number; measuredFrames: number }> = {};
|
|
126
|
+
for (const p of points) {
|
|
127
|
+
if (p.latency !== undefined && latency[String(p.rate)] === undefined) latency[String(p.rate)] = p.latency;
|
|
128
|
+
}
|
|
129
|
+
const withRt = points.filter((p) => p.rtSafety !== undefined);
|
|
130
|
+
return {
|
|
131
|
+
uri: `urn:clap:${output.plugin.id}`,
|
|
132
|
+
id: output.plugin.id,
|
|
133
|
+
binary: { path: output.binary.path, sha256: output.binary.sha256 },
|
|
134
|
+
hostExtensions: output.offer.clapExtensions ?? [],
|
|
135
|
+
clap: {
|
|
136
|
+
mainInputChannels: 0,
|
|
137
|
+
mainOutputChannels: 0,
|
|
138
|
+
sidechainInputs: 0,
|
|
139
|
+
features: [],
|
|
140
|
+
headlessOk: output.steps.some((s) => s.step === "instantiate" && s.outcome === PROBE_OK),
|
|
141
|
+
noteInputs: facts.noteInputs ?? (noteSweep.applicable ? 1 : 0),
|
|
142
|
+
latency,
|
|
143
|
+
threadViolations: total(withRt, (p) => p.rtSafety?.threadViolations ?? 0) ?? 0,
|
|
144
|
+
processErrors: total(withRt, (p) => p.rtSafety?.processErrors ?? 0) ?? 0,
|
|
145
|
+
// the sweep ran as many blocks as the points that counted say; a run with none measured nothing
|
|
146
|
+
processCalls: rtSafety.interposer ? (rtSafety.blocks ?? 0) : points.length,
|
|
147
|
+
nonFinite: output.steps.some((s) => s.outcome === "probe.non-finite-output"),
|
|
148
|
+
},
|
|
149
|
+
rtSafety,
|
|
150
|
+
noteSweep,
|
|
151
|
+
integrity: integrityOfProbeOutput(output),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** One reason as the record writes it: the code is a string since a failed run's reason is the run's outcome code. */
|
|
156
|
+
export interface RecordReason {
|
|
157
|
+
readonly dimension: string;
|
|
158
|
+
readonly rating: SuitabilityRating;
|
|
159
|
+
readonly code: string;
|
|
160
|
+
readonly params: Readonly<Record<string, string | number>>;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const asRecordReason = (r: HostingReason): RecordReason => ({ dimension: r.dimension, rating: contributedHostingRating(r), code: r.code, params: r.params });
|
|
164
|
+
|
|
165
|
+
/** What one host profile makes of one probe output. */
|
|
166
|
+
export interface HostJudgment {
|
|
167
|
+
readonly verdict: QualifyVerdict;
|
|
168
|
+
/** In-process only for a suitable plugin on a host that runs its plugins in its own process, else isolated. */
|
|
169
|
+
readonly hosting: "in-process" | "isolated";
|
|
170
|
+
readonly reasons: readonly RecordReason[];
|
|
171
|
+
/** The cost reasons at this host's rate and quantum, kept beside the verdict, not folded into it. */
|
|
172
|
+
readonly cost: { readonly rate: number; readonly quantum: number; readonly reasons: readonly RecordReason[] };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export interface Judgment {
|
|
176
|
+
readonly output: ProbeOutput;
|
|
177
|
+
readonly hosts: readonly HostJudgment[];
|
|
178
|
+
/** For a CLAP plugin, the document of each host that today's judge writes; the pins are taken from these. */
|
|
179
|
+
readonly documents: readonly RtSafetyDocument[];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function decidingOf(reasons: readonly RecordReason[], rating: SuitabilityRating): RecordReason | undefined {
|
|
183
|
+
if (rating === "suitable") return undefined;
|
|
184
|
+
return [...reasons].sort((a, b) => RATING_SEVERITY[b.rating] - RATING_SEVERITY[a.rating]).find((r) => r.rating === rating);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function costOf(output: ProbeOutput, profile: HostProfile): HostJudgment["cost"] {
|
|
188
|
+
const { rate, quantum } = profile.cost;
|
|
189
|
+
const reasons = hostingCostReasons(
|
|
190
|
+
{ cpuCost: { perRateQuantum: gridCostReadings(output.grid, PROBE_OK) } },
|
|
191
|
+
{ rate, quantum, profile },
|
|
192
|
+
DEFAULT_HOSTING_POLICY,
|
|
193
|
+
);
|
|
194
|
+
return { rate, quantum, reasons: reasons.map(asRecordReason) };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The outcome that ended the run badly, or `undefined` when it ended well. The run's own outcome is the
|
|
199
|
+
* first word; a grid point that failed under a run that says `probe.ok` is a document that contradicts
|
|
200
|
+
* itself, and is read as the failure it records rather than as a pass that skipped the point.
|
|
201
|
+
*/
|
|
202
|
+
function failedOutcome(output: ProbeOutput): string | undefined {
|
|
203
|
+
if (output.run.outcome !== PROBE_OK) return output.run.outcome;
|
|
204
|
+
return output.grid.find((p) => p.outcome !== PROBE_OK && p.outcome !== PROBE_NOT_ATTEMPTED)?.outcome;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Every profile's judgment of one probe output.
|
|
209
|
+
*
|
|
210
|
+
* A run that did not end `probe.ok` is `unknown` under every profile, with the run's outcome code as
|
|
211
|
+
* the reason: the measurement did not finish, so there is nothing to rate, and it is not a plugin
|
|
212
|
+
* `unsuitable` on evidence the run never gathered. A step that was not attempted is part of a run that
|
|
213
|
+
* ended well (a plugin with no note input has no note sweep).
|
|
214
|
+
*/
|
|
215
|
+
export function judgeProbeOutput(output: ProbeOutput, profiles: readonly HostProfile[], facts: JudgeFacts = {}): Judgment {
|
|
216
|
+
const uri = output.plugin.format === "clap" ? `urn:clap:${output.plugin.id}` : output.plugin.id;
|
|
217
|
+
const failure = failedOutcome(output);
|
|
218
|
+
if (failure !== undefined) {
|
|
219
|
+
const reason: RecordReason = { dimension: "rtSafety", rating: "unknown", code: failure, params: {} };
|
|
220
|
+
return {
|
|
221
|
+
output,
|
|
222
|
+
documents: [],
|
|
223
|
+
hosts: profiles.map((profile) => ({
|
|
224
|
+
verdict: { uri, host: profile.id, measured: false, rating: "unknown", qualified: false, deciding: failure },
|
|
225
|
+
hosting: "isolated",
|
|
226
|
+
reasons: [reason],
|
|
227
|
+
cost: costOf(output, profile),
|
|
228
|
+
})),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
const line = output.plugin.format === "clap" ? clapLineFromProbeOutput(output, facts) : undefined;
|
|
232
|
+
const documents: RtSafetyDocument[] = [];
|
|
233
|
+
const hosts = profiles.map((profile): HostJudgment => {
|
|
234
|
+
let reasons: RecordReason[];
|
|
235
|
+
if (line !== undefined) {
|
|
236
|
+
const doc = rtSafetyDocument(line, profile);
|
|
237
|
+
documents.push(doc);
|
|
238
|
+
reasons = doc.verdict.reasons.map(asRecordReason);
|
|
239
|
+
} else {
|
|
240
|
+
reasons = rtSafetyReasons(rtSafetyOfProbeOutput(output)).map(asRecordReason);
|
|
241
|
+
}
|
|
242
|
+
const rating = worstRating(reasons.map((r) => r.rating));
|
|
243
|
+
const deciding = decidingOf(reasons, rating);
|
|
244
|
+
return {
|
|
245
|
+
verdict: {
|
|
246
|
+
uri,
|
|
247
|
+
host: profile.id,
|
|
248
|
+
measured: true,
|
|
249
|
+
rating,
|
|
250
|
+
qualified: rating === "suitable",
|
|
251
|
+
...(deciding === undefined ? {} : { deciding: deciding.code }),
|
|
252
|
+
},
|
|
253
|
+
hosting: rating === "suitable" && profile.isolation === "in-process" ? "in-process" : "isolated",
|
|
254
|
+
reasons,
|
|
255
|
+
cost: costOf(output, profile),
|
|
256
|
+
};
|
|
257
|
+
});
|
|
258
|
+
return { output, hosts, documents };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** The verdict rows of a judgment, as the CLI prints them. */
|
|
262
|
+
export function judgmentVerdicts(judgment: Judgment): QualifyVerdict[] {
|
|
263
|
+
return judgment.hosts.map((h) => h.verdict);
|
|
264
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
// Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
|
|
3
|
+
/**
|
|
4
|
+
* The probe-output documents the tests build on, read from `fixtures/probe-output/`: plugin-hostd's
|
|
5
|
+
* three example documents (a success on the reference rig, a crash, a refusal for memory). Kept
|
|
6
|
+
* out of the test files so every test reads the same documents; a `.test.ts` imported for a helper
|
|
7
|
+
* runs its whole suite in each importer.
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
|
|
13
|
+
import type { ClapQualifyLine } from "./clap-qualify.js";
|
|
14
|
+
import { OMX_CLAP_HOST_EXTENSIONS } from "./host-profiles.js";
|
|
15
|
+
import { readProbeOutput, type ProbeOutput } from "./probe-output.js";
|
|
16
|
+
|
|
17
|
+
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), "..", "fixtures", "probe-output");
|
|
18
|
+
|
|
19
|
+
export function fixtureText(name: string): string {
|
|
20
|
+
return readFileSync(join(FIXTURES, `${name}.json`), "utf8");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A fixture as a mutable JSON value, for the tests that break one field of it. */
|
|
24
|
+
export function fixtureJson(name: string): Record<string, any> {
|
|
25
|
+
return JSON.parse(fixtureText(name));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A fixture through the reader; throws when the reader refuses it. */
|
|
29
|
+
export function readFixture(name: string): ProbeOutput {
|
|
30
|
+
const r = readProbeOutput(fixtureText(name));
|
|
31
|
+
if (!r.ok) throw new Error(`${name}: ${r.code}: ${r.problem}`);
|
|
32
|
+
return r.output;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The probe output that carries what a prober line carries, for the proofs that a verdict read from a
|
|
37
|
+
* probe output equals the verdict read from today's document. Built on the success fixture, so its machine,
|
|
38
|
+
* limits and run are a real document's.
|
|
39
|
+
*
|
|
40
|
+
* What the line holds and a probe output cannot is not encoded: the port layout and the features (no
|
|
41
|
+
* verdict reads them), the note sweep's process errors and non-finite flag, and a block count apart
|
|
42
|
+
* from the sweep's (the probe output has one, the blocks the sweep processed, which is `processCalls`).
|
|
43
|
+
*/
|
|
44
|
+
export function probeOutputOfLine(line: ClapQualifyLine): Record<string, any> {
|
|
45
|
+
const doc = fixtureJson("success-msi");
|
|
46
|
+
const rt = line.rtSafety;
|
|
47
|
+
const note = line.noteSweep;
|
|
48
|
+
const pin = line.integrity;
|
|
49
|
+
const rates = Object.keys(line.clap.latency);
|
|
50
|
+
const quantum = rt.blockFrames ?? 512;
|
|
51
|
+
doc["plugin"] = { format: "clap", id: line.id, ...(pin.version === undefined ? {} : { version: pin.version }) };
|
|
52
|
+
doc["binary"] = {
|
|
53
|
+
path: line.binary.path,
|
|
54
|
+
sha256: line.binary.sha256,
|
|
55
|
+
files: pin.files ?? [],
|
|
56
|
+
...(pin.layout === undefined ? {} : { layout: pin.layout }),
|
|
57
|
+
...(pin.unmeasuredReason === undefined ? {} : { unpinnable: pin.unmeasuredDetail ?? pin.unmeasuredReason }),
|
|
58
|
+
};
|
|
59
|
+
delete doc["descriptor"];
|
|
60
|
+
doc["offer"] = { id: "default", clapExtensions: [...line.hostExtensions], realtime: "none" };
|
|
61
|
+
doc["steps"] = [{ step: "hash", outcome: "probe.ok", elapsedMs: 1 }, { step: "instantiate", outcome: "probe.ok", elapsedMs: 1 }];
|
|
62
|
+
const first: Record<string, unknown> = {};
|
|
63
|
+
if (rt.interposer) {
|
|
64
|
+
Object.assign(first, {
|
|
65
|
+
rtSafety: {
|
|
66
|
+
interposer: true,
|
|
67
|
+
swept: rt.swept,
|
|
68
|
+
sweepApplicable: rt.sweepApplicable,
|
|
69
|
+
blocks: line.clap.processCalls,
|
|
70
|
+
allocations: rt.allocationsInRun,
|
|
71
|
+
locks: rt.locksInRun,
|
|
72
|
+
syscalls: rt.syscallsInRun,
|
|
73
|
+
threadViolations: line.clap.threadViolations,
|
|
74
|
+
processErrors: line.clap.processErrors,
|
|
75
|
+
events: rt.events ?? [],
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
} else first["rtSafety"] = { interposer: false };
|
|
79
|
+
if (note.applicable) {
|
|
80
|
+
first["noteSweep"] = {
|
|
81
|
+
applicable: true,
|
|
82
|
+
...(note.dialect === undefined ? {} : { dialect: note.dialect }),
|
|
83
|
+
...(note.interposer === true ? { allocations: note.allocationsInRun, locks: note.locksInRun, syscalls: note.syscallsInRun } : {}),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
const at = rates.length > 0 ? rates : ["48000"];
|
|
87
|
+
doc["grid"] = at.map((rate, i) => ({
|
|
88
|
+
rate: Number(rate),
|
|
89
|
+
quantum,
|
|
90
|
+
outcome: "probe.ok",
|
|
91
|
+
...(line.clap.latency[rate] === undefined ? {} : { latency: { ...line.clap.latency[rate], method: "impulse-onset" } }),
|
|
92
|
+
...(i === 0 ? first : {}),
|
|
93
|
+
}));
|
|
94
|
+
return doc;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const PIN = {
|
|
98
|
+
version: "1",
|
|
99
|
+
files: [{ path: "rt-safe.clap", sha256: "a".repeat(64) }],
|
|
100
|
+
layout: { scheme: "omx-layout/1", sha256: "b".repeat(64) },
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/** A prober line for a clean stereo effect, with `patch` merged over it. */
|
|
104
|
+
export function clapLineFixture(patch: {
|
|
105
|
+
rtSafety?: object;
|
|
106
|
+
clap?: object;
|
|
107
|
+
hostExtensions?: string[];
|
|
108
|
+
noteSweep?: object;
|
|
109
|
+
integrity?: object;
|
|
110
|
+
} = {}): ClapQualifyLine {
|
|
111
|
+
return {
|
|
112
|
+
uri: "urn:clap:org.example.fx",
|
|
113
|
+
id: "org.example.fx",
|
|
114
|
+
binary: { path: "/x/rt-safe.clap", sha256: "a".repeat(64) },
|
|
115
|
+
hostExtensions: patch.hostExtensions ?? [...OMX_CLAP_HOST_EXTENSIONS],
|
|
116
|
+
clap: {
|
|
117
|
+
mainInputChannels: 2,
|
|
118
|
+
mainOutputChannels: 2,
|
|
119
|
+
sidechainInputs: 0,
|
|
120
|
+
features: ["audio-effect"],
|
|
121
|
+
headlessOk: true,
|
|
122
|
+
noteInputs: 0,
|
|
123
|
+
latency: { "48000": { declaredFrames: 0, measuredFrames: 0 }, "96000": { declaredFrames: 4, measuredFrames: 4 } },
|
|
124
|
+
threadViolations: 0,
|
|
125
|
+
processErrors: 0,
|
|
126
|
+
processCalls: 512,
|
|
127
|
+
nonFinite: false,
|
|
128
|
+
...patch.clap,
|
|
129
|
+
},
|
|
130
|
+
rtSafety: {
|
|
131
|
+
interposer: true,
|
|
132
|
+
swept: true,
|
|
133
|
+
sweepApplicable: true,
|
|
134
|
+
blocks: 512,
|
|
135
|
+
blockFrames: 512,
|
|
136
|
+
allocationsInRun: 0,
|
|
137
|
+
locksInRun: 0,
|
|
138
|
+
syscallsInRun: 0,
|
|
139
|
+
events: [],
|
|
140
|
+
...patch.rtSafety,
|
|
141
|
+
},
|
|
142
|
+
noteSweep: { applicable: false, ...patch.noteSweep },
|
|
143
|
+
integrity: { ...PIN, ...patch.integrity },
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|