@openmixer/plugin-qualify 0.20.0 → 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 (48) hide show
  1. package/bin/plugin-qualify-records-package.mjs +9 -7
  2. package/bin/plugin-qualify.mjs +34 -0
  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/qualification-record.d.ts +8 -0
  22. package/dist/qualification-record.js +12 -2
  23. package/dist/qualification-record.js.map +1 -1
  24. package/dist/qualify-isolated.d.ts +5 -3
  25. package/dist/qualify-isolated.js +6 -4
  26. package/dist/qualify-isolated.js.map +1 -1
  27. package/dist/qualify-sync.d.ts +7 -2
  28. package/dist/qualify-sync.js +21 -11
  29. package/dist/qualify-sync.js.map +1 -1
  30. package/dist/records-package.d.ts +31 -4
  31. package/dist/records-package.js +149 -24
  32. package/dist/records-package.js.map +1 -1
  33. package/fixtures/records-rpi5-0.19.1/http___drumkv1.sourceforge.net_lv2@0.2-7d179bdcf9e5.default.probe.json +58 -0
  34. package/fixtures/records-rpi5-0.19.1/http___drumkv1.sourceforge.net_lv2@0.2-7d179bdcf9e5.default.qualification.json +305 -0
  35. package/fixtures/records-rpi5-0.19.1/http___samplv1.sourceforge.net_lv2@0.2-c9cd8fb3102f.default.probe.json +58 -0
  36. package/fixtures/records-rpi5-0.19.1/http___samplv1.sourceforge.net_lv2@0.2-c9cd8fb3102f.default.qualification.json +301 -0
  37. package/package.json +2 -2
  38. package/src/distributed-records.ts +41 -4
  39. package/src/index.ts +2 -0
  40. package/src/judged-layer.ts +197 -0
  41. package/src/judgement-line.ts +22 -0
  42. package/src/measurement/cli.ts +26 -2
  43. package/src/measurement/format.ts +8 -0
  44. package/src/qualification-record.ts +19 -2
  45. package/src/qualify-isolated.ts +6 -4
  46. package/src/qualify-sync.ts +22 -10
  47. package/src/records-package.ts +156 -25
  48. 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),
