@openmixer/plugin-qualify 0.14.1 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +117 -14
  2. package/bin/plugin-qualify-sync.mjs +57 -0
  3. package/bin/plugin-qualify.mjs +66 -15
  4. package/dist/adjust-cli.d.ts +83 -0
  5. package/dist/adjust-cli.js +214 -0
  6. package/dist/adjust-cli.js.map +1 -0
  7. package/dist/clap-loader.d.ts +3 -3
  8. package/dist/clap-loader.js +11 -11
  9. package/dist/clap-loader.js.map +1 -1
  10. package/dist/history-adjust.d.ts +77 -0
  11. package/dist/history-adjust.js +99 -0
  12. package/dist/history-adjust.js.map +1 -0
  13. package/dist/history-file.d.ts +73 -0
  14. package/dist/history-file.js +133 -0
  15. package/dist/history-file.js.map +1 -0
  16. package/dist/host-admission.d.ts +53 -0
  17. package/dist/host-admission.js +96 -0
  18. package/dist/host-admission.js.map +1 -0
  19. package/dist/index.d.ts +6 -0
  20. package/dist/index.js +6 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/lv2-loader.d.ts +16 -2
  23. package/dist/lv2-loader.js +168 -17
  24. package/dist/lv2-loader.js.map +1 -1
  25. package/dist/measurement/cli.d.ts +6 -4
  26. package/dist/measurement/cli.js +12 -19
  27. package/dist/measurement/cli.js.map +1 -1
  28. package/dist/pins-file.d.ts +8 -2
  29. package/dist/pins-file.js +17 -3
  30. package/dist/pins-file.js.map +1 -1
  31. package/dist/probe-judge.d.ts +11 -1
  32. package/dist/probe-judge.js +20 -1
  33. package/dist/probe-judge.js.map +1 -1
  34. package/dist/probe-output-fixtures.d.ts +10 -0
  35. package/dist/probe-output-fixtures.js +86 -0
  36. package/dist/probe-output-fixtures.js.map +1 -1
  37. package/dist/probe-output.d.ts +2 -0
  38. package/dist/probe-output.js +1 -0
  39. package/dist/probe-output.js.map +1 -1
  40. package/dist/probe-runner.d.ts +9 -3
  41. package/dist/probe-runner.js +36 -12
  42. package/dist/probe-runner.js.map +1 -1
  43. package/dist/qualification-record.d.ts +109 -3
  44. package/dist/qualification-record.js +79 -13
  45. package/dist/qualification-record.js.map +1 -1
  46. package/dist/qualify-cli.d.ts +0 -1
  47. package/dist/qualify-cli.js +2 -4
  48. package/dist/qualify-cli.js.map +1 -1
  49. package/dist/qualify-declarations.d.ts +1 -1
  50. package/dist/qualify-declarations.js +1 -1
  51. package/dist/qualify-options.js +1 -2
  52. package/dist/qualify-options.js.map +1 -1
  53. package/dist/qualify-sync.d.ts +202 -0
  54. package/dist/qualify-sync.js +731 -0
  55. package/dist/qualify-sync.js.map +1 -0
  56. package/dist/whole-file.d.ts +3 -0
  57. package/dist/whole-file.js +36 -0
  58. package/dist/whole-file.js.map +1 -0
  59. package/fixtures/admission/2026-10-26.json +19 -0
  60. package/fixtures/clap-scan/fake.json +24 -24
  61. package/package.json +5 -4
  62. package/src/adjust-cli.ts +262 -0
  63. package/src/clap-loader.ts +9 -13
  64. package/src/history-adjust.ts +156 -0
  65. package/src/history-file.ts +177 -0
  66. package/src/host-admission.ts +136 -0
  67. package/src/index.ts +6 -0
  68. package/src/lv2-loader.ts +150 -15
  69. package/src/measurement/cli.ts +11 -17
  70. package/src/pins-file.ts +17 -3
  71. package/src/probe-judge.ts +27 -1
  72. package/src/probe-output-fixtures.ts +86 -0
  73. package/src/probe-output.ts +3 -0
  74. package/src/probe-runner.ts +45 -14
  75. package/src/qualification-record.ts +174 -15
  76. package/src/qualify-cli.ts +2 -5
  77. package/src/qualify-declarations.ts +1 -1
  78. package/src/qualify-options.ts +1 -2
  79. package/src/qualify-sync.ts +830 -0
  80. package/src/whole-file.ts +35 -0
  81. package/tools/_declarations.py +1 -1
  82. package/tools/gen-hosting-codes.mjs +1 -1
  83. package/tools/mod-host-admit.mjs +74 -0
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@openmixer/plugin-qualify",
3
- "version": "0.14.1",
4
- "description": "Qualify audio plugins for real-time hosting: scan, offline latency and cost measurement, a mod-host hosting sweep, and a fail-closed rating.",
3
+ "version": "0.15.1",
4
+ "description": "Qualify audio plugins for real-time hosting: scan, offline latency and cost measurement, and a fail-closed rating.",
5
5
  "type": "module",
6
6
  "license": "GPL-3.0-or-later",
7
7
  "repository": {
@@ -17,7 +17,8 @@
17
17
  "node": ">=18"
18
18
  },
19
19
  "bin": {
20
- "plugin-qualify": "./bin/plugin-qualify.mjs"
20
+ "plugin-qualify": "./bin/plugin-qualify.mjs",
21
+ "plugin-qualify-sync": "./bin/plugin-qualify-sync.mjs"
21
22
  },
