@openmixer/plugin-qualify 0.19.1 → 0.20.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 (55) hide show
  1. package/bin/plugin-qualify-records-package.mjs +9 -7
  2. package/bin/plugin-qualify.mjs +36 -1
  3. package/dist/distributed-records.d.ts +16 -3
  4. package/dist/distributed-records.js +38 -4
  5. package/dist/distributed-records.js.map +1 -1
  6. package/dist/index.d.ts +2 -0
  7. package/dist/index.js +2 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/judged-layer.d.ts +91 -0
  10. package/dist/judged-layer.js +144 -0
  11. package/dist/judged-layer.js.map +1 -0
  12. package/dist/judgement-line.d.ts +6 -0
  13. package/dist/judgement-line.js +20 -0
  14. package/dist/judgement-line.js.map +1 -0
  15. package/dist/measurement/cli.d.ts +2 -0
  16. package/dist/measurement/cli.js +25 -2
  17. package/dist/measurement/cli.js.map +1 -1
  18. package/dist/measurement/format.d.ts +2 -0
  19. package/dist/measurement/format.js +5 -0
  20. package/dist/measurement/format.js.map +1 -1
  21. package/dist/probe-merge.js +6 -0
  22. package/dist/probe-merge.js.map +1 -1
  23. package/dist/probe-runner.d.ts +4 -0
  24. package/dist/probe-runner.js +12 -3
  25. package/dist/probe-runner.js.map +1 -1
  26. package/dist/qualification-record.d.ts +15 -1
  27. package/dist/qualification-record.js +27 -5
  28. package/dist/qualification-record.js.map +1 -1
  29. package/dist/qualify-isolated.d.ts +5 -3
  30. package/dist/qualify-isolated.js +6 -4
  31. package/dist/qualify-isolated.js.map +1 -1
  32. package/dist/qualify-sync.d.ts +8 -3
  33. package/dist/qualify-sync.js +25 -15
  34. package/dist/qualify-sync.js.map +1 -1
  35. package/dist/records-package.d.ts +31 -4
  36. package/dist/records-package.js +149 -24
  37. package/dist/records-package.js.map +1 -1
  38. package/fixtures/records-rpi5-0.19.1/http___drumkv1.sourceforge.net_lv2@0.2-7d179bdcf9e5.default.probe.json +58 -0
  39. package/fixtures/records-rpi5-0.19.1/http___drumkv1.sourceforge.net_lv2@0.2-7d179bdcf9e5.default.qualification.json +305 -0
  40. package/fixtures/records-rpi5-0.19.1/http___samplv1.sourceforge.net_lv2@0.2-c9cd8fb3102f.default.probe.json +58 -0
  41. package/fixtures/records-rpi5-0.19.1/http___samplv1.sourceforge.net_lv2@0.2-c9cd8fb3102f.default.qualification.json +301 -0
  42. package/package.json +2 -2
  43. package/src/distributed-records.ts +41 -4
  44. package/src/index.ts +2 -0
  45. package/src/judged-layer.ts +197 -0
  46. package/src/judgement-line.ts +22 -0
  47. package/src/measurement/cli.ts +26 -2
  48. package/src/measurement/format.ts +8 -0
  49. package/src/probe-merge.ts +4 -0
  50. package/src/probe-runner.ts +16 -6
  51. package/src/qualification-record.ts +40 -6
  52. package/src/qualify-isolated.ts +6 -4
  53. package/src/qualify-sync.ts +27 -15
  54. package/src/records-package.ts +156 -25
  55. package/tools/lv2-measure.mjs +21 -2
@@ -12,8 +12,10 @@
12
12
  * treats the plugin as one it measured.
13
13
  */
14
14
  import { readFileSync, readdirSync } from "node:fs";
15
+ import { machine } from "node:os";
15
16
  import { join, resolve } from "node:path";
16
17
 
18
+ import { cellKey, type GridCell } from "./measurement-grid.js";
17
19
  import { clapPinUri, pinVerdictOf, type PinEntry } from "./pins-file.js";
18
20
  import { integrityOfProbeOutput } from "./probe-judge.js";
19
21
  import { readProbeOutput, sha256Hex } from "./probe-output.js";
@@ -60,6 +62,20 @@ export function hostClassOfBoard(model: string | undefined): string | undefined
60
62
  return m === null ? undefined : `rpi${m[1]!}`;
61
63
  }
62
64
 
65
+ /** The architecture `uname -m` names, or `undefined` when it cannot be read. */
66
+ export function readMachine(): string | undefined {
67
+ try {
68
+ return machine();
69
+ } catch {
70
+ return undefined;
71
+ }
72
+ }
73
+
74
+ /** The host class of a machine with no board name, by `uname -m`: `x86-64` for an x86_64 one; none for an architecture without a class. */
75
+ export function hostClassOfMachine(arch: string | undefined): string | undefined {
76
+ return arch?.trim() === "x86_64" ? "x86-64" : undefined;
77
+ }
78
+
63
79
  /** One distributed record, read: where it is, what it says, and the set it came with. */