@@ -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. */
@@ -483,13 +498,15 @@ export function buildQualificationRecord(
483
498
  tiers: tiersOf(judgment),
484
499
  cost: judgment.hosts.map((h) => ({ host: h.verdict.host, ...h.cost })),
485
500
  measurements: {
486
- grid: o.grid.map((p) => ({ ...p, ...memoryOfCell(p), latency: latencyOfCell(o, p) })),
501
+ grid: o.grid.map((p) => gridPointOf(o, p)),
487
502
  ...(o.behaviour === undefined ? {} : { behaviour: o.behaviour }),
488
503
  ...(unmeasuredFigures(o).length === 0 ? {} : { unmeasured: unmeasuredFigures(o) }),
489
504
  },
490
505
  ...(notMeasured.length === 0 ? {} : { notMeasured }),
491
506
  tool: o.run.tools,
492
507
  measuredAt: o.run.startedAt,
508
+ latencyLineMs: LATENCY_LINE_MS,
509
+ rules: judgementRules(),
493
510
  probe: {
494
511
  file: copy.file,
495
512
  sha256: copy.sha256,
@@ -100,16 +100,18 @@ function figuresOf(run: IsolatedRunFacts): IsolatedFigures | undefined {
100
100
 
101
101
  /**
102
102
  * A verdict whose only gap is memory nobody measured: it reads `unknown` and every reason that is not `suitable` is
103
- * `hosting.memory.unmeasured`. Nothing says the plugin is unfit, so it is hosted in a worker of its own until its
104
- * footprint is measured, and the isolated step is what shows it running alone.
103
+ * `hosting.memory.unmeasured`, or a `conditional` one the isolated step answers (an RT violation seen in the run). Nothing
104
+ * says the plugin is unfit, so it is hosted in a worker of its own until its footprint is measured, and the isolated step
105
+ * is what shows it running alone. A conditional reason beside the memory gap owes the step as a conditional verdict does:
106
+ * whether one run saw a violation and another did not must not decide whether the step is run (#319).
105
107
  */
106
108
  export function memoryGapOnly(v: Pick<RecordVerdict, "measured" | "rating" | "reasons">): boolean {
107
109
  if (!v.measured || v.rating !== "unknown") return false;
108
110
  const open = v.reasons.filter((r) => r.rating !== "suitable");
109
- return open.length > 0 && open.every((r) => r.code === HOSTING_CODE.memoryUnmeasured);
111
+ return open.some((r) => r.code === HOSTING_CODE.memoryUnmeasured) && open.every((r) => r.code === HOSTING_CODE.memoryUnmeasured || r.rating === "conditional");
110
112
  }
111
113
 
112
- /** Whether a record's outcome calls for the isolated step: rated `conditional` by some host, a memory gap and nothing else, or a probe that crashed or hung. */
114
+ /** Whether a record's outcome calls for the isolated step: rated `conditional` by some host, a memory gap and nothing else unanswered, or a probe that crashed or hung. */
113
115
  export function owesIsolated(record: QualificationRecord): boolean {
114
116
  return PROBE_FAILED_HARD.has(record.probe.outcome) || record.verdicts.some((v) => v.measured && (v.rating === "conditional" || memoryGapOnly(v)));
115
117
  }
@@ -24,7 +24,7 @@ import { homedir, tmpdir } from "node:os";
24
24
  import { basename, dirname, join, resolve, sep } from "node:path";
25
25
 
26
26
  import { clapQualifyTools } from "./clap-qualify.js";
27
- import { DISTRIBUTED_DIR_SYSTEM, EMPTY_LAYER, applyDistributed, distributedUri, hostClassOfBoard, outcomeOfVerdict, matchDistributed, readBoardModel, readDistributedLayer, type DistributedLayer } from "./distributed-records.js";
27
+ import { DISTRIBUTED_DIR_SYSTEM, EMPTY_LAYER, applyDistributed, distributedUri, hostClassOfBoard, hostClassOfMachine, outcomeOfVerdict, matchDistributed, readBoardModel, readMachine, readDistributedLayer, type DistributedLayer } from "./distributed-records.js";
28
28
  import { clapSearchPath, parseClapScan } from "./clap-loader.js";
29
29
  import { HOSTING_CODE } from "./hosting-codes.js";
30
30
  import { shippedHostProfile, type HostProfile } from "./host-profiles.js";
@@ -34,7 +34,7 @@ import { POOL_RESULT, completePool, judgePoolRun, pinAfterPool, pinFromRecordWit
34
34
  import { hostdPinFiles, type PinFilesRun } from "./hostd-pin-files.js";
35
35
  import type { Judgment } from "./probe-judge.js";
36
36
  import { qualifyTargets, type ProbeRun } from "./probe-runner.js";
37
- import { GRID_FULL, parseGridSpec, type GridSpec } from "./measurement-grid.js";
37
+ import { GRID_FULL, hostCells, parseGridSpec, type GridCell, type GridSpec } from "./measurement-grid.js";
38
38
  import { adapterPass, type AdapterPassOptions, type AdapterPassResult } from "./qualify-adapter-pass.js";
39
39
  import { ISOLATED_RESULT, baseSteps, completeIsolated, isolatedCell, missingSteps, judgeIsolatedRun, owesIsolated, pinAfterStep, spawnedIsolatedRunner, type IsolatedRunner } from "./qualify-isolated.js";
40
40
  import { lv2World, rejudgeRecord, rejudgeState, rejudgeToolName, memoryPass, type MemoryPassResult, type RejudgeResult } from "./qualify-rejudge.js";
@@ -228,6 +228,7 @@ export function resolveSyncSettings(
228
228
  env: Readonly<Record<string, string | undefined>>,
229
229
  home: string = homedir(),
230
230
  boardModel: () => string | undefined = readBoardModel,
231
+ machineArch: () => string | undefined = readMachine,
231
232
  ): { ok: true; settings: SyncSettings } | { ok: false; error: string } {
232
233
  const get = (key: string): string | undefined => args.values.get(key) ?? file.get(key);
233
234
  const state = env["XDG_STATE_HOME"] !== undefined && env["XDG_STATE_HOME"] !== "" ? env["XDG_STATE_HOME"] : join(home, ".local", "state");
@@ -276,7 +277,7 @@ export function resolveSyncSettings(
276
277
  ? [DISTRIBUTED_DIR_SYSTEM]
277
278
  : [dataHome, ...dataDirs].map((d) => join(d, "omx", "qualification"));
278
279
  const classNamed = get("host_class");
279
- const hostClass = classNamed !== undefined && classNamed !== "" ? classNamed : hostClassOfBoard(boardModel());
280
+ const hostClass = classNamed !== undefined && classNamed !== "" ? classNamed : hostClassOfBoard(boardModel()) ?? hostClassOfMachine(machineArch());
280
281
  if (hostClass !== undefined && !/^[A-Za-z0-9][A-Za-z0-9._+-]*$/.test(hostClass)) return { ok: false, error: `host_class ${hostClass} is not a directory name` };
281
282
  return {
282
283
  ok: true,
@@ -681,7 +682,10 @@ export function refreshPins(ctx: SyncContext, state: SyncState): { state: SyncSt
681
682
  const pin = plugin.pin;
682
683
  if (pin === undefined || pin.integrity.files === undefined) return plugin;
683
684
  const r = hostdPinFiles(pin.uri, ctx.settings.lv2Dirs, ctx.env, ...(ctx.pinFilesRun === undefined ? [] : [ctx.pinFilesRun]));
684
- if (!r.ok) return plugin;
685
+ if (!r.ok) {
686
+ ctx.log(`${pin.uri}: pin files not refreshed: ${r.detail}`);
687
+ return plugin;
688
+ }
685
689
  const old = new Map(pin.integrity.files.map((f) => [f.path, f.sha256]));
686
690
  const changed = r.files.filter((f) => old.has(f.path) && old.get(f.path) !== f.sha256).map((f) => f.path);
687
691
  const same = r.files.length === old.size && r.files.every((f) => old.get(f.path) === f.sha256);
@@ -931,7 +935,9 @@ export function isolatedPass(ctx: SyncContext, state: SyncState): { readonly sta
931
935
  return rest.outcome === "qualified" && rebuilt.verdict === "refused" ? { ...rest, outcome: "refused" as const, pin: rebuilt } : { ...rest, pin: rebuilt };
932
936
  }
933
937
  ctx.log(`${p.uri}: isolated step ${done.step.result}${done.step.code === undefined ? "" : ` (${done.step.code})`}`);
934
- const pin = pinAfterStep(p.pin, done.step);
938
+ // a plugin the state holds no pin for is pinned from the record the step now completes: the bytes it names and the word
939
+ // the step earned (plugin-qualify#316: 38 plugins read hosting.memory.unmeasured and had none)
940
+ const pin = p.pin !== undefined ? pinAfterStep(p.pin, done.step) : withHostdFiles(ctx, pinFromRecordWithPool(done.record));
935
941
  const { pin: _pin, code: _code, ...rest } = p;
936
942
  if (done.step.result === ISOLATED_RESULT.isolated) {
937
943
  count.isolated++;
@@ -985,6 +991,14 @@ export function poolPass(ctx: SyncContext, state: SyncState): { readonly state:
985
991
 
986
992
  // --- the distributed records ----------------------------------------------------------------------------------------
987
993
 
994
+ /**
995
+ * The cells a sync runs at, which a distributed set must hold to apply (#317): the grid's own cells when it names them
996
+ * (`grid 48000/512`), else the cells the host profile judges cost and memory at.
997
+ */
998
+ export function syncCells(settings: Pick<SyncSettings, "grid" | "hostProfile">): GridCell[] {
999
+ return settings.grid.kind === "cells" ? [...settings.grid.cells] : hostCells(settings.hostProfile);
1000
+ }
1001
+
988
1002
  /** The layer a run reads: none under `--force` or `--no-distributed`, or without a host class. */
989
1003
  function distributedLayerOf(settings: SyncSettings, force: boolean, log: (line: string) => void): DistributedLayer {
990
1004
  if (force || !settings.distributed) return EMPTY_LAYER;
@@ -1007,12 +1021,10 @@ function takeDistributed(ctx: SyncContext, layer: DistributedLayer, item: SyncIt
1007
1021
  if (layer.records === 0) return undefined;
1008
1022
  const started = Date.now();
1009
1023
  const host = ctx.settings.hostProfile.id;
1010
- const match = matchDistributed(layer, item, host, (paths) => digestFiles(item.root, paths, files));
1024
+ const match = matchDistributed(layer, item, host, (paths) => digestFiles(item.root, paths, files), syncCells(ctx.settings));
1011
1025
  if (match.status === "none") return undefined;
1012
- if (match.status === "ignored") {
1013
- for (const line of match.lines) ctx.log(line);
1014
- return undefined;
1015
- }
1026
+ for (const line of match.lines) ctx.log(line);
1027
+ if (match.status === "ignored") return undefined;
1016
1028
  for (const r of match.records) {
1017
1029
  const missing = missingSteps(r.record);
1018
1030
  if (missing.length > 0) {