22
23
  "main": "./dist/index.js",
23
24
  "types": "./dist/index.d.ts",
@@ -96,6 +97,6 @@
96
97
  "provenance": true
97
98
  },
98
99
  "dependencies": {
99
- "@openmixer/omx-contract": "^3.0.0"
100
+ "@openmixer/omx-contract": "^3.4.0"
100
101
  }
101
102
  }
@@ -0,0 +1,262 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
+ /**
4
+ * `plugin-qualify adjust --history <file>`: read the qualification records `judge` (or a run with `--out`) wrote, read
5
+ * plugin-hostd's running history, and write a second verdict beside each measured one, for the host class of the history,
6
+ * with a reason code (`history-adjust.ts` holds the rules). The measurement and the measured verdicts of a record are
7
+ * never touched: the second verdict lives in the record's `history` block, one entry per host class. `--pins` writes the
8
+ * adjusted verdict instead of the measured one. `bin/plugin-qualify.mjs` only reads argv and prints.
9
+ */
10
+ import { readFileSync, readdirSync } from "node:fs";
11
+ import { join } from "node:path";
12
+
13
+ import { findHistory, historyClasses, readHistory, type HistoryRead, type HistoryRecord } from "./history-file.js";
14
+ import { adjustVerdict, summariseHistory, type Adjustment } from "./history-adjust.js";
15
+ import type { HostProfile } from "./host-profiles.js";
16
+ import { OPENMIXER_CONSOLE_PROFILE } from "./host-profiles.js";
17
+ import { clapPinUri, pinVerdictOf, type PinEntry, type PinVerdict } from "./pins-file.js";
18
+ import { QUALIFICATION_RECORD_SCHEMA, type HistoryBlock, type QualificationRecord, type RecordVerdict } from "./qualification-record.js";
19
+ import { EXIT_CODE, OUTPUT_FORMATS, type OutputFormat } from "./qualify-options.js";
20
+ import type { SuitabilityRating } from "./rating.js";
21
+
22
+ export interface AdjustArgs {
23
+ readonly history: string;
24
+ readonly hostProfiles: readonly string[];
25
+ readonly qualificationDir?: string;
26
+ readonly hostClass?: string;
27
+ readonly pins?: string;
28
+ /** UTC seconds the history is read at; the clock when absent. */
29
+ readonly now?: number;
30
+ readonly format: OutputFormat;
31
+ readonly help: boolean;
32
+ }
33
+
34
+ export type ParseAdjustResult = { ok: true; args: AdjustArgs } | { ok: false; error: string };
35
+
36
+ const VALUE_FLAGS = ["--history", "--host-profile", "--qualification-dir", "--host-class", "--pins", "--now", "--format"];
37
+
38
+ export function parseAdjustArgs(argv: readonly string[]): ParseAdjustResult {
39
+ const specs: string[] = [];
40
+ const got = new Map<string, string>();
41
+ let help = false;
42
+ for (let i = 0; i < argv.length; i++) {
43
+ const a = argv[i]!;
44
+ if (!a.startsWith("--")) return { ok: false, error: `${a}: adjust takes options only; the records are read from --qualification-dir` };
45
+ if (a === "--help") {
46
+ help = true;
47
+ continue;
48
+ }
49
+ if (!VALUE_FLAGS.includes(a)) return { ok: false, error: `unknown option ${a}` };
50
+ const v = argv[++i];
51
+ if (v === undefined || v.startsWith("--")) return { ok: false, error: `${a} needs a value` };
52
+ if (a === "--host-profile") specs.push(...v.split(",").map((t) => t.trim()).filter((t) => t !== ""));
53
+ else got.set(a, v);
54
+ }
55
+ const format = got.get("--format") ?? "text";
56
+ if (!(OUTPUT_FORMATS as readonly string[]).includes(format)) return { ok: false, error: `--format must be one of ${OUTPUT_FORMATS.join(", ")}` };
57
+ const nowText = got.get("--now");
58
+ const now = nowText === undefined ? undefined : Number(nowText);
59
+ if (now !== undefined && !(Number.isInteger(now) && now >= 0)) return { ok: false, error: "--now must be UTC seconds, a whole number" };
60
+ const history = got.get("--history");
61
+ if (!help && history === undefined) return { ok: false, error: "name the history file with --history" };
62
+ const dir = got.get("--qualification-dir");
63
+ const hostClass = got.get("--host-class");
64
+ const pins = got.get("--pins");
65
+ return {
66
+ ok: true,
67
+ args: {
68
+ history: history ?? "",
69
+ hostProfiles: [...new Set(specs.length > 0 ? specs : [OPENMIXER_CONSOLE_PROFILE.id])],
70
+ ...(dir === undefined ? {} : { qualificationDir: dir }),
71
+ ...(hostClass === undefined ? {} : { hostClass }),
72
+ ...(pins === undefined ? {} : { pins }),
73
+ ...(now === undefined ? {} : { now }),
74
+ format: format as OutputFormat,
75
+ help,
76
+ },
77
+ };
78
+ }
79
+
80
+ export function adjustHelpText(): string {
81
+ return [
82
+ "plugin-qualify adjust — write a second verdict beside each measured one from plugin-hostd's running history",
83
+ "",
84
+ " plugin-qualify adjust --history <file> [options]",
85
+ "",
86
+ " --history <file> plugin-hostd's history file ($XDG_STATE_HOME/plugin-hostd/history); required",
87
+ " --qualification-dir <dir> the records `judge` wrote; each gets a `history` block (default: the one judge writes to)",
88
+ " --host-profile <file|id> adjust the verdict of this host; repeat or comma-separate (default: openmixer-console)",
89
+ " --host-class <class> read only this host class of the history (arch/cores/clock); default: every class the file holds",
90
+ " --pins <file> write the pins with the ADJUSTED verdict of the first host profile; needs exactly one host class",
91
+ " --now <seconds> the UTC second the history is read at (default: the clock)",
92
+ " --format <json|text> json = the records; text = one row per plugin, host and host class (default: text)",
93
+ "",
94
+ "The measured verdicts and the measurement are never changed. History can demote in-process to isolated (a crash, xruns when",
95
+ "grouped), isolated to refused (three solo crashes), and promote isolated to in-process only when every reason against the",
96
+ "plugin is a cost reason and it ran steady alone. It never lifts a real-time-safety, feature or unknown verdict, or a refused one.",
97
+ "",
98
+ `exit ${EXIT_CODE.allQualified} done / ${EXIT_CODE.couldNotMeasure} the history, a record or an option could not be read`,
99
+ "",
100
+ ].join("\n");
101
+ }
102
+
103
+ /** A record read from the qualification directory, with the file it came from. */
104
+ export interface RecordFile {
105
+ readonly path: string;
106
+ readonly record: QualificationRecord;
107
+ }
108
+
109
+ /** Every `*.qualification.json` of the directory with this reader's schema; a file that does not read is a failure, and the others go on. */
110
+ export function readRecords(dir: string, read: (path: string) => string = (p) => readFileSync(p, "utf8")): {
111
+ readonly records: readonly RecordFile[];
112
+ readonly failures: readonly { readonly file: string; readonly problem: string }[];
113
+ } {
114
+ const records: RecordFile[] = [];
115
+ const failures: { file: string; problem: string }[] = [];
116
+ let names: string[];
117
+ try {
118
+ names = readdirSync(dir).filter((n) => n.endsWith(".qualification.json")).sort();
119
+ } catch (e) {
120
+ return { records, failures: [{ file: dir, problem: `cannot read: ${e instanceof Error ? e.message : String(e)}` }] };
121
+ }
122
+ for (const name of names) {
123
+ const path = join(dir, name);
124
+ try {
125
+ const doc = JSON.parse(read(path)) as QualificationRecord;
126
+ if (doc.schema !== QUALIFICATION_RECORD_SCHEMA) failures.push({ file: path, problem: `schema ${String(doc.schema)}, not ${QUALIFICATION_RECORD_SCHEMA}` });
127
+ else records.push({ path, record: doc });
128
+ } catch (e) {
129
+ failures.push({ file: path, problem: `cannot read: ${e instanceof Error ? e.message : String(e)}` });
130
+ }
131
+ }
132
+ return { records, failures };
133
+ }
134
+
135
+ /** plugin-hostd's key of a record's plugin: its uri and the digest of the first file of its pin (files sorted by path, as the pin lists them). `undefined` when the record holds no whole pin. */
136
+ export function historyKeyOf(record: QualificationRecord): { readonly uri: string; readonly digest: string } | undefined {
137
+ const files = [...record.binary.files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
138
+ if (files.length === 0 || record.binary.layout === undefined) return undefined;
139
+ const uri = record.plugin.format === "clap" ? clapPinUri(record.binary.path, record.plugin.id) : record.plugin.id;
140
+ return { uri, digest: files[0]!.sha256 };
141
+ }
142
+
143
+ /** The pin verdict of a record's verdict row: `unknown` and an unmeasured row have none. */
144
+ function measuredPinVerdict(v: RecordVerdict): PinVerdict | undefined {
145
+ return v.measured ? pinVerdictOf(v.rating as SuitabilityRating) : undefined;
146
+ }
147
+
148
+ /** The `history` block of one record for one host class. */
149
+ export function historyBlock(
150
+ record: QualificationRecord,
151
+ history: readonly HistoryRecord[],
152
+ hostClass: string,
153
+ profiles: readonly HostProfile[],
154
+ options: { file: string; now: number },
155
+ ): HistoryBlock {
156
+ const key = historyKeyOf(record);
157
+ const found = key === undefined ? undefined : findHistory(history, key.uri, key.digest, hostClass);
158
+ const verdicts = profiles.flatMap((profile) => {
159
+ const row = record.verdicts.find((v) => v.host === profile.id);
160
+ if (row === undefined) return [];
161
+ const a: Adjustment = adjustVerdict({
162
+ measured: measuredPinVerdict(row),
163
+ reasons: row.reasons.map((r) => ({ code: r.code, rating: r.rating })),
164
+ record: found,
165
+ profile,
166
+ now: options.now,
167
+ });
168
+ return [{ host: profile.id, ...a }];
169
+ });
170
+ return {
171
+ hostClass,
172
+ file: options.file,
173
+ at: new Date(options.now * 1000).toISOString(),
174
+ ...(key === undefined ? {} : { key }),
175
+ found: found !== undefined,
176
+ ...(found === undefined
177
+ ? {}
178
+ : {
179
+ windows: { solo: found.windowsSolo, grouped: found.windowsGrouped },
180
+ summary: summariseHistory(found, options.now),
181
+ ...(Object.keys(found.unknownKeys).length === 0 ? {} : { unknownKeys: found.unknownKeys }),
182
+ }),
183
+ verdicts,
184
+ };
185
+ }
186
+
187
+ export interface AdjustedRecord {
188
+ readonly path: string;
189
+ readonly record: QualificationRecord;
190
+ }
191
+
192
+ export type AdjustResult =
193
+ | {
194
+ readonly ok: true;
195
+ readonly records: readonly AdjustedRecord[];
196
+ /** The host classes read. */
197
+ readonly hostClasses: readonly string[];
198
+ readonly ignored: Extract<HistoryRead, { ok: true }>["ignored"];
199
+ }
200
+ | { readonly ok: false; readonly problem: string };
201
+
202
+ /**
203
+ * Each record with its `history` block replaced by the blocks of the host classes read (an earlier block of a class not read
204
+ * is kept, so two histories of two classes can be applied one after the other). The measured fields are copied as they are.
205
+ */
206
+ export function adjustRecords(
207
+ records: readonly RecordFile[],
208
+ historyText: string,
209
+ options: { profiles: readonly HostProfile[]; file: string; now: number; hostClass?: string },
210
+ ): AdjustResult {
211
+ const read = readHistory(historyText);
212
+ if (!read.ok) return { ok: false, problem: `${options.file}: ${read.problem}` };
213
+ const hostClasses = options.hostClass === undefined ? historyClasses(read.records) : [options.hostClass];
214
+ if (hostClasses.length === 0) return { ok: false, problem: `${options.file} holds no record, so there is no host class to adjust for` };
215
+ const adjusted = records.map(({ path, record }) => {
216
+ const fresh = hostClasses.map((c) => historyBlock(record, read.records, c, options.profiles, { file: options.file, now: options.now }));
217
+ const kept = (record.history ?? []).filter((b) => !hostClasses.includes(b.hostClass));
218
+ return { path, record: { ...record, history: [...kept, ...fresh].sort((a, b) => (a.hostClass < b.hostClass ? -1 : a.hostClass > b.hostClass ? 1 : 0)) } };
219
+ });
220
+ return { ok: true, records: adjusted, hostClasses, ignored: read.ignored };
221
+ }
222
+
223
+ /** The adjusted verdict of a record's host for a host class: the pin verdict, `null` when it has none, `undefined` when the record holds no block for the pair. */
224
+ export function adjustedVerdictOf(record: QualificationRecord, hostClass: string, host: string): PinVerdict | null | undefined {
225
+ return record.history?.find((b) => b.hostClass === hostClass)?.verdicts.find((v) => v.host === host)?.adjusted;
226
+ }
227
+
228
+ /**
229
+ * The pin entries of adjusted records for one host and one host class: the integrity is the record's, the verdict is the
230
+ * ADJUSTED one. A record with no verdict row for the host is pinned without a verdict word, as a run without one is.
231
+ */
232
+ export function adjustedPinEntries(records: readonly AdjustedRecord[], hostClass: string, host: string): PinEntry[] {
233
+ return records.flatMap(({ record }) => {
234
+ const key = historyKeyOf(record);
235
+ if (key === undefined) return [];
236
+ const adjusted = adjustedVerdictOf(record, hostClass, host);
237
+ return [
238
+ {
239
+ uri: key.uri,
240
+ integrity: {
241
+ ...(record.testedVersion === undefined ? {} : { version: record.testedVersion }),
242
+ files: [...record.binary.files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)),
243
+ layout: record.binary.layout!,
244
+ },
245
+ ...(adjusted === null || adjusted === undefined ? {} : { verdict: adjusted }),
246
+ },
247
+ ];
248
+ });
249
+ }
250
+
251
+ /** One row per plugin, host and host class: the measured verdict, the adjusted one, the reason. */
252
+ export function adjustTable(records: readonly AdjustedRecord[]): string {
253
+ const rows = records.flatMap(({ record }) =>
254
+ (record.history ?? []).flatMap((b) =>
255
+ b.verdicts.map((v) => [record.plugin.id, v.host, b.hostClass, v.measured ?? "-", v.adjusted ?? "-", v.reason]),
256
+ ),
257
+ );
258
+ const head = ["plugin", "host", "host class", "measured", "adjusted", "reason"];
259
+ const width = head.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i]!.length)));
260
+ const line = (r: readonly string[]): string => r.map((c, i) => c.padEnd(width[i]!)).join(" ").trimEnd();
261
+ return [line(head), ...rows.map(line), ""].join("\n");
262
+ }
@@ -18,7 +18,7 @@ import { homedir } from "node:os";
18
18
  import { basename, dirname, join, resolve } from "node:path";