64
80
  export interface DistributedRecord {
65
81
  readonly path: string;
@@ -156,26 +172,38 @@ export function outcomeOfVerdict(v: Pick<RecordVerdict, "measured" | "rating" |
156
172
  return v.rating === "unsuitable" ? "refused" : "unmeasured";
157
173
  }
158
174
 
175
+ /** The cells a record's grid holds, by rate then quantum: where the set it came in was measured. */
176
+ export function recordCells(record: Pick<QualificationRecord, "measurements">): GridCell[] {
177
+ const seen = new Map<string, GridCell>();
178
+ for (const p of record.measurements?.grid ?? []) seen.set(cellKey(p), { rate: p.rate, quantum: p.quantum });
179
+ return [...seen.values()].sort((a, b) => a.rate - b.rate || a.quantum - b.quantum);
180
+ }
181
+
159
182
  /** What the layer makes of one item. */
160
183
  export type DistributedMatch =
161
184
  /** No distributed record names the plugin: it is measured, and nothing is said. */
162
185
  | { readonly status: "none" }
163
186
  /** Every reason a record found was not taken, one line each; the plugin is measured. */
164
187
  | { readonly status: "ignored"; readonly lines: readonly string[] }
165
- | { readonly status: "applies"; readonly set: string; readonly records: readonly DistributedRecord[]; readonly files: readonly string[] };
188
+ /** A set applies; `lines` names every set tried before it and why it was passed over. */
189
+ | { readonly status: "applies"; readonly set: string; readonly records: readonly DistributedRecord[]; readonly files: readonly string[]; readonly lines: readonly string[] };
166
190
 
167
191
  /**
168
192
  * Whether the item is taken from the layer. The records that find it are tried one set at a time, in the order the
169
193
  * layer read them. Of a set, only the records whose every file is installed with the SHA-256 it recorded (computed
170
194
  * here by `digest`) count: a record of other bytes is another build of the plugin and is passed over. The set applies
171
195
  * when at least one record of these bytes holds a verdict for `host`, and none of those verdicts is one the source
172
- * could not measure; a set with no record of these bytes is a binary that differs.
196
+ * could not measure; a set with no record of these bytes is a binary that differs. A set is per machine class, rate
197
+ * and quantum: of these records, only the ones whose grid holds every cell in `cells` (the cells the sync runs at)
198
+ * count, so a set measured at another cell is passed over, naming the cells it was measured at, and never answers a
199
+ * sync at this one by the order its name sorts in (#317). No cells given asks nothing of the grid.
173
200
  */
174
201
  export function matchDistributed(
175
202
  layer: DistributedLayer,
176
203
  item: SyncItem,
177
204
  host: string,
178
205
  digest: (files: readonly string[]) => readonly FileDigest[] | undefined,
206
+ cells: readonly GridCell[] = [],
179
207
  ): DistributedMatch {
180
208
  const found = item.format === "clap" ? layer.clap.get(resolve(join(item.root, item.files[0]!))) : layer.lv2.get(item.uri ?? "");
181
209
  if (found === undefined || found.length === 0) return { status: "none" };
@@ -195,7 +223,16 @@ export function matchDistributed(
195
223
  say(`distributed: ${distributedUri(item, found.find((r) => r.set === set)!)}: binary differs`);
196
224
  continue;
197
225
  }
198
- const judged = records.filter((r) => r.record.verdicts.some((v) => v.host === host));
226
+ const atCell = records.filter((r) => {
227
+ const held = new Set(recordCells(r.record).map(cellKey));
228
+ return cells.every((c) => held.has(cellKey(c)));
229
+ });
230
+ if (atCell.length === 0) {
231
+ const measuredAt = [...new Set(records.flatMap((r) => recordCells(r.record).map(cellKey)))];
232
+ say(`distributed: ${distributedUri(item, records[0]!)}: set ${set} measured at ${measuredAt.length === 0 ? "no cell" : measuredAt.join(", ")}`);
233
+ continue;
234
+ }
235
+ const judged = atCell.filter((r) => r.record.verdicts.some((v) => v.host === host));
199
236
  if (judged.length === 0) {
200
237
  say(`distributed: ${distributedUri(item, records[0]!)}: no verdict for ${host} in ${set}`);
201
238
  continue;
@@ -205,7 +242,7 @@ export function matchDistributed(
205
242
  say(`distributed: ${distributedUri(item, unmeasured)}: unmeasured in ${set}, measured here`);
206
243
  continue;
207
244
  }
208
- return { status: "applies", set, records, files: [...new Set(records.flatMap((r) => r.record.binary.files.map((f) => f.path)))] };
245
+ return { status: "applies", set, records: atCell, files: [...new Set(atCell.flatMap((r) => r.record.binary.files.map((f) => f.path)))], lines };
209
246
  }
210
247
  return { status: "ignored", lines };
211
248
  }
package/src/index.ts CHANGED
@@ -24,6 +24,8 @@ export * from "./cost-fields.js";
24
24
  export * from "./probe-output.js";
25
25
  export * from "./probe-judge.js";
26
26
  export * from "./qualification-record.js";
27
+ export * from "./judgement-line.js";
28
+ export * from "./judged-layer.js";
27
29
  export * from "./probe-runner.js";
28
30
  export * from "./measurement-grid.js";
29
31
  export * from "./memory-bound.js";
@@ -0,0 +1,197 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
+ /**
4
+ * The judged layer of a plugin-db entry, from a qualification record: plugin-qualify is that layer's one writer
5
+ * (plugin-db's records design note, FreeMixer/plugin-db#10). The reader never parses a record; it reads the layer this writes, so every field
6
+ * the state function reads is set here, from the record and from what plugin-qualify declares once:
7
+ *
8
+ * - the latency line (`LATENCY_LINE_MS`), written beside each cell's figure (`latencyMs`), so a surface shows the
9
+ * figure within or over the line and keeps no tier word (openmixer plugin-system design 7.15);
10
+ * - the rules the record was judged by (`judgementRules`), the minor of the plugin-qualify that judged it: a rule
11
+ * changes only in a minor, so a patch release leaves every record current;
12
+ * - the hosting epoch of the host the verdict is for (`HOSTING_EPOCH`).
13
+ *
14
+ * One layer is one verdict under one host: the host plugin-hostd runs a plugin with. Its verdict is the one the pins
15
+ * file gives the same bytes (`pinVerdictOf` of the isolated tier's host, omx-clap-host, which is plugin-hostd's
16
+ * worker). A record whose verdict for that host was not measured has no judged layer: the reader then answers
17
+ * unqualified, with its reason.
18
+ */
19
+ import { judgementRules, latencyMsOf, LATENCY_LINE_MS } from "./judgement-line.js";
20
+ import { pinVerdictOf, type PinVerdict } from "./pins-file.js";
21
+ import { QUALIFICATION_RECORD_SCHEMA, ISOLATED_TIER_HOST, type QualificationRecord } from "./qualification-record.js";
22
+ import type { SuitabilityRating } from "./rating.js";
23
+
24
+ /** The host a judged layer is written for: the one that runs plugins, in workers of its own (PLUGIN_DB_ISOLATION_HOSTS). */
25
+ export const JUDGED_HOST = "plugin-hostd";
26
+
27
+ /**
28
+ * The hosting epoch of each host a layer is written for. The epoch is the host owner's to move, when a release of it
29
+ * changes how it hosts; plugin-hostd has declared no hosting change since records were written, so its epoch is 0.
30
+ */
31
+ export const HOSTING_EPOCH = { [JUDGED_HOST]: 0 } as const satisfies Readonly<Record<string, number>>;
32
+
33
+ /** The plugin-db envelope version this writes (plugin-db data/store.schema.json). */
34
+ export const PLUGIN_DB_ENVELOPE = 1;
35
+
36
+ /** The pin a layer is written for, as plugin-db's pinned layer holds it. */
37
+ export interface LayerPin {
38
+ /** The `omx-files/1` SHA-256 of the plugin's files. */
39
+ readonly files: string;
40
+ /** `<scheme>:<sha256>` of the parameter layout, or null when the pin holds none. */
41
+ readonly layout: string | null;
42
+ }
43
+
44
+ export interface JudgedCell {
45
+ readonly rate: number;
46
+ readonly quantum: number;
47
+ readonly latencyMs?: number;
48
+ }
49
+
50
+ export interface JudgedBody {
51
+ readonly verdict: PinVerdict;
52
+ readonly machineClass: string;
53
+ readonly host: { readonly name: string; readonly version: string; readonly hostingEpoch: number };
54
+ readonly rules: string;
55
+ readonly recordSchema: string;
56
+ readonly cells: readonly JudgedCell[];
57
+ readonly conditions: Readonly<Record<string, string>>;
58
+ readonly latencyLineMs: number;
59
+ }
60
+
61
+ export interface JudgedLayer {
62
+ readonly envelope: typeof PLUGIN_DB_ENVELOPE;
63
+ readonly layer: "judged";
64
+ readonly address: string;
65
+ readonly pin: LayerPin;
66
+ readonly writer: "plugin-qualify";
67
+ readonly body: JudgedBody;
68
+ }
69
+
70
+ /** What the converter is given beside the record: the class word, the pin, and the rules when the record states none. */
71
+ export interface JudgedLayerInput {
72
+ /** The machine class word the record was measured on (`rpi5`, `x86-64`): the set's host class. */
73
+ readonly machineClass: string;
74
+ readonly pin: LayerPin;
75
+ /** plugin-db's address; by default `<format>:<id>`. */
76
+ readonly address?: string;
77
+ /** The rules of a record that states none: the plugin-qualify version that judged its set (the set's provenance). */
78
+ readonly judgedBy?: string;
79
+ }
80
+
81
+ /** Why a record has no judged layer, when it has none. */
82
+ export type NoLayer = { readonly layer: null; readonly reason: string };
83
+
84
+ /** The version of a tool named `<name> <version>` in the record's `tool`. */
85
+ function versionOf(record: QualificationRecord, name: string): string | undefined {
86
+ for (const v of Object.values(record.tool)) {
87
+ const m = new RegExp(`^${name.replace(/[-.]/g, "\\$&")} (\\S+)$`).exec(v);
88
+ if (m) return m[1];
89
+ }
90
+ return undefined;
91
+ }
92
+
93
+ /** The cells of the record: each measured rate and quantum, with its latency in milliseconds when one was measured. */
94
+ export function judgedCells(record: QualificationRecord): JudgedCell[] {
95
+ return record.measurements.grid
96
+ .filter((p) => p.outcome === "probe.ok")
97
+ .map((p) => {
98
+ const frames = (p.latency as { measuredFrames?: number }).measuredFrames;
99
+ return { rate: p.rate, quantum: p.quantum, ...(frames === undefined ? {} : { latencyMs: latencyMsOf(frames, p.rate) }) };
100
+ });
101
+ }
102
+
103
+ /**
104
+ * The judged layer of a record, or why there is none. The verdict is the isolated tier host's, as the pins file gives
105
+ * it; the host is plugin-hostd at the probe runner's version, which is plugin-hostd's.
106
+ */
107
+ export function judgedLayerOf(record: QualificationRecord, input: JudgedLayerInput): JudgedLayer | NoLayer {
108
+ if (record.schema !== QUALIFICATION_RECORD_SCHEMA) return { layer: null, reason: `record schema ${JSON.stringify(record.schema)} is not ${QUALIFICATION_RECORD_SCHEMA}` };
109
+ const tierHost = ISOLATED_TIER_HOST[record.plugin.format];
110
+ const v = record.verdicts.find((r) => r.host === tierHost);
111
+ if (v === undefined) return { layer: null, reason: `no verdict under ${tierHost ?? `a host for ${record.plugin.format}`}` };
112
+ const verdict = v.measured ? pinVerdictOf(v.rating as SuitabilityRating) : undefined;
113
+ if (verdict === undefined) return { layer: null, reason: `${tierHost}: not measured (${v.deciding ?? v.rating})` };
114
+ const hostVersion = versionOf(record, "plugin-hostd-probe");
115
+ if (hostVersion === undefined) return { layer: null, reason: "the record names no plugin-hostd-probe version" };
116
+ const rules = record.rules ?? (input.judgedBy === undefined ? undefined : judgementRules(input.judgedBy));
117
+ if (rules === undefined) return { layer: null, reason: "the record states no rules, and no judgedBy was given" };
118
+ return {
119
+ envelope: PLUGIN_DB_ENVELOPE,
120
+ layer: "judged",
121
+ address: input.address ?? `${record.plugin.format}:${record.plugin.id}`,
122
+ pin: input.pin,
123
+ writer: "plugin-qualify",
124
+ body: {
125
+ verdict,
126
+ machineClass: input.machineClass,
127
+ host: { name: JUDGED_HOST, version: hostVersion, hostingEpoch: HOSTING_EPOCH[JUDGED_HOST] },
128
+ rules,
129
+ recordSchema: record.schema,
130
+ cells: judgedCells(record),
131
+ conditions: {},
132
+ latencyLineMs: record.latencyLineMs ?? LATENCY_LINE_MS,
133
+ },
134
+ };
135
+ }
136
+
137
+ export interface JudgedLayerArgs {
138
+ readonly record: string;
139
+ readonly machineClass: string;
140
+ readonly files: string;
141
+ readonly layout: string | null;
142
+ readonly judgedBy?: string;
143
+ readonly address?: string;
144
+ /** Print the body only, as `plugin-db put <address> judged --writer plugin-qualify` reads it from stdin. */
145
+ readonly body: boolean;
146
+ }
147
+
148
+ export function judgedLayerHelpText(): string {
149
+ return [
150
+ "usage: plugin-qualify judged-layer <record.qualification.json> --machine-class <word> --files <sha256>",
151
+ " [--layout <scheme>:<sha256>] [--judged-by <version>] [--address <address>] [--body]",
152
+ "",
153
+ "Print the plugin-db judged layer of a qualification record, for the pin plugin-db holds (its files digest and",
154
+ "layout). --judged-by names the plugin-qualify that judged a record stating no rules (the set's provenance). --body",
155
+ "prints the body only, for `plugin-db put <address> judged --writer plugin-qualify`. A record whose verdict under",
156
+ "plugin-hostd's worker was not measured has no layer: the reason is printed and the exit status is 1.",
157
+ "",
158
+ ].join("\n");
159
+ }
160
+
161
+ export type ParsedJudgedLayerArgs = { ok: true; args: JudgedLayerArgs } | { ok: true; help: true } | { ok: false; error: string };
162
+
163
+ export function parseJudgedLayerArgs(argv: readonly string[]): ParsedJudgedLayerArgs {
164
+ const opts: Record<string, string> = {};
165
+ const files: string[] = [];
166
+ let body = false;
167
+ for (let i = 0; i < argv.length; i++) {
168
+ const a = argv[i]!;
169
+ if (a === "--help" || a === "-h") return { ok: true, help: true };
170
+ if (a === "--body") body = true;
171
+ else if (["--machine-class", "--files", "--layout", "--judged-by", "--address"].includes(a)) {
172
+ const v = argv[++i];
173
+ if (v === undefined || v.startsWith("--")) return { ok: false, error: `${a} needs a value` };
174
+ opts[a] = v;
175
+ } else if (a.startsWith("--")) return { ok: false, error: `unknown option ${a}` };
176
+ else files.push(a);
177
+ }
178
+ if (files.length !== 1) return { ok: false, error: "name one record" };
179
+ const machineClass = opts["--machine-class"];
180
+ const pinFiles = opts["--files"];
181
+ if (machineClass === undefined) return { ok: false, error: "--machine-class is required" };
182
+ if (pinFiles === undefined || !/^[0-9a-f]{64}$/.test(pinFiles)) return { ok: false, error: "--files must be the 64 hex digits of the files pin" };
183
+ const layout = opts["--layout"] ?? null;
184
+ if (layout !== null && !/^[^:]+:[0-9a-f]{64}$/.test(layout)) return { ok: false, error: "--layout must be <scheme>:<sha256>" };
185
+ return {
186
+ ok: true,
187
+ args: {
188
+ record: files[0]!,
189
+ machineClass,
190
+ files: pinFiles,
191
+ layout,
192
+ body,
193
+ ...(opts["--judged-by"] === undefined ? {} : { judgedBy: opts["--judged-by"] }),
194
+ ...(opts["--address"] === undefined ? {} : { address: opts["--address"] }),
195
+ },
196
+ };
197
+ }
@@ -0,0 +1,22 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
+ /**
4
+ * What plugin-qualify's judgement declares once and writes into every record (and so into every judged layer): the
5
+ * latency line and the rules a record was judged by (openmixer plugin-system design 7.15, plugin-db records.md).
6
+ */
7
+ import { readFileSync } from "node:fs";
8
+
9
+ /** The latency line, in milliseconds: declared once, here, and written into every record and judged layer. */
10
+ export const LATENCY_LINE_MS = 5;
11
+
12
+ /** A measured latency in milliseconds at its rate, rounded to the microsecond. */
13
+ export const latencyMsOf = (frames: number, rate: number): number => Math.round((frames * 1e9) / rate) / 1e6;
14
+
15
+ const VERSION: string = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
16
+
17
+ /** The rules a plugin-qualify of this version judges by: `plugin-qualify-rules/<major>.<minor>`. */
18
+ export function judgementRules(version: string = VERSION): string {
19
+ const m = /^(\d+)\.(\d+)\.\d+/.exec(version);
20
+ if (m === null) throw new Error(`judgementRules: ${JSON.stringify(version)} is no version`);
21
+ return `plugin-qualify-rules/${m[1]}.${m[2]}`;
22
+ }
@@ -35,7 +35,7 @@
35
35
  import { join } from "node:path";
36
36
 
37
37
  import type { MeasurementDocument, MeasurementRun, MeasuredPlugin } from "./format.js";
38
- import { buildMeasurementDocument } from "./format.js";
38
+ import { buildMeasurementDocument, readIntegrity } from "./format.js";
39
39
  import { measuredPluginsFromProbeOutput } from "./probe-json.js";
40
40
  import { buildRun, costMethodFromProbeProvenance, hostFromProbeProvenance } from "./provenance.js";
41
41
  import type { MeasurementHost, LatencyMethod } from "./format.js";
@@ -237,10 +237,17 @@ export function buildPipeline(options: MeasureOptions, native?: HostingTools): P
237
237
  });
