@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
@@ -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);
@@ -735,10 +739,10 @@ function writeUnmeasuredRecord(ctx: SyncContext, item: SyncItem, files: readonly
735
739
  const isolatedEnv = (ctx: SyncContext): Readonly<Record<string, string | undefined>> => ({ ...ctx.env, [ADMIT_STEP_MS_ENV]: String(ctx.settings.budgetS * 1000) });
736
740
 
737
741
  /** Each judged run of a pass as the plugins it qualified: the record written, the pin taken, the outcome. */
738
- function judgedPlugins(ctx: SyncContext, runs: readonly { bytes: Buffer; judgment: Judgment }[], describeProblem?: string): SyncPlugin[] {
742
+ function judgedPlugins(ctx: SyncContext, runs: readonly { bytes: Buffer; judgment: Judgment; stderr?: readonly Buffer[] }[], describeProblem?: string): SyncPlugin[] {
739
743
  const host = ctx.settings.hostProfile.id;
740
- const entries = runs.map(({ bytes, judgment }) => {
741
- const { recordPath, record } = writeQualification(ctx.settings.qualificationDir, bytes, judgment, QUICK_NOT_MEASURED);
744
+ const entries = runs.map(({ bytes, judgment, stderr }) => {
745
+ const { recordPath, record } = writeQualification(ctx.settings.qualificationDir, bytes, judgment, QUICK_NOT_MEASURED, stderr);
742
746
  const steps = baseSteps(record, describeProblem);
743
747
  let pin = withHostdFiles(ctx, judgmentPinEntries(judgment, host)[0]);
744
748
  let { outcome, code } = outcomeOf(judgment, host);
@@ -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) {
@@ -1102,9 +1114,9 @@ export function removedPinUris(before: string, after: string): string[] {
1102
1114
  return [...pinLinesOf(before).keys()].filter((uri) => !now.has(uri));
1103
1115
  }
1104
1116
 
1105
- /** `<id>@<version>-<binary sha first 12>`, the stem a qualification record, its probe copy and its descriptor share. */
1117
+ /** `<id>@<version>-<binary sha first 12>`, the stem a qualification record, its probe copy, its children's stderr and its descriptor share. */
1106
1118
  export function recordStem(recordPath: string): string | undefined {
1107
- return /^(.*-[0-9a-f]{12})\.(?:[^.]+\.(?:qualification|probe)|unmeasured)\.json$/.exec(basename(recordPath))?.[1];
1119
+ return /^(.*-[0-9a-f]{12})\.(?:(?:[^.]+\.(?:qualification|probe)|unmeasured)\.json|[^.]+\.stderr\.[0-9a-f]{12}\.log)$/.exec(basename(recordPath))?.[1];
1108
1120
  }
1109
1121
 
1110
1122
  /**
@@ -4,10 +4,13 @@
4
4
  * `plugin-qualify-records-package`: the Debian package of a device's qualification records, so other devices of the
5
5
  * machine class install them as the distributed layer instead of measuring the same plugin set again.
6
6
  *
7
- * In: a qualification directory as plugin-qualify-sync leaves it. Out: `plugin-db-qualification-<class>-<set>_<version>_all.deb`,
8
- * a package of plugin-db's qualification layer,
9
- * installing every record and the probe output it judged under `/usr/share/omx/qualification/<class>/<set>/`, beside
10
- * the set's `provenance.json`. A record goes in only with its probe output, byte for byte the copy the record names;
7
+ * A record set is per machine class, rate and quantum (#317). In: the qualification directories of a run, one per
8
+ * cell it was measured at, as plugin-qualify-sync leaves them, or the run's root holding `b<rate>-<quantum>/qualification`.
9
+ * Out: one `plugin-db-qualification-<class>-<set>-<rate>-<quantum>_<version>_all.deb` per cell, a package of plugin-db's
10
+ * qualification layer, installing every record of that cell and the probe output it judged under
11
+ * `/usr/share/omx/qualification/<class>/<set>-<rate>-<quantum>/`, beside the set's `provenance.json`, which names the
12
+ * cell. A directory's cell is the one its base is named for (`b48000-512`), else `--cell`, else the one cell every
13
+ * record's grid holds; a record whose grid does not hold its set's cell stays out, named. A record goes in only with its probe output, byte for byte the copy the record names;
11
14
  * an unmeasured record never applies and stays out. The archive is written here, `ar` and two `tar.xz` members, so a
12
15
  * machine without dpkg builds it too; the tar members are GNU tar's, owned by root, in name order, at one time.
13
16
  */
@@ -15,14 +18,18 @@ import { createHash } from "node:crypto";
15
18
  import { spawnSync } from "node:child_process";
16
19
  import { closeSync, copyFileSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
17
20
  import { tmpdir } from "node:os";
18
- import { join } from "node:path";
21
+ import { basename, dirname, join, resolve } from "node:path";
19
22
 
20
- import { DISTRIBUTED_DIR_SYSTEM, DISTRIBUTED_PROVENANCE_FILE, DISTRIBUTED_PROVENANCE_SCHEMA, type DistributedProvenance } from "./distributed-records.js";
23
+ import { cellKey, type GridCell } from "./measurement-grid.js";
24
+ import { DISTRIBUTED_DIR_SYSTEM, recordCells, DISTRIBUTED_PROVENANCE_FILE, DISTRIBUTED_PROVENANCE_SCHEMA, type DistributedProvenance } from "./distributed-records.js";
21
25
  import { sha256Hex } from "./probe-output.js";
22
26
  import { QUALIFICATION_RECORD_SCHEMA } from "./qualification-record.js";
23
27
 
24
28
  export interface RecordsPackageArgs {
25
- readonly stateDir: string;
29
+ /** Qualification directories, or run roots holding `b<rate>-<quantum>/qualification`: one set per cell. */
30
+ readonly sources: readonly string[];
31
+ /** The cell of a lone directory whose name says none. */
32
+ readonly cell?: GridCell;
26
33
  readonly hostClass: string;
27
34
  readonly set: string;
28
35
  /** A JSON file, or the JSON itself. */
@@ -44,18 +51,22 @@ export function recordsPackageHelp(): string {
44
51
  return [
45
52
  "plugin-qualify-records-package: the Debian package of a device's qualification records, for the distributed layer",
46
53
  "",
47
- " plugin-qualify-records-package <qualification dir> --host-class <class> --set <set> --provenance <json> [options]",
54
+ " plugin-qualify-records-package <qualification dir|run root>... --host-class <class> --set <set> --provenance <json> [options]",
55
+ "",
56
+ " one package per cell (rate and quantum): each qualification directory is a cell, named by its base",
57
+ " (<run>/b48000-512/qualification); a run root stands for every b<rate>-<quantum>/qualification under it",
48
58
  "",
49
59
  " --host-class <class> the machine class the records were measured on (rpi5): the directory they install in",
50
- " --set <set> the plugin set they cover (zynthian-vangelis)",
60
+ " --set <set> the plugin set they cover (zynthian-vangelis); each package's set is <set>-<rate>-<quantum>",
61
+ " --cell <rate>/<quantum> the cell of a lone directory not named b<rate>-<quantum> whose records hold several",
51
62
  " --provenance <json> a JSON file, or the JSON itself: the image, the package versions, the source revisions",
52
63
  " --version <version> the package's version (default: today, 2026.10.29)",
53
64
  " --out <dir> where the .deb is written (default: .)",
54
65
  " --maintainer <who> the package's Maintainer field",
55
66
  " --help this text",
56
67
  "",
57
- `the package installs ${DISTRIBUTED_DIR_SYSTEM}/<class>/<set>/ and depends on this plugin-qualify's minor, up to the next one`,
58
- "exit 0 the package was written, 2 it could not be",
68
+ `each package installs ${DISTRIBUTED_DIR_SYSTEM}/<class>/<set>-<rate>-<quantum>/ and depends on this plugin-qualify's minor, up to the next one`,
69
+ "exit 0 every package was written, 2 one could not be",
59
70
  "",
60
71
  ].join("\n");
61
72
  }
@@ -63,7 +74,7 @@ export function recordsPackageHelp(): string {
63
74
  export function parseRecordsPackageArgs(argv: readonly string[]): { ok: true; args: RecordsPackageArgs } | { ok: true; help: true } | { ok: false; error: string } {
64
75
  const values = new Map<string, string>();
65
76
  const positional: string[] = [];
66
- const valued = new Set(["--host-class", "--set", "--provenance", "--version", "--out", "--maintainer"]);
77
+ const valued = new Set(["--host-class", "--set", "--cell", "--provenance", "--version", "--out", "--maintainer"]);
67
78
  for (let i = 0; i < argv.length; i++) {
68
79
  const a = argv[i]!;
69
80
  if (a === "--help") return { ok: true, help: true };
@@ -74,19 +85,24 @@ export function parseRecordsPackageArgs(argv: readonly string[]): { ok: true; ar
74
85
  } else if (a.startsWith("--")) return { ok: false, error: `unknown argument ${a}` };
75
86
  else positional.push(a);
76
87
  }
77
- if (positional.length !== 1) return { ok: false, error: "name one qualification directory" };
88
+ if (positional.length === 0) return { ok: false, error: "name a qualification directory or a run root" };
78
89
  for (const flag of ["--host-class", "--set", "--provenance"]) if (!values.has(flag)) return { ok: false, error: `${flag} is needed` };
79
90
  const hostClass = values.get("--host-class")!;
80
91
  const set = values.get("--set")!;
81
92
  for (const [what, v] of [["--host-class", hostClass], ["--set", set]] as const) {
82
93
  if (!DEB_WORD.test(v)) return { ok: false, error: `${what} ${v}: a package name holds lowercase letters, digits, + - and . only` };
83
94
  }
95
+ const cellText = values.get("--cell");
96
+ const cell = cellText === undefined ? undefined : parseCell(cellText);
97
+ if (cellText !== undefined && cell === undefined) return { ok: false, error: `--cell ${cellText} is not <rate>/<quantum>` };
98
+ if (cell !== undefined && positional.length !== 1) return { ok: false, error: "--cell names the cell of one directory; name one" };
84
99
  const version = values.get("--version");
85
100
  if (version !== undefined && !DEB_VERSION.test(version)) return { ok: false, error: `--version ${version} is not a Debian version` };
86
101
  return {
87
102
  ok: true,
88
103
  args: {
89
- stateDir: positional[0]!,
104
+ sources: positional,
105
+ ...(cell === undefined ? {} : { cell }),
90
106
  hostClass,
91
107
  set,
92
108
  provenance: values.get("--provenance")!,
@@ -97,6 +113,68 @@ export function parseRecordsPackageArgs(argv: readonly string[]): { ok: true; ar
97
113
  };
98
114
  }
99
115
 
116
+ function parseCell(text: string): GridCell | undefined {
117
+ const m = /^(\d+)\/(\d+)$/.exec(text.trim());
118
+ if (m === null) return undefined;
119
+ const [rate, quantum] = [Number(m[1]), Number(m[2])];
120
+ return rate > 0 && quantum > 0 && Number.isSafeInteger(rate) && Number.isSafeInteger(quantum) ? { rate, quantum } : undefined;
121
+ }
122
+
123
+ /** A run's base directory names its cell: `b48000-512`. */
124
+ const BASE_NAME = /^b(\d+)-(\d+)$/;
125
+
126
+ /** The set of a cell: `<set>-<rate>-<quantum>`. */
127
+ export const recordsSetName = (set: string, cell: GridCell): string => `${set}-${String(cell.rate)}-${String(cell.quantum)}`;
128
+
129
+ /** One qualification directory of a run and the cell its name says, when it says one. */
130
+ export interface RecordsSource {
131
+ readonly dir: string;
132
+ readonly named?: GridCell;
133
+ }
134
+
135
+ /**
136
+ * The qualification directories the arguments stand for, in name order: a directory holding records is one; a run
137
+ * root stands for every `b<rate>-<quantum>/qualification` (or `b<rate>-<quantum>` holding records) under it.
138
+ */
139
+ export function recordsSources(args: readonly string[]): RecordsSource[] {
140
+ const holdsRecords = (d: string): boolean => {
141
+ try {
142
+ return readdirSync(d).some((n) => n.endsWith(".qualification.json") || n.endsWith(".unmeasured.json"));
143
+ } catch {
144
+ return false;
145
+ }
146
+ };
147
+ const namedBy = (d: string): GridCell | undefined => {
148
+ for (const part of [basename(d), basename(dirname(d))]) {
149
+ const m = BASE_NAME.exec(part);
150
+ if (m !== null) return { rate: Number(m[1]), quantum: Number(m[2]) };
151
+ }
152
+ return undefined;
153
+ };
154
+ const out: RecordsSource[] = [];
155
+ for (const a of args) {
156
+ const dir = resolve(a);
157
+ statSync(dir);
158
+ if (holdsRecords(dir)) {
159
+ const named = namedBy(dir);
160
+ out.push({ dir, ...(named === undefined ? {} : { named }) });
161
+ continue;
162
+ }
163
+ const bases = readdirSync(dir).filter((n) => BASE_NAME.test(n)).sort();
164
+ let found = 0;
165
+ for (const b of bases) {
166
+ for (const d of [join(dir, b, "qualification"), join(dir, b)]) {
167
+ if (!holdsRecords(d)) continue;
168
+ out.push({ dir: d, named: namedBy(d)! });
169
+ found++;
170
+ break;
171
+ }
172
+ }
173
+ if (found === 0) throw new Error(`${dir} holds no record and no b<rate>-<quantum> base with records`);
174
+ }
175
+ return out;
176
+ }
177
+
100
178
  /** The package of a class and a set. */
101
179
  export const recordsPackageName = (hostClass: string, set: string): string => `plugin-db-qualification-${hostClass}-${set}`;
102
180
 
@@ -122,7 +200,7 @@ export function today(now = new Date()): { version: string; date: string } {
122
200
  }
123
201
 
124
202
  /** The files of a qualification directory that go in the package: each record with its probe output, and what stays out, by name and why. */
125
- export function collectRecords(stateDir: string): { records: string[]; files: string[]; left: { name: string; why: string }[] } {
203
+ export function collectRecords(stateDir: string, cell?: GridCell): { records: string[]; files: string[]; left: { name: string; why: string }[] } {
126
204
  const names = readdirSync(stateDir).sort();
127
205
  const records: string[] = [];
128
206
  const files: string[] = [];
@@ -133,7 +211,7 @@ export function collectRecords(stateDir: string): { records: string[]; files: st
133
211
  continue;
134
212
  }
135
213
  if (!name.endsWith(".qualification.json")) continue;
136
- let record: { schema?: unknown; probe?: { file?: unknown; sha256?: unknown } };
214
+ let record: { schema?: unknown; probe?: { file?: unknown; sha256?: unknown }; measurements?: { grid?: readonly GridCell[] } };
137
215
  try {
138
216
  record = JSON.parse(readFileSync(join(stateDir, name), "utf8"));
139
217
  } catch {
@@ -144,6 +222,11 @@ export function collectRecords(stateDir: string): { records: string[]; files: st
144
222
  left.push({ name, why: `not a ${QUALIFICATION_RECORD_SCHEMA} naming its probe output` });
145
223
  continue;
146
224
  }
225
+ const cells = gridCellsOf(record);
226
+ if (cell !== undefined && !cells.includes(cellKey(cell))) {
227
+ left.push({ name, why: `measured at ${cells.length === 0 ? "no cell" : cells.join(", ")}, not at its set's ${cellKey(cell)}` });
228
+ continue;
229
+ }
147
230
  let probe: Buffer;
148
231
  try {
149
232
  probe = readFileSync(join(stateDir, record.probe.file));
@@ -161,6 +244,29 @@ export function collectRecords(stateDir: string): { records: string[]; files: st
161
244
  return { records, files: [...new Set(files)].sort(), left };
162
245
  }
163
246
 
247
+ /** The cells of a record's grid, `rate/quantum`, by rate then quantum: `recordCells`, keyed. */
248
+ const gridCellsOf = (record: { measurements?: { grid?: readonly GridCell[] } }): string[] =>
249
+ recordCells({ measurements: { grid: Array.isArray(record.measurements?.grid) ? record.measurements.grid : [] } } as unknown as Parameters<typeof recordCells>[0]).map(cellKey);
250
+
251
+ /**
252
+ * The cell of a source: its base's name, else the one given, else the one cell every record of it holds alone.
253
+ * Throws, naming the cells, when none decides it.
254
+ */
255
+ export function cellOfSource(source: RecordsSource, given?: GridCell): GridCell {
256
+ if (source.named !== undefined) return source.named;
257
+ if (given !== undefined) return given;
258
+ const seen = new Set<string>();
259
+ for (const name of readdirSync(source.dir).filter((n) => n.endsWith(".qualification.json"))) {
260
+ try {
261
+ for (const c of gridCellsOf(JSON.parse(readFileSync(join(source.dir, name), "utf8")))) seen.add(c);
262
+ } catch {
263
+ // a record that does not read is named by the collection, and decides no cell
264
+ }
265
+ }
266
+ if (seen.size === 1) return parseCell([...seen][0]!)!;
267
+ throw new Error(`${source.dir}: its records are measured at ${seen.size === 0 ? "no cell" : [...seen].join(", ")}; a set is one cell: name the base b<rate>-<quantum> or give --cell`);
268
+ }
269
+
164
270
  /** The provenance argument read: a file's JSON, or the JSON itself. */
165
271
  function readProvenanceArg(arg: string): Record<string, unknown> {
166
272
  const text = arg.trimStart().startsWith("{") ? arg : readFileSync(arg, "utf8");
@@ -203,6 +309,9 @@ function tarXz(tree: string, out: string, mtime: number): void {
203
309
 
204
310
  export interface BuiltRecordsPackage {
205
311
  readonly path: string;
312
+ /** The set, `<set>-<rate>-<quantum>`, and its cell. */
313
+ readonly set: string;
314
+ readonly cell: GridCell;
206
315
  readonly name: string;
207
316
  readonly version: string;
208
317
  readonly records: number;
@@ -211,28 +320,49 @@ export interface BuiltRecordsPackage {
211
320
  readonly left: readonly { name: string; why: string }[];
212
321
  }
213
322
 
214
- /** The package of `args`, written into `args.out`. Throws, naming why, when it cannot be built. */
215
- export function buildRecordsPackage(args: RecordsPackageArgs, now = new Date()): BuiltRecordsPackage {
216
- const name = recordsPackageName(args.hostClass, args.set);
323
+ /**
324
+ * One package per cell of `args.sources`, written into `args.out`, in the order of the cells. Every source's cell is
325
+ * decided and no two may share one before any package is written; throws, naming why, when one cannot be built.
326
+ */
327
+ export function buildRecordsPackages(args: RecordsPackageArgs, now = new Date()): BuiltRecordsPackage[] {
328
+ const sources = recordsSources(args.sources);
329
+ if (args.cell !== undefined && sources.length !== 1) throw new Error("--cell names the cell of one directory, and these are several");
330
+ const cells = sources.map((s) => ({ source: s, cell: cellOfSource(s, args.cell) }));
331
+ const seen = new Map<string, string>();
332
+ for (const { source, cell } of cells) {
333
+ const other = seen.get(cellKey(cell));
334
+ if (other !== undefined) throw new Error(`${source.dir} and ${other} are both measured at ${cellKey(cell)}: one set per cell`);
335
+ seen.set(cellKey(cell), source.dir);
336
+ }
337
+ return cells
338
+ .sort((a, b) => a.cell.rate - b.cell.rate || a.cell.quantum - b.cell.quantum)
339
+ .map(({ source, cell }) => buildRecordsPackage({ ...args, stateDir: source.dir, cell }, now));
340
+ }
341
+
342
+ /** The package of one cell's directory, written into `args.out`. Throws, naming why, when it cannot be built. */
343
+ function buildRecordsPackage(args: RecordsPackageArgs & { readonly stateDir: string; readonly cell: GridCell }, now: Date): BuiltRecordsPackage {
344
+ const set = recordsSetName(args.set, args.cell);
345
+ const name = recordsPackageName(args.hostClass, set);
217
346
  const range = recordsDependsRange(ownVersion());
218
347
  if (range === undefined) throw new Error("this plugin-qualify names no version, so the package cannot say which one reads it");
219
348
  const day = today(now);
220
349
  const version = args.version ?? day.version;
221
350
  statSync(args.stateDir);
222
- const { records, files, left } = collectRecords(args.stateDir);
351
+ const { records, files, left } = collectRecords(args.stateDir, args.cell);
223
352
  if (records.length === 0) throw new Error(`${args.stateDir} holds no record that can go in the package`);
224
353
  const provenance: DistributedProvenance = {
225
354
  ...readProvenanceArg(args.provenance),
226
355
  schema: DISTRIBUTED_PROVENANCE_SCHEMA,
227
- set: args.set,
356
+ set,
228
357
  hostClass: args.hostClass,
358
+ cell: { rate: args.cell.rate, quantum: args.cell.quantum },
229
359
  package: { name, version },
230
360
  date: day.date,
231
361
  };
232
362
  const work = mkdtempSync(join(tmpdir(), "records-package-"));
233
363
  try {
234
364
  const data = join(work, "data");
235
- const rel = join("usr", "share", "omx", "qualification", args.hostClass, args.set);
365
+ const rel = join("usr", "share", "omx", "qualification", args.hostClass, set);
236
366
  const dir = join(data, rel);
237
367
  mkdirSync(dir, { recursive: true });
238
368
  for (const f of files) copyFileSync(join(args.stateDir, f), join(dir, f));
@@ -259,9 +389,10 @@ export function buildRecordsPackage(args: RecordsPackageArgs, now = new Date()):
259
389
  "Section: sound",
260
390
  "Priority: optional",
261
391
  "Multi-Arch: foreign",
262
- `Description: plugin-db qualification layer: ${args.set} set for ${args.hostClass}`,
392
+ `Description: plugin-db qualification layer: ${args.set} set for ${args.hostClass} at ${String(args.cell.rate)} Hz, ${String(args.cell.quantum)} frames`,
263
393
  ` The qualification layer of the plugin database: the plugin-qualify records`,
264
- ` of the ${args.set} plugin set, measured on a ${args.hostClass} device. plugin-qualify-sync takes a plugin from them instead of`,
394
+ ` of the ${args.set} plugin set, measured on a ${args.hostClass} device at ${String(args.cell.rate)} Hz`,
395
+ ` and a quantum of ${String(args.cell.quantum)} frames. plugin-qualify-sync takes a plugin from them instead of`,
265
396
  " measuring it, and only when every file of the installed plugin hashes as the",
266
397
  " measured one did; any other plugin is measured on the device as before.",
267
398
  "",
@@ -282,7 +413,7 @@ export function buildRecordsPackage(args: RecordsPackageArgs, now = new Date()):
282
413
  ],
283
414
  mtime,
284
415
  );
285
- return { path, name, version, records: records.length, files: installed.length, left };
416
+ return { path, set, cell: args.cell, name, version, records: records.length, files: installed.length, left };
286
417
  } finally {
287
418
  rmSync(work, { recursive: true, force: true });
288
419
  }
@@ -50,6 +50,7 @@ import {
50
50
  buildPipeline,
51
51
  collectHostFacts,
52
52
  HOSTING_PASS_DIMENSIONS,
53
+ PROBED_DIMENSIONS,
53
54
  hostingLine,
54
55
  INITIAL_PROGRESS,
55
56
  parseMeasureArgs,
@@ -131,6 +132,20 @@ function hostingTools() {
131
132
  return { tool, interposer };
132
133
  }
133
134
 
135
+ /**
136
+ * The native measurer for the pin step of a run with no hosting pass: absent, the run still measures and says on stderr
137
+ * that its plugins carry no integrity block (it does not need the interposer, so a half-populated pair is not refused here).
138
+ */
139
+ function pinTools() {
140
+ const { tools } = locateNativeTools([LV2_MEASURE_TOOL_NAME, RT_INTERPOSE_NAME], process.env, existsSync);
141
+ const [tool, interposer] = tools;
142
+ if (tool?.path === undefined) {
143
+ process.stderr.write(`${TOOL_NAME}: no ${LV2_MEASURE_TOOL_NAME} at ${tool?.looked.join(", ") ?? "its places"}: the plugins are written with no integrity block (build it with make -C native or set ${tool?.setting})\n`);
144
+ return undefined;
145
+ }
146
+ return { tool: tool.path, interposer: interposer?.path ?? "" };
147
+ }
148
+
134
149
  /** The hosting pass: the measurer once per plugin, each in its own process, its lines written as JSONL. */
135
150
  function runHosting(step, uris, onProgress) {
136
151
  const lines = [];
@@ -201,7 +216,8 @@ async function main() {
201
216
  const options = parsed.options;
202
217
 
203
218
  const hosting = options.dimensions.some((d) => HOSTING_PASS_DIMENSIONS.includes(d));
204
- const pipeline = buildPipeline(options, hosting ? hostingTools() : undefined);
219
+ const probedOnly = !hosting && options.dimensions.some((d) => PROBED_DIMENSIONS.includes(d));
220
+ const pipeline = buildPipeline(options, hosting ? hostingTools() : probedOnly ? pinTools() : undefined);
205
221
 
206
222
  if (options.dryRun) {
207
223
  process.stderr.write(
@@ -242,11 +258,13 @@ async function main() {
242
258
  const measuredAt = new Date().toISOString();
243
259
  emit({ step: "start", steps: pipeline.steps.map((s) => s.id) });
244
260
  let hostingLines;
261
+ let pinLines;
245
262
  for (const step of pipeline.steps) {
246
263
  process.stderr.write(`== ${step.id}\n`);
247
264
  if (step.perPlugin) {
248
265
  const uris = options.plugins ?? urisFromScan(readJson(pipeline.hostingScan));
249
- hostingLines = runHosting(step, uris, onProgress);
266
+ if (step.id === "pin") pinLines = runHosting(step, uris, onProgress);
267
+ else hostingLines = runHosting(step, uris, onProgress);
250
268
  } else {
251
269
  await runStep(step, onProgress);
252
270
  }
@@ -257,6 +275,7 @@ async function main() {
257
275
  options,
258
276
  ...(pipeline.finalCatalog === undefined ? {} : { catalog: readJson(pipeline.finalCatalog) }),
259
277
  ...(hostingLines === undefined ? {} : { hosting: hostingLines }),
278
+ ...(pinLines === undefined ? {} : { pins: pinLines }),
260
279
  costProvenance:
261
280
  pipeline.costProvenance !== undefined && existsSync(pipeline.costProvenance)
262
281
  ? readJson(pipeline.costProvenance)