19
19
 
20
20
  import type { PluginLoader, PluginTarget } from "./loader.js";
21
- import { lv2BundlesOnPath } from "./lv2-loader.js";
21
+ import { lv2BundlesOnPath, lv2Loader } from "./lv2-loader.js";
22
22
  import {
23
23
  CLAP_PATH_ENV,
24
24
  CLAP_SCAN_TIMEOUT_S,
@@ -464,23 +464,19 @@ export function discoverClapTargets(options: ClapDiscoverOptions = {}): PluginTa
464
464
  export interface DiscoverAllOptions extends ClapDiscoverOptions {}
465
465
 
466
466
  /**
467
- * The targets of `--all`. The LV2 plugins lilv finds cannot be measured from this entry point, so
468
- * when the LV2 search path holds any bundle the run refuses (exit 2) and names how many, rather
469
- * than qualifying only the CLAP side and reporting everything qualified.
467
+ * The targets of `--all`: every LV2 plugin of the bundles on the LV2 search path ({@link lv2BundlesOnPath}, each
468
+ * through {@link lv2Loader} as a named bundle is), then every CLAP plugin on the CLAP path. An LV2 URI found in two
469
+ * bundles keeps the first, as the search path does. A directory that cannot be read is a {@link ClapScanError} naming it.
470
470
  */
471
471
  export function discoverAllTargets(options: DiscoverAllOptions = {}): PluginTarget[] {
472
472
  const env = options.env ?? process.env;
473
- let bundles: string[];
473
+ const lv2 = new Map<string, PluginTarget>();
474
474
  try {
475
- bundles = lv2BundlesOnPath(env, options.home ?? homedir());
475
+ for (const bundle of lv2BundlesOnPath(env, options.home ?? homedir())) {
476
+ for (const t of lv2Loader.resolve(bundle) ?? []) if (!lv2.has(t.uri)) lv2.set(t.uri, t);
477
+ }
476
478
  } catch (e) {
477
479
  throw unreadable("the LV2 search path", e);
478
480
  }
479
- if (bundles.length > 0) {
480
- throw new ClapScanError(
481
- `--all cannot measure LV2 from this entry point and ${String(bundles.length)} LV2 bundle(s) are on the LV2 path ` +
482
- `(first: ${bundles[0]}); name the plugins or bundles to qualify instead, or unset LV2_PATH to an empty directory`,
483
- );
484
- }
485
- return discoverClapTargets(options);
481
+ return [...lv2.values(), ...discoverClapTargets(options)];
486
482
  }
@@ -0,0 +1,156 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
+ /**
4
+ * What the running history may do to a measured verdict (the grouping-planner design note, How plugin-qualify uses it).
5
+ * A pure function of the measured verdict, the reasons behind it, one history record and the clock: it moves a pin
6
+ * verdict one step toward safer, or promotes `isolated` to `in-process` for a steady plugin whose only fault was a cost
7
+ * reason, or leaves the measured verdict alone. It never changes the measurement, and it never lifts a verdict that is a
8
+ * fact about the plugin (real-time safety, a missing feature, a crash in the prober, an `unknown`), nor a `refused`.
9
+ *
10
+ * The thresholds are the note's starting values, declared once in {@link HISTORY_POLICY}; the benchmark tunes them.
11
+ * The summaries (`crashed`, `raises-xruns`) are plugin-hostd's `history_get` rules, read here from the file because
12
+ * plugin-qualify runs without a daemon.
13
+ */
14
+ import type { HostProfile } from "./host-profiles.js";
15
+ import type { HistoryRecord } from "./history-file.js";
16
+ import { PIN_VERDICTS, type PinVerdict } from "./pins-file.js";
17
+
18
+ export const HISTORY_POLICY = {
19
+ /** plugin-hostd's PHD_HISTORY_KEEP_DAYS: a crash older than this is not read. */
20
+ keepDays: 30,
21
+ /** plugin-hostd's PHD_HISTORY_MIN_WINDOWS: grouped windows before an xrun share is read. */
22
+ minWindows: 30,
23
+ /** plugin-hostd's PHD_HISTORY_XRUN_MARGIN_PCT: percentage points the grouped xrun share may exceed the solo one by. */
24
+ xrunMarginPct: 5,
25
+ /** Solo crashes, on this binary and class within `keepDays`, that demote `isolated` to `refused`. */
26
+ crashedAloneCount: 3,
27
+ /** Windows run alone before a promotion is read: 360 windows of 60 s are six hours. */
28
+ steadyWindowsSolo: 360,
29
+ } as const;
30
+
31
+ const SECONDS_PER_DAY = 86_400;
32
+
33
+ /** The codes of the second verdict. Codes, never prose. */
34
+ export const HISTORY_REASON = {
35
+ crashed: "history.crashed",
36
+ xrunsGrouped: "history.xruns-grouped",
37
+ crashedAlone: "history.crashed-alone",
38
+ steady: "history.steady",
39
+ /** History has no line for this plugin binary on this host class: the measured verdict stands. */
40
+ noRecord: "history.no-record",
41
+ /** The record was read and no rule moves this verdict. */
42
+ noChange: "history.no-change",
43
+ /** The record would promote, but the measured verdict is a fact about the plugin and history does not lift it. */
44
+ notLiftable: "history.not-liftable",
45
+ } as const;
46
+ export type HistoryReasonCode = (typeof HISTORY_REASON)[keyof typeof HISTORY_REASON];
47
+
48
+ export type HistoryMove = "demote" | "promote" | "none";
49
+
50
+ /** What the daemon's `history_get` summary says of a record at `now` (UTC seconds). */
51
+ export interface HistorySummary {
52
+ /** A crash was counted and the latest one is within `keepDays`. */
53
+ readonly crashed: boolean;
54
+ /** The grouped windows' share of xruns exceeds the solo share by the margin, over at least `minWindows` grouped windows. */
55
+ readonly raisesXruns: boolean;
56
+ }
57
+
58
+ export function summariseHistory(r: HistoryRecord, now: number): HistorySummary {
59
+ const last = Math.max(r.lastCrash, r.lastCrashGrouped);
60
+ const crashed = r.crashesSolo + r.crashesGrouped > 0 && now - last <= HISTORY_POLICY.keepDays * SECONDS_PER_DAY;
61
+ const wg = r.windowsGrouped;
62
+ const xg = r.xrunWindowsGrouped;
63
+ const ws = r.windowsSolo;
64
+ const xs = r.xrunWindowsSolo;
65
+ const m = HISTORY_POLICY.xrunMarginPct;
66
+ // xg/wg > xs/ws + m/100, cross-multiplied so no division is rounded
67
+ const raisesXruns = wg < HISTORY_POLICY.minWindows ? false : ws === 0 ? 100 * xg > m * wg : 100 * xg * ws > 100 * xs * wg + m * wg * ws;
68
+ return { crashed, raisesXruns };
69
+ }
70
+
71
+ /** One reason of the measured verdict, as the record holds it. */
72
+ export interface MeasuredReason {
73
+ readonly code: string;
74
+ /** The rating the reason contributed to the verdict. */
75
+ readonly rating: string;
76
+ }
77
+
78
+ export interface AdjustInput {
79
+ /** The measured pin verdict of the host, or `undefined` for an `unknown` rating (no verdict word). */
80
+ readonly measured: PinVerdict | undefined;
81
+ /** The reasons of the measured verdict, all of them. */
82
+ readonly reasons: readonly MeasuredReason[];
83
+ /** The record of this plugin binary on the host class, or `undefined` when the history has none. */
84
+ readonly record: HistoryRecord | undefined;
85
+ /** The host the verdict is for; its cost ceiling is the budget a promotion is read against. */
86
+ readonly profile: HostProfile;
87
+ /** UTC seconds. */
88
+ readonly now: number;
89
+ }
90
+
91
+ export interface Adjustment {
92
+ readonly measured: PinVerdict | null;
93
+ readonly adjusted: PinVerdict | null;
94
+ readonly move: HistoryMove;
95
+ readonly reason: HistoryReasonCode;
96
+ /** For `history.not-liftable`: why the measured verdict is not history's to lift: `refused`, `unknown`, or the codes of the reasons that are not cost reasons. */
97
+ readonly blockedBy?: readonly string[];
98
+ }
99
+
100
+ const COST_PREFIX = "hosting.cost.";
101
+
102
+ /**
103
+ * Why the measured verdict cannot be promoted by history, or an empty list when it can: the verdict must be `isolated`
104
+ * (conditional), and every reason that is not `suitable` must be a cost reason. A reason of any other dimension
105
+ * (real-time safety, features, stability, latency, topology) is a measurement of the plugin, not of this machine.
106
+ */
107
+ function blockers(measured: Exclude<PinVerdict, "in-process"> | undefined, reasons: readonly MeasuredReason[]): string[] {
108
+ if (measured === undefined) return ["unknown"];
109
+ if (measured === "refused") return ["refused"];
110
+ const deciding = reasons.filter((r) => r.rating !== "suitable");
111
+ const notCost = deciding.filter((r) => !r.code.startsWith(COST_PREFIX)).map((r) => r.code);
112
+ if (notCost.length > 0) return notCost;
113
+ // an isolated verdict that no reason explains is not one history can vouch for
114
+ return deciding.length === 0 ? ["no-deciding-reason"] : [];
115
+ }
116
+
117
+ function stay(measured: PinVerdict | undefined, reason: HistoryReasonCode, blockedBy?: readonly string[]): Adjustment {
118
+ return { measured: measured ?? null, adjusted: measured ?? null, move: "none", reason, ...(blockedBy === undefined ? {} : { blockedBy }) };
119
+ }
120
+
121
+ function move(measured: PinVerdict, adjusted: PinVerdict, reason: HistoryReasonCode): Adjustment {
122
+ const demote = PIN_VERDICTS.indexOf(adjusted) > PIN_VERDICTS.indexOf(measured);
123
+ return { measured, adjusted, move: demote ? "demote" : "promote", reason };
124
+ }
125
+
126
+ /** Whether the record shows a plugin that has run alone long enough, without a crash or a restart, under the host's cost budget. */
127
+ function steady(r: HistoryRecord, profile: HostProfile, summary: HistorySummary): boolean {
128
+ const budgetPct = profile.cost.coreFractionCeiling * 100;
129
+ return (
130
+ r.windowsSolo >= HISTORY_POLICY.steadyWindowsSolo &&
131
+ r.crashesSolo + r.crashesGrouped === 0 &&
132
+ r.restartsGrouped === 0 &&
133
+ !summary.raisesXruns &&
134
+ r.cpuP95Solo < budgetPct
135
+ );
136
+ }
137
+
138
+ /** The second verdict beside the measured one. Demotions are read first and promotions last; a demotion is one step. */
139
+ export function adjustVerdict(input: AdjustInput): Adjustment {
140
+ const { measured, reasons, record, profile, now } = input;
141
+ if (record === undefined) return stay(measured, HISTORY_REASON.noRecord);
142
+ const summary = summariseHistory(record, now);
143
+ if (measured === "in-process") {
144
+ if (summary.crashed) return move(measured, "isolated", HISTORY_REASON.crashed);
145
+ if (summary.raisesXruns) return move(measured, "isolated", HISTORY_REASON.xrunsGrouped);
146
+ return stay(measured, HISTORY_REASON.noChange);
147
+ }
148
+ if (measured === "isolated") {
149
+ const aloneCrashed = record.crashesSolo >= HISTORY_POLICY.crashedAloneCount && now - record.lastCrash <= HISTORY_POLICY.keepDays * SECONDS_PER_DAY;
150
+ if (aloneCrashed) return move(measured, "refused", HISTORY_REASON.crashedAlone);
151
+ }
152
+ if (!steady(record, profile, summary)) return stay(measured, HISTORY_REASON.noChange);
153
+ const blocked = blockers(measured, reasons);
154
+ if (blocked.length > 0) return stay(measured, HISTORY_REASON.notLiftable, blocked);
155
+ return move("isolated", "in-process", HISTORY_REASON.steady);
156
+ }
@@ -0,0 +1,177 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
+ /**
4
+ * The running-history file plugin-hostd writes and `plugin-qualify adjust --history` reads (plugin-hostd's `data/history.json`
5
+ * and `src/history.c`): a header line, then one line per plugin binary and host class,
6
+ *
7
+ * history <uri> <binary digest> <host class> key=value ...
8
+ *
9
+ * The reader keeps the daemon's rules for what it reads: the header must be the one it knows, a line outside the grammar
10
+ * is not read (and counted, so the caller can say so), a second line of one plugin binary on one host class is not read,
11
+ * a value is a whole number or a decimal and never negative. It differs in one place on purpose: a key it does not know
12
+ * is not a reason to skip the line, it is kept on the record (`unknownKeys`) and the known keys are read, so a daemon
13
+ * that grows a key inside history/1 does not blind the qualifier. Nothing here writes the file; only the daemon does.
14
+ */
15
+
16
+ /** The header line of the format this reader knows, plugin-hostd's `PHD_HISTORY_HEADER`. */
17
+ export const HISTORY_HEADER = "# plugin-hostd history/1";
18
+
19
+ /** The verb a record line starts with, plugin-hostd's `PHD_HISTORY_RECORD`. */
20
+ export const HISTORY_RECORD = "history";
21
+
22
+ /** How a key's value is written: a whole number (`count`, `time`) or a decimal (`decimal`). */
23
+ type KeyKind = "count" | "time" | "decimal";
24
+
25
+ /**
26
+ * PLUGIN_HOSTD_HISTORY_KEYS in the daemon's order, each with the field it fills and how its value is written. The words
27
+ * are plugin-hostd's (`data/history.json`); `history-file.test.ts` holds this table to them.
28
+ */
29
+ const KEYS: readonly { readonly word: string; readonly field: keyof HistoryCounters; readonly kind: KeyKind }[] = [
30
+ { word: "first", field: "first", kind: "time" },
31
+ { word: "last", field: "last", kind: "time" },
32
+ { word: "windows_solo", field: "windowsSolo", kind: "count" },
33
+ { word: "windows_grouped", field: "windowsGrouped", kind: "count" },
34
+ { word: "cpu_p95_solo", field: "cpuP95Solo", kind: "decimal" },
35
+ { word: "cpu_max_solo", field: "cpuMaxSolo", kind: "decimal" },
36
+ { word: "pss_mb_solo", field: "pssMbSolo", kind: "decimal" },
37
+ { word: "xrun_windows_solo", field: "xrunWindowsSolo", kind: "count" },
38
+ { word: "xrun_windows_grouped", field: "xrunWindowsGrouped", kind: "count" },
39
+ { word: "crashes_solo", field: "crashesSolo", kind: "count" },
40
+ { word: "crashes_grouped", field: "crashesGrouped", kind: "count" },
41
+ { word: "restarts_grouped", field: "restartsGrouped", kind: "count" },
42
+ { word: "quarantines", field: "quarantines", kind: "count" },
43
+ { word: "last_crash", field: "lastCrash", kind: "time" },
44
+ { word: "last_crash_grouped", field: "lastCrashGrouped", kind: "time" },
45
+ ];
46
+
47
+ /** The words of the keys this reader knows, in the daemon's order. */
48
+ export const HISTORY_KEY_WORDS: readonly string[] = KEYS.map((k) => k.word);
49
+
50
+ /** The figures of one record. A key the line did not carry reads as 0, as the daemon's record starts at 0. */
51
+ export interface HistoryCounters {
52
+ /** UTC seconds of the first and the last window. */
53
+ readonly first: number;
54
+ readonly last: number;
55
+ readonly windowsSolo: number;
56
+ readonly windowsGrouped: number;
57
+ /** Percent of one core. */
58
+ readonly cpuP95Solo: number;
59
+ readonly cpuMaxSolo: number;
60
+ /** MiB. */
61
+ readonly pssMbSolo: number;
62
+ readonly xrunWindowsSolo: number;
63
+ readonly xrunWindowsGrouped: number;
64
+ readonly crashesSolo: number;
65
+ readonly crashesGrouped: number;
66
+ readonly restartsGrouped: number;
67
+ readonly quarantines: number;
68
+ /** UTC seconds of the last crash the plugin was blamed for alone, and in a group. */
69
+ readonly lastCrash: number;
70
+ readonly lastCrashGrouped: number;
71
+ }
72
+
73
+ /** One line of the history: a plugin binary on a host class. */
74
+ export interface HistoryRecord extends HistoryCounters {
75
+ /** plugin-hostd's uri: `clap:<path of the .clap>#<id>`, or an LV2 plugin's own URI. */
76
+ readonly uri: string;
77
+ /** The SHA-256 of the first file of the plugin's pin, 64 lowercase hex digits. */
78
+ readonly digest: string;
79
+ /** The machine class the daemon recorded under, `arch/cores/clock`. */
80
+ readonly hostClass: string;
81
+ /** The keys of the line this reader does not know, as written: kept, never read. */
82
+ readonly unknownKeys: Readonly<Record<string, string>>;
83
+ }
84
+
85
+ export type HistoryRead =
86
+ | {
87
+ readonly ok: true;
88
+ readonly records: readonly HistoryRecord[];
89
+ /** The lines that were not read, each with the line number and why: outside the grammar, or a second line of one binary and class. */
90
+ readonly ignored: readonly { readonly line: number; readonly problem: string }[];
91
+ }
92
+ | { readonly ok: false; readonly problem: string };
93
+
94
+ const HEX = /^[0-9a-f]{64}$/;
95
+ const WHOLE = /^[0-9]+$/;
96
+ const DECIMAL = /^([0-9]+\.?[0-9]*|\.[0-9]+)([eE][+-]?[0-9]+)?$/;
97
+
98
+ function number(text: string, kind: KeyKind): number | undefined {
99
+ if (kind === "decimal") {
100
+ if (!DECIMAL.test(text)) return undefined;
101
+ const v = Number(text);
102
+ return Number.isFinite(v) ? v : undefined;
103
+ }
104
+ if (!WHOLE.test(text)) return undefined;
105
+ const v = Number(text);
106
+ return Number.isSafeInteger(v) ? v : undefined;
107
+ }
108
+
109
+ /** One record line, or the reason it is outside the grammar. */
110
+ function parseRecord(words: readonly string[]): HistoryRecord | string {
111
+ if (words.length < 4 || words[0] !== HISTORY_RECORD) return `not a ${HISTORY_RECORD} line with a uri, a digest and a host class`;
112
+ const [, uri, digest, hostClass] = words as [string, string, string, string];
113
+ if (!HEX.test(digest)) return `${digest}: not a digest of 64 lowercase hex digits`;
114
+ const values: Record<string, number> = {};
115
+ const unknownKeys: Record<string, string> = {};
116
+ const seen = new Set<string>();
117
+ for (const word of words.slice(4)) {
118
+ const eq = word.indexOf("=");
119
+ if (eq < 0) return `${word}: not key=value`;
120
+ const key = word.slice(0, eq);
121
+ const text = word.slice(eq + 1);
122
+ if (seen.has(key)) return `${key}: given twice`;
123
+ seen.add(key);
124
+ const known = KEYS.find((k) => k.word === key);
125
+ if (known === undefined) {
126
+ unknownKeys[key] = text;
127
+ continue;
128
+ }
129
+ const v = number(text, known.kind);
130
+ if (v === undefined) return `${key}=${text}: not ${known.kind === "decimal" ? "a decimal" : "a whole number"}, or negative`;
131
+ values[known.field] = v;
132
+ }
133
+ const counters = Object.fromEntries(KEYS.map((k) => [k.field, values[k.field] ?? 0])) as unknown as HistoryCounters;
134
+ return { uri, digest, hostClass, ...counters, unknownKeys };
135
+ }
136
+
137
+ /**
138
+ * Read the text of a history file. A file whose first line (blank lines apart) is not the header is refused as a whole,
139
+ * as the daemon blocks it: a version this reader does not know is not read by guessing. Comment lines and blank lines
140
+ * after the header are skipped.
141
+ */
142
+ export function readHistory(text: string): HistoryRead {
143
+ const lines = text.split("\n").map((l) => l.replace(/\r$/, ""));
144
+ const first = lines.findIndex((l) => l.trim() !== "");
145
+ if (first < 0) return { ok: true, records: [], ignored: [] };
146
+ if (lines[first] !== HISTORY_HEADER) {
147
+ return {
148
+ ok: false,
149
+ problem: lines[first]!.startsWith("# plugin-hostd history/")
150
+ ? `${JSON.stringify(lines[first])} is a version this reader does not know (it reads ${HISTORY_HEADER})`
151
+ : "not a history file: it does not begin with " + HISTORY_HEADER,
152
+ };
153
+ }
154
+ const records: HistoryRecord[] = [];
155
+ const ignored: { line: number; problem: string }[] = [];
156
+ for (let i = first + 1; i < lines.length; i++) {
157
+ const line = lines[i]!;
158
+ if (line.trim() === "" || line.startsWith("#")) continue;
159
+ const parsed = parseRecord(line.split(/[ \t]+/).filter((w) => w !== ""));
160
+ if (typeof parsed === "string") {
161
+ ignored.push({ line: i + 1, problem: parsed });
162
+ } else if (records.some((r) => r.uri === parsed.uri && r.digest === parsed.digest && r.hostClass === parsed.hostClass)) {
163
+ ignored.push({ line: i + 1, problem: "a second line of one plugin binary on one host class" });
164
+ } else records.push(parsed);
165
+ }
166
+ return { ok: true, records, ignored };
167
+ }
168
+
169
+ /** The record of one plugin binary on one host class, or `undefined`. */
170
+ export function findHistory(records: readonly HistoryRecord[], uri: string, digest: string, hostClass: string): HistoryRecord | undefined {
171
+ return records.find((r) => r.uri === uri && r.digest === digest && r.hostClass === hostClass);
172
+ }
173
+
174
+ /** The host classes a history holds a record for, sorted. */
175
+ export function historyClasses(records: readonly HistoryRecord[]): string[] {
176
+ return [...new Set(records.map((r) => r.hostClass))].sort();
177
+ }