238
238
  }
239
239
 
240
+ // a run of the prober's dimensions alone takes the pin too: a plugin measured with no integrity block cannot be pinned
241
+ // from its figures (plugin-qualify#316), and the measurer names the files without instantiating anything
242
+ const pinned = !hosting && probed && native !== undefined && (options.plugins !== undefined || current !== undefined);
243
+ if (pinned && native !== undefined) {
244
+ steps.push({ id: "pin", command: native.tool, args: ["--pin"], perPlugin: true, produces: join(options.workDir, "pins.jsonl") });
245
+ }
246
+
240
247
  return {
241
248
  steps,
242
249
  ...(probed && current !== undefined ? { finalCatalog: current } : {}),
243
- ...(hosting && options.plugins === undefined && current !== undefined ? { hostingScan: current } : {}),
250
+ ...((hosting || pinned) && options.plugins === undefined && current !== undefined ? { hostingScan: current } : {}),
244
251
  ...(costProvenance === undefined ? {} : { costProvenance }),
245
252
  };
246
253
  }
@@ -599,6 +606,8 @@ export interface AssemblyInput {
599
606
  readonly catalog?: unknown;
600
607
  /** The hosting pass's lines ({@link hostingLine}), one per plugin. Absent when it did not run. */
601
608
  readonly hosting?: readonly unknown[];
609
+ /** The measurer's `--pin` lines ({@link hostingLine}), one per plugin, for a run with no hosting pass. Absent when the pin step did not run. */
610
+ readonly pins?: readonly unknown[];
602
611
  /** The cost pass's provenance JSON, already parsed. Absent on a latency-only run. */
603
612
  readonly costProvenance?: unknown;
604
613
  /** Host facts, for a run whose provenance the cost pass did not write. */
@@ -636,6 +645,7 @@ export function assembleDocument(input: AssemblyInput): MeasurementDocument {
636
645
  const plugins: MeasuredPlugin[] =
637
646
  probed.length > 0 && input.catalog !== undefined ? measuredPluginsFromProbeOutput(input.catalog, probed) : [];
638
647
  if (input.hosting !== undefined) mergeHosting(plugins, measuredPluginsFromSweep({ measurements: input.hosting }), input.options.dimensions);
648
+ if (input.pins !== undefined) mergePins(plugins, input.pins);
639
649
  const costMethod = costMethodFromProbeProvenance(input.costProvenance);
640
650
  const host = hostFromProbeProvenance(input.costProvenance) ?? input.host;
641
651
  const run: MeasurementRun = buildRun({
@@ -655,6 +665,20 @@ export function assembleDocument(input: AssemblyInput): MeasurementDocument {
655
665
  return buildMeasurementDocument(run, plugins);
656
666
  }
657
667
 
668
+ /** The pin lines folded into the prober's plugins, by URI: a plugin without one keeps none, and a line for a plugin not measured adds none. */
669
+ function mergePins(plugins: MeasuredPlugin[], rows: readonly unknown[]): void {
670
+ for (const row of rows) {
671
+ if (typeof row !== "object" || row === null) continue;
672
+ const r = row as Record<string, unknown>;
673
+ const integrity = readIntegrity(r["integrity"]);
674
+ const at = plugins.findIndex((p) => p.uri === r["uri"]);
675
+ if (integrity === undefined || at < 0) continue;
676
+ const p = plugins[at]!;
677
+ const version = typeof r["version"] === "string" && r["version"] !== "" ? r["version"] : undefined;
678
+ plugins[at] = { ...p, ...(p.integrity === undefined ? { integrity } : {}), ...(p.version === undefined && version !== undefined ? { version } : {}) };
679
+ }
680
+ }
681
+
658
682
  /**
659
683
  * The hosting pass's plugins folded into the prober's, by URI: each keeps the hosting dimensions the
660
684
  * run asked for and the pin, and a plugin only the hosting pass measured joins with its own topology.
@@ -303,6 +303,8 @@ export interface MemoryFootprint {
303
303
  /** Activated, after processing: what a long-running host carries. */
304
304
  readonly atRestKb?: number;
305
305
  readonly notesHeldKb?: number;
306
+ /** The notes held (voices sounding) when `notesHeldKb` was read, the count `basis` says where it came from. */
307
+ readonly notesHeld?: number;
306
308
  readonly secondInstanceKb?: number;
307
309
  readonly basis?: VoiceBasis;
308
310
  readonly unmeasuredReason?: string;
@@ -950,6 +952,11 @@ function readLatency(value: unknown): LatencyMeasurement | undefined {
950
952
  }
951
953
 
952
954
  /** A KB figure: finite and never negative. */
955
+ function notesCount(value: unknown): number | undefined {
956
+ const n = positiveNumber(value);
957
+ return n !== undefined && Number.isInteger(n) ? n : undefined;
958
+ }
959
+
953
960
  function kilobytes(value: unknown): number | undefined {
954
961
  const n = finiteNumber(value);
955
962
  return n !== undefined && n >= 0 ? n : undefined;
@@ -963,6 +970,7 @@ export function readMemoryFootprint(value: unknown): MemoryFootprint | undefined
963
970
  ...optional("afterActivateKb", kilobytes(value.afterActivateKb)),
964
971
  ...optional("atRestKb", kilobytes(value.atRestKb)),
965
972
  ...optional("notesHeldKb", kilobytes(value.notesHeldKb)),
973
+ ...optional("notesHeld", notesCount(value.notesHeld)),
966
974
  ...optional("secondInstanceKb", kilobytes(value.secondInstanceKb)),
967
975
  ...optional("basis", isVoiceBasis(value.basis) ? value.basis : undefined),
968
976
  ...reasonOf(value),
@@ -83,6 +83,10 @@ export function mergeProbeRuns(parts: readonly ProbeRunPart[], bound: MemoryBoun
83
83
  };
84
84
  merged["steps"] = steps;
85
85
  merged["grid"] = grid;
86
+ // every run's stderr is cited, not only the first's: the cell that crashed is as often a later one
87
+ const logs = docs.flatMap((d) => (Array.isArray(d["logs"]) ? (d["logs"] as Json[]) : []));
88
+ if (logs.length > 0) merged["logs"] = logs;
89
+ else delete merged["logs"];
86
90
  const bytes = Buffer.from(`${JSON.stringify(merged, null, 2)}\n`);
87
91
  const read = readProbeOutput(bytes.toString("utf8"));
88
92
  if (!read.ok) throw new ProbeMergeError(`the merged runs of ${first.output.plugin.id} do not read as a probe output (${read.code}): ${read.problem}`);
@@ -165,6 +165,9 @@ export interface ProbeLoad {
165
165
  export interface ProbedTarget {
166
166
  readonly bytes: Buffer;
167
167
  readonly output: ProbeOutput;
168
+ /** What each run's child printed on stderr (the file the document's `logs` cites, read before the run's directory goes),
169
+ * one entry a run that printed anything: a crash's own words are kept with the record (#samplv1, 459 bytes lost on the Pi). */
170
+ readonly stderr: readonly Buffer[];
168
171
  /** The most loaded the machine was when any of the target's runs started. */
169
172
  readonly load?: ProbeLoad;
170
173
  }
@@ -226,7 +229,14 @@ function runOne(target: PluginTarget, options: ProbeTargetOptions, invocation: R
226
229
  if (read.output.plugin.id !== expectedId) {
227
230
  throw new ProbeRunnerError(`${tool} measured ${read.output.plugin.id} when asked for ${expectedId}: not judged as the plugin asked for`);
228
231
  }
229
- return { bytes, output: read.output, load };
232
+ let stderr: Buffer[] = [];
233
+ try {
234
+ const said = readFileSync(log);
235
+ if (said.length > 0) stderr = [said];
236
+ } catch {
237
+ // the runner wrote no log: the document cites none
238
+ }
239
+ return { bytes, output: read.output, load, stderr };
230
240
  } finally {
231
241
  rmSync(work, { recursive: true, force: true });
232
242
  }
@@ -246,7 +256,7 @@ export function probeTarget(target: PluginTarget, options: ProbeTargetOptions):
246
256
  const loads = parts.flatMap((p) => (p.load === undefined ? [] : [p.load]));
247
257
  const load = loads.length === 0 ? undefined : loads.reduce((a, b) => (b.loadavg1 > a.loadavg1 ? b : a));
248
258
  try {
249
- return { ...mergeProbeRuns(parts, options.memory), ...(load === undefined ? {} : { load }) };
259
+ return { ...mergeProbeRuns(parts, options.memory), stderr: parts.flatMap((p) => p.stderr), ...(load === undefined ? {} : { load }) };
250
260
  } catch (e) {
251
261
  if (e instanceof ProbeMergeError) throw new ProbeRunnerError(e.message);
252
262
  throw e;
@@ -256,7 +266,7 @@ export function probeTarget(target: PluginTarget, options: ProbeTargetOptions):
256
266
  /** What the pass over a run's targets produced. */
257
267
  export interface ProbePass {
258
268
  /** Every probe output that was judged, with its bytes, for the qualification directory. */
259
- readonly runs: readonly { readonly bytes: Buffer; readonly judgment: Judgment }[];
269
+ readonly runs: readonly { readonly bytes: Buffer; readonly judgment: Judgment; readonly stderr: readonly Buffer[] }[];
260
270
  readonly verdicts: readonly QualifyVerdict[];
261
271
  /** The CLAP documents of today's judge, which the pins file is taken from. */
262
272
  readonly documents: readonly RtSafetyDocument[];
@@ -292,7 +302,7 @@ export function qualifyTargets(
292
302
  const admissionRuns = options.admissions ?? SPAWNED_ADMISSIONS;
293
303
  const runEnvironment = options.env ?? process.env;
294
304
  const admissionEnv = options.admissionLv2Path === undefined ? runEnvironment : { ...runEnvironment, LV2_PATH: options.admissionLv2Path.join(":") };
295
- const runs: { bytes: Buffer; judgment: Judgment }[] = [];
305
+ const runs: { bytes: Buffer; judgment: Judgment; stderr: readonly Buffer[] }[] = [];
296
306
  const verdicts: QualifyVerdict[] = [];
297
307
  const documents: RtSafetyDocument[] = [];
298
308
  const failures: { uri: string; problem: string }[] = [];
@@ -330,13 +340,13 @@ export function qualifyTargets(
330
340
  const judged = new Map<string, QualifyVerdict>();
331
341
  // a target's runs and documents count only when every one of its runs gave a probe output: one that
332
342
  // failed leaves the target not measured, and then nothing of it is recorded or pinned
333
- const targetRuns: { bytes: Buffer; judgment: Judgment }[] = [];
343
+ const targetRuns: { bytes: Buffer; judgment: Judgment; stderr: readonly Buffer[] }[] = [];
334
344
  try {
335
345
  for (const { offer, profiles: forOffer } of byOffer.values()) {
336
346
  const probed = probeTarget(target, { ...options, ...measured, offer });
337
347
  const plain = judgeProbeOutput(probed.output, forOffer, { admissions });
338
348
  const judgment = { ...plain, ...(adapter === undefined ? {} : { adapter }), ...(probed.load === undefined ? {} : { load: probed.load }) };
339
- targetRuns.push({ bytes: probed.bytes, judgment });
349
+ targetRuns.push({ bytes: probed.bytes, judgment, stderr: probed.stderr });
340
350
  for (const v of judgmentVerdicts(judgment)) judged.set(v.host, v);
341
351
  }
342
352
  } catch (e) {
@@ -20,11 +20,12 @@ import { join } from "node:path";
20
20
 
21
21
  import type { Adjustment } from "./history-adjust.js";
22
22
  import type { Judgment, RecordReason } from "./probe-judge.js";
23
- import { sha256Hex, PROBE_OK, type ProbeGridPoint, type ProbeOutput } from "./probe-output.js";
23
+ import { sha256Hex, PROBE_NOT_ATTEMPTED, PROBE_OK, type ProbeGridPoint, type ProbeOutput } from "./probe-output.js";
24
24
  import type { VoicesHeld } from "./measurement/format.js";
25
25
  import { writeFileWhole } from "./whole-file.js";
26
26
  import { HOSTING_CODE } from "./hosting-codes.js";
27
27
  import { MEASUREMENT_UNMEASURED_REASONS } from "@openmixer/omx-contract";
28
+ import { judgementRules, latencyMsOf, LATENCY_LINE_MS } from "./judgement-line.js";
28
29
 
29
30
  export const QUALIFICATION_RECORD_SCHEMA = "plugin-qualify.qualification-record/1";
30
31
 
@@ -201,6 +202,8 @@ export type RecordGridPoint = Omit<ProbeGridPoint, "latency" | "memory"> & {
201
202
  /** The footprint as measured, with what it comes to: what a client adding the plugin reads (`memory-budget.ts`). */
202
203
  readonly memory?: MemoryFootprint & { readonly firstInstanceKb?: number; readonly perInstanceKb?: number };
203
204
  readonly latency: (Omit<NonNullable<ProbeGridPoint["latency"]>, "measuredFrames"> & { readonly measuredFrames: number }) | UnmeasuredLatency;
205
+ /** The measured latency in milliseconds at the cell's rate, beside the record's `latencyLineMs`; absent when none was measured. */
206
+ readonly latencyMs?: number;
204
207
  };
205
208
 
206
209
  const UNMEASURED_WORDS: ReadonlySet<string> = new Set(Object.values(MEASUREMENT_UNMEASURED_REASONS).map((r) => r.word));
@@ -220,12 +223,20 @@ export function memoryOfCell(p: ProbeGridPoint): Pick<RecordGridPoint, "memory">
220
223
  return { memory: { ...p.memory, ...(first === undefined ? {} : { firstInstanceKb: first }), ...(per === undefined ? {} : { perInstanceKb: per }) } };
221
224
  }
222
225
 
226
+ /** A grid point as the record writes it: its footprint, its latency or why there is none, and the latency in milliseconds. */
227
+ export function gridPointOf(output: ProbeOutput, p: ProbeGridPoint): RecordGridPoint {
228
+ const latency = latencyOfCell(output, p);
229
+ const frames = (latency as { measuredFrames?: number }).measuredFrames;
230
+ return { ...p, ...memoryOfCell(p), latency, ...(frames === undefined ? {} : { latencyMs: latencyMsOf(frames, p.rate) }) };
231
+ }
232
+
223
233
  /** The unmeasured word of a cell whose run did not end well, by its outcome code. */
224
234
  const OUTCOME_WORD: Readonly<Record<string, string>> = {
225
235
  "probe.crashed": word("probeCrashed"),
226
236
  "probe.timeout": word("probeTimeout"),
227
237
  "probe.refused-instantiation": word("refusedInstantiation"),
228
238
  "probe.non-finite-output": word("nonFiniteOutput"),
239
+ [PROBE_NOT_ATTEMPTED]: word("notAttempted"),
229
240
  };
230
241
 
231
242
  /**
@@ -361,6 +372,10 @@ export interface QualificationRecord {
361
372
  /** Who measured and when. */
362
373
  readonly tool: Readonly<Record<string, string>>;
363
374
  readonly measuredAt: string;
375
+ /** The latency line the cells' `latencyMs` stand against (`LATENCY_LINE_MS`); absent on a record written before it was declared. */
376
+ readonly latencyLineMs?: number;
377
+ /** The rules the verdicts were judged by (`judgementRules`); absent on a record written before they were stated. */
378
+ readonly rules?: string;
364
379
  /** The second verdict, per host class of the history that was read; absent until `adjust --history` has run. */
365
380
  readonly history?: readonly HistoryBlock[];
366
381
  /** Where the record came from when it was not measured on this device: the distributed set's provenance, as the set's package wrote it. */
@@ -428,6 +443,8 @@ export function qualificationBaseName(output: ProbeOutput): string {
428
443
 
429
444
  export const recordFileName = (output: ProbeOutput): string => `${qualificationBaseName(output)}.qualification.json`;
430
445
  export const probeCopyFileName = (output: ProbeOutput): string => `${qualificationBaseName(output)}.probe.json`;
446
+ /** A child's stderr beside the record, named by the first 12 of the SHA-256 the probe copy's `logs` cites it by. */
447
+ export const stderrCopyFileName = (output: ProbeOutput, sha256: string): string => `${qualificationBaseName(output)}.stderr.${sha256.slice(0, 12)}.log`;
431
448
 
432
449
  function tierOf(judgment: Judgment, host: string | undefined): TierVerdict | undefined {
433
450
  const v = judgment.hosts.find((h) => h.verdict.host === host)?.verdict;
@@ -481,13 +498,15 @@ export function buildQualificationRecord(
481
498
  tiers: tiersOf(judgment),
482
499
  cost: judgment.hosts.map((h) => ({ host: h.verdict.host, ...h.cost })),
483
500
  measurements: {
484
- grid: o.grid.map((p) => ({ ...p, ...memoryOfCell(p), latency: latencyOfCell(o, p) })),
501
+ grid: o.grid.map((p) => gridPointOf(o, p)),
485
502
  ...(o.behaviour === undefined ? {} : { behaviour: o.behaviour }),
486
503
  ...(unmeasuredFigures(o).length === 0 ? {} : { unmeasured: unmeasuredFigures(o) }),
487
504
  },
488
505
  ...(notMeasured.length === 0 ? {} : { notMeasured }),
489
506
  tool: o.run.tools,
490
507
  measuredAt: o.run.startedAt,
508
+ latencyLineMs: LATENCY_LINE_MS,
509
+ rules: judgementRules(),
491
510
  probe: {
492
511
  file: copy.file,
493
512
  sha256: copy.sha256,
@@ -518,6 +537,8 @@ export interface WrittenQualification {
518
537
  readonly recordPath: string;
519
538
  readonly probePath: string;
520
539
  readonly record: QualificationRecord;
540
+ /** The children's stderr written beside the record, one file each run that printed anything. */
541
+ readonly stderrPaths: readonly string[];
521
542
  }
522
543
 
523
544
  /**
@@ -530,33 +551,46 @@ export function writeQualification(
530
551
  probeBytes: Uint8Array,
531
552
  judgment: Judgment,
532
553
  notMeasured: readonly NotMeasured[] = [],
554
+ stderr: readonly Uint8Array[] = [],
533
555
  ): WrittenQualification {
534
556
  mkdirSync(dir, { recursive: true });
557
+ // the children's own words first, as the probe copy cites them: a crash's reason is kept, not only its size
558
+ const stderrPaths = stderr
559
+ .filter((s) => s.length > 0)
560
+ .map((s) => {
561
+ const path = join(dir, stderrCopyFileName(judgment.output, sha256Hex(s)));
562
+ writeFileWhole(path, s);
563
+ return path;
564
+ });
535
565
  const probePath = join(dir, probeCopyFileName(judgment.output));
536
566
  writeFileWhole(probePath, probeBytes);
537
567
  const copy = readFileSync(probePath);
538
568
  const record = buildQualificationRecord(judgment, { file: probeCopyFileName(judgment.output), sha256: sha256Hex(copy), bytes: copy.length }, notMeasured);
539
569
  const recordPath = join(dir, recordFileName(judgment.output));
540
570
  writeFileWhole(recordPath, `${JSON.stringify(record, null, 2)}\n`);
541
- return { recordPath, probePath, record };
571
+ return { recordPath, probePath, record, stderrPaths };
542
572
  }
543
573
 
544
574
  /**
545
575
  * Runs that name the same binary and offer share one record file (a CLAP judged with and without the extra extensions
546
576
  * offered): their verdicts become one record over the first run's probe output, so the second is never written over the first.
547
577
  */
548
- export function mergeRunsByRecord<T extends { readonly bytes: Uint8Array; readonly judgment: Judgment }>(runs: readonly T[]): { bytes: Uint8Array; judgment: Judgment }[] {
549
- const byName = new Map<string, { bytes: Uint8Array; judgment: Judgment }>();
578
+ export function mergeRunsByRecord<T extends { readonly bytes: Uint8Array; readonly judgment: Judgment; readonly stderr?: readonly Uint8Array[] }>(
579
+ runs: readonly T[],
580
+ ): { bytes: Uint8Array; judgment: Judgment; stderr: readonly Uint8Array[] }[] {
581
+ // the stderr kept is the first run's, as its probe output is the copy that cites it
582
+ const byName = new Map<string, { bytes: Uint8Array; judgment: Judgment; stderr: readonly Uint8Array[] }>();
550
583
  for (const r of runs) {
551
584
  const name = qualificationBaseName(r.judgment.output);
552
585
  const first = byName.get(name);
553
586
  byName.set(
554
587
  name,
555
588
  first === undefined
556
- ? r
589
+ ? { bytes: r.bytes, judgment: r.judgment, stderr: r.stderr ?? [] }
557
590
  : {
558
591
  bytes: first.bytes,
559
592
  judgment: { ...first.judgment, hosts: [...first.judgment.hosts, ...r.judgment.hosts], documents: [...first.judgment.documents, ...r.judgment.documents] },
593
+ stderr: first.stderr,
560
594
  },
561
595
  );
562
596
  }